improved completion
This commit is contained in:
@@ -6,6 +6,7 @@ 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
|
||||
@@ -17,8 +18,11 @@ 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*$""")
|
||||
|
||||
@@ -43,6 +47,12 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
|
||||
) {
|
||||
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)
|
||||
@@ -52,12 +62,18 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +116,53 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -149,6 +212,23 @@ private class JaiCompletionContext(
|
||||
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 ""
|
||||
@@ -210,6 +290,11 @@ private class JaiCompletionContext(
|
||||
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,
|
||||
|
||||
@@ -3,22 +3,16 @@ package dev.hgh.jai.reference
|
||||
import com.intellij.openapi.util.TextRange
|
||||
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.module.JaiModuleResolver
|
||||
import dev.hgh.jai.module.JaiModuleTarget
|
||||
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.JaiPsiElementFactory
|
||||
@@ -115,109 +109,6 @@ private class JaiSymbolReference(
|
||||
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 class JaiModuleReference(
|
||||
private val sourceElement: PsiElement,
|
||||
rangeInElement: TextRange,
|
||||
|
||||
410
src/main/kotlin/dev/hgh/jai/reference/JaiSymbolResolver.kt
Normal file
410
src/main/kotlin/dev/hgh/jai/reference/JaiSymbolResolver.kt
Normal file
@@ -0,0 +1,410 @@
|
||||
package dev.hgh.jai.reference
|
||||
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.PsiReferenceService
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import dev.hgh.jai.lexer.JaiTokenTypes
|
||||
import dev.hgh.jai.module.JaiModuleResolver
|
||||
import dev.hgh.jai.module.JaiModuleTarget
|
||||
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.JaiForName
|
||||
import dev.hgh.jai.psi.JaiForStatement
|
||||
import dev.hgh.jai.psi.JaiInitializer
|
||||
import dev.hgh.jai.psi.JaiLiteralExpr
|
||||
import dev.hgh.jai.psi.JaiParamName
|
||||
import dev.hgh.jai.psi.JaiProcLiteralExpr
|
||||
import dev.hgh.jai.psi.JaiRefExpr
|
||||
import dev.hgh.jai.settings.JaiProjectSettings
|
||||
|
||||
private const val COMPLETION_DEBUG_PROPERTY = "jai.completion.debug"
|
||||
private val LOG = Logger.getInstance("dev.hgh.jai.reference.JaiSymbolResolver")
|
||||
private val RECOVERABLE_IMPORT =
|
||||
Regex("""(?m)(#(?:import|load))((?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*"((?:\\.|[^"\\])*)"""")
|
||||
private val RECOVERABLE_PROCEDURE =
|
||||
Regex("""(?s)(?:^|[;}\n])\s*[A-Za-z_][A-Za-z0-9_]*\s*::\s*\([^{}]*\)\s*(?:->[^\{;}]*)?\{""")
|
||||
private val RECOVERABLE_LOCAL_DECLARATION =
|
||||
Regex("""(?m)(?:^|[;{}])\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::=|:(?!=))""")
|
||||
|
||||
/** A declaration-like PSI element that can be inserted by identifier completion. */
|
||||
internal data class JaiSymbolCandidate(
|
||||
val element: PsiElement,
|
||||
val name: String,
|
||||
val kind: String = "symbol",
|
||||
)
|
||||
|
||||
/** Shared symbol lookup for references and symbol-aware completion. */
|
||||
internal object JaiSymbolResolver {
|
||||
private data class ScopedSymbol(
|
||||
val element: PsiElement,
|
||||
val name: String,
|
||||
val scope: PsiElement?,
|
||||
val fallbackScope: PsiElement?,
|
||||
val visibleFrom: Int,
|
||||
)
|
||||
|
||||
private data class ImportBinding(
|
||||
val file: PsiFile,
|
||||
val scope: PsiElement?,
|
||||
val visibleFrom: Int,
|
||||
val boundName: String?,
|
||||
val importsIntoScope: Boolean,
|
||||
val excludedNames: Set<String>,
|
||||
)
|
||||
|
||||
fun resolve(reference: JaiRefExpr): PsiElement? {
|
||||
val file = reference.containingFile ?: return null
|
||||
return completionCandidates(file, reference)
|
||||
.firstOrNull { it.name == reference.text }
|
||||
?.element
|
||||
}
|
||||
|
||||
/** Returns visible local declarations followed by visible unqualified imports. */
|
||||
fun completionCandidates(
|
||||
file: PsiFile,
|
||||
position: PsiElement,
|
||||
): List<JaiSymbolCandidate> {
|
||||
val candidates = linkedMapOf<String, JaiSymbolCandidate>()
|
||||
|
||||
val localSymbols = localSymbols(file)
|
||||
val recoveredLocals = recoverLocalSymbols(file, position)
|
||||
localSymbols
|
||||
.filter { isVisible(it, position) && !isInside(it.element, position) }
|
||||
.sortedWith(
|
||||
compareByDescending<ScopedSymbol> { scopeDepth(it.scope ?: it.fallbackScope) }
|
||||
.thenBy { it.visibleFrom }
|
||||
.thenBy { it.element.textOffset },
|
||||
).forEach { symbol ->
|
||||
candidates.putIfAbsent(
|
||||
symbol.name,
|
||||
JaiSymbolCandidate(symbol.element, symbol.name),
|
||||
)
|
||||
}
|
||||
|
||||
recoveredLocals.forEach { symbol ->
|
||||
candidates.putIfAbsent(symbol.name, symbol)
|
||||
}
|
||||
importedCandidates(file, position).forEach { symbol ->
|
||||
candidates.putIfAbsent(symbol.name, symbol)
|
||||
}
|
||||
val result = candidates.values.toList()
|
||||
debug(
|
||||
"file=${file.virtualFile?.path ?: file.name} roots=${JaiProjectSettings.getInstance(file.project).rootPaths()} " +
|
||||
"locals=${localSymbols.joinToString {
|
||||
"${it.name}@${it.element.textOffset}/scope=${it.scope?.textRange}/fallback=${it.fallbackScope?.textRange}"
|
||||
}} recovered=${recoveredLocals.joinToString { it.name }} " +
|
||||
"imports=${importBindings(file).joinToString { it.file.virtualFile?.path ?: it.file.name }} " +
|
||||
"candidates=${result.take(40).joinToString { it.name }}",
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Returns members of a module bound to [qualifier] in the visible lexical scope. */
|
||||
fun memberCompletionCandidates(
|
||||
file: PsiFile,
|
||||
position: PsiElement,
|
||||
qualifier: String,
|
||||
): List<JaiSymbolCandidate> {
|
||||
val binding =
|
||||
importBindings(file)
|
||||
.filter { it.boundName == normalizeName(qualifier) && isVisible(it, position) }
|
||||
.sortedWith(
|
||||
compareByDescending<ImportBinding> { scopeDepth(it.scope) }
|
||||
.thenByDescending { it.visibleFrom },
|
||||
).firstOrNull()
|
||||
?: return emptyList()
|
||||
|
||||
return topLevelDeclarations(binding.file)
|
||||
.asSequence()
|
||||
.filter { symbolName(it) !in binding.excludedNames }
|
||||
.mapNotNull { name ->
|
||||
symbolName(name)?.let { JaiSymbolCandidate(name, it, "member") }
|
||||
}.distinctBy { it.name }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun localSymbols(file: PsiFile): List<ScopedSymbol> {
|
||||
val declarations =
|
||||
PsiTreeUtil
|
||||
.findChildrenOfType(file, JaiDeclaration::class.java)
|
||||
.flatMap { declaration ->
|
||||
declaration.declNames.declNameList.mapNotNull { name ->
|
||||
symbolName(name)?.let {
|
||||
ScopedSymbol(
|
||||
element = name,
|
||||
name = it,
|
||||
scope = PsiTreeUtil.getParentOfType(name, JaiBlock::class.java),
|
||||
fallbackScope = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java),
|
||||
visibleFrom = name.textOffset,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val parameters =
|
||||
PsiTreeUtil
|
||||
.findChildrenOfType(file, JaiParamName::class.java)
|
||||
.mapNotNull { name ->
|
||||
val symbol = symbolName(name) ?: return@mapNotNull null
|
||||
val procedure = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java)
|
||||
ScopedSymbol(
|
||||
element = name,
|
||||
name = symbol,
|
||||
// Keep the procedure itself as the parameter scope. The body block is a
|
||||
// nested scope, so a local declaration correctly shadows a parameter.
|
||||
scope = procedure,
|
||||
fallbackScope = null,
|
||||
visibleFrom = procedure?.textOffset ?: name.textOffset,
|
||||
)
|
||||
}
|
||||
|
||||
val forNames =
|
||||
PsiTreeUtil
|
||||
.findChildrenOfType(file, JaiForName::class.java)
|
||||
.mapNotNull { name ->
|
||||
val symbol = symbolName(name) ?: return@mapNotNull null
|
||||
ScopedSymbol(
|
||||
element = name,
|
||||
name = symbol,
|
||||
scope = PsiTreeUtil.getParentOfType(name, JaiForStatement::class.java),
|
||||
fallbackScope = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java),
|
||||
visibleFrom = name.textOffset,
|
||||
)
|
||||
}
|
||||
|
||||
return declarations + parameters + forNames
|
||||
}
|
||||
|
||||
private fun importedCandidates(
|
||||
file: PsiFile,
|
||||
position: PsiElement,
|
||||
): List<JaiSymbolCandidate> =
|
||||
importBindings(file)
|
||||
.asSequence()
|
||||
.filter { (it.boundName == null || it.importsIntoScope) && isVisible(it, position) }
|
||||
.flatMap { binding ->
|
||||
topLevelDeclarations(binding.file)
|
||||
.asSequence()
|
||||
.filter { symbolName(it) !in binding.excludedNames }
|
||||
.mapNotNull { name ->
|
||||
symbolName(name)?.let { JaiSymbolCandidate(name, it, "import") }
|
||||
}
|
||||
}.distinctBy { it.name }
|
||||
.toList()
|
||||
|
||||
/** Recovers simple locals when an unfinished statement prevents the procedure PSI from closing. */
|
||||
private fun recoverLocalSymbols(
|
||||
file: PsiFile,
|
||||
position: PsiElement,
|
||||
): List<JaiSymbolCandidate> {
|
||||
val caretOffset = position.textOffset.coerceIn(0, file.textLength)
|
||||
val beforeCaret = file.text.substring(0, caretOffset)
|
||||
val procedure = RECOVERABLE_PROCEDURE.findAll(beforeCaret).lastOrNull() ?: return emptyList()
|
||||
val bodyStart = procedure.range.last + 1
|
||||
if (bodyStart >= beforeCaret.length) return emptyList()
|
||||
|
||||
return RECOVERABLE_LOCAL_DECLARATION
|
||||
.findAll(beforeCaret.substring(bodyStart))
|
||||
.map { match -> JaiSymbolCandidate(position, match.groupValues[1], "local") }
|
||||
.distinctBy { it.name }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun importBindings(file: PsiFile): List<ImportBinding> {
|
||||
val parsed =
|
||||
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
|
||||
|
||||
val declaration = boundDeclaration(literal)
|
||||
val boundName =
|
||||
declaration
|
||||
?.declNames
|
||||
?.declNameList
|
||||
?.firstOrNull()
|
||||
?.let(::symbolName)
|
||||
val declarationPrefix = declaration?.let { prefixBeforeNames(it) }.orEmpty()
|
||||
val importsIntoScope = declaration == null || declarationPrefix.trimStart().startsWith("using")
|
||||
val excludedNames = excludedNames(declarationPrefix)
|
||||
val target =
|
||||
PsiReferenceService
|
||||
.getService()
|
||||
.getContributedReferences(literal)
|
||||
.firstOrNull()
|
||||
?.resolve() as? PsiFile
|
||||
?: return@mapNotNull null
|
||||
|
||||
ImportBinding(
|
||||
file = target,
|
||||
scope = PsiTreeUtil.getParentOfType(literal, JaiBlock::class.java),
|
||||
visibleFrom = literal.textOffset,
|
||||
boundName = boundName,
|
||||
importsIntoScope = importsIntoScope,
|
||||
excludedNames = excludedNames,
|
||||
)
|
||||
}
|
||||
|
||||
// An unfinished statement immediately before the caret can make Grammar-Kit retain the
|
||||
// file's top-level declarations but omit later directive PSI. Recover bare imports from
|
||||
// the source text so completion still works while the user is typing.
|
||||
val recovered = if (parsed.isEmpty()) recoverImportBindings(file) else emptyList()
|
||||
return (parsed + recovered).distinctBy { binding ->
|
||||
listOf(
|
||||
binding.file.virtualFile?.path ?: binding.file.name,
|
||||
binding.scope?.textRange?.startOffset,
|
||||
binding.boundName,
|
||||
binding.visibleFrom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverImportBindings(file: PsiFile): List<ImportBinding> {
|
||||
val sourceFile = file.virtualFile ?: return emptyList()
|
||||
return RECOVERABLE_IMPORT
|
||||
.findAll(file.text)
|
||||
.mapNotNull { match ->
|
||||
val directive = match.groupValues[1]
|
||||
val flags =
|
||||
Regex("""[A-Za-z_][A-Za-z0-9_]*""")
|
||||
.findAll(match.groupValues[2])
|
||||
.map { it.value }
|
||||
.toSet()
|
||||
if ("string" in flags) return@mapNotNull null
|
||||
val path = unescapeImportPath(match.groupValues[3])
|
||||
val target =
|
||||
JaiModuleResolver.resolve(
|
||||
file.project,
|
||||
sourceFile,
|
||||
JaiModuleTarget(directive, path, flags),
|
||||
) ?: return@mapNotNull null
|
||||
val targetPsi = PsiManager.getInstance(file.project).findFile(target) ?: return@mapNotNull null
|
||||
ImportBinding(
|
||||
file = targetPsi,
|
||||
scope = null,
|
||||
visibleFrom = match.range.first,
|
||||
boundName = null,
|
||||
importsIntoScope = true,
|
||||
excludedNames = emptySet(),
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun unescapeImportPath(text: String): String =
|
||||
buildString(text.length) {
|
||||
var index = 0
|
||||
while (index < text.length) {
|
||||
if (text[index] == '\\' && index + 1 < text.length) {
|
||||
append(text[index + 1])
|
||||
index += 2
|
||||
} else {
|
||||
append(text[index++])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The declaration that binds an import, not an enclosing procedure declaration. */
|
||||
private fun boundDeclaration(literal: JaiLiteralExpr): JaiDeclaration? {
|
||||
val initializer = PsiTreeUtil.getParentOfType(literal, JaiInitializer::class.java) ?: return null
|
||||
return PsiTreeUtil.getParentOfType(initializer, JaiDeclaration::class.java)
|
||||
}
|
||||
|
||||
private fun prefixBeforeNames(declaration: JaiDeclaration): String {
|
||||
val start = declaration.textRange.startOffset
|
||||
val end = declaration.declNames.textRange.startOffset
|
||||
return declaration.containingFile.text.substring(start, end)
|
||||
}
|
||||
|
||||
private fun excludedNames(prefix: String): Set<String> {
|
||||
val body = Regex("""except\s*\(([^)]*)\)""").find(prefix)?.groupValues?.get(1) ?: return emptySet()
|
||||
return body
|
||||
.split(',')
|
||||
.map { normalizeName(it.trim()) }
|
||||
.filter(String::isNotEmpty)
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun topLevelDeclarations(file: PsiFile): List<JaiDeclName> =
|
||||
PsiTreeUtil
|
||||
.findChildrenOfType(file, JaiDeclaration::class.java)
|
||||
.asSequence()
|
||||
.flatMap { declaration -> declaration.declNames.declNameList.asSequence() }
|
||||
.filter { PsiTreeUtil.getParentOfType(it, JaiBlock::class.java) == null }
|
||||
.sortedBy { it.textOffset }
|
||||
.toList()
|
||||
|
||||
private fun isVisible(
|
||||
symbol: ScopedSymbol,
|
||||
position: PsiElement,
|
||||
): Boolean {
|
||||
val inScope =
|
||||
when {
|
||||
symbol.scope == null && symbol.fallbackScope == null -> true
|
||||
symbol.scope?.textRange?.contains(position.textOffset) == true -> true
|
||||
symbol.fallbackScope?.textRange?.contains(position.textOffset) == true -> true
|
||||
else -> false
|
||||
}
|
||||
if (!inScope) return false
|
||||
// Block-local declarations and loop names are not visible before their declaration. Keep
|
||||
// top-level declarations available for forward references, which Jai permits.
|
||||
if (symbol.scope != null && symbol.visibleFrom > position.textOffset) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isVisible(
|
||||
binding: ImportBinding,
|
||||
position: PsiElement,
|
||||
): Boolean =
|
||||
// File-scope imports are declarations for the whole file even when the directive is
|
||||
// written below its first use (a common Jai layout). Block-local imports remain ordered.
|
||||
(binding.scope == null || binding.scope.textRange.contains(position.textOffset)) &&
|
||||
(binding.scope == null || binding.visibleFrom <= position.textOffset)
|
||||
|
||||
private fun isInside(
|
||||
element: PsiElement,
|
||||
position: PsiElement,
|
||||
): Boolean = element == position || element.textRange.contains(position.textOffset)
|
||||
|
||||
private fun scopeDepth(scope: PsiElement?): Int {
|
||||
var current = scope
|
||||
var depth = 0
|
||||
while (current != null) {
|
||||
if (current is JaiBlock || current is JaiForStatement || current is JaiProcLiteralExpr) depth++
|
||||
current = current.parent
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
private fun debug(message: String) {
|
||||
if (java.lang.Boolean.getBoolean(COMPLETION_DEBUG_PROPERTY)) LOG.info(message)
|
||||
}
|
||||
|
||||
private fun symbolName(element: PsiElement): String? {
|
||||
val text = element.text.trim()
|
||||
if (text.isEmpty()) return null
|
||||
val name =
|
||||
when (element) {
|
||||
is JaiParamName -> text.removePrefix("$$").removePrefix("$")
|
||||
else -> text
|
||||
}
|
||||
return normalizeName(name).takeIf(String::isNotEmpty)
|
||||
}
|
||||
|
||||
private fun normalizeName(name: String): String {
|
||||
val trimmed = name.trim()
|
||||
return if (trimmed.length >= 2 && trimmed.first() == '`' && trimmed.last() == '`') {
|
||||
trimmed.substring(1, trimmed.length - 1)
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JaiDirectiveExpr.directiveName(): String? = node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.hgh.jai.settings
|
||||
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.PathMacroManager
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.Storage
|
||||
@@ -62,7 +63,7 @@ class JaiProjectSettings(
|
||||
val trimmed = rawPath.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val expanded =
|
||||
var expanded =
|
||||
when {
|
||||
trimmed == "~" -> {
|
||||
System.getProperty("user.home")
|
||||
@@ -76,6 +77,20 @@ class JaiProjectSettings(
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
// Workspace files may persist project-relative roots using IntelliJ path macros. The
|
||||
// settings UI normally supplies absolute paths, but loading a project saved elsewhere
|
||||
// must expand these before the resolver tries to find the VFS root.
|
||||
expanded = PathMacroManager.getInstance(project).expandPath(expanded)
|
||||
expanded =
|
||||
expanded
|
||||
.replace("\$USER_HOME\$", System.getProperty("user.home"))
|
||||
.replace("\$USER_HOME", System.getProperty("user.home"))
|
||||
.let { value ->
|
||||
val basePath = project.basePath ?: return@let value
|
||||
value
|
||||
.replace("\$PROJECT_DIR\$", basePath)
|
||||
.replace("\$PROJECT_DIR", basePath)
|
||||
}
|
||||
val file = File(expanded)
|
||||
val absolute =
|
||||
if (file.isAbsolute) {
|
||||
|
||||
Reference in New Issue
Block a user