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

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