Compare commits

..

12 Commits

Author SHA1 Message Date
hgranthorner
c6b30e32d6 Add unresolved import and load inspection 2026-08-06 16:18:34 -04:00
hgranthorner
8fcd2cca05 Document formatter support 2026-08-06 15:50:54 -04:00
hgranthorner
0658550d09 Serialize Gradle and test runs 2026-08-06 13:07:31 -04:00
hgranthorner
427269ad78 Implement Jai formatter 2026-08-06 11:10:07 -04:00
hgranthorner
065e0b0bf4 Preserve navigation targets for module completions 2026-08-05 17:10:47 -04:00
hgranthorner
e7f9d60be8 Complete symbols loaded by imported modules 2026-08-05 16:44:16 -04:00
hgranthorner
98e25c5468 improved completion 2026-08-05 16:32:40 -04:00
hgranthorner
ede8522716 Document completion scope and gaps 2026-08-05 10:43:30 -04:00
hgranthorner
2bc1b9f4be Phase 5a: add configurable Jai module roots 2026-08-05 10:24:32 -04:00
hgranthorner
9122b5a5c0 Document configurable module roots as next phase 2026-08-04 16:01:59 -04:00
hgranthorner
460fc969db Phase 5: add rename and find usages 2026-08-04 15:54:31 -04:00
hgranthorner
4758bf12e2 Phase 5: add completion and symbol navigation 2026-08-04 15:25:11 -04:00
36 changed files with 3096 additions and 203 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
.intellijPlatform .intellijPlatform
.kotlin .kotlin
build build
.gradle-test.lock

View File

@@ -59,20 +59,27 @@ class and still prints `OK`. `jaitest` therefore passes
Last verified state (all green, `./jaigradle check` and `verifyPlugin` too): Last verified state (all green, `./jaigradle check` and `verifyPlugin` too):
```text ```text
dev.hgh.HarnessSmokeTest tests=2 dev.hgh.HarnessSmokeTest tests=2
dev.hgh.jai.JaiFileTypeTest tests=4 dev.hgh.jai.JaiFileTypeTest tests=4
dev.hgh.jai.editor.JaiEditorSupportTest tests=7 dev.hgh.jai.completion.JaiCompletionTest tests=20
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3 dev.hgh.jai.editor.JaiEditorSupportTest tests=7
dev.hgh.jai.highlighting.* tests=8 dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate dev.hgh.jai.findusages.JaiFindUsagesTest tests=2
dev.hgh.jai.lexer.JaiLexerTest tests=13 dev.hgh.jai.highlighting.* tests=8
dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate dev.hgh.jai.inspection.JaiUnresolvedModuleInspectionTest tests=5
dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions dev.hgh.jai.lexer.JaiLexerTest tests=13
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate
dev.hgh.jai.reference.JaiReferenceTest tests=4 dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees
dev.hgh.jai.structure.JaiStructureViewTest tests=3 dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions
-> total 55, failures+errors 0 dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
dev.hgh.jai.refactoring.JaiRenameTest tests=3
dev.hgh.jai.reference.JaiReferenceTest tests=10
dev.hgh.jai.structure.JaiStructureViewTest tests=3
dev.hgh.jai.formatter.JaiFormatterTest tests=4
dev.hgh.jai.settings.JaiProjectSettingsTest tests=3
dev.hgh.jai.settings.JaiConfiguredRootTest tests=6
-> total 104, failures+errors 0
``` ```
The corpus gates report what they actually did; check both lines are still there: The corpus gates report what they actually did; check both lines are still there:
@@ -115,6 +122,23 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements
`#import`/`#load` path references with standard go-to-definition resolution. `#import`/`#load` path references with standard go-to-definition resolution.
Headless tests cover source-ordered declarations, nested structure members, Headless tests cover source-ordered declarations, nested structure members,
folding ranges, local `#load`, and Jai module directories. folding ranges, local `#load`, and Jai module directories.
- **Phase 5** — completion for keywords, directives, module/file paths, visible
same-file or imported declarations, procedure parameters, and module aliases;
same-file and imported symbol navigation; rename; and find-usages for
declaration names. Struct-field completion remains future work.
- **Phase 5a** — persistent project settings for Jai module/import roots,
shared root-aware resolution and completion, and indexed external Jai library
sources. Headless tests cover state round-tripping, the Settings panel,
custom-root `#import`/`#load` navigation and completion, and cross-root
find-usages.
- **Phase 6** — PSI-aware formatter with operator/punctuation spacing,
block indentation, opaque `#asm` preservation, and a corpus-wide idempotence
gate. Headless formatter tests cover registration, representative formatting,
opaque/directive token preservation, and all 714 corpus files.
- **Phase 7a** — unresolved `#import`/`#load` module and file path inspection,
reusing the existing reference resolver. Headless tests cover unresolved
imports and loads, resolved project/local modules and files, and
`#import,string` exclusions.
### Lexer design facts worth knowing before touching it ### Lexer design facts worth knowing before touching it
@@ -174,17 +198,27 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements
`JaiReferenceHostMixin` from the BNF. Do not remove the mixin and expect a `JaiReferenceHostMixin` from the BNF. Do not remove the mixin and expect a
`PsiReferenceContributor` to be queried through the standard reference service. `PsiReferenceContributor` to be queried through the standard reference service.
### Not done — pick up here ### Known code-insight gaps
1. **Phase 5** — completion (keywords, directives, module names), rename, and - Completion currently offers keywords, compiler directives, module/file paths,
find-usages; see `docs/BUILD_PLAN.md`. visible same-file or imported declarations, procedure parameters, and members
2. **Phases 6+** — formatter, inspections, compiler integration, and other of imported aliases, but not struct fields.
optional work remain. `#asm` bodies are intentionally consumed opaquely, so - Struct fields do not yet have symbol resolution or completion.
nothing inside them has PSI yet.
### In progress — pick up here
1. **Phases 7+** — additional inspections, quick fixes, live templates, and
compiler integration remain. `#asm` bodies are intentionally consumed
opaquely, so nothing inside them has PSI yet.
### Next planned increment
- Expand Phase 7 with additional high-confidence inspections and quick fixes;
struct-field resolution and completion remain a separate known gap.
### Open questions for the user (unanswered) ### Open questions for the user (unanswered)
Scope (Phases 04 vs 07), compiler integration, target IDEs, JDK policy. See Scope (Phases 05a vs 07), compiler integration, target IDEs, JDK policy. See
`docs/BUILD_PLAN.md` §6. Scope is the one that most affects the work. `docs/BUILD_PLAN.md` §6. Scope is the one that most affects the work.
--- ---
@@ -248,12 +282,13 @@ The fast loop for grammar work:
`ls -l build/classes/java/main/dev/hgh/jai/parser/JaiParser.class `ls -l build/classes/java/main/dev/hgh/jai/parser/JaiParser.class
src/main/gen/dev/hgh/jai/parser/JaiParser.java`. The build wiring in src/main/gen/dev/hgh/jai/parser/JaiParser.java`. The build wiring in
`build.gradle.kts` should prevent both, but `rm -rf build` settles it. `build.gradle.kts` should prevent both, but `rm -rf build` settles it.
- **Concurrent Gradle runs corrupt the test results.** If something else (an - **Concurrent Gradle runs corrupt the test results.** `./jaigradle` and
IDE, an agent's background checker) runs `test` at the same time, one of the `./jaitest` serialize project Gradle work with the atomic `.gradle-test.lock`
two dies with `java.io.EOFException` or lock, including test-result cleanup and retries. Never run `./gradlew`
`NoSuchFileException: .../in-progress-results-generic.bin`, and no XML is directly: it bypasses both the JDK wrapper and this lock. If an interrupted
written. It is infrastructure, not a test failure — `./jaitest` retries once process leaves a lock behind, confirm no Gradle run is active, then remove
automatically. Do not go debugging the test that "failed". `.gradle-test.lock` and rerun. The JUnit wrapper still retries once for
unexpected no-XML failures; do not debug a test until the XML exists.
- **A poisoned build cache can make tests silently vanish.** Symptom: - **A poisoned build cache can make tests silently vanish.** Symptom:
`BUILD SUCCESSFUL`, `:compileTestKotlin FROM-CACHE`, `:test NO-SOURCE`, and `BUILD SUCCESSFUL`, `:compileTestKotlin FROM-CACHE`, `:test NO-SOURCE`, and
`build/classes/kotlin/test` is empty. The cache stored an empty output `build/classes/kotlin/test` is empty. The cache stored an empty output
@@ -264,7 +299,7 @@ The fast loop for grammar work:
```bash ```bash
./jaigradle --stop ./jaigradle --stop
rm -rf build .gradle ~/.gradle/caches/build-cache-1 rm -rf .gradle-test.lock build .gradle ~/.gradle/caches/build-cache-1
./jaitest ./jaitest
``` ```

View File

@@ -20,6 +20,18 @@
- Folding for blocks, nested comments, and multi-line here-strings. - Folding for blocks, nested comments, and multi-line here-strings.
- Go-to-definition references for `#import` and `#load`, including project-relative - Go-to-definition references for `#import` and `#load`, including project-relative
files and the local Jai module layout (`Name.jai` or `Name/module.jai`). files and the local Jai module layout (`Name.jai` or `Name/module.jai`).
- Completion for Jai keywords, compiler directives, module/file paths, visible
same-file or imported declarations, procedure parameters, and members of
imported module aliases.
- Project-level Jai module/import root settings with ordered custom roots.
- Go-to-definition for procedure and type symbols in the same file and through
loaded, imported, or configured external Jai files.
- Indexed external Jai roots so find-usages can cross project/library boundaries.
- Rename and find-usages support for declaration names and resolved references.
- PSI-aware formatter with operator and punctuation spacing, block indentation,
directive-flag handling, and preservation of opaque `#asm` bodies; formatting
is idempotent across all 714 corpus files.
- Inspection warnings for unresolved `#import` and `#load` module/file paths.
### Known gaps ### Known gaps
@@ -27,5 +39,5 @@
not have inner PSI. not have inner PSI.
- `#asm` bodies remain intentionally opaque; assembly instructions do not have - `#asm` bodies remain intentionally opaque; assembly instructions do not have
inner PSI or structure entries. inner PSI or structure entries.
- Import resolution uses the project/module roots and the local Jai installation; - Struct fields and other type-qualified members do not yet have symbol
custom compiler `-import_dir` arguments are not discovered yet. resolution or completion.

View File

@@ -11,13 +11,29 @@ never from online material (see `AGENTS.md`).
(`#import`, `#run`, …), notes (`@Cleanup`), numbers (hex / binary / hexfloat / (`#import`, `#run`, …), notes (`@Cleanup`), numbers (hex / binary / hexfloat /
`_` separators), strings, here-strings (`#string DONE … DONE`), nesting block `_` separators), strings, here-strings (`#string DONE … DONE`), nesting block
comments, operators, `---`, `$`/`$$`, `it` / `it_index` comments, operators, `---`, `$`/`$$`, `it` / `it_index`
- Grammar-Kit parser and PSI for the full local 714-file corpus
- Structure view and folding for blocks, comments, and here-strings
- Colour settings page (**Settings → Editor → Color Scheme → Jai**) - Colour settings page (**Settings → Editor → Color Scheme → Jai**)
- Comment / uncomment (`//` and nesting `/* */`) - Comment / uncomment (`//` and nesting `/* */`)
- Brace matching, including the Jai-only `.{` struct-literal and `.[` - Brace matching, including the Jai-only `.{` struct-literal and `.[`
array-literal openers array-literal openers
- Completion for keywords, directives, Jai module/file paths, visible
same-file or imported declarations, procedure parameters, and members of
imported module aliases
- Project settings for ordered Jai module/import roots
- Go-to-definition for procedures and types in the same file and through
`#import`/`#load`, including configured external roots
- Indexed external Jai roots so find-usages can cross project/library boundaries
- Rename and find-usages for declaration names and their resolved references
- PSI-aware formatting for operator and punctuation spacing, block indentation,
directive flags, and opaque `#asm` bodies; formatting is idempotent across the
full local corpus
- Inspection warnings for unresolved `#import` and `#load` module/file paths
Not yet: parser and PSI, so no structure view, go-to-definition, completion, Remaining code-insight gaps include completion and symbol resolution for struct
formatting, or inspections. See `docs/BUILD_PLAN.md` for the phase plan. fields and other type-qualified members. Additional inspections and compiler
integration are planned later.
See `docs/BUILD_PLAN.md` for the phase plan.
## Install it locally ## Install it locally
@@ -30,7 +46,9 @@ In the IDE: **Settings → Plugins → ⚙ → Install Plugin from Disk…**, pi
ZIP, restart when prompted. Requires build 253 (2025.3) or newer; verified ZIP, restart when prompted. Requires build 253 (2025.3) or newer; verified
compatible with IU-253, IU-261 and IU-262 by the JetBrains Plugin Verifier. compatible with IU-253, IU-261 and IU-262 by the JetBrains Plugin Verifier.
To try it, open any file from `~/.local/jai/how_to/`. To try it, open any file from `~/.local/jai/how_to/`. Configure additional
module/import directories under **Settings → Languages & Frameworks → Jai**;
configured roots are searched in order and indexed as Jai library sources.
Uninstall or update the same way — installing a new ZIP over the old one Uninstall or update the same way — installing a new ZIP over the old one
replaces it. replaces it.

View File

@@ -169,16 +169,27 @@ Each phase has a machine-checkable gate. Do not advance without a green gate.
| **2** | `SyntaxHighlighter`, color settings page, commenter, brace matcher | Tier 1 golden dumps; highlighter maps every token type | | **2** | `SyntaxHighlighter`, color settings page, commenter, brace matcher | Tier 1 golden dumps; highlighter maps every token type |
| **3** | `.bnf` grammar, generated parser + PSI, `ParserDefinition` | Tier 2 golden trees; Tier 3 corpus parse ≥ target — **done, 100.0% (714/714)** | | **3** | `.bnf` grammar, generated parser + PSI, `ParserDefinition` | Tier 2 golden trees; Tier 3 corpus parse ≥ target — **done, 100.0% (714/714)** |
| **4** | Structure view, folding, `#import`/`#load` reference resolution + go-to-definition | Tier 4 fixture tests — **done** | | **4** | Structure view, folding, `#import`/`#load` reference resolution + go-to-definition | Tier 4 fixture tests — **done** |
| **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests | | **5** | Completion (keywords, directives, module names, visible declarations, parameters, module aliases, incomplete-expression recovery), rename, find-usages | Tier 4 fixture tests**done** (84-test suite) |
| **6** | Formatter, code style settings | Formatter round-trip: formatting the corpus is idempotent | | **5a** | Configurable Jai module/import roots and indexed external search scope | Tier 4 settings, resolution, completion, navigation, and find-usages tests — **done** (93-test suite) |
| **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` | | **6** | Formatter, code style settings | Formatter round-trip — **done**, formatting all 714 corpus files is idempotent |
| **7** | Inspections, quick fixes, live templates | Tier 4 + `verifyPlugin` — initial unresolved `#import`/`#load` path inspection implemented |
| **8** | Optional: run-configuration to invoke the `jai` compiler, parse its error output | Integration test against `~/.local/jai/bin` | | **8** | Optional: run-configuration to invoke the `jai` compiler, parse its error output | Integration test against `~/.local/jai/bin` |
Phase 6's idempotence check (format twice, assert no change) is another Phase 6's idempotence check (format twice, assert no change) is another
corpus-scale invariant that needs no human judgment. corpus-scale invariant that needs no human judgment. `JaiFormatterTest` runs this
check across all 714 files and also covers formatter registration, representative
spacing/indentation, directive flags, strings, and opaque `#asm` bodies.
--- ---
### Completion follow-up (not yet scheduled)
Phase 5 completion now covers keywords, compiler directives,
`#import`/`#load` module and file paths, visible same-file or imported
top-level declarations, procedure parameters, and members of imported module
aliases. Struct-field completion remains a possible follow-up, but it is not
currently a committed milestone.
## 5. Risks and how the plan handles them ## 5. Risks and how the plan handles them
| Risk | Mitigation | | Risk | Mitigation |
@@ -196,8 +207,9 @@ corpus-scale invariant that needs no human judgment.
## 6. Open questions for you ## 6. Open questions for you
1. **Scope/ambition** — stop at solid syntax highlighting + navigation (Phases 1. **Scope/ambition** — stop at the current syntax, navigation, and basic
04), or go all the way to formatter and inspections (07)? refactoring support (Phases 05a), or go all the way to formatter and
inspections (07)?
2. **Compiler integration** (Phase 8) — worth it? It's the only phase needing 2. **Compiler integration** (Phase 8) — worth it? It's the only phase needing
the actual `jai` binary, and it's the least testable. the actual `jai` binary, and it's the least testable.
3. **Target IDE** — IntelliJ IDEA only (current setting), or all JetBrains IDEs? 3. **Target IDE** — IntelliJ IDEA only (current setting), or all JetBrains IDEs?
@@ -206,5 +218,6 @@ corpus-scale invariant that needs no human judgment.
setup, works today), or install a system JDK via Homebrew? I'd default to the setup, works today), or install a system JDK via Homebrew? I'd default to the
wrapper. wrapper.
My recommendation: approve Phases 04 now, decide on 58 once the parser gate is Phases 06 are implemented and verified headlessly. Phase 7 has an initial
green and we can see how the grammar behaved against real code. unresolved `#import`/`#load` path inspection; additional inspections and
compiler integration remain optional follow-up work.

