fix #88 fix #90 plugin with autocompletion

This commit is contained in:
sergeych committed 2025-12-08 03:28:27 +01:00
1 parent c35d684df1
commit 708b908415
9 files changed
+415 -27

No files matched your search

@@ -9,7 +9,6 @@ import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.icons.AllIcons
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Document
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.util.Key
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiFile
@@ -36,7 +35,7 @@ class LyngCompletionContributor : CompletionContributor() {
private object Provider : CompletionProvider<CompletionParameters>() {
private val log = Logger.getInstance(LyngCompletionContributor::class.java)
private const val DEBUG_COMPLETION = true
private const val DEBUG_COMPLETION = false
override fun addCompletions(
parameters: CompletionParameters,
@@ -45,6 +44,8 @@ class LyngCompletionContributor : CompletionContributor() {
) {
// Ensure external/bundled docs are registered (e.g., lyng.io.fs with Path)
DocsBootstrap.ensure()
// Ensure stdlib Obj*-defined docs (e.g., String methods via ObjString.addFnDoc) are initialized
StdlibDocsBootstrap.ensure()
val file: PsiFile = parameters.originalFile
if (file.language != LyngLanguage) return
// Feature toggle: allow turning completion off from settings
@@ -150,6 +151,60 @@ class LyngCompletionContributor : CompletionContributor() {
}
emit(builder)
}
// In member context, ensure stdlib extension-like methods (e.g., String.re) are present
if (memberDotPos != null) {
val existing = engineItems.map { it.name }.toMutableSet()
val fromText = extractImportsFromText(text)
val imported = LinkedHashSet<String>().apply {
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
val inferredClass =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMethodNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
for (name in ext) {
if (existing.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, inferredClass, name)
if (resolved != null) {
when (val member = resolved.second) {
is MiniMemberFunDecl -> {
val params = member.params.joinToString(", ") { it.name }
val ret = typeOf(member.returnType)
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("(${ '$' }params)", true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
existing.add(name)
}
is MiniMemberValDecl -> {
val builder = LookupElementBuilder.create(name)
.withIcon(if (member.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field)
.withTypeText(typeOf(member.type), true)
emit(builder)
existing.add(name)
}
}
} else {
// Fallback: emit simple method name without detailed types
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("()", true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
existing.add(name)
}
}
}
}
// If in member context and engine items are suspiciously sparse, try to enrich via local inference + offerMembers
if (memberDotPos != null && engineItems.size < 3) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Engine produced only ${engineItems.size} items in member context — trying enrichment")
@@ -312,10 +367,17 @@ class LyngCompletionContributor : CompletionContributor() {
fun emitGroup(map: LinkedHashMap<String, MutableList<MiniMemberDecl>>) {
val keys = map.keys.sortedBy { it.lowercase() }
for (name in keys) {
ProgressManager.checkCanceled()
val list = map[name] ?: continue
// Choose a representative (prefer method over value for typical UX)
val rep = list.firstOrNull { it is MiniMemberFunDecl } ?: list.first()
// Choose a representative for display:
// 1) Prefer a method with a known return type
// 2) Else any method
// 3) Else the first variant
val rep =
list.asSequence()
.filterIsInstance<MiniMemberFunDecl>()
.firstOrNull { it.returnType != null }
?: list.firstOrNull { it is MiniMemberFunDecl }
?: list.first()
when (rep) {
is MiniMemberFunDecl -> {
val params = rep.params.joinToString(", ") { it.name }
@@ -333,9 +395,13 @@ class LyngCompletionContributor : CompletionContributor() {
}
is MiniMemberValDecl -> {
val icon = if (rep.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field
// Prefer a field variant with known type if available
val chosen = list.asSequence()
.filterIsInstance<MiniMemberValDecl>()
.firstOrNull { it.type != null } ?: rep
val builder = LookupElementBuilder.create(name)
.withIcon(icon)
.withTypeText(typeOf(rep.type), true)
.withTypeText(typeOf((chosen as MiniMemberValDecl).type), true)
emit(builder)
}
}
@@ -407,13 +473,40 @@ class LyngCompletionContributor : CompletionContributor() {
}
}
// Supplement with stdlib extension methods defined in root.lyng (e.g., fun String.trim(...))
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.trim(...))
run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Extensions for $className: count=${ext.size} -> ${ext}")
for (name in ext) {
if (already.contains(name)) continue
// Try to resolve full signature via registry first to get params and return type
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, className, name)
if (resolved != null) {
when (val member = resolved.second) {
is MiniMemberFunDecl -> {
val params = member.params.joinToString(", ") { it.name }
val ret = typeOf(member.returnType)
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("(${params})", true)
.withTypeText(ret, true)
.withInsertHandler(ParenInsertHandler)
emit(builder)
already.add(name)
continue
}
is MiniMemberValDecl -> {
val builder = LookupElementBuilder.create(name)
.withIcon(if (member.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field)
.withTypeText(typeOf(member.type), true)
emit(builder)
already.add(name)
continue
}
}
}
// Fallback: emit without detailed types if we couldn't resolve
val builder = LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Method)
.withTailText("()", true)
@@ -558,7 +651,7 @@ class LyngCompletionContributor : CompletionContributor() {
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it }
// 2) Literal heuristics based on the immediate char before '.'
val i = TextCtx.prevNonWs(text, dotPos - 1)
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> {
@@ -567,6 +660,28 @@ class LyngCompletionContributor : CompletionContributor() {
}
']' -> return "List" // very rough heuristic
'}' -> return "Dict" // map/dictionary literal heuristic
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
if (inner.startsWith('"') && inner.endsWith('"')) return "String"
if (inner.startsWith('[') && inner.endsWith(']')) return "List"
if (inner.startsWith('{') && inner.endsWith('}')) return "Dict"
}
}
}
}
// Numeric literal: support decimal, hex (0x..), and scientific notation (1e-3)
var j = i
@@ -43,9 +43,17 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
private val log = Logger.getInstance(LyngDocumentationProvider::class.java)
// Toggle to trace inheritance-based resolutions in Quick Docs. Keep false for normal use.
private val DEBUG_INHERITANCE = false
// Global Quick Doc debug toggle (OFF by default). When false, [LYNG_DEBUG] logs are suppressed.
private val DEBUG_LOG = false
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
// Try load external docs registrars (e.g., lyngio) if present on classpath
ensureExternalDocsRegistered()
// Ensure stdlib Obj*-defined docs (e.g., String methods via ObjString.addFnDoc) are initialized
try {
net.sergeych.lyng.miniast.StdlibDocsBootstrap.ensure()
} catch (_: Throwable) {
// best-effort; absence must not break Quick Doc
}
if (element == null) return null
val file: PsiFile = element.containingFile ?: return null
val document: Document = file.viewProvider.document ?: return null
@@ -54,12 +62,12 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
// Determine caret/lookup offset from the element range
val offset = originalElement?.textRange?.startOffset ?: element.textRange.startOffset
val idRange = TextCtx.wordRangeAt(text, offset) ?: run {
log.info("[LYNG_DEBUG] QuickDoc: no word at offset=$offset in ${file.name}")
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: no word at offset=$offset in ${file.name}")
return null
}
if (idRange.isEmpty) return null
val ident = text.substring(idRange.startOffset, idRange.endOffset)
log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}")
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}")
// Build MiniAst for this file (fast and resilient). Best-effort; on failure continue with registry lookup only.
val sink = MiniAstBuilder()
@@ -71,7 +79,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
sink.build()
} catch (t: Throwable) {
// Do not bail out completely: we still can resolve built-in and imported docs (e.g., println)
log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}")
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}")
null
}
val haveMini = mini != null
@@ -87,7 +95,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val s = source.offsetOf(d.nameStart)
val e = (s + d.name.length).coerceAtMost(text.length)
if (offset in s until e) {
log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}")
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}")
return renderDeclDoc(d)
}
}
@@ -97,12 +105,72 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val s = source.offsetOf(p.nameStart)
val e = (s + p.name.length).coerceAtMost(text.length)
if (offset in s until e) {
log.info("[LYNG_DEBUG] QuickDoc: matched param '${p.name}' in fun '${fn.name}'")
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: matched param '${p.name}' in fun '${fn.name}'")
return renderParamDoc(fn, p)
}
}
}
// 3) As a fallback, if the caret is on an identifier text that matches any declaration name, show that
// 3) Member-context resolution first (dot immediately before identifier): handle literals and calls
run {
val dotPos = TextCtx.findDotLeft(text, idRange.startOffset)
?: TextCtx.findDotLeft(text, offset)
if (dotPos != null) {
// Build imported modules (MiniAst-derived if available, else lenient from text) and ensure stdlib is present
var importedModules = if (haveMini) DocLookupUtils.canonicalImportedModules(mini) else emptyList()
if (importedModules.isEmpty()) {
val fromText = extractImportsFromText(text)
importedModules = if (fromText.isEmpty()) listOf("lyng.stdlib") else fromText
}
if (!importedModules.contains("lyng.stdlib")) importedModules = importedModules + "lyng.stdlib"
// Try literal and call-based receiver inference around the dot
val i = TextCtx.prevNonWs(text, dotPos - 1)
val className: String? = when {
i >= 0 && text[i] == '"' -> "String"
i >= 0 && text[i] == ']' -> "List"
i >= 0 && text[i] == '}' -> "Dict"
i >= 0 && text[i] == ')' -> {
// Parenthesized expression: walk back to matching '(' and inspect the inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
when {
inner.startsWith('"') && inner.endsWith('"') -> "String"
inner.startsWith('[') && inner.endsWith(']') -> "List"
inner.startsWith('{') && inner.endsWith('}') -> "Dict"
else -> null
}
} else null
} else null
}
else -> DocLookupUtils.guessClassFromCallBefore(text, dotPos, importedModules)
}
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)?.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)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
log.info("[LYNG_DEBUG] QuickDoc: resolve failed for ${className}.${ident}")
}
}
}
// 4) As a fallback, if the caret is on an identifier text that matches any declaration name, show that
if (haveMini) mini.declarations.firstOrNull { it.name == ident }?.let {
log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
return renderDeclDoc(it)
@@ -177,6 +245,32 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
}
}
} else {
// Extra fallback: try a small set of known receiver classes (covers literals when guess failed)
run {
val candidates = listOf("String", "Iterable", "Iterator", "List", "Collection", "Array", "Dict", "Regex")
for (c in candidates) {
DocLookupUtils.resolveMemberWithInheritance(importedModules, c, ident)?.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)
is MiniMemberValDecl -> renderMemberValDoc(owner, member)
}
}
}
}
// As a last resort try aggregated String members (extensions from stdlib text)
run {
val classes = DocLookupUtils.aggregateClasses(importedModules)
val stringCls = classes["String"]
val m = stringCls?.members?.firstOrNull { it.name == ident }
if (m != null) {
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Aggregated fallback resolved String.$ident")
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc("String", m)
is MiniMemberValDecl -> renderMemberValDoc("String", m)
}
}
}
// Search across classes; prefer Iterable, then Iterator, then List for common ops
DocLookupUtils.findMemberAcrossClasses(importedModules, ident)?.let { (owner, member) ->
if (DEBUG_INHERITANCE) log.info("[LYNG_DEBUG] Cross-class '$ident' resolved to $owner.${member.name}")
@@ -189,7 +283,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
}
}
log.info("[LYNG_DEBUG] QuickDoc: nothing found for ident='$ident'")
if (DEBUG_LOG) log.info("[LYNG_DEBUG] QuickDoc: nothing found for ident='$ident'")
return null
}