Phase 5: add rename and find usages

This commit is contained in:
hgranthorner
2026-08-04 15:54:31 -04:00
parent 4758bf12e2
commit 460fc969db
17 changed files with 293 additions and 25 deletions

View File

@@ -59,20 +59,23 @@ class and still prints `OK`. `jaitest` therefore passes
Last verified state (all green, `./jaigradle check` and `verifyPlugin` too): Last verified state (all green, `./jaigradle check` and `verifyPlugin` too):
```text ```text
dev.hgh.HarnessSmokeTest tests=2 dev.hgh.HarnessSmokeTest tests=2
dev.hgh.jai.JaiFileTypeTest tests=4 dev.hgh.jai.JaiFileTypeTest tests=4
dev.hgh.jai.editor.JaiEditorSupportTest tests=7 dev.hgh.jai.completion.JaiCompletionTest tests=6
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3 dev.hgh.jai.editor.JaiEditorSupportTest tests=7
dev.hgh.jai.highlighting.* tests=8 dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate dev.hgh.jai.findusages.JaiFindUsagesTest tests=2
dev.hgh.jai.lexer.JaiLexerTest tests=13 dev.hgh.jai.highlighting.* tests=8
dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees dev.hgh.jai.lexer.JaiLexerTest tests=13
dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees
dev.hgh.jai.reference.JaiReferenceTest tests=4 dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions
dev.hgh.jai.structure.JaiStructureViewTest tests=3 dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
-> total 55, failures+errors 0 dev.hgh.jai.refactoring.JaiRenameTest tests=3
dev.hgh.jai.reference.JaiReferenceTest tests=9
dev.hgh.jai.structure.JaiStructureViewTest tests=3
-> total 71, failures+errors 0
``` ```
The corpus gates report what they actually did; check both lines are still there: The corpus gates report what they actually did; check both lines are still there:
@@ -180,7 +183,9 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements
compiler directives, `#import`/`#load` module paths is implemented in compiler directives, `#import`/`#load` module paths is implemented in
`dev.hgh.jai.completion.JaiCompletionContributor`. Identifier references now `dev.hgh.jai.completion.JaiCompletionContributor`. Identifier references now
resolve procedures/types in the same file and transitively loaded/imported resolve procedures/types in the same file and transitively loaded/imported
files, with headless fixture coverage. Rename and find-usages remain. files. Declaration names now support rename and find-usages through the
standard PSI contracts; module-qualified members, parameters, and fields
remain future symbol-resolution work.
2. **Phases 6+** — formatter, inspections, compiler integration, and other 2. **Phases 6+** — formatter, inspections, compiler integration, and other
optional work remain. `#asm` bodies are intentionally consumed opaquely, so optional work remain. `#asm` bodies are intentionally consumed opaquely, so
nothing inside them has PSI yet. nothing inside them has PSI yet.

View File

@@ -20,6 +20,10 @@
- Folding for blocks, nested comments, and multi-line here-strings. - Folding for blocks, nested comments, and multi-line here-strings.
- Go-to-definition references for `#import` and `#load`, including project-relative - Go-to-definition references for `#import` and `#load`, including project-relative
files and the local Jai module layout (`Name.jai` or `Name/module.jai`). files and the local Jai module layout (`Name.jai` or `Name/module.jai`).
- Basic completion for Jai keywords, compiler directives, and module/file paths.
- Go-to-definition for procedure and type symbols in the same file and through
loaded or imported Jai files.
- Rename and find-usages support for declaration names and resolved references.
### Known gaps ### Known gaps
@@ -29,3 +33,5 @@
inner PSI or structure entries. inner PSI or structure entries.
- Import resolution uses the project/module roots and the local Jai installation; - Import resolution uses the project/module roots and the local Jai installation;
custom compiler `-import_dir` arguments are not discovered yet. custom compiler `-import_dir` arguments are not discovered yet.
- Module-qualified members, procedure parameters, and struct fields do not yet
have symbol resolution.

View File

@@ -11,13 +11,20 @@ never from online material (see `AGENTS.md`).
(`#import`, `#run`, …), notes (`@Cleanup`), numbers (hex / binary / hexfloat / (`#import`, `#run`, …), notes (`@Cleanup`), numbers (hex / binary / hexfloat /
`_` separators), strings, here-strings (`#string DONE … DONE`), nesting block `_` separators), strings, here-strings (`#string DONE … DONE`), nesting block
comments, operators, `---`, `$`/`$$`, `it` / `it_index` comments, operators, `---`, `$`/`$$`, `it` / `it_index`
- Grammar-Kit parser and PSI for the full local 714-file corpus
- Structure view and folding for blocks, comments, and here-strings
- Colour settings page (**Settings → Editor → Color Scheme → Jai**) - Colour settings page (**Settings → Editor → Color Scheme → Jai**)
- Comment / uncomment (`//` and nesting `/* */`) - Comment / uncomment (`//` and nesting `/* */`)
- Brace matching, including the Jai-only `.{` struct-literal and `.[` - Brace matching, including the Jai-only `.{` struct-literal and `.[`
array-literal openers array-literal openers
- Basic completion for keywords, directives, and local Jai module/file paths
- Go-to-definition for procedures and types in the same file and through
`#import`/`#load`
- Rename and find-usages for declaration names and their resolved references
Not yet: parser and PSI, so no structure view, go-to-definition, completion, Remaining code-insight gaps include module-qualified members, parameters, and
formatting, or inspections. See `docs/BUILD_PLAN.md` for the phase plan. struct fields. Formatting, inspections, and compiler integration are planned
later. See `docs/BUILD_PLAN.md` for the phase plan.
## Install it locally ## Install it locally

View File