50
gradle-lock.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Shared, process-safe lock for Gradle and IntelliJ platform test runs.
# This file is sourced by jaigradle and jaitest; it is not a standalone command.
JAI_GRADLE_LOCK_DIR="${JAI_GRADLE_LOCK_DIR:-.gradle-test.lock}"
JAI_GRADLE_LOCK_PID_FILE="$JAI_GRADLE_LOCK_DIR/pid"
jai_gradle_lock_acquire() {
if [[ "${JAI_GRADLE_LOCK_HELD:-0}" == "1" ]]; then
return 0
fi
local reported=0
while ! mkdir "$JAI_GRADLE_LOCK_DIR" 2>/dev/null; do
if [[ -f "$JAI_GRADLE_LOCK_PID_FILE" ]]; then
local owner
owner=$(<"$JAI_GRADLE_LOCK_PID_FILE")
if [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then
rm -rf "$JAI_GRADLE_LOCK_DIR"
continue
fi
else
# A process can be killed between mkdir and writing its PID. Give that
# tiny window a chance to finish, then reclaim the incomplete lock.
sleep 1
if [[ ! -f "$JAI_GRADLE_LOCK_PID_FILE" ]]; then
rm -rf "$JAI_GRADLE_LOCK_DIR"
continue
fi
fi
if ((reported == 0)); then
echo "Waiting for another Gradle/test run in this worktree..." >&2
reported=1
fi
sleep 1
done
printf '%s\n' "$$" >"$JAI_GRADLE_LOCK_PID_FILE"
JAI_GRADLE_LOCK_HELD=1
JAI_GRADLE_LOCK_OWNER_PID=$$
export JAI_GRADLE_LOCK_HELD JAI_GRADLE_LOCK_OWNER_PID
}
jai_gradle_lock_release() {
if [[ "${JAI_GRADLE_LOCK_OWNER_PID:-}" == "$$" ]]; then
rm -rf "$JAI_GRADLE_LOCK_DIR"
unset JAI_GRADLE_LOCK_OWNER_PID JAI_GRADLE_LOCK_HELD
fi
}

View File

@@ -13,11 +13,22 @@
# and throw UnsupportedClassVersionError on 17. # and throw UnsupportedClassVersionError on 17.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
source ./gradle-lock.sh
if command -v mise >/dev/null 2>&1; then # `--stop` is an explicit recovery operation; it must be able to stop a
exec mise exec -- ./gradlew "$@" # daemon even when a stale lock was left by an interrupted run.
if [[ "${1:-}" != "--stop" ]]; then
jai_gradle_lock_acquire
trap jai_gradle_lock_release EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fi fi
status=0
if command -v mise >/dev/null 2>&1; then
mise exec -- ./gradlew "$@" || status=$?
else
# Fallback: mise is not installed. Try an already-set JAVA_HOME, then any # Fallback: mise is not installed. Try an already-set JAVA_HOME, then any
# JDK 21 that Gradle has auto-provisioned. # JDK 21 that Gradle has auto-provisioned.
if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then
@@ -35,4 +46,9 @@ if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then
exit 1 exit 1
fi fi
exec ./gradlew "$@" ./gradlew "$@" || status=$?
fi
jai_gradle_lock_release
trap - EXIT INT TERM
exit "$status"

12
jaitest
View File

@@ -4,10 +4,9 @@
# A green `test` task does not prove tests ran (see AGENTS.md), so this always # A green `test` task does not prove tests ran (see AGENTS.md), so this always
# prints tests/failures/errors per suite and dumps failure messages. # prints tests/failures/errors per suite and dumps failure messages.
# #
# Two concurrent Gradle invocations (e.g. an editor/agent running tests in the # A repository-wide lock serializes Gradle/test runs. Without it, an editor or
# background at the same time) fight over build/test-results and the loser dies # another agent can delete build/test-results while this process is writing JUnit
# with `java.io.EOFException` or a missing in-progress-results bin. That is an # XML, producing EOFException or a missing in-progress-results bin.
# infrastructure failure, not a test failure, so it is retried once.
# #
# A `--tests` filter STICKS: Gradle reuses the configuration cache entry from the # A `--tests` filter STICKS: Gradle reuses the configuration cache entry from the
# previous run, so a later unfiltered `./jaitest` silently re-runs just that one # previous run, so a later unfiltered `./jaitest` silently re-runs just that one
@@ -18,6 +17,11 @@
set -uo pipefail set -uo pipefail
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
source ./gradle-lock.sh
jai_gradle_lock_acquire
trap jai_gradle_lock_release EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
extra=() extra=()
if [[ " $* " != *" --tests "* ]]; then if [[ " $* " != *" --tests "* ]]; then

View File

@@ -8,12 +8,12 @@ import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementVisitor; import com.intellij.psi.PsiElementVisitor;
import com.intellij.psi.util.PsiTreeUtil; import com.intellij.psi.util.PsiTreeUtil;
import static dev.hgh.jai.psi.JaiTypes.*; import static dev.hgh.jai.psi.JaiTypes.*;
import com.intellij.extapi.psi.ASTWrapperPsiElement; import dev.hgh.jai.psi.mixin.JaiNamedElementMixin;
import dev.hgh.jai.psi.*; import dev.hgh.jai.psi.*;
public class JaiDeclNameImpl extends ASTWrapperPsiElement implements JaiDeclName { public class JaiDeclNameImpl extends JaiNamedElementMixin implements JaiDeclName {
public JaiDeclNameImpl(@NotNull ASTNode node) { public JaiDeclNameImpl(ASTNode node) {
super(node); super(node);
} }

View File

@@ -8,15 +8,15 @@ import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementVisitor; import com.intellij.psi.PsiElementVisitor;
import com.intellij.psi.util.PsiTreeUtil; import com.intellij.psi.util.PsiTreeUtil;
import static dev.hgh.jai.psi.JaiTypes.*; import static dev.hgh.jai.psi.JaiTypes.*;
import dev.hgh.jai.psi.mixin.JaiReferenceHostMixin;
import dev.hgh.jai.psi.*; import dev.hgh.jai.psi.*;
public class JaiRefExprImpl extends JaiExprImpl implements JaiRefExpr { public class JaiRefExprImpl extends JaiReferenceHostMixin implements JaiRefExpr {
public JaiRefExprImpl(@NotNull ASTNode node) { public JaiRefExprImpl(@NotNull ASTNode node) {
super(node); super(node);
} }
@Override
public void accept(@NotNull JaiVisitor visitor) { public void accept(@NotNull JaiVisitor visitor) {
visitor.visitRefExpr(this); visitor.visitRefExpr(this);
} }

View File

@@ -227,6 +227,7 @@ private declModifier_ ::= <<dirWithBlock>> directiveFlag_* block
private usingModifier_ ::= 'using' [directiveFlag_ [<<exprNoAssign>>]] private usingModifier_ ::= 'using' [directiveFlag_ [<<exprNoAssign>>]]
declNames ::= declName (['='] ',' declName ['='])* ['='] declNames ::= declName (['='] ',' declName ['='])* ['=']
declName ::= ['`'] (IDENT | keywordAsName_) declName ::= ['`'] (IDENT | keywordAsName_)
{ mixin="dev.hgh.jai.psi.mixin.JaiNamedElementMixin" }
private declTail_ ::= ':' [<<exprNoAssign>>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_] private declTail_ ::= ':' [<<exprNoAssign>>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_]
| ':=' initializerList_ | ':=' initializerList_
| '::' initializerList_ | '::' initializerList_
@@ -361,6 +362,7 @@ primaryExpr ::= procLiteralExpr
| uninitializedExpr | uninitializedExpr
refExpr ::= IDENT refExpr ::= IDENT
{ mixin="dev.hgh.jai.psi.mixin.JaiReferenceHostMixin" }
literalExpr ::= NUMBER | STRING | HERE_STRING | 'true' | 'false' | 'null' | 'context' literalExpr ::= NUMBER | STRING | HERE_STRING | 'true' | 'false' | 'null' | 'context'
{ mixin="dev.hgh.jai.psi.mixin.JaiReferenceHostMixin" } { mixin="dev.hgh.jai.psi.mixin.JaiReferenceHostMixin" }
uninitializedExpr ::= '---' | '--' uninitializedExpr ::= '---' | '--'

View File

@@ -0,0 +1,431 @@
package dev.hgh.jai.completion
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionProvider
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.tree.IElementType
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.ProcessingContext
import dev.hgh.jai.lexer.JaiLexer
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.module.JaiModuleResolver
import dev.hgh.jai.module.JaiPathMode
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.reference.JaiSymbolResolver
/** Directives and their adjacent comma flags before an import/load string. */
private const val COMPLETION_DEBUG_PROPERTY = "jai.completion.debug"
private val LOG = Logger.getInstance("dev.hgh.jai.completion")
private val DIRECTIVE_WITH_FLAGS =
Regex("""#(import|load)((?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*$""")
/** Basic completion for Jai's context-free language and module names. */
class JaiCompletionContributor :
CompletionContributor(),
DumbAware {
init {
extend(
CompletionType.BASIC,
PlatformPatterns.psiElement(),
JaiCompletionProvider(),
)
}
}
private class JaiCompletionProvider : CompletionProvider<CompletionParameters>() {
override fun addCompletions(
parameters: CompletionParameters,
context: ProcessingContext,
resultSet: CompletionResultSet,
) {
val completionContext = JaiCompletionContext(parameters)
val moduleDirective = completionContext.moduleDirective
debug(
"request file=${parameters.originalFile.virtualFile?.path ?: parameters.originalFile.name} " +
"offset=${parameters.offset} token=${completionContext.debugToken} " +
"prefix='${completionContext.prefix}' module=$moduleDirective " +
"member=${completionContext.memberContext}",
)
when {
moduleDirective != null -> {
addModuleNames(parameters, moduleDirective, completionContext.prefix, resultSet)
}
completionContext.isCommentOrStringOrHereString -> {
return
}
completionContext.memberContext != null -> {
val member = completionContext.memberContext!!
addMemberSymbols(parameters, member, resultSet)
}
completionContext.isDirectivePrefix -> {
addDirectives(completionContext.prefix, resultSet)
}
else -> {
addKeywords(completionContext.prefix, resultSet)
addSymbols(parameters, completionContext.prefix, resultSet)
}
}
}
private fun addKeywords(
prefix: String,
resultSet: CompletionResultSet,
) {
JaiTokenTypes.KEYWORD_MAP.keys
.filter { it.startsWith(prefix) }
.sorted()
.forEach { keyword ->
resultSet.addElement(
LookupElementBuilder
.create(keyword)
.withTypeText("keyword"),
)
}
}
private fun addDirectives(
prefix: String,
resultSet: CompletionResultSet,
) {
JaiCompletionCatalog.DIRECTIVES
.map { "#$it" }
.filter { it.startsWith(prefix) }
.forEach { directive ->
val name = directive.removePrefix("#")
resultSet.addElement(
LookupElementBuilder
// Keep the insertion text free of `#`: the prefix starts after
// `#` for a directive token, so inserting `#import` would yield
// `##import`. The alternate lookup string still matches `#im`.
.create(name)
.withPresentableText(directive)
.withLookupString(directive)
.withTypeText("directive"),
)
}
}
private fun addSymbols(
parameters: CompletionParameters,
prefix: String,
resultSet: CompletionResultSet,
) {
val candidates = JaiSymbolResolver.completionCandidates(parameters.originalFile, parameters.position)
debug("unqualified candidates=${candidates.size} names=${candidates.take(40).joinToString { it.name }}")
candidates
.filter { it.name.startsWith(prefix) }
.forEach { candidate ->
resultSet.addElement(
LookupElementBuilder
.createWithSmartPointer(candidate.name, candidate.element)
.withTypeText(candidate.kind),
)
}
}
private fun addMemberSymbols(
parameters: CompletionParameters,
member: JaiCompletionContext.MemberContext,
resultSet: CompletionResultSet,
) {
val candidates =
JaiSymbolResolver.memberCompletionCandidates(
parameters.originalFile,
parameters.position,
member.qualifier,
)
debug("member qualifier=${member.qualifier} candidates=${candidates.size} names=${candidates.take(40).joinToString { it.name }}")
candidates
.filter { it.name.startsWith(member.prefix) }
.forEach { candidate ->
resultSet.addElement(
LookupElementBuilder
.createWithSmartPointer(candidate.name, candidate.element)
.withTypeText(candidate.kind),
)
}
}
private fun debug(message: String) {
if (java.lang.Boolean.getBoolean(COMPLETION_DEBUG_PROPERTY)) LOG.info(message)
}
private fun addModuleNames(
parameters: CompletionParameters,
directive: String,
prefix: String,
resultSet: CompletionResultSet,
) {
val sourceFile = parameters.originalFile.virtualFile
val project = parameters.originalFile.project
val variants =
if (directive == "#import") {
JaiModuleCompletion.moduleNames(project, sourceFile)
} else {
JaiModuleCompletion.loadPaths(project, sourceFile)
}
variants
.filter { it.startsWith(prefix) }
.forEach { path ->
resultSet.addElement(
LookupElementBuilder
.create(path)
.withTypeText(if (directive == "#import") "module" else "file"),
)
}
}
}
private class JaiCompletionContext(
private val parameters: CompletionParameters,
) {
private val text =
parameters.editor.document.charsSequence
.toString()
private val offset = parameters.offset.coerceIn(0, text.length)
private val token = tokenAtCaret(text, offset)
val isCommentOrStringOrHereString: Boolean
get() =
token?.type in
setOf(
JaiTokenTypes.LINE_COMMENT,
JaiTokenTypes.BLOCK_COMMENT,
JaiTokenTypes.STRING,
JaiTokenTypes.HERE_STRING,
JaiTokenTypes.NOTE,
)
val isDirectivePrefix: Boolean
get() = token?.type === JaiTokenTypes.DIRECTIVE || token?.type === JaiTokenTypes.HASH
val debugToken: String
get() = token?.let { "${it.type}@${it.start}..${it.end}" } ?: "none"
data class MemberContext(
val qualifier: String,
val prefix: String,
)
val memberContext: MemberContext?
get() =
MEMBER_PREFIX.find(text.substring(0, offset))?.let { match ->
MemberContext(
qualifier = match.groupValues[1],
prefix = match.groupValues.getOrNull(2).orEmpty(),
)
}
val prefix: String
get() {
val current = token ?: return ""
val start =
when {
moduleDirective != null && current.type === JaiTokenTypes.STRING -> current.start + 1
isDirectivePrefix -> current.start
current.type === JaiTokenTypes.IDENT || current.type in JaiTokenTypes.KEYWORDS -> current.start
else -> return ""
}
return if (start <= offset) text.substring(start, offset) else ""
}
val moduleDirective: String?
get() {
if (token?.type !== JaiTokenTypes.STRING || token.end < offset) return null
if (isAfterClosedString()) return null
val beforeString = text.substring(0, token.start)
val directiveMatch = DIRECTIVE_WITH_FLAGS.find(beforeString)
val directive =
PsiTreeUtil
.getParentOfType(parameters.position, JaiDirectiveExpr::class.java)
?.node
?.findChildByType(JaiTokenTypes.DIRECTIVE)
?.text
?: directiveMatch?.let { "#${it.groupValues[1]}" }
if (directive != "#import" && directive != "#load") return null
if (directive == "#import" && hasStringFlag(directiveMatch?.groupValues?.get(2).orEmpty())) {
return null
}
return directive
}
private fun hasStringFlag(flags: String): Boolean =
Regex("""[A-Za-z_][A-Za-z0-9_]*""")
.findAll(flags)
.any { it.value == "string" }
private fun isAfterClosedString(): Boolean {
if (token == null || token.end != offset || token.start >= offset) return false
val tokenText = text.substring(token.start, offset)
return tokenText.length > 1 && tokenText.last() == '"' && isUnescapedQuote(tokenText.lastIndex)
}
private fun isUnescapedQuote(index: Int): Boolean {
var backslashes = 0
var position = index - 1
while (position >= 0 && text[token!!.start + position] == '\\') {
backslashes++
position--
}
return backslashes % 2 == 0
}
private data class LexedToken(
val type: IElementType,
val start: Int,
val end: Int,
)
private companion object {
val MEMBER_PREFIX =
Regex("""(?:^|[^A-Za-z0-9_`])(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*\.\s*(`?[A-Za-z_][A-Za-z0-9_]*`?)?$""")
}
private fun tokenAtCaret(
text: String,
offset: Int,
): LexedToken? {
val lexer = JaiLexer()
lexer.start(text, 0, offset, 0)
var last: LexedToken? = null
while (lexer.tokenType != null) {
val current =
LexedToken(
lexer.tokenType!!,
lexer.tokenStart,
lexer.tokenEnd,
)
last = current
if (current.end >= offset) return current
lexer.advance()
}
return last
}
}
private object JaiCompletionCatalog {
/** Core/compiler directives observed in the local Jai distribution. */
val DIRECTIVES: List<String> =
setOf(
"add_context",
"align",
"as",
"asm",
"assert",
"bake",
"bake_arguments",
"bake_constants",
"body_text",
"bytes",
"c_call",
"caller_location",
"char",
"code",
"complete",
"compiler",
"cpp_method",
"cpp_return_type_is_non_pod",
"define",
"deprecated",
"discard",
"dump",
"else",
"elsewhere",
"expand",
"file",
"filepath",
"foreign",
"if",
"ifdef",
"import",
"include",
"insert",
"intrinsic",
"library",
"load",
"location",
"module_parameters",
"modify",
"no_abc",
"no_alias",
"no_aoc",
"no_context",
"no_debug",
"no_padding",
"no_reset",
"overlay",
"place",
"placeholder",
"placeholders",
"poke_name",
"pragma",
"procedure_of_call",
"program_export",
"run",
"run_and_insert",
"scope_export",
"scope_file",
"scope_module",
"specified",
"string",
"symmetric",
"this",
"through",
"type",
"type_info_none",
"type_info_no_size_complaint",
"type_info_procedures_are_void_pointers",
"undef",
"version",
).sorted()
}
private object JaiModuleCompletion {
private const val JAI_EXTENSION = "jai"
fun moduleNames(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(JaiModuleResolver.candidateRoots(project, sourceFile, JaiPathMode.MODULE)) { child ->
when {
child.isDirectory && child.findChild("module.jai") != null -> child.name
!child.isDirectory && child.extension == JAI_EXTENSION -> child.nameWithoutExtension
else -> null
}
}
fun loadPaths(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(JaiModuleResolver.candidateRoots(project, sourceFile, JaiPathMode.FILE)) { child ->
if (!child.isDirectory && child.extension == JAI_EXTENSION) child.name else null
}
private fun collectCandidates(
roots: List<VirtualFile>,
nameOf: (VirtualFile) -> String?,
): List<String> {
val names = linkedSetOf<String>()
roots.forEach { root ->
root.children
.asSequence()
.mapNotNull(nameOf)
.forEach(names::add)
}
return names.sorted()
}
}

View File

@@ -0,0 +1,41 @@
package dev.hgh.jai.findusages
import com.intellij.lang.cacheBuilder.DefaultWordsScanner
import com.intellij.lang.cacheBuilder.WordsScanner
import com.intellij.lang.findUsages.FindUsagesProvider
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiNameIdentifierOwner
import com.intellij.psi.PsiNamedElement
import com.intellij.psi.tree.TokenSet
import dev.hgh.jai.lexer.JaiLexer
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.psi.JaiRefExpr
/** Supplies word indexing and descriptions for Jai declaration usages. */
class JaiFindUsagesProvider : FindUsagesProvider {
override fun getWordsScanner(): WordsScanner =
DefaultWordsScanner(
JaiLexer(),
TokenSet.create(JaiTokenTypes.IDENT),
JaiTokenTypes.COMMENTS,
JaiTokenTypes.STRINGS,
)
override fun canFindUsagesFor(psiElement: PsiElement): Boolean = psiElement is PsiNameIdentifierOwner || psiElement is JaiRefExpr
override fun getHelpId(psiElement: PsiElement): String? = null
override fun getType(element: PsiElement): String =
if (element is PsiNameIdentifierOwner || element is JaiRefExpr) {
"Jai declaration"
} else {
""
}
override fun getDescriptiveName(element: PsiElement): String = (element as? PsiNamedElement)?.name ?: element.text
override fun getNodeText(
element: PsiElement,
useFullName: Boolean,
): String = element.text
}

View File

@@ -0,0 +1,283 @@
package dev.hgh.jai.formatter
import com.intellij.formatting.Alignment
import com.intellij.formatting.Block
import com.intellij.formatting.ChildAttributes
import com.intellij.formatting.FormattingContext
import com.intellij.formatting.FormattingModel
import com.intellij.formatting.FormattingModelBuilder
import com.intellij.formatting.FormattingModelProvider
import com.intellij.formatting.Indent
import com.intellij.formatting.Spacing
import com.intellij.formatting.SpacingBuilder
import com.intellij.formatting.Wrap
import com.intellij.lang.ASTNode
import com.intellij.psi.TokenType
import com.intellij.psi.codeStyle.CodeStyleSettings
import com.intellij.psi.formatter.common.AbstractBlock
import com.intellij.psi.tree.IElementType
import com.intellij.psi.tree.TokenSet
import dev.hgh.jai.JaiLanguage
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiExpressionStatement
import dev.hgh.jai.psi.JaiPrefixExpr
import dev.hgh.jai.psi.JaiStatement
import dev.hgh.jai.psi.JaiTypes
/** Basic PSI-aware formatter for Jai source files. */
class JaiFormattingModelBuilder : FormattingModelBuilder {
override fun createModel(formattingContext: FormattingContext): FormattingModel {
val settings = formattingContext.codeStyleSettings
val spacingBuilder = createSpacingBuilder(settings)
val block = JaiFormattingBlock(formattingContext.node, null, null, spacingBuilder)
return FormattingModelProvider.createFormattingModelForPsiFile(
formattingContext.containingFile,
block,
settings,
)
}
private companion object {
private val BINARY_OPERATORS =
TokenSet.create(
JaiTokenTypes.PLUS,
JaiTokenTypes.MINUS,
JaiTokenTypes.STAR,
JaiTokenTypes.SLASH,
JaiTokenTypes.PERCENT,
JaiTokenTypes.PLUS_EQ,
JaiTokenTypes.MINUS_EQ,
JaiTokenTypes.STAR_EQ,
JaiTokenTypes.SLASH_EQ,
JaiTokenTypes.PERCENT_EQ,
JaiTokenTypes.EQ,
JaiTokenTypes.EQ_EQ,
JaiTokenTypes.EQ_EQ_EQ,
JaiTokenTypes.NOT_EQ,
JaiTokenTypes.LT,
JaiTokenTypes.GT,
JaiTokenTypes.LT_EQ,
JaiTokenTypes.GT_EQ,
JaiTokenTypes.AND_AND,
JaiTokenTypes.OR_OR,
JaiTokenTypes.AND_AND_EQ,
JaiTokenTypes.OR_OR_EQ,
JaiTokenTypes.AND,
JaiTokenTypes.OR,
JaiTokenTypes.XOR,
JaiTokenTypes.AND_EQ,
JaiTokenTypes.OR_EQ,
JaiTokenTypes.XOR_EQ,
JaiTokenTypes.SHL,
JaiTokenTypes.SHR,
JaiTokenTypes.ROL,
JaiTokenTypes.ROR,
JaiTokenTypes.SHL_EQ,
JaiTokenTypes.SHR_EQ,
JaiTokenTypes.ROL_EQ,
JaiTokenTypes.ROR_EQ,
JaiTokenTypes.RIGHT_ARROW,
JaiTokenTypes.QUICK_LAMBDA,
)
private fun createSpacingBuilder(settings: CodeStyleSettings): SpacingBuilder =
SpacingBuilder(settings, JaiLanguage)
.before(JaiTokenTypes.COMMA)
.none()
.after(JaiTokenTypes.COMMA)
.spaces(1)
.before(JaiTokenTypes.DOUBLE_COMMA)
.none()
.after(JaiTokenTypes.DOUBLE_COMMA)
.spaces(1)
.before(JaiTokenTypes.COLON)
.none()
.after(JaiTokenTypes.COLON)
.spaces(1)
.around(JaiTokenTypes.COLON_COLON)
.spaces(1)
.around(JaiTokenTypes.COLON_EQ)
.spaces(1)
.around(BINARY_OPERATORS)
.spaces(1)
.around(JaiTokenTypes.DOT)
.none()
.around(JaiTokenTypes.POSTFIX_DEREFERENCE)
.none()
.before(JaiTokenTypes.LBRACE)
.spaces(1)
.before(JaiTypes.BLOCK)
.spaces(1)
.withinPair(JaiTokenTypes.LPAREN, JaiTokenTypes.RPAREN)
.none()
.withinPair(JaiTokenTypes.LBRACKET, JaiTokenTypes.RBRACKET)
.none()
.withinPair(JaiTokenTypes.BEGIN_STRUCT_LITERAL, JaiTokenTypes.RBRACE)
.none()
.withinPair(JaiTokenTypes.BEGIN_ARRAY_LITERAL, JaiTokenTypes.RBRACKET)
.none()
}
}
private class JaiFormattingBlock(
node: ASTNode,
wrap: Wrap?,
alignment: Alignment?,
private val spacingBuilder: SpacingBuilder,
) : AbstractBlock(node, wrap, alignment) {
private val astNode: ASTNode = node
override fun buildChildren(): List<Block> {
if (isOpaqueAsm(myNode)) return emptyList()
val blocks = mutableListOf<Block>()
var child = myNode.firstChildNode
while (child != null) {
if (child.elementType != TokenType.WHITE_SPACE && child.textLength > 0) {
blocks += JaiFormattingBlock(child, null, null, spacingBuilder)
}
child = child.treeNext
}
return blocks
}
override fun getIndent(): Indent? {
val parent = myNode.treeParent ?: return Indent.getNoneIndent()
return if (parent.elementType == JaiTypes.BLOCK &&
myNode.elementType != JaiTokenTypes.LBRACE &&
myNode.elementType != JaiTokenTypes.RBRACE
) {
Indent.getNormalIndent()
} else {
Indent.getNoneIndent()
}
}
override fun getChildAttributes(newChildIndex: Int): ChildAttributes =
if (myNode.elementType == JaiTypes.BLOCK) {
ChildAttributes(Indent.getNormalIndent(), null)
} else {
ChildAttributes(Indent.getNoneIndent(), null)
}
override fun getSpacing(
child1: Block?,
child2: Block,
): Spacing? {
val firstNode = nodeOf(child1)
val secondNode = nodeOf(child2)
val firstType = firstNode?.elementType
val secondType = secondNode?.elementType
if (myNode.elementType == JaiTypes.BLOCK &&
firstType == JaiTokenTypes.LBRACE &&
secondType == JaiTokenTypes.RBRACE
) {
return NO_SPACE
}
if (firstType == JaiTokenTypes.LINE_COMMENT) return LINE_COMMENT_END
if (secondType == JaiTokenTypes.LINE_COMMENT) return COMMENT_START
if (firstType == JaiTokenTypes.COLON_COLON || secondType == JaiTokenTypes.COLON_COLON ||
firstType == JaiTokenTypes.COLON_EQ || secondType == JaiTokenTypes.COLON_EQ
) {
return ONE_SPACE
}
if (secondType == JaiTokenTypes.COLON) return NO_SPACE
if (firstType == JaiTokenTypes.COLON) return ONE_SPACE
if (firstType == JaiTokenTypes.COMMA &&
(
isDirectiveFlagComma(firstNode) ||
(myNode.elementType == JaiTypes.DECLARATION && isUsingFlagComma(firstNode)) ||
(myNode.elementType == JaiTypes.PARAMETER && isUsingFlagComma(firstNode))
)
) {
return NO_SPACE
}
if ((myNode.psi is JaiPrefixExpr || myNode.elementType == JaiTypes.PREFIX_EXPR) &&
isPrefixOperator(firstType)
) {
return NO_SPACE
}
if (secondType == JaiTypes.STATEMENT && isBodyStatementParent(myNode.elementType)) {
return ONE_SPACE
}
if (firstType == JaiTokenTypes.DOT || secondType == JaiTokenTypes.DOT ||
firstType == JaiTokenTypes.POSTFIX_DEREFERENCE || secondType == JaiTokenTypes.POSTFIX_DEREFERENCE
) {
return NO_SPACE
}
return spacingBuilder.getSpacing(this, child1, child2)
}
override fun isLeaf(): Boolean = isOpaqueAsm(myNode) || myNode.firstChildNode == null
private fun isBodyStatementParent(type: IElementType): Boolean =
type == JaiTypes.IF_STATEMENT ||
type == JaiTypes.WHILE_STATEMENT ||
type == JaiTypes.FOR_STATEMENT ||
type == JaiTypes.ELSE_CLAUSE ||
type == JaiTypes.CASE_CLAUSE ||
type == JaiTypes.DEFER_STATEMENT ||
type == JaiTypes.PUSH_CONTEXT_STATEMENT
private fun isUsingFlagComma(comma: ASTNode?): Boolean {
if (comma == null || comma.treeNext?.elementType != JaiTokenTypes.IDENT) return false
return comma.treePrev?.elementType == JaiTokenTypes.KW_USING
}
private fun isPrefixOperator(type: IElementType?): Boolean =
type == JaiTokenTypes.PLUS ||
type == JaiTokenTypes.MINUS ||
type == JaiTokenTypes.STAR ||
type == JaiTokenTypes.NOT ||
type == JaiTokenTypes.TILDE ||
type == JaiTokenTypes.DOLLAR ||
type == JaiTokenTypes.DOUBLE_DOLLAR ||
type == JaiTokenTypes.SHL ||
type == JaiTokenTypes.SHR
private fun nodeOf(block: Block?): ASTNode? = (block as? JaiFormattingBlock)?.astNode
private fun isDirectiveFlagComma(comma: ASTNode?): Boolean {
if (comma == null) return false
if (comma.treeNext?.elementType != JaiTokenTypes.IDENT) return false
val previous = comma.treePrev ?: return false
if (previous.elementType == TokenType.WHITE_SPACE) return false
if (previous.elementType == JaiTokenTypes.DIRECTIVE ||
previous.elementType == JaiTokenTypes.KW_CAST ||
previous.elementType == JaiTokenTypes.KW_XX ||
previous.elementType == JaiTokenTypes.KW_PUSH_CONTEXT
) {
return true
}
if (previous.elementType != JaiTokenTypes.IDENT) return false
val previousComma = previous.treePrev ?: return false
return previousComma.elementType != TokenType.WHITE_SPACE &&
previousComma.elementType == JaiTokenTypes.COMMA &&
isDirectiveFlagComma(previousComma)
}
private fun isOpaqueAsm(node: ASTNode): Boolean {
val psi = node.psi
return (psi is JaiDirectiveExpr || psi is JaiExpressionStatement || psi is JaiStatement) &&
psi.text.trimStart().startsWith("#asm")
}
private companion object {
val NO_SPACE: Spacing = Spacing.createSpacing(0, 0, 0, false, 0)
val ONE_SPACE: Spacing = Spacing.createSpacing(1, 1, 0, false, 0)
val COMMENT_START: Spacing = Spacing.createSpacing(1, 1, 0, true, 0)
val LINE_COMMENT_END: Spacing = Spacing.createSpacing(0, 0, 1, true, 0)
}
}

View File

@@ -0,0 +1,45 @@
package dev.hgh.jai.inspection
import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.PsiReferenceService
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiLiteralExpr
/** Reports file and module paths in #import/#load directives that do not resolve. */
class JaiUnresolvedModuleInspection : LocalInspectionTool() {
override fun getDisplayName(): String = "Unresolved Jai import or load"
override fun buildVisitor(
holder: ProblemsHolder,
isOnTheFly: Boolean,
): PsiElementVisitor =
object : PsiElementVisitor() {
override fun visitElement(element: PsiElement) {
val literal = element as? JaiLiteralExpr ?: return
val directive = literal.parent as? JaiDirectiveExpr ?: return
val directiveName = directive.node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text ?: return
if (directiveName != "#import" && directiveName != "#load") return
val reference =
PsiReferenceService
.getService()
.getContributedReferences(literal)
.firstOrNull()
?: return
if (reference.resolve() != null) return
val path = literal.text.removeSurrounding("\"")
holder.registerProblem(
reference.element,
"Cannot resolve $directiveName path '$path'",
ProblemHighlightType.LIKE_UNKNOWN_SYMBOL,
reference.rangeInElement,
)
}
}
}

View File

@@ -0,0 +1,163 @@
package dev.hgh.jai.module
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import dev.hgh.jai.settings.JaiProjectSettings
/** The information needed to resolve an #import or #load string. */
data class JaiModuleTarget(
val directive: String,
val path: String,
val flags: Set<String>,
)
enum class JaiPathMode {
MODULE,
FILE,
DIRECTORY,
RELATIVE,
}
/** Resolves Jai paths and exposes the same root order to completion. */
object JaiModuleResolver {
private val fileSystem: LocalFileSystem
get() = LocalFileSystem.getInstance()
fun resolve(
project: Project,
sourceFile: VirtualFile,
target: JaiModuleTarget,
): VirtualFile? {
val rawPath = target.path.replace('\\', '/')
val path = normalize(rawPath)
if (path.isEmpty()) return null
val mode =
when {
target.directive == "#load" || "file" in target.flags -> JaiPathMode.FILE
"dir" in target.flags -> JaiPathMode.DIRECTORY
rawPath.startsWith("./") || rawPath.startsWith("../") -> JaiPathMode.RELATIVE
else -> JaiPathMode.MODULE
}
if (path.startsWith('/')) {
return findAbsolute(path, mode)
}
for (root in candidateRoots(project, sourceFile, mode)) {
findInRoot(root, path, mode)?.let { return it }
}
return null
}
/**
* Returns roots in resolution precedence order. Configured roots are intentionally shared by
* the resolver and completion so a path cannot resolve differently from the path offered by
* completion.
*/
fun candidateRoots(
project: Project,
sourceFile: VirtualFile?,
mode: JaiPathMode,
): List<VirtualFile> {
val roots = linkedMapOf<String, VirtualFile>()
fun add(root: VirtualFile?) {
if (root != null && root.isValid && root.isDirectory) {
roots.putIfAbsent(root.path, root)
}
}
val sourceRoot = sourceFile?.parent
when (mode) {
JaiPathMode.MODULE -> add(sourceRoot?.findChild("modules"))
JaiPathMode.FILE, JaiPathMode.DIRECTORY, JaiPathMode.RELATIVE -> add(sourceRoot)
}
// A configured root is an explicit opt-in to searching outside the project. For direct
// #load/#import paths it is searched after the containing directory; for bare module
// imports it wins over the built-in/project fallbacks below.
if (mode != JaiPathMode.RELATIVE) {
JaiProjectSettings.getInstance(project).configuredRoots().forEach(::add)
}
if (mode == JaiPathMode.MODULE) {
val projectRoot = project.basePath?.let(fileSystem::findFileByPath)
add(projectRoot?.findChild("modules"))
add(projectRoot)
add(
fileSystem.findFileByPath(
"${System.getProperty("user.home")}/.local/jai/modules",
),
)
}
return roots.values.toList()
}
private fun findAbsolute(
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = fileSystem.findFileByPath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return fileSystem.findFileByPath("$path.jai")
?: fileSystem.findFileByPath("$path/module.jai")
}
return null
}
private fun findInRoot(
root: VirtualFile,
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = root.findFileByRelativePath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return root.findFileByRelativePath("$path.jai")
?: root.findFileByRelativePath("$path/module.jai")
}
return null
}
private fun normalize(path: String): String = path.replace('\\', '/').removePrefix("./")
}

View File

@@ -0,0 +1,23 @@
package dev.hgh.jai.psi
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFileFactory
import com.intellij.psi.util.PsiTreeUtil
import dev.hgh.jai.JaiLanguage
import dev.hgh.jai.lexer.JaiTokenTypes
/** Creates small Jai PSI fragments for AST replacements used by rename refactoring. */
object JaiPsiElementFactory {
fun createIdentifier(
project: Project,
name: String,
): PsiElement {
val file = PsiFileFactory.getInstance(project).createFileFromText("rename.jai", JaiLanguage, "$name :: 0;")
val declarationName =
PsiTreeUtil.findChildOfType(file, JaiDeclName::class.java)
?: error("Could not parse a Jai declaration name for '$name'")
return declarationName.node.findChildByType(JaiTokenTypes.IDENT)?.psi
?: error("Jai name '$name' is not an identifier")
}
}

View File

@@ -0,0 +1,28 @@
package dev.hgh.jai.psi.mixin
import com.intellij.extapi.psi.ASTWrapperPsiElement
import com.intellij.lang.ASTNode
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiNameIdentifierOwner
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.psi.JaiPsiElementFactory
/** Gives declaration names the standard PSI naming contract used by rename/find-usages. */
abstract class JaiNamedElementMixin(
node: ASTNode,
) : ASTWrapperPsiElement(node),
PsiNameIdentifierOwner {
override fun getNameIdentifier(): PsiElement? = node.findChildByType(JaiTokenTypes.IDENT)?.psi
override fun getName(): String? = nameIdentifier?.text
override fun setName(name: String): PsiElement {
val identifier = nameIdentifier ?: return this
identifier.replace(JaiPsiElementFactory.createIdentifier(project, name))
return this
}
override fun getNavigationElement(): PsiElement = nameIdentifier ?: this
override fun getTextOffset(): Int = nameIdentifier?.textOffset ?: super.getTextOffset()
}

View File

@@ -0,0 +1,26 @@
package dev.hgh.jai.refactoring
import com.intellij.lang.refactoring.NamesValidator
import com.intellij.openapi.project.Project
import dev.hgh.jai.lexer.JaiTokenTypes
/** Applies Jai's ASCII identifier and keyword rules to rename input. */
class JaiNamesValidator : NamesValidator {
override fun isKeyword(
name: String,
project: Project?,
): Boolean = name in JaiTokenTypes.KEYWORD_MAP
override fun isIdentifier(
name: String,
project: Project?,
): Boolean {
if (name.isEmpty() || isKeyword(name, project)) return false
if (!isIdentifierStart(name.first())) return false
return name.drop(1).all(::isIdentifierPart)
}
private fun isIdentifierStart(character: Char): Boolean = character in 'a'..'z' || character in 'A'..'Z' || character == '_'
private fun isIdentifierPart(character: Char): Boolean = isIdentifierStart(character) || character in '0'..'9'
}

View File

@@ -0,0 +1,14 @@
package dev.hgh.jai.refactoring
import com.intellij.lang.refactoring.RefactoringSupportProvider
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiNameIdentifierOwner
import dev.hgh.jai.JaiLanguage
/** Enables standard and in-place rename for Jai declaration names. */
class JaiRefactoringSupportProvider : RefactoringSupportProvider() {
override fun isMemberInplaceRenameAvailable(
elementToRename: PsiElement,
context: PsiElement?,
): Boolean = elementToRename is PsiNameIdentifierOwner && elementToRename.language == JaiLanguage
}

View File

@@ -1,9 +1,6 @@
package dev.hgh.jai.reference package dev.hgh.jai.reference
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiElement import com.intellij.psi.PsiElement
import com.intellij.psi.PsiManager import com.intellij.psi.PsiManager
@@ -14,16 +11,24 @@ import com.intellij.psi.PsiReferenceProvider
import com.intellij.psi.PsiReferenceRegistrar import com.intellij.psi.PsiReferenceRegistrar
import com.intellij.util.ProcessingContext import com.intellij.util.ProcessingContext
import dev.hgh.jai.lexer.JaiTokenTypes import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.module.JaiModuleResolver
import dev.hgh.jai.module.JaiModuleTarget
import dev.hgh.jai.psi.JaiDirectiveExpr import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiLiteralExpr import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiPsiElementFactory
import dev.hgh.jai.psi.JaiRefExpr
/** Adds file references to string operands of Jai's import and load directives. */ /** Adds file references for directives and symbol references for Jai identifiers. */
class JaiReferenceContributor : PsiReferenceContributor() { class JaiReferenceContributor : PsiReferenceContributor() {
override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) {
registrar.registerReferenceProvider( registrar.registerReferenceProvider(
PlatformPatterns.psiElement(JaiLiteralExpr::class.java), PlatformPatterns.psiElement(JaiLiteralExpr::class.java),
JaiModuleReferenceProvider(), JaiModuleReferenceProvider(),
) )
registrar.registerReferenceProvider(
PlatformPatterns.psiElement(JaiRefExpr::class.java),
JaiSymbolReferenceProvider(),
)
} }
} }
@@ -78,17 +83,30 @@ private class JaiModuleReferenceProvider : PsiReferenceProvider() {
} }
} }
private data class JaiModuleTarget( /** Adds references from identifiers to declarations in the same or imported Jai files. */
val directive: String, private class JaiSymbolReferenceProvider : PsiReferenceProvider() {
val path: String, override fun getReferencesByElement(
val flags: Set<String>, element: PsiElement,
) context: ProcessingContext,
): Array<PsiReference> {
val reference = element as? JaiRefExpr ?: return PsiReference.EMPTY_ARRAY
if (reference.text.isEmpty()) return PsiReference.EMPTY_ARRAY
return arrayOf(JaiSymbolReference(reference))
}
}
private enum class JaiPathMode { private class JaiSymbolReference(
MODULE, private val sourceElement: JaiRefExpr,
FILE, ) : PsiReferenceBase<JaiRefExpr>(sourceElement, TextRange(0, sourceElement.textLength), true) {
DIRECTORY, override fun resolve(): PsiElement? = JaiSymbolResolver.resolve(sourceElement)
RELATIVE,
override fun handleElementRename(newElementName: String): PsiElement {
val identifier = sourceElement.node.findChildByType(JaiTokenTypes.IDENT)?.psi ?: return sourceElement
identifier.replace(JaiPsiElementFactory.createIdentifier(sourceElement.project, newElementName))
return sourceElement
}
override fun getVariants(): Array<Any> = emptyArray()
} }
private class JaiModuleReference( private class JaiModuleReference(
@@ -106,137 +124,6 @@ private class JaiModuleReference(
override fun getVariants(): Array<Any> = emptyArray() override fun getVariants(): Array<Any> = emptyArray()
} }
/** Resolves paths without requiring an external compiler or an IDE index. */
private object JaiModuleResolver {
private val fileSystem: LocalFileSystem
get() = LocalFileSystem.getInstance()
fun resolve(
project: Project,
sourceFile: VirtualFile,
target: JaiModuleTarget,
): VirtualFile? {
val rawPath = target.path.replace('\\', '/')
val path = normalize(rawPath)
if (path.isEmpty()) return null
val mode =
when {
target.directive == "#load" || "file" in target.flags -> JaiPathMode.FILE
"dir" in target.flags -> JaiPathMode.DIRECTORY
rawPath.startsWith("./") || rawPath.startsWith("../") -> JaiPathMode.RELATIVE
else -> JaiPathMode.MODULE
}
if (path.startsWith('/')) {
return findAbsolute(path, mode)
}
val roots = candidateRoots(project, sourceFile, mode)
for (root in roots) {
findInRoot(root, path, mode)?.let { return it }
}
return null
}
private fun candidateRoots(
project: Project,
sourceFile: VirtualFile,
mode: JaiPathMode,
): List<VirtualFile> {
val roots = linkedMapOf<String, VirtualFile>()
fun add(root: VirtualFile?) {
if (root != null && root.isValid && root.isDirectory) roots.putIfAbsent(root.path, root)
}
val sourceRoot = sourceFile.parent
if (mode == JaiPathMode.MODULE) {
add(sourceRoot?.findChild("modules"))
val projectRoot = project.basePath?.let(fileSystem::findFileByPath)
add(projectRoot?.findChild("modules"))
add(projectRoot)
add(
fileSystem.findFileByPath(
"${System.getProperty("user.home")}/.local/jai/modules",
),
)
} else {
// #load, #import,file, #import,dir, and explicit ./ or ../ paths are
// direct paths. Do not silently find a same-named file in another root.
add(sourceRoot)
}
return roots.values.toList()
}
private fun findAbsolute(
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = fileSystem.findFileByPath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return fileSystem.findFileByPath("$path.jai")
?: fileSystem.findFileByPath("$path/module.jai")
}
return null
}
private fun findInRoot(
root: VirtualFile,
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = root.findFileByRelativePath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return root.findFileByRelativePath("$path.jai")
?: root.findFileByRelativePath("$path/module.jai")
}
return null
}
private fun normalize(path: String): String = path.replace('\\', '/').removePrefix("./")
}
private fun JaiDirectiveExpr.directiveName(): String? = node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text private fun JaiDirectiveExpr.directiveName(): String? = node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text
private fun JaiDirectiveExpr.flagsBefore(literal: PsiElement): Set<String> { private fun JaiDirectiveExpr.flagsBefore(literal: PsiElement): Set<String> {

View File

@@ -0,0 +1,438 @@
package dev.hgh.jai.reference
import com.intellij.openapi.diagnostic.Logger
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReferenceService
import com.intellij.psi.util.PsiTreeUtil
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.module.JaiModuleResolver
import dev.hgh.jai.module.JaiModuleTarget
import dev.hgh.jai.psi.JaiBlock
import dev.hgh.jai.psi.JaiDeclName
import dev.hgh.jai.psi.JaiDeclaration
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiForName
import dev.hgh.jai.psi.JaiForStatement
import dev.hgh.jai.psi.JaiInitializer
import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiParamName
import dev.hgh.jai.psi.JaiProcLiteralExpr
import dev.hgh.jai.psi.JaiRefExpr
import dev.hgh.jai.settings.JaiProjectSettings
private const val COMPLETION_DEBUG_PROPERTY = "jai.completion.debug"
private val LOG = Logger.getInstance("dev.hgh.jai.reference.JaiSymbolResolver")
private val RECOVERABLE_IMPORT =
Regex("""(?m)(#(?:import|load))((?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*"((?:\\.|[^"\\])*)"""")
private val RECOVERABLE_PROCEDURE =
Regex("""(?s)(?:^|[;}\n])\s*[A-Za-z_][A-Za-z0-9_]*\s*::\s*\([^{}]*\)\s*(?:->[^\{;}]*)?\{""")
private val RECOVERABLE_LOCAL_DECLARATION =
Regex("""(?m)(?:^|[;{}])\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::=|:(?!=))""")
/** A declaration-like PSI element that can be inserted by identifier completion. */
internal data class JaiSymbolCandidate(
val element: PsiElement,
val name: String,
val kind: String = "symbol",
)
/** Shared symbol lookup for references and symbol-aware completion. */
internal object JaiSymbolResolver {
private data class ScopedSymbol(
val element: PsiElement,
val name: String,
val scope: PsiElement?,
val fallbackScope: PsiElement?,
val visibleFrom: Int,
)
private data class ImportBinding(
val file: PsiFile,
val directive: String,
val scope: PsiElement?,
val visibleFrom: Int,
val boundName: String?,
val importsIntoScope: Boolean,
val excludedNames: Set<String>,
)
fun resolve(reference: JaiRefExpr): PsiElement? {
val file = reference.containingFile ?: return null
return completionCandidates(file, reference)
.firstOrNull { it.name == reference.text }
?.element
}
/** Returns visible local declarations followed by visible unqualified imports. */
fun completionCandidates(
file: PsiFile,
position: PsiElement,
): List<JaiSymbolCandidate> {
val candidates = linkedMapOf<String, JaiSymbolCandidate>()
val localSymbols = localSymbols(file)
val recoveredLocals = recoverLocalSymbols(file, position)
localSymbols
.filter { isVisible(it, position) && !isInside(it.element, position) }
.sortedWith(
compareByDescending<ScopedSymbol> { scopeDepth(it.scope ?: it.fallbackScope) }
.thenBy { it.visibleFrom }
.thenBy { it.element.textOffset },
).forEach { symbol ->
candidates.putIfAbsent(
symbol.name,
JaiSymbolCandidate(symbol.element, symbol.name),
)
}
recoveredLocals.forEach { symbol ->
candidates.putIfAbsent(symbol.name, symbol)
}
importedCandidates(file, position).forEach { symbol ->
candidates.putIfAbsent(symbol.name, symbol)
}
val result = candidates.values.toList()
debug(
"file=${file.virtualFile?.path ?: file.name} roots=${JaiProjectSettings.getInstance(file.project).rootPaths()} " +
"locals=${localSymbols.joinToString {
"${it.name}@${it.element.textOffset}/scope=${it.scope?.textRange}/fallback=${it.fallbackScope?.textRange}"
}} recovered=${recoveredLocals.joinToString { it.name }} " +
"imports=${importBindings(file).joinToString { it.file.virtualFile?.path ?: it.file.name }} " +
"candidates=${result.take(40).joinToString { it.name }}",
)
return result
}
/** Returns members of a module bound to [qualifier] in the visible lexical scope. */
fun memberCompletionCandidates(
file: PsiFile,
position: PsiElement,
qualifier: String,
): List<JaiSymbolCandidate> {
val binding =
importBindings(file)
.filter { it.boundName == normalizeName(qualifier) && isVisible(it, position) }
.sortedWith(
compareByDescending<ImportBinding> { scopeDepth(it.scope) }
.thenByDescending { it.visibleFrom },
).firstOrNull()
?: return emptyList()
return topLevelDeclarations(binding.file)
.asSequence()
.filter { symbolName(it) !in binding.excludedNames }
.mapNotNull { name ->
symbolName(name)?.let { JaiSymbolCandidate(name, it, "member") }
}.distinctBy { it.name }
.toList()
}
private fun localSymbols(file: PsiFile): List<ScopedSymbol> {
val declarations =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.flatMap { declaration ->
declaration.declNames.declNameList.mapNotNull { name ->
symbolName(name)?.let {
ScopedSymbol(
element = name,
name = it,
scope = PsiTreeUtil.getParentOfType(name, JaiBlock::class.java),
fallbackScope = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java),
visibleFrom = name.textOffset,
)
}
}
}
val parameters =
PsiTreeUtil
.findChildrenOfType(file, JaiParamName::class.java)
.mapNotNull { name ->
val symbol = symbolName(name) ?: return@mapNotNull null
val procedure = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java)
ScopedSymbol(
element = name,
name = symbol,
// Keep the procedure itself as the parameter scope. The body block is a
// nested scope, so a local declaration correctly shadows a parameter.
scope = procedure,
fallbackScope = null,
visibleFrom = procedure?.textOffset ?: name.textOffset,
)
}
val forNames =
PsiTreeUtil
.findChildrenOfType(file, JaiForName::class.java)
.mapNotNull { name ->
val symbol = symbolName(name) ?: return@mapNotNull null
ScopedSymbol(
element = name,
name = symbol,
scope = PsiTreeUtil.getParentOfType(name, JaiForStatement::class.java),
fallbackScope = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java),
visibleFrom = name.textOffset,
)
}
return declarations + parameters + forNames
}
private fun importedCandidates(
file: PsiFile,
position: PsiElement,
): List<JaiSymbolCandidate> =
importBindings(file)
.asSequence()
.filter { (it.boundName == null || it.importsIntoScope) && isVisible(it, position) }
.flatMap { binding ->
topLevelDeclarations(binding.file)
.asSequence()
.filter { symbolName(it) !in binding.excludedNames }
.mapNotNull { name ->
symbolName(name)?.let { JaiSymbolCandidate(name, it, "import") }
}
}.distinctBy { it.name }
.toList()
/** Recovers simple locals when an unfinished statement prevents the procedure PSI from closing. */
private fun recoverLocalSymbols(
file: PsiFile,
position: PsiElement,
): List<JaiSymbolCandidate> {
val caretOffset = position.textOffset.coerceIn(0, file.textLength)
val beforeCaret = file.text.substring(0, caretOffset)
val procedure = RECOVERABLE_PROCEDURE.findAll(beforeCaret).lastOrNull() ?: return emptyList()
val bodyStart = procedure.range.last + 1
if (bodyStart >= beforeCaret.length) return emptyList()
return RECOVERABLE_LOCAL_DECLARATION
.findAll(beforeCaret.substring(bodyStart))
.map { match -> JaiSymbolCandidate(position, match.groupValues[1], "local") }
.distinctBy { it.name }
.toList()
}
private fun importBindings(file: PsiFile): List<ImportBinding> {
val parsed =
PsiTreeUtil
.findChildrenOfType(file, JaiLiteralExpr::class.java)
.mapNotNull { literal ->
val directive = literal.parent as? JaiDirectiveExpr ?: return@mapNotNull null
val name = directive.directiveName() ?: return@mapNotNull null
if (name != "#import" && name != "#load") return@mapNotNull null
val declaration = boundDeclaration(literal)
val boundName =
declaration
?.declNames
?.declNameList
?.firstOrNull()
?.let(::symbolName)
val declarationPrefix = declaration?.let { prefixBeforeNames(it) }.orEmpty()
val importsIntoScope = declaration == null || declarationPrefix.trimStart().startsWith("using")
val excludedNames = excludedNames(declarationPrefix)
val target =
PsiReferenceService
.getService()
.getContributedReferences(literal)
.firstOrNull()
?.resolve() as? PsiFile
?: return@mapNotNull null
ImportBinding(
file = target,
directive = name,
scope = PsiTreeUtil.getParentOfType(literal, JaiBlock::class.java),
visibleFrom = literal.textOffset,
boundName = boundName,
importsIntoScope = importsIntoScope,
excludedNames = excludedNames,
)
}
// An unfinished statement immediately before the caret can make Grammar-Kit retain the
// file's top-level declarations but omit later directive PSI. Recover bare imports from
// the source text so completion still works while the user is typing.
val recovered = if (parsed.isEmpty()) recoverImportBindings(file) else emptyList()
return (parsed + recovered).distinctBy { binding ->
listOf(
binding.file.virtualFile?.path ?: binding.file.name,
binding.scope?.textRange?.startOffset,
binding.boundName,
binding.visibleFrom,
)
}
}
private fun recoverImportBindings(file: PsiFile): List<ImportBinding> {
val sourceFile = file.virtualFile ?: return emptyList()
return RECOVERABLE_IMPORT
.findAll(file.text)
.mapNotNull { match ->
val directive = match.groupValues[1]
val flags =
Regex("""[A-Za-z_][A-Za-z0-9_]*""")
.findAll(match.groupValues[2])
.map { it.value }
.toSet()
if ("string" in flags) return@mapNotNull null
val path = unescapeImportPath(match.groupValues[3])
val target =
JaiModuleResolver.resolve(
file.project,
sourceFile,
JaiModuleTarget(directive, path, flags),
) ?: return@mapNotNull null
val targetPsi = PsiManager.getInstance(file.project).findFile(target) ?: return@mapNotNull null
ImportBinding(
file = targetPsi,
directive = directive,
scope = null,
visibleFrom = match.range.first,
boundName = null,
importsIntoScope = true,
excludedNames = emptySet(),
)
}.toList()
}
private fun unescapeImportPath(text: String): String =
buildString(text.length) {
var index = 0
while (index < text.length) {
if (text[index] == '\\' && index + 1 < text.length) {
append(text[index + 1])
index += 2
} else {
append(text[index++])
}
}
}
/** The declaration that binds an import, not an enclosing procedure declaration. */
private fun boundDeclaration(literal: JaiLiteralExpr): JaiDeclaration? {
val initializer = PsiTreeUtil.getParentOfType(literal, JaiInitializer::class.java) ?: return null
return PsiTreeUtil.getParentOfType(initializer, JaiDeclaration::class.java)
}
private fun prefixBeforeNames(declaration: JaiDeclaration): String {
val start = declaration.textRange.startOffset
val end = declaration.declNames.textRange.startOffset
return declaration.containingFile.text.substring(start, end)
}
private fun excludedNames(prefix: String): Set<String> {
val body = Regex("""except\s*\(([^)]*)\)""").find(prefix)?.groupValues?.get(1) ?: return emptySet()
return body
.split(',')
.map { normalizeName(it.trim()) }
.filter(String::isNotEmpty)
.toSet()
}
/**
* Returns declarations exported by a file, including files it textually loads. A module's
* public surface is often assembled by loading several sibling files (for example, Basic
* loads Print.jai). Do not follow #import here: an imported module's private dependencies
* must not become unqualified candidates in the importing file.
*/
private fun topLevelDeclarations(file: PsiFile): List<JaiDeclName> = topLevelDeclarations(file, linkedSetOf())
private fun topLevelDeclarations(
file: PsiFile,
visited: MutableSet<String>,
): List<JaiDeclName> {
val fileKey = file.virtualFile?.path ?: file.name
if (!visited.add(fileKey)) return emptyList()
val directDeclarations =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.asSequence()
.flatMap { declaration -> declaration.declNames.declNameList.asSequence() }
.filter { PsiTreeUtil.getParentOfType(it, JaiBlock::class.java) == null }
.sortedBy { it.textOffset }
.toList()
val loadedDeclarations =
importBindings(file)
.asSequence()
.filter { it.directive == "#load" }
.flatMap { topLevelDeclarations(it.file, visited).asSequence() }
.toList()
return directDeclarations + loadedDeclarations
}
private fun isVisible(
symbol: ScopedSymbol,
position: PsiElement,
): Boolean {
val inScope =
when {
symbol.scope == null && symbol.fallbackScope == null -> true
symbol.scope?.textRange?.contains(position.textOffset) == true -> true
symbol.fallbackScope?.textRange?.contains(position.textOffset) == true -> true
else -> false
}
if (!inScope) return false
// Block-local declarations and loop names are not visible before their declaration. Keep
// top-level declarations available for forward references, which Jai permits.
if (symbol.scope != null && symbol.visibleFrom > position.textOffset) return false
return true
}
private fun isVisible(
binding: ImportBinding,
position: PsiElement,
): Boolean =
// File-scope imports are declarations for the whole file even when the directive is
// written below its first use (a common Jai layout). Block-local imports remain ordered.
(binding.scope == null || binding.scope.textRange.contains(position.textOffset)) &&
(binding.scope == null || binding.visibleFrom <= position.textOffset)
private fun isInside(
element: PsiElement,
position: PsiElement,
): Boolean = element == position || element.textRange.contains(position.textOffset)
private fun scopeDepth(scope: PsiElement?): Int {
var current = scope
var depth = 0
while (current != null) {
if (current is JaiBlock || current is JaiForStatement || current is JaiProcLiteralExpr) depth++
current = current.parent
}
return depth
}
private fun debug(message: String) {
if (java.lang.Boolean.getBoolean(COMPLETION_DEBUG_PROPERTY)) LOG.info(message)
}
private fun symbolName(element: PsiElement): String? {
val text = element.text.trim()
if (text.isEmpty()) return null
val name =
when (element) {
is JaiParamName -> text.removePrefix("$$").removePrefix("$")
else -> text
}
return normalizeName(name).takeIf(String::isNotEmpty)
}
private fun normalizeName(name: String): String {
val trimmed = name.trim()
return if (trimmed.length >= 2 && trimmed.first() == '`' && trimmed.last() == '`') {
trimmed.substring(1, trimmed.length - 1)
} else {
trimmed
}
}
}
private fun JaiDirectiveExpr.directiveName(): String? = node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text

