From c4e1d92c9a053989c8126fccab3fadda9e316a52 Mon Sep 17 00:00:00 2001 From: hgranthorner <37941012+hgranthorner@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:49:03 -0400 Subject: [PATCH] Phase 4: add structure, folding, and references --- AGENTS.md | 29 +- CHANGELOG.md | 10 +- docs/BUILD_PLAN.md | 2 +- .../hgh/jai/psi/impl/JaiLiteralExprImpl.java | 4 +- src/main/grammar/Jai.bnf | 1 + .../dev/hgh/jai/editor/JaiFoldingBuilder.kt | 99 +++++++ .../jai/psi/mixin/JaiReferenceHostMixin.kt | 22 ++ .../jai/reference/JaiReferenceContributor.kt | 250 ++++++++++++++++++ .../jai/structure/JaiStructureViewElement.kt | 114 ++++++++ .../jai/structure/JaiStructureViewFactory.kt | 16 ++ .../jai/structure/JaiStructureViewModel.kt | 36 +++ src/main/resources/META-INF/plugin.xml | 7 + .../hgh/jai/editor/JaiEditorSupportTest.kt | 20 ++ .../hgh/jai/editor/JaiFoldingBuilderTest.kt | 65 +++++ .../dev/hgh/jai/reference/JaiReferenceTest.kt | 101 +++++++ .../hgh/jai/structure/JaiStructureViewTest.kt | 64 +++++ 16 files changed, 826 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/dev/hgh/jai/editor/JaiFoldingBuilder.kt create mode 100644 src/main/kotlin/dev/hgh/jai/psi/mixin/JaiReferenceHostMixin.kt create mode 100644 src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt create mode 100644 src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewElement.kt create mode 100644 src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewFactory.kt create mode 100644 src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewModel.kt create mode 100644 src/test/kotlin/dev/hgh/jai/editor/JaiFoldingBuilderTest.kt create mode 100644 src/test/kotlin/dev/hgh/jai/reference/JaiReferenceTest.kt create mode 100644 src/test/kotlin/dev/hgh/jai/structure/JaiStructureViewTest.kt diff --git a/AGENTS.md b/AGENTS.md index 4ae5d22..ae602fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,8 @@ 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=5 +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 @@ -69,13 +70,15 @@ 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 --> total 43, failures+errors 0 +dev.hgh.jai.reference.JaiReferenceTest tests=4 +dev.hgh.jai.structure.JaiStructureViewTest tests=3 +-> total 55, failures+errors 0 ``` The corpus gates report what they actually did; check both lines are still there: ```text -Tier 0: lexed 714 files, 17154195 chars, 3009381 tokens cleanly. +Tier 0: lexed 714 files, 17154195 chars, 3008777 tokens cleanly. Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements total. ``` @@ -108,6 +111,10 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements `src/main/gen`, `JaiParserDefinition`, `JaiParserUtil`. Tier 2 golden trees and the Tier 3 corpus gate are green at **100.0% (714/714 files parse with zero `PsiErrorElement`)**. +- **Phase 4** — structure view, folding for blocks/comments/here-strings, and + `#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. ### Lexer design facts worth knowing before touching it @@ -162,16 +169,18 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements - **Directive flags require adjacency.** `#library,system` is a flag; `(cb: (*GUID) #c_call, ctx: *void)` is a parameter separator. Without the adjacency check the flag swallowed `, ctx`. +- **Contributed references need a PSI host.** Generated AST wrappers do not + implement `ContributedReferenceHost`, so `literalExpr` uses the + `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 -1. **Phase 4** — structure view, folding, `#import`/`#load` reference resolution - and go-to-definition. The PSI is in place, and the Tier 3 corpus gate is now - fully green. `JaiFileTypeTest` used to document being blocked on the - `ParserDefinition`; that no longer applies. - `#asm` bodies are intentionally consumed opaquely, so nothing inside them has - PSI yet. -2. **Phases 5+** — see the plan. +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. ### Open questions for the user (unanswered) diff --git a/CHANGELOG.md b/CHANGELOG.md index 239d289..286a54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,17 @@ structs, enums, unions, control flow including the `if x == { case }` switch form, and compiler directives. The full 714-file Jai distribution parses with no syntax errors; `JaiCorpusParserTest` is ratcheted at 100%. +- Structure view with source-ordered declarations and nested procedure/aggregate + members. +- 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`). ### Known gaps - `#asm` bodies are parsed as opaque blocks, so their assembly instructions do not have inner PSI. -- Structure view, folding, and go-to-definition are not implemented yet. +- `#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. diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 2a08f1a..3751c27 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -168,7 +168,7 @@ Each phase has a machine-checkable gate. Do not advance without a green gate. | **1** | File type, icon, `JaiTokenTypes`, hand-written lexer | Tier 0 corpus invariants pass on all 714 files | | **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 | +| **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 | | **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` | diff --git a/src/main/gen/dev/hgh/jai/psi/impl/JaiLiteralExprImpl.java b/src/main/gen/dev/hgh/jai/psi/impl/JaiLiteralExprImpl.java index 8079bea..641fa26 100644 --- a/src/main/gen/dev/hgh/jai/psi/impl/JaiLiteralExprImpl.java +++ b/src/main/gen/dev/hgh/jai/psi/impl/JaiLiteralExprImpl.java @@ -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 JaiLiteralExprImpl extends JaiExprImpl implements JaiLiteralExpr { +public class JaiLiteralExprImpl extends JaiReferenceHostMixin implements JaiLiteralExpr { public JaiLiteralExprImpl(@NotNull ASTNode node) { super(node); } - @Override public void accept(@NotNull JaiVisitor visitor) { visitor.visitLiteralExpr(this); } diff --git a/src/main/grammar/Jai.bnf b/src/main/grammar/Jai.bnf index c5dcb6d..7445098 100644 --- a/src/main/grammar/Jai.bnf +++ b/src/main/grammar/Jai.bnf @@ -362,6 +362,7 @@ primaryExpr ::= procLiteralExpr refExpr ::= IDENT literalExpr ::= NUMBER | STRING | HERE_STRING | 'true' | 'false' | 'null' | 'context' + { mixin="dev.hgh.jai.psi.mixin.JaiReferenceHostMixin" } uninitializedExpr ::= '---' | '--' // `table.* = {};` zero-initialises. Only the empty form is an expression; a // non-empty `{ … }` in expression position would collide with a block statement. diff --git a/src/main/kotlin/dev/hgh/jai/editor/JaiFoldingBuilder.kt b/src/main/kotlin/dev/hgh/jai/editor/JaiFoldingBuilder.kt new file mode 100644 index 0000000..0972c66 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/editor/JaiFoldingBuilder.kt @@ -0,0 +1,99 @@ +package dev.hgh.jai.editor + +import com.intellij.lang.ASTNode +import com.intellij.lang.folding.FoldingBuilderEx +import com.intellij.lang.folding.FoldingDescriptor +import com.intellij.openapi.editor.Document +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiRecursiveElementWalkingVisitor +import dev.hgh.jai.lexer.JaiTokenTypes +import dev.hgh.jai.psi.JaiBlock + +/** + * Folding for Jai blocks, comments, and multi-line here-strings. + * + * Jai's block delimiters are ordinary PSI leaves even when the block belongs to a + * procedure, struct, enum, or control-flow statement, so one visitor covers all of + * those constructs. The interior of a block is folded rather than the delimiters; + * this keeps the opening and closing braces visible in the editor. + */ +class JaiFoldingBuilder : + FoldingBuilderEx(), + DumbAware { + override fun buildFoldRegions( + root: PsiElement, + document: Document, + quick: Boolean, + ): Array { + val descriptors = mutableListOf() + + root.accept( + object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(element: PsiElement) { + when { + element is JaiBlock -> { + addInteriorFold(element, document, 1, 1, descriptors) + } + + element.node.elementType == JaiTokenTypes.BLOCK_COMMENT -> { + addInteriorFold(element, document, 2, 2, descriptors) + } + + element.node.elementType == JaiTokenTypes.HERE_STRING -> { + addHereStringFold(element, document, descriptors) + } + } + super.visitElement(element) + } + }, + ) + + return descriptors.toTypedArray() + } + + override fun getPlaceholderText(node: ASTNode): String = + when (node.elementType) { + JaiTokenTypes.BLOCK_COMMENT -> "..." + JaiTokenTypes.HERE_STRING -> "#string …" + else -> "..." + } + + override fun isCollapsedByDefault(node: ASTNode): Boolean = false + + private fun addInteriorFold( + element: PsiElement, + document: Document, + openingLength: Int, + closingLength: Int, + descriptors: MutableList, + ) { + val range = element.textRange + val start = range.startOffset + openingLength + val end = range.endOffset - closingLength + if (start >= end || !isMultiLine(document, start, end)) return + + descriptors += FoldingDescriptor(element.node, TextRange(start, end)) + } + + private fun addHereStringFold( + element: PsiElement, + document: Document, + descriptors: MutableList, + ) { + val range = element.textRange + if (range.length == 0 || !isMultiLine(document, range.startOffset, range.endOffset)) return + + // The token contains both the header and its captured terminator. Folding the + // whole token is intentional: unlike a block comment, there is no fixed pair + // of delimiters whose visibility would help the reader. + descriptors += FoldingDescriptor(element.node, range) + } + + private fun isMultiLine( + document: Document, + start: Int, + end: Int, + ): Boolean = start < end && document.getLineNumber(start) < document.getLineNumber(end - 1) +} diff --git a/src/main/kotlin/dev/hgh/jai/psi/mixin/JaiReferenceHostMixin.kt b/src/main/kotlin/dev/hgh/jai/psi/mixin/JaiReferenceHostMixin.kt new file mode 100644 index 0000000..1eb96cd --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/psi/mixin/JaiReferenceHostMixin.kt @@ -0,0 +1,22 @@ +package dev.hgh.jai.psi.mixin + +import com.intellij.lang.ASTNode +import com.intellij.psi.ContributedReferenceHost +import com.intellij.psi.PsiReference +import com.intellij.psi.impl.source.resolve.reference.ReferenceProvidersRegistry +import dev.hgh.jai.psi.impl.JaiExprImpl + +/** + * Gives generated literal PSI access to reference-contributor providers. + * + * Grammar-Kit's normal AST wrapper does not implement [ContributedReferenceHost], + * so IntelliJ's reference service otherwise only sees references returned directly + * by `getReference()`. Import/load paths are supplied by a contributor and therefore + * need this small generated-PSI mixin. + */ +abstract class JaiReferenceHostMixin( + node: ASTNode, +) : JaiExprImpl(node), + ContributedReferenceHost { + override fun getReferences(): Array = ReferenceProvidersRegistry.getReferencesFromProviders(this) +} diff --git a/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt b/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt new file mode 100644 index 0000000..b6d3d82 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt @@ -0,0 +1,250 @@ +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 +import com.intellij.psi.PsiReference +import com.intellij.psi.PsiReferenceBase +import com.intellij.psi.PsiReferenceContributor +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.psi.JaiDirectiveExpr +import dev.hgh.jai.psi.JaiLiteralExpr + +/** Adds file references to string operands of Jai's import and load directives. */ +class JaiReferenceContributor : PsiReferenceContributor() { + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider( + PlatformPatterns.psiElement(JaiLiteralExpr::class.java), + JaiModuleReferenceProvider(), + ) + } +} + +private class JaiModuleReferenceProvider : PsiReferenceProvider() { + override fun getReferencesByElement( + element: PsiElement, + context: ProcessingContext, + ): Array { + val literal = element as? JaiLiteralExpr ?: return PsiReference.EMPTY_ARRAY + val directive = literal.parent as? JaiDirectiveExpr ?: return PsiReference.EMPTY_ARRAY + val directiveName = directive.directiveName() ?: return PsiReference.EMPTY_ARRAY + if (directiveName != "#import" && directiveName != "#load") return PsiReference.EMPTY_ARRAY + + val path = parseStringLiteral(literal.text) ?: return PsiReference.EMPTY_ARRAY + val flags = directive.flagsBefore(literal) + if ("string" in flags) return PsiReference.EMPTY_ARRAY + + // Keep the quotes outside the reference range. This makes Ctrl/Cmd-click and + // rename-like editor actions operate on the path rather than on its delimiters. + if (literal.textLength < 2) return PsiReference.EMPTY_ARRAY + val range = TextRange(1, literal.textLength - 1) + val target = JaiModuleTarget(directiveName, path, flags) + return arrayOf(JaiModuleReference(literal, range, target)) + } + + private fun parseStringLiteral(text: String): String? { + if (text.length < 2 || text.first() != '"' || text.last() != '"') return null + return unescape(text.substring(1, text.length - 1)) + } + + private fun unescape(text: String): String = + buildString(text.length) { + var index = 0 + while (index < text.length) { + val character = text[index] + if (character == '\\' && index + 1 < text.length) { + val escaped = text[index + 1] + append( + when (escaped) { + '\\' -> '\\' + '"' -> '"' + '/' -> '/' + else -> escaped + }, + ) + index += 2 + } else { + append(character) + index++ + } + } + } +} + +private data class JaiModuleTarget( + val directive: String, + val path: String, + val flags: Set, +) + +private enum class JaiPathMode { + MODULE, + FILE, + DIRECTORY, + RELATIVE, +} + +private class JaiModuleReference( + private val sourceElement: PsiElement, + rangeInElement: TextRange, + private val target: JaiModuleTarget, +) : PsiReferenceBase(sourceElement, rangeInElement, false) { + override fun resolve(): PsiElement? { + val containingFile = sourceElement.containingFile ?: return null + val virtualFile = containingFile.virtualFile ?: return null + val targetFile = JaiModuleResolver.resolve(sourceElement.project, virtualFile, target) ?: return null + return PsiManager.getInstance(sourceElement.project).findFile(targetFile) + } + + override fun getVariants(): Array = 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 { + val roots = linkedMapOf() + + 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 { + val prefixEnd = literal.textRange.startOffset - textRange.startOffset + if (prefixEnd <= 0 || prefixEnd > text.length) return emptySet() + val prefix = text.substring(0, prefixEnd) + return Regex(",\\s*([A-Za-z_][A-Za-z0-9_]*)") + .findAll(prefix) + .map { it.groupValues[1] } + .toSet() +} diff --git a/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewElement.kt b/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewElement.kt new file mode 100644 index 0000000..2adc8bc --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewElement.kt @@ -0,0 +1,114 @@ +package dev.hgh.jai.structure + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.structureView.StructureViewTreeElement +import com.intellij.ide.util.treeView.smartTree.SortableTreeElement +import com.intellij.ide.util.treeView.smartTree.TreeElement +import com.intellij.navigation.ItemPresentation +import com.intellij.psi.NavigatablePsiElement +import com.intellij.psi.PsiElement +import dev.hgh.jai.psi.JaiBlock +import dev.hgh.jai.psi.JaiDeclaration +import dev.hgh.jai.psi.JaiEnumExpr +import dev.hgh.jai.psi.JaiFile +import dev.hgh.jai.psi.JaiOperatorDeclaration +import dev.hgh.jai.psi.JaiProcLiteralExpr +import dev.hgh.jai.psi.JaiStatement +import dev.hgh.jai.psi.JaiStructExpr +import dev.hgh.jai.psi.JaiUnionExpr + +/** A navigable node in the Jai Structure tool window. */ +class JaiStructureViewElement( + private val element: PsiElement, +) : StructureViewTreeElement, + SortableTreeElement { + override fun getValue(): Any = element + + override fun navigate(requestFocus: Boolean) { + (element as? NavigatablePsiElement)?.navigate(requestFocus) + } + + override fun canNavigate(): Boolean = (element as? NavigatablePsiElement)?.canNavigate() == true + + override fun canNavigateToSource(): Boolean = (element as? NavigatablePsiElement)?.canNavigateToSource() == true + + override fun getAlphaSortKey(): String = label(element) + + override fun getPresentation(): ItemPresentation = PresentationData().also { it.presentableText = label(element) } + + override fun getChildren(): Array = + structureChildren(element) + .map(::JaiStructureViewElement) + .toTypedArray() + + private fun structureChildren(parent: PsiElement): List = + when (parent) { + is JaiFile -> { + directMembers(parent) + } + + is JaiDeclaration -> { + parent.initializerList.flatMap { initializer -> + directMembers(structuralBlock(initializer.expr)) + } + } + + is JaiOperatorDeclaration -> { + directMembers(structuralBlock(parent.expr)) + } + + else -> { + emptyList() + } + } + + private fun directMembers(parent: PsiElement?): List = + when (parent) { + is JaiFile -> parent.children.filterIsInstance() + is JaiBlock -> parent.statementList + else -> emptyList() + }.mapNotNull { statement -> + statement.declaration ?: statement.operatorDeclaration + } + + private fun structuralBlock(expression: PsiElement?): JaiBlock? = + when (expression) { + is JaiProcLiteralExpr -> expression.block + is JaiStructExpr -> expression.block + is JaiEnumExpr -> expression.block + is JaiUnionExpr -> expression.block + else -> null + } + + private fun label(element: PsiElement): String = + when (element) { + is JaiFile -> { + element.name + } + + is JaiDeclaration -> { + element.declNames.declNameList + .joinToString(", ") { it.text.trim('`') } + .ifEmpty { "declaration" } + } + + is JaiOperatorDeclaration -> { + "operator ${operatorName(element)}" + } + + else -> { + element.text + .lineSequence() + .firstOrNull() + ?.trim() + .orEmpty() + } + } + + private fun operatorName(declaration: JaiOperatorDeclaration): String = + declaration.text + .substringBefore("::") + .removePrefix("operator") + .trim() + .ifEmpty { "operator" } +} diff --git a/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewFactory.kt b/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewFactory.kt new file mode 100644 index 0000000..1c1aa76 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewFactory.kt @@ -0,0 +1,16 @@ +package dev.hgh.jai.structure + +import com.intellij.ide.structureView.StructureViewBuilder +import com.intellij.ide.structureView.StructureViewModel +import com.intellij.ide.structureView.TreeBasedStructureViewBuilder +import com.intellij.lang.PsiStructureViewFactory +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiFile + +/** Creates the standard IntelliJ Structure tool window for Jai files. */ +class JaiStructureViewFactory : PsiStructureViewFactory { + override fun getStructureViewBuilder(psiFile: PsiFile): StructureViewBuilder = + object : TreeBasedStructureViewBuilder() { + override fun createStructureViewModel(editor: Editor?): StructureViewModel = JaiStructureViewModel(editor, psiFile) + } +} diff --git a/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewModel.kt b/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewModel.kt new file mode 100644 index 0000000..f1d9b08 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewModel.kt @@ -0,0 +1,36 @@ +package dev.hgh.jai.structure + +import com.intellij.ide.structureView.StructureViewModel +import com.intellij.ide.structureView.StructureViewModelBase +import com.intellij.ide.structureView.StructureViewTreeElement +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiFile +import dev.hgh.jai.psi.JaiDeclaration +import dev.hgh.jai.psi.JaiFile +import dev.hgh.jai.psi.JaiOperatorDeclaration + +/** + * Structure-view model for Jai declarations. + * + * Declarations are source ordered. Jai has no declaration keyword (a procedure, + * struct, and variable are all `name :: value`), so the tree element derives its + * label from the declaration's name and leaves the complete type/value text in PSI. + */ +class JaiStructureViewModel( + editor: Editor?, + file: PsiFile, +) : StructureViewModelBase(file, editor, JaiStructureViewElement(file)), + StructureViewModel.ElementInfoProvider { + override fun isAlwaysShowsPlus(element: StructureViewTreeElement): Boolean = false + + override fun isAlwaysLeaf(element: StructureViewTreeElement): Boolean = + element !is JaiStructureViewElement || + ( + element.value !is JaiFile && + element.value !is JaiDeclaration && + element.value !is JaiOperatorDeclaration + ) + + override fun getSuitableClasses(): Array> = + arrayOf(JaiFile::class.java, JaiDeclaration::class.java, JaiOperatorDeclaration::class.java) +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f3d0ab7..673b0c2 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -31,6 +31,13 @@ + + + diff --git a/src/test/kotlin/dev/hgh/jai/editor/JaiEditorSupportTest.kt b/src/test/kotlin/dev/hgh/jai/editor/JaiEditorSupportTest.kt index 1d55f6b..a59758a 100644 --- a/src/test/kotlin/dev/hgh/jai/editor/JaiEditorSupportTest.kt +++ b/src/test/kotlin/dev/hgh/jai/editor/JaiEditorSupportTest.kt @@ -3,6 +3,8 @@ package dev.hgh.jai.editor import com.intellij.codeInsight.highlighting.BraceMatchingUtil import com.intellij.lang.LanguageBraceMatching import com.intellij.lang.LanguageCommenters +import com.intellij.lang.LanguageStructureViewBuilder +import com.intellij.lang.folding.LanguageFolding import com.intellij.openapi.editor.ex.EditorEx import com.intellij.testFramework.fixtures.BasePlatformTestCase import dev.hgh.jai.JaiFileType @@ -22,6 +24,24 @@ class JaiEditorSupportTest : BasePlatformTestCase() { assertTrue("expected JaiBraceMatcher, got $matcher", matcher is JaiBraceMatcher) } + fun testFoldingBuilderIsRegistered() { + val foldingBuilders = LanguageFolding.INSTANCE.allForLanguage(JaiLanguage) + assertTrue( + "expected JaiFoldingBuilder in $foldingBuilders", + foldingBuilders.any { it is JaiFoldingBuilder }, + ) + } + + fun testStructureViewFactoryIsRegistered() { + val file = myFixture.configureByText("structure.jai", "value := 1;") + val builder = LanguageStructureViewBuilder.getInstance().getStructureViewBuilder(file) + assertNotNull("expected a Jai structure-view builder", builder) + assertTrue( + "expected a TreeBasedStructureViewBuilder, got $builder", + builder is com.intellij.ide.structureView.TreeBasedStructureViewBuilder, + ) + } + fun testMatchesOrdinaryBraces() { assertMatchForward("main :: () |{ x := 1; }", "main :: () { x := 1; |}") } diff --git a/src/test/kotlin/dev/hgh/jai/editor/JaiFoldingBuilderTest.kt b/src/test/kotlin/dev/hgh/jai/editor/JaiFoldingBuilderTest.kt new file mode 100644 index 0000000..ea8499f --- /dev/null +++ b/src/test/kotlin/dev/hgh/jai/editor/JaiFoldingBuilderTest.kt @@ -0,0 +1,65 @@ +package dev.hgh.jai.editor + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.hgh.jai.lexer.JaiTokenTypes + +class JaiFoldingBuilderTest : BasePlatformTestCase() { + fun testFoldsMultilineBlocksAndComments() { + val text = + """ + main :: () { + value := 1; + } + + /* a comment + * with two lines + */ + """.trimIndent() + val file = myFixture.configureByText("folding.jai", text) + + val descriptors = JaiFoldingBuilder().buildFoldRegions(file, myFixture.editor.document, quick = false) + val foldedText = + descriptors.map { descriptor -> + text.substring(descriptor.range.startOffset, descriptor.range.endOffset) + } + + assertEquals(2, descriptors.size) + assertTrue(foldedText.any { it.contains("value := 1;") }) + assertTrue(foldedText.any { it.contains("with two lines") }) + } + + fun testFoldsNestedCommentsAndHereStrings() { + val text = + """ + /* outer comment + /* nested comment */ + still in the outer comment + */ + body :: #string END + raw here-string text + END; + """.trimIndent() + val file = myFixture.configureByText("literals.jai", text) + val builder = JaiFoldingBuilder() + val descriptors = builder.buildFoldRegions(file, myFixture.editor.document, quick = false) + val foldedText = + descriptors.map { descriptor -> + text.substring(descriptor.range.startOffset, descriptor.range.endOffset) + } + + assertEquals(2, descriptors.size) + assertTrue(foldedText.any { it.contains("nested comment") }) + assertTrue(foldedText.any { it.contains("raw here-string text") }) + val hereString = descriptors.first { it.element.elementType == JaiTokenTypes.HERE_STRING } + assertEquals("#string …", builder.getPlaceholderText(hereString.element)) + } + + fun testDoesNotFoldSingleLineRegions() { + val text = "main :: () { value := 1; } /* one line */" + val file = myFixture.configureByText("single-line.jai", text) + + val descriptors = JaiFoldingBuilder().buildFoldRegions(file, myFixture.editor.document, quick = false) + + assertEmpty(descriptors.toList()) + } +} diff --git a/src/test/kotlin/dev/hgh/jai/reference/JaiReferenceTest.kt b/src/test/kotlin/dev/hgh/jai/reference/JaiReferenceTest.kt new file mode 100644 index 0000000..6df0122 --- /dev/null +++ b/src/test/kotlin/dev/hgh/jai/reference/JaiReferenceTest.kt @@ -0,0 +1,101 @@ +package dev.hgh.jai.reference + +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiReference +import com.intellij.psi.PsiReferenceService +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.hgh.jai.psi.JaiLiteralExpr + +class JaiReferenceTest : BasePlatformTestCase() { + fun testLoadResolvesAFileRelativeToTheSource() { + val target = myFixture.addFileToProject("other.jai", "value := 1;") + val source = myFixture.addFileToProject("main.jai", "#load \"other.jai\";") + val literal = literalWithText(source, "\"other.jai\"") + + val reference = onlyReference(literal) + val resolved = reference.resolve() as? PsiFile + + assertNotNull("#load should resolve its sibling file", resolved) + assertEquals(target.virtualFile.path, resolved!!.virtualFile.path) + assertEquals(TextRange(1, literal.textLength - 1), reference.rangeInElement) + } + + fun testImportResolvesAJaiModuleDirectory() { + val sourceVirtualFile = + LocalFileSystem + .getInstance() + .findFileByPath("${System.getProperty("user.home")}/.local/jai/how_to/001_first.jai") + assertNotNull("expected the local Jai corpus", sourceVirtualFile) + val source = PsiManager.getInstance(project).findFile(sourceVirtualFile!!) + assertNotNull("expected PSI for ${sourceVirtualFile.path}", source) + val literal = + PsiTreeUtil + .findChildrenOfType(source, JaiLiteralExpr::class.java) + .firstOrNull { it.text == "\"Basic\"" } + assertNotNull("expected #import \"Basic\" in ${sourceVirtualFile.path}", literal) + + val resolved = onlyReference(literal!!).resolve() as? PsiFile + val expectedPath = "${System.getProperty("user.home")}/.local/jai/modules/Basic/module.jai" + + assertNotNull("#import should resolve a Jai module directory", resolved) + assertEquals(expectedPath, resolved!!.virtualFile.path) + } + + fun testExplicitImportModesUseTheContainingDirectory() { + val source = localPsiFile("how_to/040_import_and_load/main.jai") + val fileReference = onlyReference(literalWithText(source, "\"files/specific.jai\"")) + val directoryReference = onlyReference(literalWithText(source, "\"files/From_Subdirectory\"")) + + assertEquals( + "${System.getProperty("user.home")}/.local/jai/how_to/040_import_and_load/files/specific.jai", + (fileReference.resolve() as PsiFile).virtualFile.path, + ) + assertEquals( + "${System.getProperty("user.home")}/.local/jai/how_to/040_import_and_load/files/From_Subdirectory/module.jai", + (directoryReference.resolve() as PsiFile).virtualFile.path, + ) + } + + fun testImportStringIsNotTreatedAsAFileReference() { + val source = myFixture.addFileToProject("main.jai", "#import,string \"inline code\";") + val literal = literalWithText(source, "\"inline code\"") + + assertTrue(references(literal).isEmpty()) + } + + private fun localPsiFile(relativePath: String): PsiFile { + val virtualFile = + LocalFileSystem + .getInstance() + .findFileByPath("${System.getProperty("user.home")}/.local/jai/$relativePath") + assertNotNull("expected the local Jai corpus", virtualFile) + val psiFile = PsiManager.getInstance(project).findFile(virtualFile!!) + assertNotNull("expected PSI for ${virtualFile.path}", psiFile) + return psiFile!! + } + + 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 in ${source.virtualFile.path}", literal) + return literal!! + } + + private fun onlyReference(literal: JaiLiteralExpr): PsiReference { + val references = references(literal) + assertEquals("expected exactly one path reference", 1, references.size) + return references.single() + } + + private fun references(literal: JaiLiteralExpr): List = + PsiReferenceService.getService().getContributedReferences(literal).toList() +} diff --git a/src/test/kotlin/dev/hgh/jai/structure/JaiStructureViewTest.kt b/src/test/kotlin/dev/hgh/jai/structure/JaiStructureViewTest.kt new file mode 100644 index 0000000..eef04ae --- /dev/null +++ b/src/test/kotlin/dev/hgh/jai/structure/JaiStructureViewTest.kt @@ -0,0 +1,64 @@ +package dev.hgh.jai.structure + +import com.intellij.ide.structureView.StructureViewTreeElement +import com.intellij.ide.structureView.TreeBasedStructureViewBuilder +import com.intellij.psi.PsiElement +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class JaiStructureViewTest : BasePlatformTestCase() { + fun testListsTopLevelAndNestedDeclarationsInSourceOrder() { + val file = + myFixture.configureByText( + "structure.jai", + """ + main :: () { + helper :: () {} + } + Point :: struct { + x: int; + } + operator + :: (a: Point, b: Point) -> Point {} + """.trimIndent(), + ) + val model = JaiStructureViewModel(null, file) + val root: StructureViewTreeElement = model.getRoot() + + assertEquals(listOf("main", "Point", "operator +"), children(root).map(::label)) + val main = children(root)[0] + assertEquals(listOf("helper"), children(main).map(::label)) + assertEquals(0, (main.getValue() as PsiElement).textOffset) + } + + fun testOnlyDirectDeclarationsBecomeChildren() { + val file = + myFixture.configureByText( + "nested.jai", + """ + main :: () { + if true { + hidden :: () {} + } + visible :: () {} + } + """.trimIndent(), + ) + val root: StructureViewTreeElement = JaiStructureViewModel(null, file).getRoot() + + assertEquals(listOf("visible"), children(children(root)[0]).map(::label)) + } + + fun testFactoryCreatesAUsableStructureModel() { + val file = myFixture.configureByText("empty.jai", "value := 1;") + val builder = JaiStructureViewFactory().getStructureViewBuilder(file) as TreeBasedStructureViewBuilder + val model = builder.createStructureViewModel(null) + val root: StructureViewTreeElement = model.getRoot() + + assertEquals("empty.jai", label(root)) + assertEquals(listOf("value"), children(root).map(::label)) + } + + private fun children(element: StructureViewTreeElement): List = + element.getChildren().map { it as StructureViewTreeElement } + + private fun label(element: StructureViewTreeElement): String = element.getPresentation().presentableText ?: "" +}