Files
intellijai/src/main/kotlin/dev/hgh/jai/completion/JaiCompletionContributor.kt
2026-08-05 16:32:40 -04:00

434 lines
14 KiB
Kotlin

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.diagnostic.Logger
import com.intellij.openapi.project.DumbAware
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.module.JaiModuleResolver
import dev.hgh.jai.module.JaiPathMode
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.reference.JaiSymbolResolver
/** Directives and their adjacent comma flags before an import/load string. */
private const val COMPLETION_DEBUG_PROPERTY = "jai.completion.debug"
private val LOG = Logger.getInstance("dev.hgh.jai.completion")
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
debug(
"request file=${parameters.originalFile.virtualFile?.path ?: parameters.originalFile.name} " +
"offset=${parameters.offset} token=${completionContext.debugToken} " +
"prefix='${completionContext.prefix}' module=$moduleDirective " +
"member=${completionContext.memberContext}",
)
when {
moduleDirective != null -> {
addModuleNames(parameters, moduleDirective, completionContext.prefix, resultSet)
}
completionContext.isCommentOrStringOrHereString -> {
return
}
completionContext.memberContext != null -> {
val member = completionContext.memberContext!!
addMemberSymbols(parameters, member, resultSet)
}
completionContext.isDirectivePrefix -> {
addDirectives(completionContext.prefix, resultSet)
}
else -> {
addKeywords(completionContext.prefix, resultSet)
addSymbols(parameters, 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 addSymbols(
parameters: CompletionParameters,
prefix: String,
resultSet: CompletionResultSet,
) {
val candidates = JaiSymbolResolver.completionCandidates(parameters.originalFile, parameters.position)
debug("unqualified candidates=${candidates.size} names=${candidates.take(40).joinToString { it.name }}")
candidates
.filter { it.name.startsWith(prefix) }
.forEach { candidate ->
resultSet.addElement(
LookupElementBuilder
.create(candidate.name)
.withPsiElement(candidate.element)
.withTypeText(candidate.kind),
)
}
}
private fun addMemberSymbols(
parameters: CompletionParameters,
member: JaiCompletionContext.MemberContext,
resultSet: CompletionResultSet,
) {
val candidates =
JaiSymbolResolver.memberCompletionCandidates(
parameters.originalFile,
parameters.position,
member.qualifier,
)
debug("member qualifier=${member.qualifier} candidates=${candidates.size} names=${candidates.take(40).joinToString { it.name }}")
candidates
.filter { it.name.startsWith(member.prefix) }
.forEach { candidate ->
resultSet.addElement(
LookupElementBuilder
.create(candidate.name)
.withPsiElement(candidate.element)
.withTypeText(candidate.kind),
)
}
}
private fun debug(message: String) {
if (java.lang.Boolean.getBoolean(COMPLETION_DEBUG_PROPERTY)) LOG.info(message)
}
private fun addModuleNames(
parameters: CompletionParameters,
directive: String,
prefix: String,
resultSet: CompletionResultSet,
) {
val sourceFile = parameters.originalFile.virtualFile
val project = parameters.originalFile.project
val variants =
if (directive == "#import") {
JaiModuleCompletion.moduleNames(project, sourceFile)
} else {
JaiModuleCompletion.loadPaths(project, 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 debugToken: String
get() = token?.let { "${it.type}@${it.start}..${it.end}" } ?: "none"
data class MemberContext(
val qualifier: String,
val prefix: String,
)
val memberContext: MemberContext?
get() =
MEMBER_PREFIX.find(text.substring(0, offset))?.let { match ->
MemberContext(
qualifier = match.groupValues[1],
prefix = match.groupValues.getOrNull(2).orEmpty(),
)
}
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 companion object {
val MEMBER_PREFIX =
Regex("""(?:^|[^A-Za-z0-9_`])(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*\.\s*(`?[A-Za-z_][A-Za-z0-9_]*`?)?$""")
}
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"
fun moduleNames(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(JaiModuleResolver.candidateRoots(project, sourceFile, JaiPathMode.MODULE)) { child ->
when {
child.isDirectory && child.findChild("module.jai") != null -> child.name
!child.isDirectory && child.extension == JAI_EXTENSION -> child.nameWithoutExtension
else -> null
}
}
fun loadPaths(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(JaiModuleResolver.candidateRoots(project, sourceFile, JaiPathMode.FILE)) { 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()
}
}