View File

@@ -0,0 +1,17 @@
package dev.hgh.jai.settings
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.AdditionalLibraryRootsProvider
import com.intellij.openapi.roots.SyntheticLibrary
import com.intellij.openapi.vfs.VirtualFile
/** Makes configured external Jai directories part of IntelliJ's indexed library scope. */
class JaiAdditionalLibraryRootsProvider : AdditionalLibraryRootsProvider() {
override fun getAdditionalProjectLibraries(project: Project): Collection<SyntheticLibrary> {
val roots = JaiProjectSettings.getInstance(project).configuredRoots()
if (roots.isEmpty()) return emptyList()
return listOf(SyntheticLibrary.newImmutableLibrary(roots))
}
override fun getRootsToWatch(project: Project): Collection<VirtualFile> = JaiProjectSettings.getInstance(project).configuredRoots()
}

View File

@@ -0,0 +1,134 @@
package dev.hgh.jai.settings
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.PathMacroManager
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.StoragePathMacros
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.RootsChangeRescanningInfo
import com.intellij.openapi.roots.ex.ProjectRootManagerEx
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import java.io.File
/** Project-level paths used to find Jai modules and external Jai source files. */
@State(
name = "JaiProjectSettings",
storages = [Storage(StoragePathMacros.WORKSPACE_FILE)],
)
class JaiProjectSettings(
private val project: Project,
) : PersistentStateComponent<JaiProjectSettings.State> {
/** The persisted form deliberately contains paths, not VirtualFiles. */
class State {
@JvmField
var rootPaths: MutableList<String> = mutableListOf()
override fun equals(other: Any?): Boolean = other is State && rootPaths == other.rootPaths
override fun hashCode(): Int = rootPaths.hashCode()
}
private var state = State()
/** Configured roots in precedence order. Invalid paths remain visible in Settings. */
fun rootPaths(): List<String> = state.rootPaths.toList()
/** Existing configured directories, in the same order as their persisted paths. */
fun configuredRoots(): List<VirtualFile> =
rootPaths()
.mapNotNull { LocalFileSystem.getInstance().findFileByPath(it) }
.filter { it.isValid && it.isDirectory }
fun setRootPaths(paths: Collection<String>) {
val normalized = paths.mapNotNull(::normalizePath).distinct()
if (normalized == state.rootPaths) return
state = State().also { it.rootPaths.addAll(normalized) }
}
override fun getState(): State = State().also { it.rootPaths.addAll(state.rootPaths) }
override fun loadState(loadedState: State) {
val normalized = loadedState.rootPaths.mapNotNull(::normalizePath).distinct()
if (normalized == state.rootPaths) return
state = State().also { it.rootPaths.addAll(normalized) }
}
private fun normalizePath(rawPath: String): String? {
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) return null
var expanded =
when {
trimmed == "~" -> {
System.getProperty("user.home")
}
trimmed.startsWith("~/") -> {
System.getProperty("user.home") + trimmed.removePrefix("~")
}
else -> {
trimmed
}
}
// Workspace files may persist project-relative roots using IntelliJ path macros. The
// settings UI normally supplies absolute paths, but loading a project saved elsewhere
// must expand these before the resolver tries to find the VFS root.
expanded = PathMacroManager.getInstance(project).expandPath(expanded)
expanded =
expanded
.replace("\$USER_HOME\$", System.getProperty("user.home"))
.replace("\$USER_HOME", System.getProperty("user.home"))
.let { value ->
val basePath = project.basePath ?: return@let value
value
.replace("\$PROJECT_DIR\$", basePath)
.replace("\$PROJECT_DIR", basePath)
}
val file = File(expanded)
val absolute =
if (file.isAbsolute) {
file
} else {
project.basePath?.let { File(it, expanded) } ?: file.absoluteFile
}
return FileUtil.toSystemIndependentName(
absolute
.toPath()
.normalize()
.toAbsolutePath()
.toString(),
)
}
/** Rebuilds the project/index roots after Settings has been applied. */
fun refreshProjectRoots() {
if (!project.isInitialized || project.isDisposed) return
val refresh =
Runnable {
if (!project.isDisposed) {
ProjectRootManagerEx
.getInstanceEx(project)
.makeRootsChange(Runnable {}, RootsChangeRescanningInfo.TOTAL_RESCAN)
}
}
val application = ApplicationManager.getApplication()
if (application.isWriteAccessAllowed) {
refresh.run()
} else {
application.runWriteAction(refresh)
}
}
companion object {
@JvmStatic
fun getInstance(project: Project): JaiProjectSettings = project.getService(JaiProjectSettings::class.java)
}
}

