Phase 5: add completion and symbol navigation
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user