Compare commits
10 Commits
c4e1d92c9a
...
0658550d09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0658550d09 | ||
|
|
427269ad78 | ||
|
|
065e0b0bf4 | ||
|
|
e7f9d60be8 | ||
|
|
98e25c5468 | ||
|
|
ede8522716 | ||
|
|
2bc1b9f4be | ||
|
|
9122b5a5c0 | ||
|
|
460fc969db | ||
|
|
4758bf12e2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
.intellijPlatform
|
.intellijPlatform
|
||||||
.kotlin
|
.kotlin
|
||||||
build
|
build
|
||||||
|
.gradle-test.lock
|
||||||
|
|||||||
62
AGENTS.md
62
AGENTS.md
@@ -61,8 +61,10 @@ 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.completion.JaiCompletionTest tests=20
|
||||||
dev.hgh.jai.editor.JaiEditorSupportTest tests=7
|
dev.hgh.jai.editor.JaiEditorSupportTest tests=7
|
||||||
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
|
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
|
||||||
|
dev.hgh.jai.findusages.JaiFindUsagesTest tests=2
|
||||||
dev.hgh.jai.highlighting.* tests=8
|
dev.hgh.jai.highlighting.* tests=8
|
||||||
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
|
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
|
||||||
dev.hgh.jai.lexer.JaiLexerTest tests=13
|
dev.hgh.jai.lexer.JaiLexerTest tests=13
|
||||||
@@ -70,9 +72,13 @@ dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate
|
|||||||
dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees
|
dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees
|
||||||
dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions
|
dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions
|
||||||
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
|
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
|
||||||
dev.hgh.jai.reference.JaiReferenceTest tests=4
|
dev.hgh.jai.refactoring.JaiRenameTest tests=3
|
||||||
|
dev.hgh.jai.reference.JaiReferenceTest tests=10
|
||||||
dev.hgh.jai.structure.JaiStructureViewTest tests=3
|
dev.hgh.jai.structure.JaiStructureViewTest tests=3
|
||||||
-> total 55, failures+errors 0
|
dev.hgh.jai.formatter.JaiFormatterTest tests=4
|
||||||
|
dev.hgh.jai.settings.JaiProjectSettingsTest tests=3
|
||||||
|
dev.hgh.jai.settings.JaiConfiguredRootTest tests=6
|
||||||
|
-> total 99, 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 +121,19 @@ 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.
|
||||||
|
|
||||||
### Lexer design facts worth knowing before touching it
|
### Lexer design facts worth knowing before touching it
|
||||||
|
|
||||||
@@ -174,17 +193,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+** — inspections, compiler integration, and other optional work
|
||||||
|
remain. `#asm` bodies are intentionally consumed opaquely, so nothing inside
|
||||||
|
them has PSI yet.
|
||||||
|
|
||||||
|
### Next planned increment
|
||||||
|
|
||||||
|
- Decide whether to continue with inspections and compiler integration phases
|
||||||
|
(7–8).
|
||||||
|
|
||||||
### Open questions for the user (unanswered)
|
### Open questions for the user (unanswered)
|
||||||
|
|
||||||
Scope (Phases 0–4 vs 0–7), compiler integration, target IDEs, JDK policy. See
|
Scope (Phases 0–5a vs 0–7), 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 +277,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 +294,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -20,6 +20,14 @@
|
|||||||
- 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.
|
||||||
|
|
||||||
### Known gaps
|
### Known gaps
|
||||||
|
|
||||||
@@ -27,5 +35,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.
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -11,13 +11,25 @@ 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
|
||||||
|
|
||||||
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. Formatting, inspections, and compiler
|
||||||
|
integration are planned later.
|
||||||
|
See `docs/BUILD_PLAN.md` for the phase plan.
|
||||||
|
|
||||||
## Install it locally
|
## Install it locally
|
||||||
|
|
||||||
@@ -30,7 +42,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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
| **6** | Formatter, code style settings | Formatter round-trip — **done**, formatting all 714 corpus files is idempotent |
|
||||||
| **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` |
|
| **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` |
|
||||||
| **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
|
||||||
0–4), or go all the way to formatter and inspections (0–7)?
|
refactoring support (Phases 0–5a), or go all the way to formatter and
|
||||||
|
inspections (0–7)?
|
||||||
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 0–4 now, decide on 5–8 once the parser gate is
|
Phases 0–6 are implemented and verified headlessly. The next decision is
|
||||||
green and we can see how the grammar behaved against real code.
|
whether to continue with the optional inspections and compiler integration work
|
||||||
|
in Phases 7–8.
|
||||||
|
|||||||
50
gradle-lock.sh
Normal file
50
gradle-lock.sh
Normal 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
|
||||||
|
}
|
||||||
22
jaigradle
22
jaigradle
@@ -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
12
jaitest
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ::= '---' | '--'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt
Normal file
163
src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt
Normal 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("./")
|
||||||
|
}
|
||||||
23
src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt
Normal file
23
src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
26
src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt
Normal file
26
src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt
Normal 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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
438
src/main/kotlin/dev/hgh/jai/reference/JaiSymbolResolver.kt
Normal file
438
src/main/kotlin/dev/hgh/jai/reference/JaiSymbolResolver.kt
Normal 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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
134
src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt
Normal file
134
src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,18 @@
|
|||||||
<psi.referenceContributor
|
<psi.referenceContributor
|
||||||
language="Jai"
|
language="Jai"
|
||||||
implementation="dev.hgh.jai.reference.JaiReferenceContributor"/>
|
implementation="dev.hgh.jai.reference.JaiReferenceContributor"/>
|
||||||
|
<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>
|
||||||
|
|||||||
335
src/test/kotlin/dev/hgh/jai/completion/JaiCompletionTest.kt
Normal file
335
src/test/kotlin/dev/hgh/jai/completion/JaiCompletionTest.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt
Normal file
37
src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt
Normal 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"
|
||||||
|
}
|
||||||
126
src/test/kotlin/dev/hgh/jai/formatter/JaiFormatterTest.kt
Normal file
126
src/test/kotlin/dev/hgh/jai/formatter/JaiFormatterTest.kt
Normal 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 + "/")
|
||||||
|
}
|
||||||
59
src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt
Normal file
59
src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
217
src/test/kotlin/dev/hgh/jai/settings/JaiConfiguredRootTest.kt
Normal file
217
src/test/kotlin/dev/hgh/jai/settings/JaiConfiguredRootTest.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/test/testData/findusages/Declaration.jai
Normal file
1
src/test/testData/findusages/Declaration.jai
Normal file
@@ -0,0 +1 @@
|
|||||||
|
helper<caret> :: () {}
|
||||||
2
src/test/testData/findusages/Usage.jai
Normal file
2
src/test/testData/findusages/Usage.jai
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#load "Declaration.jai";
|
||||||
|
main :: () { helper(); }
|
||||||
Reference in New Issue
Block a user