View File

@@ -0,0 +1,138 @@
package dev.hgh.jai.settings
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.SearchableConfigurable
import com.intellij.openapi.project.Project
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import java.awt.BorderLayout
import java.awt.FlowLayout
import javax.swing.DefaultListModel
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ListSelectionModel
/** Settings UI for project-specific Jai module/import roots. */
class JaiProjectSettingsConfigurable(
private val project: Project,
) : SearchableConfigurable,
Configurable.NoScroll {
private var component: JPanel? = null
private var rootsModel: DefaultListModel<String>? = null
private var rootsList: JBList<String>? = null
override fun getId(): String = "dev.hgh.intellijai.jai.settings"
override fun getDisplayName(): String = "Jai"
override fun createComponent(): JComponent {
val model = DefaultListModel<String>()
val list = JBList(model)
list.emptyText.text = "No configured roots"
list.selectionMode = ListSelectionModel.SINGLE_SELECTION
val addButton = JButton("Add…")
addButton.addActionListener {
val selected =
FileChooser.chooseFile(
FileChooserDescriptorFactory
.createSingleFolderDescriptor()
.withTitle("Select Jai module/import root"),
component,
project,
null,
)
if (selected != null && !contains(model, selected.path)) {
model.addElement(selected.path)
list.selectedIndex = model.size() - 1
}
}
val removeButton = JButton("Remove")
removeButton.addActionListener {
val selectedIndex = list.selectedIndex
if (selectedIndex >= 0) model.remove(selectedIndex)
}
val upButton = JButton("Move Up")
upButton.addActionListener { moveSelected(list, model, -1) }
val downButton = JButton("Move Down")
downButton.addActionListener { moveSelected(list, model, 1) }
val buttons = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0))
buttons.add(addButton)
buttons.add(removeButton)
buttons.add(upButton)
buttons.add(downButton)
val rootPanel = JPanel(BorderLayout(0, 8))
rootPanel.add(JBLabel("Jai module/import roots:"), BorderLayout.NORTH)
rootPanel.add(JBScrollPane(list), BorderLayout.CENTER)
val footer = JPanel(BorderLayout(0, 4))
footer.add(buttons, BorderLayout.NORTH)
footer.add(
JBLabel("Roots are searched in the order shown and are indexed as Jai library sources.")
.also { it.foreground = JBColor.GRAY },
BorderLayout.SOUTH,
)
rootPanel.add(footer, BorderLayout.SOUTH)
component = rootPanel
rootsModel = model
rootsList = list
reset()
return rootPanel
}
override fun getPreferredFocusedComponent(): JComponent? = rootsList
override fun isModified(): Boolean = rootsModel?.let { values(it) != JaiProjectSettings.getInstance(project).rootPaths() } ?: false
override fun apply() {
val model = rootsModel ?: return
val settings = JaiProjectSettings.getInstance(project)
settings.setRootPaths(values(model))
settings.refreshProjectRoots()
}
override fun reset() {
val model = rootsModel ?: return
model.removeAllElements()
JaiProjectSettings.getInstance(project).rootPaths().forEach(model::addElement)
}
override fun disposeUIResources() {
component = null
rootsModel = null
rootsList = null
}
private fun values(model: DefaultListModel<String>): List<String> = (0 until model.size()).map(model::getElementAt)
private fun contains(
model: DefaultListModel<String>,
path: String,
): Boolean = (0 until model.size()).any { model.getElementAt(it) == path }
private fun moveSelected(
list: JBList<String>,
model: DefaultListModel<String>,
delta: Int,
) {
val current = list.selectedIndex
val target = current + delta
if (current < 0 || target !in 0 until model.size()) return
val value = model.getElementAt(current)
model.remove(current)
model.add(target, value)
list.selectedIndex = target
}
}

