From b233d4c15f088bf2783388532122472ad6ee9479 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 15 Feb 2026 11:05:57 +0300 Subject: [PATCH] Improve plugin symbol accuracy in completion and docs --- .../completion/LyngCompletionContributor.kt | 17 ++- .../idea/docs/LyngDocumentationProvider.kt | 15 +- .../lyng/idea/navigation/LyngPsiReference.kt | 3 +- .../lyng/miniast/CompletionEngineLight.kt | 44 ++++-- .../sergeych/lyng/miniast/DocLookupUtils.kt | 135 ++++++++++++++++-- lynglib/src/commonTest/kotlin/MiniAstTest.kt | 15 ++ .../lyng/tools/LyngLanguageToolsTest.kt | 17 +++ 7 files changed, 212 insertions(+), 34 deletions(-) diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt index f61ddb7..8f8ca60 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt @@ -122,6 +122,7 @@ class LyngCompletionContributor : CompletionContributor() { fromText.forEach { add(it) } add("lyng.stdlib") }.toList() + val staticOnly = DocLookupUtils.isStaticReceiver(mini, text, memberDotPos, imported, binding) // Try inferring return/receiver class around the dot val inferred = @@ -136,7 +137,7 @@ class LyngCompletionContributor : CompletionContributor() { if (inferred != null) { if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members") - offerMembers(emit, imported, inferred, sourceText = text, mini = mini) + offerMembers(emit, imported, inferred, staticOnly = staticOnly, sourceText = text, mini = mini) return } else { if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback could not infer class; keeping list empty (no globals after dot)") @@ -295,6 +296,9 @@ class LyngCompletionContributor : CompletionContributor() { } is MiniEnumDecl -> LookupElementBuilder.create(name) .withIcon(AllIcons.Nodes.Enum) + is MiniTypeAliasDecl -> LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Class) + .withTypeText(typeOf(d.target), true) } emit(builder) } @@ -372,6 +376,7 @@ class LyngCompletionContributor : CompletionContributor() { when (m) { is MiniMemberFunDecl -> if (!m.isStatic) continue is MiniMemberValDecl -> if (!m.isStatic) continue + is MiniMemberTypeAliasDecl -> if (!m.isStatic) continue is MiniInitDecl -> continue } } @@ -461,6 +466,16 @@ class LyngCompletionContributor : CompletionContributor() { emit(builder) } } + is MiniMemberTypeAliasDecl -> { + val builder = LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Class) + .withTypeText(typeOf(rep.target), true) + if (groupPriority != 0.0) { + emit(PrioritizedLookupElement.withPriority(builder, groupPriority)) + } else { + emit(builder) + } + } is MiniInitDecl -> {} } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt index 361bcfd..03fc1a8 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -317,7 +317,8 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { } if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: memberCtx dotPos=${dotPos} chBeforeDot='${if (dotPos > 0) text[dotPos - 1] else ' '}' classGuess=${className} imports=${importedModules}") if (className != null) { - DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident, mini)?.let { (owner, member) -> + val staticOnly = DocLookupUtils.isStaticReceiver(mini, text, dotPos, importedModules, analysis.binding) + DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident, mini, staticOnly = staticOnly)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] QuickDoc: literal/call '$ident' resolved to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -380,7 +381,9 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { val lhs = previousWordBefore(text, idRange.startOffset) if (lhs != null && hasDotBetween(text, lhs.endOffset, idRange.startOffset)) { val className = text.substring(lhs.startOffset, lhs.endOffset) - DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident, mini)?.let { (owner, member) -> + val dotPos = findDotLeft(text, idRange.startOffset) + val staticOnly = dotPos?.let { DocLookupUtils.isStaticReceiver(mini, text, it, importedModules, analysis.binding) } ?: false + DocLookupUtils.resolveMemberWithInheritance(importedModules, className, ident, mini, staticOnly = staticOnly)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Inheritance resolved $className.$ident to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -405,7 +408,8 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules, mini) } if (guessed != null) { - DocLookupUtils.resolveMemberWithInheritance(importedModules, guessed, ident, mini)?.let { (owner, member) -> + val staticOnly = DocLookupUtils.isStaticReceiver(mini, text, dotPos, importedModules, analysis.binding) + DocLookupUtils.resolveMemberWithInheritance(importedModules, guessed, ident, mini, staticOnly = staticOnly)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Heuristic '$guessed.$ident' resolved via inheritance to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -424,7 +428,8 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { run { val candidates = listOf("String", "Iterable", "Iterator", "List", "Collection", "Array", "Dict", "Regex") for (c in candidates) { - DocLookupUtils.resolveMemberWithInheritance(importedModules, c, ident, mini)?.let { (owner, member) -> + val staticOnly = DocLookupUtils.isStaticReceiver(mini, text, dotPos, importedModules, analysis.binding) + DocLookupUtils.resolveMemberWithInheritance(importedModules, c, ident, mini, staticOnly = staticOnly)?.let { (owner, member) -> if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Candidate '$c.$ident' resolved via inheritance to $owner.${member.name}") return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) @@ -461,11 +466,13 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return when (member) { is MiniMemberFunDecl -> renderMemberFunDoc(owner, member) is MiniMemberValDecl -> renderMemberValDoc(owner, member) + is MiniMemberTypeAliasDecl -> renderMemberTypeAliasDoc(owner, member) is MiniInitDecl -> null is MiniFunDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniValDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniClassDecl -> renderDeclDoc(member, text, mini, importedModules) is MiniEnumDecl -> renderDeclDoc(member, text, mini, importedModules) + is MiniTypeAliasDecl -> renderDeclDoc(member, text, mini, importedModules) } } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt index 5f4733d..33b872c 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/navigation/LyngPsiReference.kt @@ -48,9 +48,10 @@ class LyngPsiReference(element: PsiElement) : PsiPolyVariantReferenceBase - 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, prefix: String, imported: List, className: String, mini: MiniScript? = null) { + private fun offerMembersAdd(out: MutableList, prefix: String, imported: List, className: String, mini: MiniScript? = null, staticOnly: Boolean = false) { val classes = DocLookupUtils.aggregateClasses(imported, mini) val visited = mutableSetOf() val directMap = LinkedHashMap>() @@ -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) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt index 1ed69e1..3efe640 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -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, className: String, member: String, localMini: MiniScript? = null): Pair? { + fun resolveMemberWithInheritance( + importedModules: List, + className: String, + member: String, + localMini: MiniScript? = null, + staticOnly: Boolean = false + ): Pair? { val classes = aggregateClasses(importedModules, localMini) fun dfs(name: String, visited: MutableSet): Pair? { 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 { + 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, + 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() + 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-- diff --git a/lynglib/src/commonTest/kotlin/MiniAstTest.kt b/lynglib/src/commonTest/kotlin/MiniAstTest.kt index 0e857d3..29c6dd6 100644 --- a/lynglib/src/commonTest/kotlin/MiniAstTest.kt +++ b/lynglib/src/commonTest/kotlin/MiniAstTest.kt @@ -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. + """ + 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 = """ diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt index c2a4de8..c53cdb6 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt @@ -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" + + } + """.trimIndent() + val caret = code.indexOf("") + val text = code.replace("", "") + 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 = """