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
|
||||
.kotlin
|
||||
build
|
||||
.gradle-test.lock
|
||||
|
||||
86
AGENTS.md
86
AGENTS.md
@@ -59,20 +59,26 @@ class and still prints `OK`. `jaitest` therefore passes
|
||||
Last verified state (all green, `./jaigradle check` and `verifyPlugin` too):
|
||||
|
||||
```text
|
||||
dev.hgh.HarnessSmokeTest tests=2
|
||||
dev.hgh.jai.JaiFileTypeTest tests=4
|
||||
dev.hgh.jai.editor.JaiEditorSupportTest tests=7
|
||||
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
|
||||
dev.hgh.jai.highlighting.* tests=8
|
||||
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
|
||||
dev.hgh.jai.lexer.JaiLexerTest tests=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.JaiParserLongTailTest tests=1 <- focused parser regressions
|
||||
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
|
||||
dev.hgh.jai.reference.JaiReferenceTest tests=4
|
||||
dev.hgh.jai.structure.JaiStructureViewTest tests=3
|
||||
-> total 55, failures+errors 0
|
||||
dev.hgh.HarnessSmokeTest tests=2
|
||||
dev.hgh.jai.JaiFileTypeTest tests=4
|
||||
dev.hgh.jai.completion.JaiCompletionTest tests=20
|
||||
dev.hgh.jai.editor.JaiEditorSupportTest tests=7
|
||||
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
|
||||
dev.hgh.jai.findusages.JaiFindUsagesTest tests=2
|
||||
dev.hgh.jai.highlighting.* tests=8
|
||||
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
|
||||
dev.hgh.jai.lexer.JaiLexerTest tests=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.JaiParserLongTailTest tests=1 <- focused parser regressions
|
||||
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
|
||||
dev.hgh.jai.refactoring.JaiRenameTest tests=3
|
||||
dev.hgh.jai.reference.JaiReferenceTest tests=10
|
||||
dev.hgh.jai.structure.JaiStructureViewTest tests=3
|
||||
dev.hgh.jai.formatter.JaiFormatterTest tests=4
|
||||
dev.hgh.jai.settings.JaiProjectSettingsTest tests=3
|
||||
dev.hgh.jai.settings.JaiConfiguredRootTest tests=6
|
||||
-> total 99, failures+errors 0
|
||||
```
|
||||
|
||||
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.
|
||||
Headless tests cover source-ordered declarations, nested structure members,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
find-usages; see `docs/BUILD_PLAN.md`.
|
||||
2. **Phases 6+** — formatter, inspections, compiler integration, and other
|
||||
optional work remain. `#asm` bodies are intentionally consumed opaquely, so
|
||||
nothing inside them has PSI yet.
|
||||
- Completion currently offers keywords, compiler directives, module/file paths,
|
||||
visible same-file or imported declarations, procedure parameters, and members
|
||||
of imported aliases, but not struct fields.
|
||||
- Struct fields do not yet have symbol resolution or completion.
|
||||
|
||||
### 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)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -248,12 +277,13 @@ The fast loop for grammar work:
|
||||
`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
|
||||
`build.gradle.kts` should prevent both, but `rm -rf build` settles it.
|
||||
- **Concurrent Gradle runs corrupt the test results.** If something else (an
|
||||
IDE, an agent's background checker) runs `test` at the same time, one of the
|
||||
two dies with `java.io.EOFException` or
|
||||
`NoSuchFileException: .../in-progress-results-generic.bin`, and no XML is
|
||||
written. It is infrastructure, not a test failure — `./jaitest` retries once
|
||||
automatically. Do not go debugging the test that "failed".
|
||||
- **Concurrent Gradle runs corrupt the test results.** `./jaigradle` and
|
||||
`./jaitest` serialize project Gradle work with the atomic `.gradle-test.lock`
|
||||
lock, including test-result cleanup and retries. Never run `./gradlew`
|
||||
directly: it bypasses both the JDK wrapper and this lock. If an interrupted
|
||||
process leaves a lock behind, confirm no Gradle run is active, then remove
|
||||
`.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:
|
||||
`BUILD SUCCESSFUL`, `:compileTestKotlin FROM-CACHE`, `:test NO-SOURCE`, and
|
||||
`build/classes/kotlin/test` is empty. The cache stored an empty output
|
||||
@@ -264,7 +294,7 @@ The fast loop for grammar work:
|
||||
|
||||
```bash
|
||||
./jaigradle --stop
|
||||
rm -rf build .gradle ~/.gradle/caches/build-cache-1
|
||||
rm -rf .gradle-test.lock build .gradle ~/.gradle/caches/build-cache-1
|
||||
./jaitest
|
||||
```
|
||||
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -20,6 +20,14 @@
|
||||
- Folding for blocks, nested comments, and multi-line here-strings.
|
||||
- Go-to-definition references for `#import` and `#load`, including project-relative
|
||||
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
|
||||
|
||||
@@ -27,5 +35,5 @@
|
||||
not have inner PSI.
|
||||
- `#asm` bodies remain intentionally opaque; assembly instructions do not have
|
||||
inner PSI or structure entries.
|
||||
- Import resolution uses the project/module roots and the local Jai installation;
|
||||
custom compiler `-import_dir` arguments are not discovered yet.
|
||||
- Struct fields and other type-qualified members do not yet have symbol
|
||||
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 /
|
||||
`_` separators), strings, here-strings (`#string DONE … DONE`), nesting block
|
||||
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**)
|
||||
- Comment / uncomment (`//` and nesting `/* */`)
|
||||
- Brace matching, including the Jai-only `.{` struct-literal and `.[`
|
||||
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,
|
||||
formatting, or inspections. See `docs/BUILD_PLAN.md` for the phase plan.
|
||||
Remaining code-insight gaps include completion and symbol resolution for struct
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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 |
|
||||
| **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** |
|
||||
| **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests |
|
||||
| **6** | Formatter, code style settings | Formatter round-trip: formatting the corpus is idempotent |
|
||||
| **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) |
|
||||
| **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` |
|
||||
| **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
|
||||
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
|
||||
|
||||
| Risk | Mitigation |
|
||||
@@ -196,8 +207,9 @@ corpus-scale invariant that needs no human judgment.
|
||||
|
||||
## 6. Open questions for you
|
||||
|
||||
1. **Scope/ambition** — stop at solid syntax highlighting + navigation (Phases
|
||||
0–4), or go all the way to formatter and inspections (0–7)?
|
||||
1. **Scope/ambition** — stop at the current syntax, navigation, and basic
|
||||
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
|
||||
the actual `jai` binary, and it's the least testable.
|
||||
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
|
||||
wrapper.
|
||||
|
||||
My recommendation: approve Phases 0–4 now, decide on 5–8 once the parser gate is
|
||||
green and we can see how the grammar behaved against real code.
|
||||
Phases 0–6 are implemented and verified headlessly. The next decision is
|
||||
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.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
source ./gradle-lock.sh
|
||||
|
||||
if command -v mise >/dev/null 2>&1; then
|
||||
exec mise exec -- ./gradlew "$@"
|
||||
# `--stop` is an explicit recovery operation; it must be able to stop a
|
||||
# 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
|
||||
|
||||
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
|
||||
# JDK 21 that Gradle has auto-provisioned.
|
||||
if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then
|
||||
@@ -35,4 +46,9 @@ if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then
|
||||
exit 1
|
||||
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
|
||||
# prints tests/failures/errors per suite and dumps failure messages.
|
||||
#
|
||||
# Two concurrent Gradle invocations (e.g. an editor/agent running tests in the
|
||||
# background at the same time) fight over build/test-results and the loser dies
|
||||
# with `java.io.EOFException` or a missing in-progress-results bin. That is an
|
||||
# infrastructure failure, not a test failure, so it is retried once.
|
||||
# A repository-wide lock serializes Gradle/test runs. Without it, an editor or
|
||||
# another agent can delete build/test-results while this process is writing JUnit
|
||||
# XML, producing EOFException or a missing in-progress-results bin.
|
||||
#
|
||||
# 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
|
||||
@@ -18,6 +17,11 @@
|
||||
set -uo pipefail
|
||||
|
||||
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=()
|
||||
if [[ " $* " != *" --tests "* ]]; then
|
||||
|
||||
@@ -8,12 +8,12 @@ import com.intellij.psi.PsiElement;
|
||||
import com.intellij.psi.PsiElementVisitor;
|
||||
import com.intellij.psi.util.PsiTreeUtil;
|
||||
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.*;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@ import com.intellij.psi.PsiElement;
|
||||
import com.intellij.psi.PsiElementVisitor;
|
||||
import com.intellij.psi.util.PsiTreeUtil;
|
||||
import static dev.hgh.jai.psi.JaiTypes.*;
|
||||
import dev.hgh.jai.psi.mixin.JaiReferenceHostMixin;
|
||||
import dev.hgh.jai.psi.*;
|
||||
|
||||
public class JaiRefExprImpl extends JaiExprImpl implements JaiRefExpr {
|
||||
public class JaiRefExprImpl extends JaiReferenceHostMixin implements JaiRefExpr {
|
||||
|
||||
public JaiRefExprImpl(@NotNull ASTNode node) {
|
||||
super(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void accept(@NotNull JaiVisitor visitor) {
|
||||
visitor.visitRefExpr(this);
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ private declModifier_ ::= <<dirWithBlock>> directiveFlag_* block
|
||||
private usingModifier_ ::= 'using' [directiveFlag_ [<<exprNoAssign>>]]
|
||||
declNames ::= declName (['='] ',' declName ['='])* ['=']
|
||||
declName ::= ['`'] (IDENT | keywordAsName_)
|
||||
{ mixin="dev.hgh.jai.psi.mixin.JaiNamedElementMixin" }
|
||||
private declTail_ ::= ':' [<<exprNoAssign>>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_]
|
||||
| ':=' initializerList_
|
||||
| '::' initializerList_
|
||||
@@ -361,6 +362,7 @@ primaryExpr ::= procLiteralExpr
|
||||
| uninitializedExpr
|
||||
|
||||
refExpr ::= IDENT
|
||||
{ mixin="dev.hgh.jai.psi.mixin.JaiReferenceHostMixin" }
|
||||
literalExpr ::= NUMBER | STRING | HERE_STRING | 'true' | 'false' | 'null' | 'context'
|
||||
{ mixin="dev.hgh.jai.psi.mixin.JaiReferenceHostMixin" }
|
||||
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
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
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.psi.PsiElement
|
||||
import com.intellij.psi.PsiManager
|
||||
@@ -14,16 +11,24 @@ import com.intellij.psi.PsiReferenceProvider
|
||||
import com.intellij.psi.PsiReferenceRegistrar
|
||||
import com.intellij.util.ProcessingContext
|
||||
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.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() {
|
||||
override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) {
|
||||
registrar.registerReferenceProvider(
|
||||
PlatformPatterns.psiElement(JaiLiteralExpr::class.java),
|
||||
JaiModuleReferenceProvider(),
|
||||
)
|
||||
registrar.registerReferenceProvider(
|
||||
PlatformPatterns.psiElement(JaiRefExpr::class.java),
|
||||
JaiSymbolReferenceProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +83,30 @@ private class JaiModuleReferenceProvider : PsiReferenceProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
private data class JaiModuleTarget(
|
||||
val directive: String,
|
||||
val path: String,
|
||||
val flags: Set<String>,
|
||||
)
|
||||
/** Adds references from identifiers to declarations in the same or imported Jai files. */
|
||||
private class JaiSymbolReferenceProvider : PsiReferenceProvider() {
|
||||
override fun getReferencesByElement(
|
||||
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 {
|
||||
MODULE,
|
||||
FILE,
|
||||
DIRECTORY,
|
||||
RELATIVE,
|
||||
private class JaiSymbolReference(
|
||||
private val sourceElement: JaiRefExpr,
|
||||
) : PsiReferenceBase<JaiRefExpr>(sourceElement, TextRange(0, sourceElement.textLength), true) {
|
||||
override fun resolve(): PsiElement? = JaiSymbolResolver.resolve(sourceElement)
|
||||
|
||||
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(
|
||||
@@ -106,137 +124,6 @@ private class JaiModuleReference(
|
||||
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.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"/>
|
||||
|
||||
<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"/>
|
||||
|
||||
@@ -38,6 +49,18 @@
|
||||
<psi.referenceContributor
|
||||
language="Jai"
|
||||
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>
|
||||
|
||||
</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
|
||||
|
||||
import com.intellij.codeInsight.TargetElementUtil
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.PsiReference
|
||||
@@ -9,6 +11,7 @@ import com.intellij.psi.PsiReferenceService
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import dev.hgh.jai.psi.JaiLiteralExpr
|
||||
import dev.hgh.jai.psi.JaiRefExpr
|
||||
|
||||
class JaiReferenceTest : BasePlatformTestCase() {
|
||||
fun testLoadResolvesAFileRelativeToTheSource() {
|
||||
@@ -24,6 +27,149 @@ class JaiReferenceTest : BasePlatformTestCase() {
|
||||
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() {
|
||||
val sourceVirtualFile =
|
||||
LocalFileSystem
|
||||
@@ -90,12 +236,12 @@ class JaiReferenceTest : BasePlatformTestCase() {
|
||||
return literal!!
|
||||
}
|
||||
|
||||
private fun onlyReference(literal: JaiLiteralExpr): PsiReference {
|
||||
val references = references(literal)
|
||||
assertEquals("expected exactly one path reference", 1, references.size)
|
||||
private fun onlyReference(element: PsiElement): PsiReference {
|
||||
val references = references(element)
|
||||
assertEquals("expected exactly one reference", 1, references.size)
|
||||
return references.single()
|
||||
}
|
||||
|
||||
private fun references(literal: JaiLiteralExpr): List<PsiReference> =
|
||||
PsiReferenceService.getService().getContributedReferences(literal).toList()
|
||||
private fun references(element: PsiElement): List<PsiReference> =
|
||||
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