View File

@@ -22,6 +22,17 @@
extensions="jai"/> extensions="jai"/>
<lang.parserDefinition language="Jai" implementationClass="dev.hgh.jai.psi.JaiParserDefinition"/> <lang.parserDefinition language="Jai" implementationClass="dev.hgh.jai.psi.JaiParserDefinition"/>
<lang.formatter language="Jai" implementationClass="dev.hgh.jai.formatter.JaiFormattingModelBuilder"/>
<projectService serviceImplementation="dev.hgh.jai.settings.JaiProjectSettings"/>
<projectConfigurable
parentId="language"
instance="dev.hgh.jai.settings.JaiProjectSettingsConfigurable"
id="dev.hgh.intellijai.jai.settings"
displayName="Jai"
nonDefaultProject="true"/>
<additionalLibraryRootsProvider
implementation="dev.hgh.jai.settings.JaiAdditionalLibraryRootsProvider"/>
<colorSettingsPage implementation="dev.hgh.jai.highlighting.JaiColorSettingsPage"/> <colorSettingsPage implementation="dev.hgh.jai.highlighting.JaiColorSettingsPage"/>
@@ -38,6 +49,25 @@
<psi.referenceContributor <psi.referenceContributor
language="Jai" language="Jai"
implementation="dev.hgh.jai.reference.JaiReferenceContributor"/> implementation="dev.hgh.jai.reference.JaiReferenceContributor"/>
<localInspection
language="Jai"
displayName="Unresolved Jai import or load"
groupName="Jai"
enabledByDefault="true"
level="WARNING"
implementationClass="dev.hgh.jai.inspection.JaiUnresolvedModuleInspection"/>
<completion.contributor
language="Jai"
implementationClass="dev.hgh.jai.completion.JaiCompletionContributor"/>
<lang.refactoringSupport
language="Jai"
implementationClass="dev.hgh.jai.refactoring.JaiRefactoringSupportProvider"/>
<lang.namesValidator
language="Jai"
implementationClass="dev.hgh.jai.refactoring.JaiNamesValidator"/>
<lang.findUsagesProvider
language="Jai"
implementationClass="dev.hgh.jai.findusages.JaiFindUsagesProvider"/>
</extensions> </extensions>
</idea-plugin> </idea-plugin>