@@ -169,7 +169,7 @@ Each phase has a machine-checkable gate. Do not advance without a green gate.
| **2** | `SyntaxHighlighter`, color settings page, commenter, brace matcher | Tier 1 golden dumps; highlighter maps every token type | | **2** | `SyntaxHighlighter`, color settings page, commenter, brace matcher | Tier 1 golden dumps; highlighter maps every token type |
| **3** | `.bnf` grammar, generated parser + PSI, `ParserDefinition` | Tier 2 golden trees; Tier 3 corpus parse ≥ target — **done, 100.0% (714/714)** | | **3** | `.bnf` grammar, generated parser + PSI, `ParserDefinition` | Tier 2 golden trees; Tier 3 corpus parse ≥ target — **done, 100.0% (714/714)** |
| **4** | Structure view, folding, `#import`/`#load` reference resolution + go-to-definition | Tier 4 fixture tests — **done** | | **4** | Structure view, folding, `#import`/`#load` reference resolution + go-to-definition | Tier 4 fixture tests — **done** |
| **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests | | **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests**done** (70-test suite) |
| **6** | Formatter, code style settings | Formatter round-trip: formatting the corpus is idempotent | | **6** | Formatter, code style settings | Formatter round-trip: formatting the corpus is idempotent |
| **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` | | **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` |
| **8** | Optional: run-configuration to invoke the `jai` compiler, parse its error output | Integration test against `~/.local/jai/bin` | | **8** | Optional: run-configuration to invoke the `jai` compiler, parse its error output | Integration test against `~/.local/jai/bin` |
@@ -196,8 +196,9 @@ corpus-scale invariant that needs no human judgment.
## 6. Open questions for you ## 6. Open questions for you
1. **Scope/ambition** — stop at solid syntax highlighting + navigation (Phases 1. **Scope/ambition** — stop at the current syntax, navigation, and basic
04), or go all the way to formatter and inspections (07)? refactoring support (Phases 05), or go all the way to formatter and
inspections (07)?
2. **Compiler integration** (Phase 8) — worth it? It's the only phase needing 2. **Compiler integration** (Phase 8) — worth it? It's the only phase needing
the actual `jai` binary, and it's the least testable. the actual `jai` binary, and it's the least testable.
3. **Target IDE** — IntelliJ IDEA only (current setting), or all JetBrains IDEs? 3. **Target IDE** — IntelliJ IDEA only (current setting), or all JetBrains IDEs?
@@ -206,5 +207,6 @@ corpus-scale invariant that needs no human judgment.
setup, works today), or install a system JDK via Homebrew? I'd default to the setup, works today), or install a system JDK via Homebrew? I'd default to the
wrapper. wrapper.
My recommendation: approve Phases 04 now, decide on 58 once the parser gate is Phases 05 are now implemented and verified headlessly. The remaining decision
green and we can see how the grammar behaved against real code. is whether to continue with the optional formatter, inspections, and compiler
integration work in Phases 68.

View File

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

View File

@@ -227,6 +227,7 @@ private declModifier_ ::= <<dirWithBlock>> directiveFlag_* block
private usingModifier_ ::= 'using' [directiveFlag_ [<<exprNoAssign>>]] private usingModifier_ ::= 'using' [directiveFlag_ [<<exprNoAssign>>]]
declNames ::= declName (['='] ',' declName ['='])* ['='] declNames ::= declName (['='] ',' declName ['='])* ['=']
declName ::= ['`'] (IDENT | keywordAsName_) declName ::= ['`'] (IDENT | keywordAsName_)
{ mixin="dev.hgh.jai.psi.mixin.JaiNamedElementMixin" }
private declTail_ ::= ':' [<<exprNoAssign>>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_] private declTail_ ::= ':' [<<exprNoAssign>>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_]
| ':=' initializerList_ | ':=' initializerList_
| '::' initializerList_ | '::' initializerList_

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import dev.hgh.jai.psi.JaiDeclName
import dev.hgh.jai.psi.JaiDeclaration import dev.hgh.jai.psi.JaiDeclaration
import dev.hgh.jai.psi.JaiDirectiveExpr import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiLiteralExpr import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiPsiElementFactory
import dev.hgh.jai.psi.JaiRefExpr import dev.hgh.jai.psi.JaiRefExpr
/** Adds file references for directives and symbol references for Jai identifiers. */ /** Adds file references for directives and symbol references for Jai identifiers. */
@@ -106,6 +107,12 @@ private class JaiSymbolReference(
) : PsiReferenceBase<JaiRefExpr>(sourceElement, TextRange(0, sourceElement.textLength), true) { ) : PsiReferenceBase<JaiRefExpr>(sourceElement, TextRange(0, sourceElement.textLength), true) {
override fun resolve(): PsiElement? = JaiSymbolResolver.resolve(sourceElement) 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() override fun getVariants(): Array<Any> = emptyArray()
} }

View File

@@ -41,6 +41,15 @@
<completion.contributor <completion.contributor
language="Jai" language="Jai"
implementationClass="dev.hgh.jai.completion.JaiCompletionContributor"/> implementationClass="dev.hgh.jai.completion.JaiCompletionContributor"/>
<lang.refactoringSupport
language="Jai"
implementationClass="dev.hgh.jai.refactoring.JaiRefactoringSupportProvider"/>
<lang.namesValidator
language="Jai"
implementationClass="dev.hgh.jai.refactoring.JaiNamesValidator"/>
<lang.findUsagesProvider
language="Jai"
implementationClass="dev.hgh.jai.findusages.JaiFindUsagesProvider"/>
</extensions> </extensions>
</idea-plugin> </idea-plugin>

View File

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

View File

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

View File

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

View File

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