Phase 4: add structure, folding, and references
This commit is contained in:
29
AGENTS.md
29
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)
|
||||
|
||||
|
||||
10
CHANGELOG.md
10
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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
99
src/main/kotlin/dev/hgh/jai/editor/JaiFoldingBuilder.kt
Normal file
99
src/main/kotlin/dev/hgh/jai/editor/JaiFoldingBuilder.kt
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
250
src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt
Normal file
250
src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt
Normal 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()
|
||||
}
|
||||
114
src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewElement.kt
Normal file
114
src/main/kotlin/dev/hgh/jai/structure/JaiStructureViewElement.kt
Normal 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" }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; |}")
|
||||
}
|
||||
|
||||
65
src/test/kotlin/dev/hgh/jai/editor/JaiFoldingBuilderTest.kt
Normal file
65
src/test/kotlin/dev/hgh/jai/editor/JaiFoldingBuilderTest.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
101
src/test/kotlin/dev/hgh/jai/reference/JaiReferenceTest.kt
Normal file
101
src/test/kotlin/dev/hgh/jai/reference/JaiReferenceTest.kt
Normal 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()
|
||||
}
|
||||
@@ -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 ?: ""
|
||||
}
|
||||
Reference in New Issue
Block a user