View File

@@ -0,0 +1,335 @@
package dev.hgh.jai.completion
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class JaiCompletionTest : BasePlatformTestCase() {
fun testCompletesKeywords() {
myFixture.configureByText("keyword.jai", "ret<caret>")
myFixture.completeBasic()
myFixture.checkResult("return")
}
fun testCompletesDirectivesWithHash() {
myFixture.configureByText("directive.jai", "#imp<caret>")
myFixture.completeBasic()
myFixture.checkResult("#import")
}
fun testDoesNotCompleteInsideStringLiterals() {
myFixture.configureByText("string.jai", "#char \"ret<caret>\";")
myFixture.complete(CompletionType.BASIC, 1)
assertTrue("strings should not offer keyword completion", myFixture.lookupElementStrings.isNullOrEmpty())
}
fun testDoesNotOfferModulesForStringImport() {
myFixture.configureByText("string-import.jai", "#import,string \"Bas<caret>\";")
myFixture.complete(CompletionType.BASIC, 1)
assertTrue("#import,string should not offer file modules", myFixture.lookupElementStrings.isNullOrEmpty())
}
fun testCompletesImportModulesFromLocalJaiModulesDirectory() {
myFixture.configureByText("main.jai", "#import \"Bas<caret>\";")
val strings = completeStrings()
assertNotNull("module completion should be available", strings)
assertTrue("expected Basic in $strings", strings!!.contains("Basic"))
assertTrue("expected Base64 in $strings", strings.contains("Base64"))
assertFalse("prefix should filter Bucket_Array from $strings", strings.contains("Bucket_Array"))
assertFalse("prefix should filter Bit_Array from $strings", strings.contains("Bit_Array"))
}
fun testCompletesLoadFilesRelativeToSource() {
myFixture.addFileToProject("other.jai", "value := 1;")
myFixture.configureByText("main.jai", "#load \"oth<caret>\";")
myFixture.completeBasic()
myFixture.checkResult("#load \"other.jai\";")
}
fun testCompletesVisibleDeclarationsInSameFile() {
myFixture.configureByText(
"symbols.jai",
"""
helper :: () {}
main :: () { hel<caret>(); }
""".trimIndent(),
)
myFixture.completeBasic()
myFixture.checkResult(
"""
helper :: () {}
main :: () { helper(); }
""".trimIndent(),
)
}
fun testCompletesLocalDeclarationsInProcedure() {
myFixture.configureByText(
"locals.jai",
"""
main :: () {
local_value := 1;
print(local<caret>);
}
""".trimIndent(),
)
myFixture.completeBasic()
assertEquals(
"completion should insert the local declaration",
"""
main :: () {
local_value := 1;
print(local_value);
}
""".trimIndent(),
myFixture.file.text,
)
}
fun testCompletesSymbolsFromImportedModule() {
myFixture.configureByText(
"module-consumer.jai",
"""
#import "Basic";
main :: () { allo<caret>(); }
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
val strings = myFixture.lookupElementStrings
assertNotNull("expected symbols from the imported Basic module", strings)
assertTrue("expected alloc in $strings", strings!!.contains("alloc"))
}
fun testCompletesSymbolsLoadedByImportedModule() {
myFixture.configureByText(
"nested-module-consumer.jai",
"""
#import "Basic";
main :: () { pri<caret>("hello"); }
""".trimIndent(),
)
val lookupElements = myFixture.complete(CompletionType.BASIC, 1)
assertTrue(
"expected print from a file loaded by the imported Basic module: ${myFixture.lookupElementStrings}",
myFixture.lookupElementStrings.orEmpty().contains("print"),
)
val printLookup = lookupElements.firstOrNull { it.lookupString == "print" }
assertNotNull("print completion should retain its declaration target", printLookup)
assertEquals(
"${System.getProperty("user.home")}/.local/jai/modules/Basic/Print.jai",
printLookup!!
.psiElement
?.containingFile
?.virtualFile
?.path,
)
}
fun testCompletesSymbolsFromLoadedFile() {
myFixture.addFileToProject("library.jai", "Helper :: () {}")
myFixture.configureByText(
"consumer.jai",
"""
#load "library.jai";
main :: () { Hel<caret>(); }
""".trimIndent(),
)
myFixture.completeBasic()
assertEquals(
"completion should insert the loaded symbol",
"""
#load "library.jai";
main :: () { Helper(); }
""".trimIndent(),
myFixture.file.text,
)
}
fun testCompletesLocalDeclarationsWithEmptyPrefix() {
myFixture.configureByText(
"empty-prefix.jai",
"""
main :: () {
local_value := 1;
print(<caret>);
}
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
assertTrue(
"local declarations should be offered with an empty prefix",
myFixture.lookupElementStrings.orEmpty().contains("local_value"),
)
}
fun testCompletesProcedureParameters() {
myFixture.configureByText(
"parameters.jai",
"""
main :: (local_value: int) {
print(local<caret>);
}
""".trimIndent(),
)
myFixture.completeBasic()
myFixture.checkResult(
"""
main :: (local_value: int) {
print(local_value);
}
""".trimIndent(),
)
}
fun testCompletesLocalDeclarationBeforeUnterminatedExpression() {
myFixture.configureByText(
"unterminated-local.jai",
"""
main :: () {
foobazz := 1;
fo<caret>
}
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
assertTrue(
"local declaration should survive an unterminated completion expression: ${myFixture.lookupElementStrings}",
myFixture.lookupElementStrings.orEmpty().contains("foobazz"),
)
}
fun testCompletesLoadedSymbolsBeforeUnterminatedExpression() {
myFixture.addFileToProject("library.jai", "loaded_value :: 1;")
myFixture.configureByText(
"unterminated-import.jai",
"""
main :: () {
local_value := 1;
lo<caret>
}
#load "library.jai";
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
val strings = myFixture.lookupElementStrings.orEmpty()
assertTrue("local symbol should be offered: $strings", "local_value" in strings)
assertTrue("loaded symbol should be offered: $strings", "loaded_value" in strings)
}
fun testDoesNotLeakDeclarationsFromTransitiveImports() {
myFixture.addFileToProject("dependency.jai", "transitive_value :: 1;")
myFixture.addFileToProject(
"library.jai",
"""
#import,file "dependency.jai";
direct_value :: 1;
""".trimIndent(),
)
myFixture.configureByText(
"transitive-consumer.jai",
"""
#load "library.jai";
main :: () { tra<caret>; }
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
assertFalse(
"completion should not recursively expose every imported file: ${myFixture.lookupElementStrings}",
myFixture.lookupElementStrings.orEmpty().contains("transitive_value"),
)
}
fun testCompletesMembersOfAnImportedAlias() {
myFixture.addFileToProject("library.jai", "Helper :: () {}\nother_value :: 1;")
myFixture.configureByText(
"consumer.jai",
"""
Library :: #import,file "library.jai";
main :: () { Library.Hel<caret>(); }
""".trimIndent(),
)
myFixture.completeBasic()
myFixture.checkResult(
"""
Library :: #import,file "library.jai";
main :: () { Library.Helper(); }
""".trimIndent(),
)
}
fun testCompletesImportedAliasMembersWithEmptyPrefix() {
myFixture.addFileToProject("library.jai", "Helper :: () {}")
myFixture.configureByText(
"consumer.jai",
"""
Library :: #import,file "library.jai";
main :: () { Library.<caret>(); }
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
assertTrue(
"module members should be offered after a dot with an empty prefix",
myFixture.lookupElementStrings.orEmpty().contains("Helper"),
)
}
fun testBoundImportDoesNotLeakMembersIntoUnqualifiedCompletion() {
myFixture.addFileToProject("library.jai", "Helper :: () {}")
myFixture.configureByText(
"consumer.jai",
"""
Library :: #import,file "library.jai";
main :: () { Hel<caret>(); }
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
assertFalse(
"bound module members should require the module qualifier",
myFixture.lookupElementStrings.orEmpty().contains("Helper"),
)
}
fun testCompletesFileScopeImportsDeclaredAfterUse() {
myFixture.addFileToProject("library.jai", "Helper :: () {}")
myFixture.configureByText(
"consumer.jai",
"""
main :: () { Hel<caret>(); }
#import,file "library.jai";
""".trimIndent(),
)
myFixture.completeBasic()
myFixture.checkResult(
"""
main :: () { Helper(); }
#import,file "library.jai";
""".trimIndent(),
)
}
private fun completeStrings(): List<String>? {
myFixture.complete(CompletionType.BASIC, 1)
return myFixture.lookupElementStrings
}
}

View File

@@ -0,0 +1,37 @@
package dev.hgh.jai.findusages
import com.intellij.psi.search.searches.ReferencesSearch
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.psi.JaiDeclName
class JaiFindUsagesTest : BasePlatformTestCase() {
fun testFindUsagesActionFindsProcedureReference() {
val usages = myFixture.testFindUsages("Declaration.jai", "Usage.jai")
assertEquals(1, usages.size)
}
fun testFindsProcedureReferencesFromDeclaration() {
val source =
myFixture.addFileToProject(
"find-usages.jai",
"""
helper :: () {}
first :: () { helper(); }
second :: () { helper(); }
""".trimIndent(),
)
val declaration =
PsiTreeUtil
.findChildrenOfType(source, JaiDeclName::class.java)
.single { it.text == "helper" }
val usages = ReferencesSearch.search(declaration).findAll()
assertEquals(2, usages.size)
assertTrue(usages.all { it.element.text == "helper" })
}
override fun getTestDataPath(): String = "src/test/testData/findusages"
}

View File

@@ -0,0 +1,126 @@
package dev.hgh.jai.formatter
import com.intellij.lang.LanguageFormatting
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.util.Computable
import com.intellij.psi.PsiFileFactory
import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.JaiLanguage
import java.io.File
class JaiFormatterTest : BasePlatformTestCase() {
fun testFormatterIsRegistered() {
val builder = LanguageFormatting.INSTANCE.forLanguage(JaiLanguage)
assertNotNull("expected a formatter for Jai", builder)
assertTrue("expected JaiFormattingModelBuilder, got $builder", builder is JaiFormattingModelBuilder)
}
fun testFormatsSpacingAndIndentation() {
myFixture.configureByText(
"format.jai",
"""
main::()->int{
value:=1+2;
negative:=-69;
if value>0{
return value;
}
}
""".trimIndent(),
)
WriteCommandAction.runWriteCommandAction(project) {
CodeStyleManager.getInstance(project).reformatText(myFixture.file, 0, myFixture.file.textLength)
}
myFixture.checkResult(
"""
main :: () -> int {
value := 1 + 2;
negative := -69;
if value > 0 {
return value;
}
}
""".trimIndent(),
)
}
fun testPreservesDirectiveFlagsOpaqueAssemblyAndStringText() {
myFixture.configureByText(
"opaque.jai",
"""
#import,file "library.jai";
main::(){
value:=cast,trunc(u32) input;
#asm { mov rax, rbx; }
text:=" keep ";
}
""".trimIndent(),
)
WriteCommandAction.runWriteCommandAction(project) {
CodeStyleManager.getInstance(project).reformatText(myFixture.file, 0, myFixture.file.textLength)
}
myFixture.checkResult(
"""
#import,file "library.jai";
main :: () {
value := cast,trunc(u32) input;
#asm { mov rax, rbx; }
text := " keep ";
}
""".trimIndent(),
)
}
fun testCorpusFormattingIsIdempotent() {
val corpusDir = File(System.getProperty("user.home"), ".local/jai")
if (!corpusDir.exists()) {
println("Skipping formatter corpus test: $corpusDir does not exist")
return
}
val jaiFiles =
corpusDir
.walkTopDown()
.filter { it.isFile && it.extension == "jai" }
.sorted()
.toList()
assertTrue("Corpus files must be found in $corpusDir", jaiFiles.isNotEmpty())
val nonIdempotent = mutableListOf<String>()
jaiFiles.forEach { file ->
val normalizedText = file.readText().replace("\r\n", "\n").replace('\r', '\n')
val psiFile = PsiFileFactory.getInstance(project).createFileFromText(file.name, JaiLanguage, normalizedText)
val once = reformatPsiFile(psiFile)
val reparsed = PsiFileFactory.getInstance(project).createFileFromText(file.name, JaiLanguage, once)
val hasParseErrors =
com.intellij.psi.util.PsiTreeUtil
.findChildrenOfType(reparsed, com.intellij.psi.PsiErrorElement::class.java)
.isNotEmpty()
val twice = reformatPsiFile(psiFile)
if (hasParseErrors || once != twice) {
nonIdempotent += file.relativePath(corpusDir)
}
}
println("Formatter corpus: checked ${jaiFiles.size} files, non-idempotent=${nonIdempotent.size}")
assertTrue(
"Formatter is not idempotent for ${nonIdempotent.take(20)}",
nonIdempotent.isEmpty(),
)
}
private fun reformatPsiFile(psiFile: com.intellij.psi.PsiFile): String =
WriteCommandAction.runWriteCommandAction(
project,
Computable {
CodeStyleManager.getInstance(project).reformatRange(psiFile, 0, psiFile.textLength)
psiFile.text
},
)
private fun File.relativePath(base: File): String = absolutePath.removePrefix(base.absolutePath + "/")
}

View File

@@ -0,0 +1,47 @@
package dev.hgh.jai.inspection
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class JaiUnresolvedModuleInspectionTest : BasePlatformTestCase() {
override fun setUp() {
super.setUp()
myFixture.enableInspections(JaiUnresolvedModuleInspection::class.java)
}
fun testUnresolvedLoadPathIsReported() {
myFixture.configureByText(
"main.jai",
"#load \"<warning descr=\"Cannot resolve #load path 'missing.jai'\">missing.jai</warning>\";",
)
myFixture.checkHighlighting()
}
fun testUnresolvedImportPathIsReported() {
myFixture.configureByText(
"main.jai",
"#import \"<warning descr=\"Cannot resolve #import path 'missing-module'\">missing-module</warning>\";",
)
myFixture.checkHighlighting()
}
fun testResolvedLoadPathIsNotReported() {
myFixture.addFileToProject("other.jai", "value := 1;")
myFixture.configureByText("main.jai", "#load \"other.jai\";")
myFixture.checkHighlighting()
}
fun testResolvedImportPathIsNotReported() {
myFixture.configureByText("main.jai", "#import \"Basic\";")
myFixture.checkHighlighting()
}
fun testImportStringIsNotReportedAsAnUnresolvedPath() {
myFixture.configureByText("main.jai", "#import,string \"inline code\";")
myFixture.checkHighlighting()
}
}

View File

@@ -0,0 +1,59 @@
package dev.hgh.jai.refactoring
import com.intellij.psi.PsiNameIdentifierOwner
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class JaiRenameTest : BasePlatformTestCase() {
fun testDeclarationNamesParticipateInRename() {
myFixture.configureByText(
"rename.jai",
"""
helper<caret> :: () {}
main :: () { helper(); }
""".trimIndent(),
)
val declaration = myFixture.elementAtCaret
assertTrue("declaration name should be a named PSI element", declaration is PsiNameIdentifierOwner)
assertEquals("helper", (declaration as PsiNameIdentifierOwner).name)
}
fun testRenameProcedureUpdatesSameFileReferences() {
myFixture.configureByText(
"rename.jai",
"""
helper<caret> :: () {}
main :: () { helper(); }
""".trimIndent(),
)
myFixture.renameElementAtCaret("renamed")
myFixture.checkResult(
"""
renamed :: () {}
main :: () { renamed(); }
""".trimIndent(),
)
}
fun testRenameTypeUpdatesTypeReferences() {
myFixture.configureByText(
"rename-type.jai",
"""
Point<caret> :: struct { x: int; }
use_point :: (point: Point) -> int { return point.x; }
""".trimIndent(),
)
myFixture.renameElementAtCaret("Coordinate")
myFixture.checkResult(
"""
Coordinate :: struct { x: int; }
use_point :: (point: Coordinate) -> int { return point.x; }
""".trimIndent(),
)
}
}

View File

@@ -1,7 +1,9 @@
package dev.hgh.jai.reference package dev.hgh.jai.reference
import com.intellij.codeInsight.TargetElementUtil
import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReference import com.intellij.psi.PsiReference
@@ -9,6 +11,7 @@ import com.intellij.psi.PsiReferenceService
import com.intellij.psi.util.PsiTreeUtil import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.psi.JaiLiteralExpr import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiRefExpr
class JaiReferenceTest : BasePlatformTestCase() { class JaiReferenceTest : BasePlatformTestCase() {
fun testLoadResolvesAFileRelativeToTheSource() { fun testLoadResolvesAFileRelativeToTheSource() {
@@ -24,6 +27,149 @@ class JaiReferenceTest : BasePlatformTestCase() {
assertEquals(TextRange(1, literal.textLength - 1), reference.rangeInElement) assertEquals(TextRange(1, literal.textLength - 1), reference.rangeInElement)
} }
fun testProcedureReferenceResolvesToSameFileDeclaration() {
val source =
myFixture.addFileToProject(
"procedures.jai",
"""
helper :: () {}
main :: () { helper(); }
""".trimIndent(),
)
val referenceElement =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "helper" }
val reference = onlyReference(referenceElement)
assertEquals("helper", reference.resolve()?.text)
assertEquals(TextRange(0, referenceElement.textLength), reference.rangeInElement)
}
fun testTypeReferenceResolvesToSameFileDeclaration() {
val source =
myFixture.addFileToProject(
"types.jai",
"""
Point :: struct { x: int; }
use_point :: (point: Point) -> int { return point.x; }
""".trimIndent(),
)
val referenceElement =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "Point" }
val reference = onlyReference(referenceElement)
assertEquals("Point", reference.resolve()?.text)
}
fun testSymbolsResolveThroughLoadedFile() {
val target =
myFixture.addFileToProject(
"library.jai",
"""
Helper :: () {}
Point :: struct { x: int; }
""".trimIndent(),
)
val source =
myFixture.addFileToProject(
"consumer.jai",
"""
#load "library.jai";
main :: (point: Point) { Helper(); }
""".trimIndent(),
)
val references = PsiTreeUtil.findChildrenOfType(source, JaiRefExpr::class.java)
val helperReference = references.single { it.text == "Helper" }
val pointReference = references.single { it.text == "Point" }
assertEquals(
target.virtualFile.path,
onlyReference(helperReference)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
assertEquals(
target.virtualFile.path,
onlyReference(pointReference)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
}
fun testImportedProcedureReferenceResolvesFromJaiModule() {
val source =
myFixture.addFileToProject(
"module-consumer.jai",
"""
#import "Basic";
main :: () { alloc(1); }
""".trimIndent(),
)
val referenceElement =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "alloc" }
val resolved = onlyReference(referenceElement).resolve()
assertEquals("alloc", resolved?.text)
assertEquals(
"${System.getProperty("user.home")}/.local/jai/modules/Basic/module.jai",
resolved?.containingFile?.virtualFile?.path,
)
}
fun testGotoDeclarationResolvesProcedureFromFileLoadedByImportedModule() {
myFixture.configureByText(
"nested-module-consumer.jai",
"""
#import "Basic";
main :: () { print<caret>("hello"); }
""".trimIndent(),
)
val source = myFixture.file
val referenceElement =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "print" }
val resolved = onlyReference(referenceElement).resolve()
assertEquals("print", resolved?.text)
val expectedPath = "${System.getProperty("user.home")}/.local/jai/modules/Basic/Print.jai"
assertEquals(expectedPath, resolved?.containingFile?.virtualFile?.path)
val targetElementUtil = TargetElementUtil.getInstance()
val gotoTarget =
targetElementUtil.findTargetElement(
myFixture.editor,
targetElementUtil.referenceSearchFlags,
myFixture.caretOffset,
)
assertNotNull("go to declaration should find the loaded procedure", gotoTarget)
assertEquals("print", gotoTarget!!.text)
assertEquals(expectedPath, gotoTarget.containingFile?.virtualFile?.path)
}
fun testUnresolvedSymbolReferenceHasNoTarget() {
val source = myFixture.addFileToProject("unresolved.jai", "main :: () { missing(); }")
val referenceElement =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "missing" }
assertNull(onlyReference(referenceElement).resolve())
}
fun testImportResolvesAJaiModuleDirectory() { fun testImportResolvesAJaiModuleDirectory() {
val sourceVirtualFile = val sourceVirtualFile =
LocalFileSystem LocalFileSystem
@@ -90,12 +236,12 @@ class JaiReferenceTest : BasePlatformTestCase() {
return literal!! return literal!!
} }
private fun onlyReference(literal: JaiLiteralExpr): PsiReference { private fun onlyReference(element: PsiElement): PsiReference {
val references = references(literal) val references = references(element)
assertEquals("expected exactly one path reference", 1, references.size) assertEquals("expected exactly one reference", 1, references.size)
return references.single() return references.single()
} }
private fun references(literal: JaiLiteralExpr): List<PsiReference> = private fun references(element: PsiElement): List<PsiReference> =
PsiReferenceService.getService().getContributedReferences(literal).toList() PsiReferenceService.getService().getContributedReferences(element).toList()
} }

View File

@@ -0,0 +1,217 @@
package dev.hgh.jai.settings
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReferenceService
import com.intellij.psi.search.searches.ReferencesSearch
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.psi.JaiDeclName
import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiRefExpr
import java.nio.file.Files
import java.nio.file.Path
class JaiConfiguredRootTest : BasePlatformTestCase() {
private var temporaryRoot: Path? = null
fun testConfiguredRootResolvesImportedModuleAndSymbol() {
val module = configureRoot("Custom/module.jai" to "Helper :: () {}")
val source =
myFixture.addFileToProject(
"consumer.jai",
"""
#import "Custom";
main :: () { Helper(); }
""".trimIndent(),
)
val moduleLiteral = literalWithText(source, "\"Custom\"")
assertEquals(
module.path,
onlyReference(moduleLiteral)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
val symbolReference =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "Helper" }
assertEquals(
module.path,
onlyReference(symbolReference)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
}
fun testConfiguredRootResolvesLoadFileFromConfiguredRoot() {
val externalFile = configureRoot("external.jai" to "value :: 1;")
val source = myFixture.addFileToProject("consumer.jai", "#load \"external.jai\";")
val literal = literalWithText(source, "\"external.jai\"")
assertEquals(
externalFile.path,
onlyReference(literal)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
}
fun testConfiguredRootSuppliesImportAndLoadCompletion() {
configureRoot(
"Custom/module.jai" to "value :: 1;",
"external.jai" to "value :: 1;",
)
val configuredRoots = JaiProjectSettings.getInstance(project).configuredRoots()
assertEquals("expected one configured VFS root", 1, configuredRoots.size)
assertNotNull("expected the configured module directory", configuredRoots.single().findChild("Custom"))
myFixture.configureByText("import-consumer.jai", "#import \"Cus<caret>\";")
myFixture.completeBasic()
myFixture.checkResult("#import \"Custom\";")
myFixture.configureByText("load-consumer.jai", "#load \"ext<caret>\";")
myFixture.completeBasic()
myFixture.checkResult("#load \"external.jai\";")
}
fun testConfiguredRootSuppliesImportedSymbolCompletion() {
configureRoot("Custom/module.jai" to "Helper :: () {}")
myFixture.configureByText(
"consumer.jai",
"""
#import "Custom";
main :: () { Hel<caret>(); }
""".trimIndent(),
)
myFixture.completeBasic()
myFixture.checkResult(
"""
#import "Custom";
main :: () { Helper(); }
""".trimIndent(),
)
}
fun testProbeStyleWorkspaceRootsSupplyLocalAndImportedCompletions() {
val settings = JaiProjectSettings.getInstance(project)
val jaiModules = Path.of(System.getProperty("user.home"), ".local", "jai", "modules")
val probeModules = Path.of(System.getProperty("user.home"), "programming", "probe", "modules")
settings.setRootPaths(listOf(jaiModules.toString(), probeModules.toString()))
myFixture.configureByText(
"main.jai",
"""
Foo :: struct { bar: int; }
main :: () {
foobazz := Foo.{};
fo<caret>;
}
#import "Foo";
#import "Basic";
""".trimIndent(),
)
myFixture.complete(CompletionType.BASIC, 1)
val strings = myFixture.lookupElementStrings.orEmpty()
assertTrue("local variable should be offered: $strings", "foobazz" in strings)
assertTrue("Foo module symbol should be offered: $strings", "foo" in strings)
}
fun testReferencesSearchIncludesProjectUsagesForConfiguredExternalDeclaration() {
val module = configureRoot("Custom/module.jai" to "Helper :: () {}")
val source =
myFixture.addFileToProject(
"consumer.jai",
"""
#import "Custom";
main :: () { Helper(); }
""".trimIndent(),
)
JaiProjectSettings.getInstance(project).refreshProjectRoots()
val externalFile =
PsiManager
.getInstance(project)
.findFile(module)
assertNotNull("expected PSI for configured external module", externalFile)
val declaration =
PsiTreeUtil
.findChildrenOfType(externalFile!!, JaiDeclName::class.java)
.single { it.text == "Helper" }
assertTrue(
"configured Jai files should be indexed as library sources",
com.intellij.openapi.roots.ProjectFileIndex
.getInstance(project)
.isInLibrarySource(module),
)
val usages = ReferencesSearch.search(declaration).findAll()
assertEquals(
"expected the project usage; scope=${declaration.useScope}, usages=$usages",
1,
usages.size,
)
assertEquals("Helper", usages.single().element.text)
assertEquals(
source.virtualFile.path,
usages
.single()
.element.containingFile.virtualFile.path,
)
}
private fun configureRoot(vararg files: Pair<String, String>): com.intellij.openapi.vfs.VirtualFile {
val root = Files.createTempDirectory("jai-configured-root-").toAbsolutePath().normalize()
temporaryRoot = root
files.forEach { (relativePath, contents) ->
val file = root.resolve(relativePath)
Files.createDirectories(file.parent)
Files.writeString(file, contents)
}
val virtualRoot = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(root)
assertNotNull("expected the configured root to be visible to VFS", virtualRoot)
JaiProjectSettings.getInstance(project).setRootPaths(listOf(root.toString()))
return virtualRoot!!.findFileByRelativePath(files.first().first)!!
}
private fun literalWithText(
source: PsiFile,
text: String,
): JaiLiteralExpr {
val literal =
PsiTreeUtil
.findChildrenOfType(source, JaiLiteralExpr::class.java)
.firstOrNull { it.text == text }
assertNotNull("expected string literal $text", literal)
return literal!!
}
private fun onlyReference(element: com.intellij.psi.PsiElement) =
PsiReferenceService
.getService()
.getContributedReferences(element)
.also { assertEquals("expected exactly one reference", 1, it.size) }
.single()
override fun tearDown() {
try {
super.tearDown()
} finally {
temporaryRoot?.toFile()?.deleteRecursively()
}
}
}

View File

@@ -0,0 +1,74 @@
package dev.hgh.jai.settings
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.nio.file.Files
import java.nio.file.Path
class JaiProjectSettingsTest : BasePlatformTestCase() {
private var temporaryRoot: Path? = null
fun testRootPathsNormalizeDeduplicateAndRoundTripThroughState() {
val root = Files.createTempDirectory("jai-settings-").toAbsolutePath().normalize()
temporaryRoot = root
val settings = JaiProjectSettings.getInstance(project)
settings.setRootPaths(
listOf(
" ${root.resolve(".")} ",
root.toString(),
"",
),
)
val expected = root.toString().replace('\\', '/')
assertEquals(listOf(expected), settings.rootPaths())
val savedState = settings.state
settings.setRootPaths(emptyList())
assertTrue(settings.rootPaths().isEmpty())
settings.loadState(savedState)
assertEquals(listOf(expected), settings.rootPaths())
assertEquals(listOf(expected), settings.state.rootPaths)
}
fun testConfiguredRootsArePublishedAsIndexedSyntheticLibrarySources() {
val root = Files.createTempDirectory("jai-library-root-").toAbsolutePath().normalize()
temporaryRoot = root
val virtualRoot =
LocalFileSystem
.getInstance()
.refreshAndFindFileByNioFile(root)
assertNotNull("expected the configured root to be visible to VFS", virtualRoot)
JaiProjectSettings.getInstance(project).setRootPaths(listOf(root.toString()))
val provider = JaiAdditionalLibraryRootsProvider()
val libraries = provider.getAdditionalProjectLibraries(project)
assertEquals(1, libraries.size)
assertTrue(libraries.single().sourceRoots.contains(virtualRoot))
assertTrue(provider.getRootsToWatch(project).contains(virtualRoot))
}
fun testSettingsConfigurableCreatesAndResetsAProjectPanel() {
val settings = JaiProjectSettings.getInstance(project)
val root = Files.createTempDirectory("jai-configurable-root-").toAbsolutePath().normalize()
temporaryRoot = root
settings.setRootPaths(listOf(root.toString()))
val configurable = JaiProjectSettingsConfigurable(project)
assertEquals("Jai", configurable.displayName)
assertNotNull(configurable.createComponent())
assertFalse("reset should load the persisted state", configurable.isModified)
configurable.disposeUIResources()
}
override fun tearDown() {
try {
super.tearDown()
} finally {
temporaryRoot?.toFile()?.deleteRecursively()
}
}
}

View File

@@ -0,0 +1 @@
helper<caret> :: () {}

View File

@@ -0,0 +1,2 @@
#load "Declaration.jai";
main :: () { helper(); }