Phase 5: add completion and symbol navigation

This commit is contained in:
hgranthorner
2026-08-04 15:25:11 -04:00
parent c4e1d92c9a
commit 4758bf12e2
8 changed files with 696 additions and 11 deletions

View File

@@ -0,0 +1,370 @@
package dev.hgh.jai.completion
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionProvider
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.tree.IElementType
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.ProcessingContext
import dev.hgh.jai.lexer.JaiLexer
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.psi.JaiDirectiveExpr
/** Directives and their adjacent comma flags before an import/load string. */
private val DIRECTIVE_WITH_FLAGS =
Regex("""#(import|load)((?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*$""")
/** Basic completion for Jai's context-free language and module names. */
class JaiCompletionContributor :
CompletionContributor(),
DumbAware {
init {
extend(
CompletionType.BASIC,
PlatformPatterns.psiElement(),
JaiCompletionProvider(),
)
}
}
private class JaiCompletionProvider : CompletionProvider<CompletionParameters>() {
override fun addCompletions(
parameters: CompletionParameters,
context: ProcessingContext,
resultSet: CompletionResultSet,
) {
val completionContext = JaiCompletionContext(parameters)
val moduleDirective = completionContext.moduleDirective
when {
moduleDirective != null -> {
addModuleNames(parameters, moduleDirective, completionContext.prefix, resultSet)
}
completionContext.isCommentOrStringOrHereString -> {
return
}
completionContext.isDirectivePrefix -> {
addDirectives(completionContext.prefix, resultSet)
}
else -> {
addKeywords(completionContext.prefix, resultSet)
}
}
}
private fun addKeywords(
prefix: String,
resultSet: CompletionResultSet,
) {
JaiTokenTypes.KEYWORD_MAP.keys
.filter { it.startsWith(prefix) }
.sorted()
.forEach { keyword ->
resultSet.addElement(
LookupElementBuilder
.create(keyword)
.withTypeText("keyword"),
)
}
}
private fun addDirectives(
prefix: String,
resultSet: CompletionResultSet,
) {
JaiCompletionCatalog.DIRECTIVES
.map { "#$it" }
.filter { it.startsWith(prefix) }
.forEach { directive ->
val name = directive.removePrefix("#")
resultSet.addElement(
LookupElementBuilder
// Keep the insertion text free of `#`: the prefix starts after
// `#` for a directive token, so inserting `#import` would yield
// `##import`. The alternate lookup string still matches `#im`.
.create(name)
.withPresentableText(directive)
.withLookupString(directive)
.withTypeText("directive"),
)
}
}
private fun addModuleNames(
parameters: CompletionParameters,
directive: String,
prefix: String,
resultSet: CompletionResultSet,
) {
val sourceFile = parameters.originalFile.virtualFile
val variants =
if (directive == "#import") {
JaiModuleCompletion.moduleNames(parameters.originalFile.project, sourceFile)
} else {
JaiModuleCompletion.loadPaths(sourceFile)
}
variants
.filter { it.startsWith(prefix) }
.forEach { path ->
resultSet.addElement(
LookupElementBuilder
.create(path)
.withTypeText(if (directive == "#import") "module" else "file"),
)
}
}
}
private class JaiCompletionContext(
private val parameters: CompletionParameters,
) {
private val text =
parameters.editor.document.charsSequence
.toString()
private val offset = parameters.offset.coerceIn(0, text.length)
private val token = tokenAtCaret(text, offset)
val isCommentOrStringOrHereString: Boolean
get() =
token?.type in
setOf(
JaiTokenTypes.LINE_COMMENT,
JaiTokenTypes.BLOCK_COMMENT,
JaiTokenTypes.STRING,
JaiTokenTypes.HERE_STRING,
JaiTokenTypes.NOTE,
)
val isDirectivePrefix: Boolean
get() = token?.type === JaiTokenTypes.DIRECTIVE || token?.type === JaiTokenTypes.HASH
val prefix: String
get() {
val current = token ?: return ""
val start =
when {
moduleDirective != null && current.type === JaiTokenTypes.STRING -> current.start + 1
isDirectivePrefix -> current.start
current.type === JaiTokenTypes.IDENT || current.type in JaiTokenTypes.KEYWORDS -> current.start
else -> return ""
}
return if (start <= offset) text.substring(start, offset) else ""
}
val moduleDirective: String?
get() {
if (token?.type !== JaiTokenTypes.STRING || token.end < offset) return null
if (isAfterClosedString()) return null
val beforeString = text.substring(0, token.start)
val directiveMatch = DIRECTIVE_WITH_FLAGS.find(beforeString)
val directive =
PsiTreeUtil
.getParentOfType(parameters.position, JaiDirectiveExpr::class.java)
?.node
?.findChildByType(JaiTokenTypes.DIRECTIVE)
?.text
?: directiveMatch?.let { "#${it.groupValues[1]}" }
if (directive != "#import" && directive != "#load") return null
if (directive == "#import" && hasStringFlag(directiveMatch?.groupValues?.get(2).orEmpty())) {
return null
}
return directive
}
private fun hasStringFlag(flags: String): Boolean =
Regex("""[A-Za-z_][A-Za-z0-9_]*""")
.findAll(flags)
.any { it.value == "string" }
private fun isAfterClosedString(): Boolean {
if (token == null || token.end != offset || token.start >= offset) return false
val tokenText = text.substring(token.start, offset)
return tokenText.length > 1 && tokenText.last() == '"' && isUnescapedQuote(tokenText.lastIndex)
}
private fun isUnescapedQuote(index: Int): Boolean {
var backslashes = 0
var position = index - 1
while (position >= 0 && text[token!!.start + position] == '\\') {
backslashes++
position--
}
return backslashes % 2 == 0
}
private data class LexedToken(
val type: IElementType,
val start: Int,
val end: Int,
)
private fun tokenAtCaret(
text: String,
offset: Int,
): LexedToken? {
val lexer = JaiLexer()
lexer.start(text, 0, offset, 0)
var last: LexedToken? = null
while (lexer.tokenType != null) {
val current =
LexedToken(
lexer.tokenType!!,
lexer.tokenStart,
lexer.tokenEnd,
)
last = current
if (current.end >= offset) return current
lexer.advance()
}
return last
}
}
private object JaiCompletionCatalog {
/** Core/compiler directives observed in the local Jai distribution. */
val DIRECTIVES: List<String> =
setOf(
"add_context",
"align",
"as",
"asm",
"assert",
"bake",
"bake_arguments",
"bake_constants",
"body_text",
"bytes",
"c_call",
"caller_location",
"char",
"code",
"complete",
"compiler",
"cpp_method",
"cpp_return_type_is_non_pod",
"define",
"deprecated",
"discard",
"dump",
"else",
"elsewhere",
"expand",
"file",
"filepath",
"foreign",
"if",
"ifdef",
"import",
"include",
"insert",
"intrinsic",
"library",
"load",
"location",
"module_parameters",
"modify",
"no_abc",
"no_alias",
"no_aoc",
"no_context",
"no_debug",
"no_padding",
"no_reset",
"overlay",
"place",
"placeholder",
"placeholders",
"poke_name",
"pragma",
"procedure_of_call",
"program_export",
"run",
"run_and_insert",
"scope_export",
"scope_file",
"scope_module",
"specified",
"string",
"symmetric",
"this",
"through",
"type",
"type_info_none",
"type_info_no_size_complaint",
"type_info_procedures_are_void_pointers",
"undef",
"version",
).sorted()
}
private object JaiModuleCompletion {
private const val JAI_EXTENSION = "jai"
private val fileSystem: LocalFileSystem
get() = LocalFileSystem.getInstance()
fun moduleNames(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(candidateRoots(project, sourceFile, moduleMode = true)) { child ->
when {
child.isDirectory && child.findChild("module.jai") != null -> child.name
!child.isDirectory && child.extension == JAI_EXTENSION -> child.nameWithoutExtension
else -> null
}
}
fun loadPaths(sourceFile: VirtualFile?): List<String> =
collectCandidates(
listOfNotNull(sourceFile?.parent),
) { child ->
if (!child.isDirectory && child.extension == JAI_EXTENSION) child.name else null
}
private fun collectCandidates(
roots: List<VirtualFile>,
nameOf: (VirtualFile) -> String?,
): List<String> {
val names = linkedSetOf<String>()
roots.forEach { root ->
root.children
.asSequence()
.mapNotNull(nameOf)
.forEach(names::add)
}
return names.sorted()
}
private fun candidateRoots(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
moduleMode: Boolean,
): List<VirtualFile> {
val roots = linkedMapOf<String, VirtualFile>()
fun add(root: VirtualFile?) {
if (root != null && root.isValid && root.isDirectory) roots.putIfAbsent(root.path, root)
}
if (moduleMode) {
add(sourceFile?.parent?.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 {
add(sourceFile?.parent)
}
return roots.values.toList()
}
}

View File

@@ -6,24 +6,35 @@ 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.PsiFile
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.psi.PsiReferenceService
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.ProcessingContext
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.psi.JaiBlock
import dev.hgh.jai.psi.JaiDeclName
import dev.hgh.jai.psi.JaiDeclaration
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiRefExpr
/** Adds file references to string operands of Jai's import and load directives. */
/** Adds file references for directives and symbol references for Jai identifiers. */
class JaiReferenceContributor : PsiReferenceContributor() {
override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) {
registrar.registerReferenceProvider(
PlatformPatterns.psiElement(JaiLiteralExpr::class.java),
JaiModuleReferenceProvider(),
)
registrar.registerReferenceProvider(
PlatformPatterns.psiElement(JaiRefExpr::class.java),
JaiSymbolReferenceProvider(),
)
}
}
@@ -78,6 +89,129 @@ private class JaiModuleReferenceProvider : PsiReferenceProvider() {
}
}
/** Adds references from identifiers to declarations in the same or imported Jai files. */
private class JaiSymbolReferenceProvider : PsiReferenceProvider() {
override fun getReferencesByElement(
element: PsiElement,
context: ProcessingContext,
): Array<PsiReference> {
val reference = element as? JaiRefExpr ?: return PsiReference.EMPTY_ARRAY
if (reference.text.isEmpty()) return PsiReference.EMPTY_ARRAY
return arrayOf(JaiSymbolReference(reference))
}
}
private class JaiSymbolReference(
private val sourceElement: JaiRefExpr,
) : PsiReferenceBase<JaiRefExpr>(sourceElement, TextRange(0, sourceElement.textLength), true) {
override fun resolve(): PsiElement? = JaiSymbolResolver.resolve(sourceElement)
override fun getVariants(): Array<Any> = emptyArray()
}
private object JaiSymbolResolver {
private data class Candidate(
val name: JaiDeclName,
val scope: JaiBlock?,
)
fun resolve(reference: JaiRefExpr): JaiDeclName? {
val file = reference.containingFile ?: return null
resolveInFile(file, reference)?.let { return it }
return resolveImported(file, reference.text, linkedSetOf())
}
private fun resolveInFile(
file: PsiFile,
reference: JaiRefExpr,
): JaiDeclName? {
val candidates =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.flatMap { declaration ->
declaration.declNames.declNameList.map { name ->
Candidate(name, PsiTreeUtil.getParentOfType(name, JaiBlock::class.java))
}
}.filter { candidate ->
symbolName(candidate.name) == reference.text && isVisible(candidate, reference)
}.sortedWith(
compareByDescending<Candidate> { scopeDepth(it.scope) }
.thenByDescending {
it.name.textRange.startOffset <= reference.textRange.startOffset
}.thenBy {
kotlin.math.abs(it.name.textRange.startOffset - reference.textRange.startOffset)
},
)
return candidates.firstOrNull()?.name
}
private fun resolveImported(
file: PsiFile,
referenceName: String,
visited: MutableSet<String>,
): JaiDeclName? {
val fileKey = file.virtualFile?.path ?: file.name
if (!visited.add(fileKey)) return null
for (importedFile in importedFiles(file)) {
topLevelDeclaration(importedFile, referenceName)?.let { return it }
resolveImported(importedFile, referenceName, visited)?.let { return it }
}
return null
}
private fun topLevelDeclaration(
file: PsiFile,
referenceName: String,
): JaiDeclName? =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.asSequence()
.flatMap { declaration -> declaration.declNames.declNameList.asSequence() }
.firstOrNull {
PsiTreeUtil.getParentOfType(it, JaiBlock::class.java) == null &&
symbolName(it) == referenceName
}
private fun importedFiles(file: PsiFile): List<PsiFile> =
PsiTreeUtil
.findChildrenOfType(file, JaiLiteralExpr::class.java)
.mapNotNull { literal ->
val directive = literal.parent as? JaiDirectiveExpr ?: return@mapNotNull null
val name = directive.directiveName() ?: return@mapNotNull null
if (name != "#import" && name != "#load") return@mapNotNull null
PsiReferenceService
.getService()
.getContributedReferences(literal)
.firstOrNull()
?.resolve() as? PsiFile
}.distinctBy { it.virtualFile?.path ?: it.name }
private fun isVisible(
candidate: Candidate,
reference: JaiRefExpr,
): Boolean = candidate.scope == null || PsiTreeUtil.isAncestor(candidate.scope, reference, false)
private fun scopeDepth(scope: JaiBlock?): Int {
var current: PsiElement? = scope
var depth = 0
while (current != null) {
if (current is JaiBlock) depth++
current = current.parent
}
return depth
}
private fun symbolName(name: JaiDeclName): String {
val text = name.text
return if (text.length >= 2 && text.first() == '`' && text.last() == '`') {
text.substring(1, text.length - 1)
} else {
text
}
}
}
private data class JaiModuleTarget(
val directive: String,
val path: String,