diff --git a/AGENTS.md b/AGENTS.md index 250b8d3..4e8eb55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,20 +59,23 @@ 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=6 +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=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: @@ -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 `dev.hgh.jai.completion.JaiCompletionContributor`. Identifier references now 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 optional work remain. `#asm` bodies are intentionally consumed opaquely, so nothing inside them has PSI yet. diff --git a/CHANGELOG.md b/CHANGELOG.md index 286a54e..1dbf95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - 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`). +- 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 @@ -29,3 +33,5 @@ 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. +- Module-qualified members, procedure parameters, and struct fields do not yet + have symbol resolution. diff --git a/README.md b/README.md index f1067b0..58140f5 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,20 @@ 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 +- 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, -formatting, or inspections. See `docs/BUILD_PLAN.md` for the phase plan. +Remaining code-insight gaps include module-qualified members, parameters, and +struct fields. Formatting, inspections, and compiler integration are planned +later. See `docs/BUILD_PLAN.md` for the phase plan. ## Install it locally diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 3751c27..3802345 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -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 | | **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 | +| **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 | | **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` | @@ -196,8 +196,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–5), 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 +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 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–5 are now implemented and verified headlessly. The remaining decision +is whether to continue with the optional formatter, inspections, and compiler +integration work in Phases 6–8. diff --git a/src/main/gen/dev/hgh/jai/psi/impl/JaiDeclNameImpl.java b/src/main/gen/dev/hgh/jai/psi/impl/JaiDeclNameImpl.java index cafd411..dbd34d9 100644 --- a/src/main/gen/dev/hgh/jai/psi/impl/JaiDeclNameImpl.java +++ b/src/main/gen/dev/hgh/jai/psi/impl/JaiDeclNameImpl.java @@ -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); } diff --git a/src/main/grammar/Jai.bnf b/src/main/grammar/Jai.bnf index 37729e2..8e1083f 100644 --- a/src/main/grammar/Jai.bnf +++ b/src/main/grammar/Jai.bnf @@ -227,6 +227,7 @@ private declModifier_ ::= <> directiveFlag_* block private usingModifier_ ::= 'using' [directiveFlag_ [<>]] declNames ::= declName (['='] ',' declName ['='])* ['='] declName ::= ['`'] (IDENT | keywordAsName_) + { mixin="dev.hgh.jai.psi.mixin.JaiNamedElementMixin" } private declTail_ ::= ':' [<>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_] | ':=' initializerList_ | '::' initializerList_ diff --git a/src/main/kotlin/dev/hgh/jai/findusages/JaiFindUsagesProvider.kt b/src/main/kotlin/dev/hgh/jai/findusages/JaiFindUsagesProvider.kt new file mode 100644 index 0000000..3472d98 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/findusages/JaiFindUsagesProvider.kt @@ -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 +} diff --git a/src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt b/src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt new file mode 100644 index 0000000..80afd83 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt @@ -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") + } +} diff --git a/src/main/kotlin/dev/hgh/jai/psi/mixin/JaiNamedElementMixin.kt b/src/main/kotlin/dev/hgh/jai/psi/mixin/JaiNamedElementMixin.kt new file mode 100644 index 0000000..490f993 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/psi/mixin/JaiNamedElementMixin.kt @@ -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() +} diff --git a/src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt b/src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt new file mode 100644 index 0000000..56b9ad6 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt @@ -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' +} diff --git a/src/main/kotlin/dev/hgh/jai/refactoring/JaiRefactoringSupportProvider.kt b/src/main/kotlin/dev/hgh/jai/refactoring/JaiRefactoringSupportProvider.kt new file mode 100644 index 0000000..ee74d54 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/refactoring/JaiRefactoringSupportProvider.kt @@ -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 +} diff --git a/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt b/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt index 55a8ec0..19b8cba 100644 --- a/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt +++ b/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt @@ -22,6 +22,7 @@ import dev.hgh.jai.psi.JaiDeclName import dev.hgh.jai.psi.JaiDeclaration 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 for directives and symbol references for Jai identifiers. */ @@ -106,6 +107,12 @@ private class JaiSymbolReference( ) : PsiReferenceBase(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 = emptyArray() } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 4e1713d..141a939 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -41,6 +41,15 @@ + + + diff --git a/src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt b/src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt new file mode 100644 index 0000000..73bd206 --- /dev/null +++ b/src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt @@ -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" +} diff --git a/src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt b/src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt new file mode 100644 index 0000000..ad59e45 --- /dev/null +++ b/src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt @@ -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 :: () {} + 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 :: () {} + main :: () { helper(); } + """.trimIndent(), + ) + + myFixture.renameElementAtCaret("renamed") + + myFixture.checkResult( + """ + renamed :: () {} + main :: () { renamed(); } + """.trimIndent(), + ) + } + + fun testRenameTypeUpdatesTypeReferences() { + myFixture.configureByText( + "rename-type.jai", + """ + Point :: 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(), + ) + } +} diff --git a/src/test/testData/findusages/Declaration.jai b/src/test/testData/findusages/Declaration.jai new file mode 100644 index 0000000..d80b21e --- /dev/null +++ b/src/test/testData/findusages/Declaration.jai @@ -0,0 +1 @@ +helper :: () {} diff --git a/src/test/testData/findusages/Usage.jai b/src/test/testData/findusages/Usage.jai new file mode 100644 index 0000000..d7a76c2 --- /dev/null +++ b/src/test/testData/findusages/Usage.jai @@ -0,0 +1,2 @@ +#load "Declaration.jai"; +main :: () { helper(); }