Improve plugin symbol accuracy in completion and docs

This commit is contained in:
sergeych committed 2026-02-15 11:05:57 +03:00
1 parent 3f2c38c471
commit b233d4c15f
7 files changed
+212 -34

No files matched your search

@@ -74,29 +74,30 @@ object CompletionEngineLight {
val word = DocLookupUtils.wordRangeAt(text, caret)
val memberDot = DocLookupUtils.findDotLeft(text, word?.first ?: caret)
if (memberDot != null) {
val staticOnly = DocLookupUtils.isStaticReceiver(mini, text, memberDot, imported, binding)
val inferredCls = (DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDot, imported, binding) ?: DocLookupUtils.guessReceiverClass(text, memberDot, imported, mini))
// 0) Try chained member call return type inference
DocLookupUtils.guessReturnClassFromMemberCallBeforeMini(mini, text, memberDot, imported, binding)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
offerMembersAdd(out, prefix, imported, cls, mini, staticOnly)
return out
}
DocLookupUtils.guessReturnClassFromMemberCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
offerMembersAdd(out, prefix, imported, cls, mini, staticOnly)
return out
}
// 0a) Top-level call before dot
DocLookupUtils.guessReturnClassFromTopLevelCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
offerMembersAdd(out, prefix, imported, cls, mini, staticOnly)
return out
}
// 0b) Across-known-callees (Iterable/Iterator/List preference)
DocLookupUtils.guessReturnClassAcrossKnownCallees(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
offerMembersAdd(out, prefix, imported, cls, mini, staticOnly)
return out
}
// 1) Receiver inference fallback
(DocLookupUtils.guessReceiverClassViaMini(mini, text, memberDot, imported, binding) ?: DocLookupUtils.guessReceiverClass(text, memberDot, imported, mini))?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
offerMembersAdd(out, prefix, imported, cls, mini, staticOnly)
return out
}
// In member context and unknown receiver/return type: show nothing (no globals after dot)
@@ -106,10 +107,20 @@ object CompletionEngineLight {
// Global identifiers: params > local decls > imported > stdlib; Functions > Classes > Values; alphabetical
offerParamsInScope(out, prefix, mini, text, caret)
val locals = DocLookupUtils.extractLocalsAt(text, caret)
for (name in locals) {
if (name.startsWith(prefix, true)) {
out.add(CompletionItem(name, Kind.Value, priority = 150.0))
val localsFromBinding = DocLookupUtils.collectLocalsFromBinding(mini, binding, caret)
if (localsFromBinding.isNotEmpty()) {
for (sym in localsFromBinding) {
if (sym.name.startsWith(prefix, true)) {
val t = sym.type?.let { ": $it" }
out.add(CompletionItem(sym.name, Kind.Value, typeText = t, priority = 150.0))
}
}
} else {
val locals = DocLookupUtils.extractLocalsAt(text, caret)
for (name in locals) {
if (name.startsWith(prefix, true)) {
out.add(CompletionItem(name, Kind.Value, priority = 150.0))
}
}
}
@@ -238,7 +249,7 @@ object CompletionEngineLight {
}
}
private fun offerMembersAdd(out: MutableList<CompletionItem>, prefix: String, imported: List<String>, className: String, mini: MiniScript? = null) {
private fun offerMembersAdd(out: MutableList<CompletionItem>, prefix: String, imported: List<String>, className: String, mini: MiniScript? = null, staticOnly: Boolean = false) {
val classes = DocLookupUtils.aggregateClasses(imported, mini)
val visited = mutableSetOf<String>()
val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
@@ -247,10 +258,15 @@ object CompletionEngineLight {
fun addMembersOf(name: String, direct: Boolean) {
val cls = classes[name] ?: return
val target = if (direct) directMap else inheritedMap
for (cf in cls.ctorFields + cls.classFields) {
target.getOrPut(cf.name) { mutableListOf() }.add(DocLookupUtils.toMemberVal(cf))
if (!staticOnly) {
for (cf in cls.ctorFields + cls.classFields) {
target.getOrPut(cf.name) { mutableListOf() }.add(DocLookupUtils.toMemberVal(cf))
}
}
for (m in cls.members) {
if (staticOnly && !m.isStatic) continue
target.getOrPut(m.name) { mutableListOf() }.add(m)
}
for (m in cls.members) target.getOrPut(m.name) { mutableListOf() }.add(m)
for (b in cls.bases) if (visited.add(b)) addMembersOf(b, false)
}
@@ -310,7 +326,7 @@ object CompletionEngineLight {
emitGroup(inheritedMap, 0.0)
// Supplement with extension members (both stdlib and local)
run {
if (!staticOnly) run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val extensions = DocLookupUtils.collectExtensionMemberNames(imported, className, mini)
for (name in extensions) {
@@ -20,6 +20,7 @@
*/
package net.sergeych.lyng.miniast
import net.sergeych.lyng.Pos
import net.sergeych.lyng.binding.BindingSnapshot
import net.sergeych.lyng.highlight.offsetOf
@@ -301,32 +302,42 @@ object DocLookupUtils {
isExtern = false
)
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String, localMini: MiniScript? = null): Pair<String, MiniNamedDecl>? {
fun resolveMemberWithInheritance(
importedModules: List<String>,
className: String,
member: String,
localMini: MiniScript? = null,
staticOnly: Boolean = false
): Pair<String, MiniNamedDecl>? {
val classes = aggregateClasses(importedModules, localMini)
fun dfs(name: String, visited: MutableSet<String>): Pair<String, MiniNamedDecl>? {
if (!visited.add(name)) return null
val cls = classes[name]
if (cls != null) {
cls.members.firstOrNull { it.name == member }?.let { return name to it }
cls.ctorFields.firstOrNull { it.name == member }?.let { return name to toMemberVal(it) }
cls.classFields.firstOrNull { it.name == member }?.let { return name to toMemberVal(it) }
cls.members.firstOrNull { it.name == member && (!staticOnly || it.isStatic) }?.let { return name to it }
if (!staticOnly) {
cls.ctorFields.firstOrNull { it.name == member }?.let { return name to toMemberVal(it) }
cls.classFields.firstOrNull { it.name == member }?.let { return name to toMemberVal(it) }
}
for (baseName in cls.bases) {
dfs(baseName, visited)?.let { return it }
}
}
// 1) local extensions in this class or bases
localMini?.declarations?.firstOrNull { d ->
(d is MiniFunDecl && d.receiver != null && simpleClassNameOf(d.receiver) == name && d.name == member) ||
(d is MiniValDecl && d.receiver != null && simpleClassNameOf(d.receiver) == name && d.name == member)
}?.let { return name to it as MiniNamedDecl }
// 2) built-in extensions from BuiltinDocRegistry
for (mod in importedModules) {
val decls = BuiltinDocRegistry.docsForModule(mod)
decls.firstOrNull { d ->
if (!staticOnly) {
// 1) local extensions in this class or bases
localMini?.declarations?.firstOrNull { d ->
(d is MiniFunDecl && d.receiver != null && simpleClassNameOf(d.receiver) == name && d.name == member) ||
(d is MiniValDecl && d.receiver != null && simpleClassNameOf(d.receiver) == name && d.name == member)
}?.let { return name to it as MiniNamedDecl }
// 2) built-in extensions from BuiltinDocRegistry
for (mod in importedModules) {
val decls = BuiltinDocRegistry.docsForModule(mod)
decls.firstOrNull { d ->
(d is MiniFunDecl && d.receiver != null && simpleClassNameOf(d.receiver) == name && d.name == member) ||
(d is MiniValDecl && d.receiver != null && simpleClassNameOf(d.receiver) == name && d.name == member)
}?.let { return name to it as MiniNamedDecl }
}
}
return null
@@ -430,6 +441,7 @@ object DocLookupUtils {
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
simpleClassNameOfType(sym.type)?.let { return it }
val type = findTypeByRange(mini, sym.name, sym.declStart, text, imported)
simpleClassNameOf(type)?.let { return it }
}
@@ -437,6 +449,7 @@ object DocLookupUtils {
// Check if it's a declaration (e.g. static access to a class)
val sym = binding.symbols.firstOrNull { it.declStart == wordRange.first && it.name == ident }
if (sym != null) {
simpleClassNameOfType(sym.type)?.let { return it }
val type = findTypeByRange(mini, sym.name, sym.declStart, text, imported)
simpleClassNameOf(type)?.let { return it }
// if it's a class/enum, return its name directly
@@ -1042,6 +1055,16 @@ object DocLookupUtils {
is MiniTypeIntersection -> null
}
fun simpleClassNameOfType(type: String?): String? {
if (type.isNullOrBlank()) return null
var t = type.trim()
if (t.endsWith("?")) t = t.dropLast(1)
val first = t.split('|', '&').firstOrNull()?.trim() ?: return null
val base = first.substringBefore('<').trim()
val short = base.substringAfterLast('.').trim()
return short.ifBlank { null }
}
fun typeOf(t: MiniTypeRef?): String = when (t) {
is MiniTypeName -> t.segments.joinToString(".") { it.name } + (if (t.nullable) "?" else "")
is MiniGenericType -> typeOf(t.base) + "<" + t.args.joinToString(", ") { typeOf(it) } + ">" + (if (t.nullable) "?" else "")
@@ -1055,6 +1078,90 @@ object DocLookupUtils {
null -> ""
}
fun collectLocalsFromBinding(mini: MiniScript?, binding: BindingSnapshot?, offset: Int): List<net.sergeych.lyng.binding.Symbol> {
if (mini == null || binding == null) return emptyList()
val src = mini.range.start.source
data class FnCtx(val nameStart: Pos, val body: MiniRange)
fun consider(nameStart: Pos, body: MiniRange?, best: FnCtx?): FnCtx? {
if (body == null) return best
val start = src.offsetOf(body.start)
val end = src.offsetOf(body.end)
if (offset < start || offset > end) return best
val len = end - start
val bestLen = best?.let { src.offsetOf(it.body.end) - src.offsetOf(it.body.start) } ?: Int.MAX_VALUE
return if (len < bestLen) FnCtx(nameStart, body) else best
}
var best: FnCtx? = null
for (d in mini.declarations) {
when (d) {
is MiniFunDecl -> best = consider(d.nameStart, d.body?.range, best)
is MiniClassDecl -> {
for (m in d.members) {
if (m is MiniMemberFunDecl) {
best = consider(m.nameStart, m.body?.range, best)
}
}
}
else -> {}
}
}
val fn = best ?: return emptyList()
val fnDeclStart = src.offsetOf(fn.nameStart)
val fnSym = binding.symbols.firstOrNull {
it.kind == net.sergeych.lyng.binding.SymbolKind.Function && it.declStart == fnDeclStart
} ?: return emptyList()
return binding.symbols.filter {
it.containerId == fnSym.id &&
(it.kind == net.sergeych.lyng.binding.SymbolKind.Parameter ||
it.kind == net.sergeych.lyng.binding.SymbolKind.Value ||
it.kind == net.sergeych.lyng.binding.SymbolKind.Variable) &&
it.declStart < offset
}
}
fun isStaticReceiver(
mini: MiniScript?,
text: String,
dotPos: Int,
importedModules: List<String>,
binding: BindingSnapshot? = null
): Boolean {
val i = prevNonWs(text, dotPos - 1)
if (i < 0 || !isIdentChar(text[i])) return false
val wordRange = wordRangeAt(text, i + 1) ?: return false
val ident = text.substring(wordRange.first, wordRange.second)
if (ident == "this") return false
if (binding != null) {
val ref = binding.references.firstOrNull { wordRange.first >= it.start && wordRange.first < it.end }
val sym = ref?.let { r -> binding.symbols.firstOrNull { it.id == r.symbolId } }
?: binding.symbols.firstOrNull { it.declStart == wordRange.first && it.name == ident }
if (sym != null) {
return sym.kind == net.sergeych.lyng.binding.SymbolKind.Class ||
sym.kind == net.sergeych.lyng.binding.SymbolKind.Enum ||
sym.kind == net.sergeych.lyng.binding.SymbolKind.TypeAlias
}
}
if (mini != null) {
val src = mini.range.start.source
val decl = mini.declarations
.filter { it.name == ident && src.offsetOf(it.nameStart) < dotPos }
.maxByOrNull { src.offsetOf(it.nameStart) }
if (decl is MiniClassDecl || decl is MiniEnumDecl || decl is MiniTypeAliasDecl) return true
if (decl is MiniFunDecl || decl is MiniValDecl) return false
}
val classes = aggregateClasses(importedModules, mini)
if (classes.containsKey(ident)) return true
for (mod in importedModules) {
val aliases = BuiltinDocRegistry.docsForModule(mod).filterIsInstance<MiniTypeAliasDecl>()
if (aliases.any { it.name == ident }) return true
}
return false
}
fun findDotLeft(text: String, offset: Int): Int? {
var i = (offset - 1).coerceAtLeast(0)
while (i >= 0 && text[i].isWhitespace()) i--
@@ -225,6 +225,21 @@ class MiniAstTest {
assertTrue(names.contains("V2"), "Should contain V2")
}
@Test
fun complete_static_members_only() = runTest {
val code = """
class C {
static fun s() {}
fun i() {}
}
C.<caret>
"""
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }.toSet()
assertTrue(names.contains("s"), "Should contain static member")
assertTrue(!names.contains("i"), "Should not contain instance member")
}
@Test
fun miniAst_captures_extern_docs() = runTest {
val code = """
@@ -84,6 +84,23 @@ class LyngLanguageToolsTest {
assertEquals("Box docs", doc.doc?.summary)
}
@Test
fun languageTools_completion_includes_local_types() = runTest {
val code = """
fun f() {
val local: String = "x"
<caret>
}
""".trimIndent()
val caret = code.indexOf("<caret>")
val text = code.replace("<caret>", "")
val res = LyngLanguageTools.analyze(text, "locals.lyng")
val items = LyngLanguageTools.completions(text, caret, res)
val local = items.firstOrNull { it.name == "local" }
assertNotNull(local, "Completion should include local")
assertTrue(local.typeText?.contains("String") == true, "Expected type for local, got ${local.typeText}")
}
@Test
fun languageTools_definition_and_usages() = runTest {
val code = """