Phase 4: add structure, folding, and references

This commit is contained in:
hgranthorner
2026-08-04 14:49:03 -04:00
parent bb54639ce5
commit c4e1d92c9a
16 changed files with 826 additions and 14 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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` |

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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<FoldingDescriptor> {
val descriptors = mutableListOf<FoldingDescriptor>()
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<FoldingDescriptor>,
) {
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<FoldingDescriptor>,
) {
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)
}

View File

@@ -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<PsiReference> = ReferenceProvidersRegistry.getReferencesFromProviders(this)
}

View File

@@ -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<PsiReference> {
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<String>,
)
private enum class JaiPathMode {
MODULE,
FILE,
DIRECTORY,
RELATIVE,
}
private class JaiModuleReference(
private val sourceElement: PsiElement,
rangeInElement: TextRange,
private val target: JaiModuleTarget,
) : PsiReferenceBase<PsiElement>(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<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> {
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()
}

View File

@@ -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<TreeElement> =
structureChildren(element)
.map(::JaiStructureViewElement)
.toTypedArray()
private fun structureChildren(parent: PsiElement): List<PsiElement> =
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<PsiElement> =
when (parent) {
is JaiFile -> parent.children.filterIsInstance<JaiStatement>()
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" }
}

View File

@@ -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)
}
}

View File

@@ -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<Class<*>> =
arrayOf(JaiFile::class.java, JaiDeclaration::class.java, JaiOperatorDeclaration::class.java)
}

View File

@@ -31,6 +31,13 @@
<lang.commenter language="Jai" implementationClass="dev.hgh.jai.editor.JaiCommenter"/>
<lang.braceMatcher language="Jai" implementationClass="dev.hgh.jai.editor.JaiBraceMatcher"/>
<lang.foldingBuilder language="Jai" implementationClass="dev.hgh.jai.editor.JaiFoldingBuilder"/>
<lang.psiStructureViewFactory
language="Jai"
implementationClass="dev.hgh.jai.structure.JaiStructureViewFactory"/>
<psi.referenceContributor
language="Jai"
implementation="dev.hgh.jai.reference.JaiReferenceContributor"/>
</extensions>
</idea-plugin>

View File

@@ -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; |}")
}

View File

@@ -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())
}
}

View File

@@ -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<PsiReference> =
PsiReferenceService.getService().getContributedReferences(literal).toList()
}

View File

@@ -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<StructureViewTreeElement> =
element.getChildren().map { it as StructureViewTreeElement }
private fun label(element: StructureViewTreeElement): String = element.getPresentation().presentableText ?: ""
}