diff --git a/CHANGELOG.md b/CHANGELOG.md index c9328d0..0dfb4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Heuristics: handles literals (`"…"` → `String`, numbers → `Int/Real`, `[...]` → `List`, `{...}` → `Dict`) and static `Namespace.` members. - Performance: capped results, early prefix filtering, per‑document MiniAst cache, cancellation checks. - Toggle: Settings | Lyng Formatter → "Enable Lyng autocompletion (experimental)" (default ON). + - Stabilization: DEBUG completion/Quick Doc logs are OFF by default; behavior aligned between IDE and isolated engine tests. - Language: Named arguments and named splats - New call-site syntax for named arguments using colon: `name: value`. 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 9c0e7d0..bada8e6 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 @@ -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() { 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().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>) { 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() + .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() + .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 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 e8d3418..496b2a6 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 @@ -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 } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt index dabcfb3..0a84212 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt @@ -68,8 +68,14 @@ object BuiltinDocRegistry : BuiltinDocSource { } override fun docsForModule(moduleName: String): List { - modules[moduleName]?.let { return it } - // Try lazy supplier once + // If module already present but we also have a lazy supplier for it, merge supplier once + modules[moduleName]?.let { existing -> + lazySuppliers.remove(moduleName)?.invoke()?.let { built -> + existing += built + } + return existing + } + // Try lazy supplier once when module is not present val built = lazySuppliers.remove(moduleName)?.invoke() if (built != null) { val list = modules.getOrPut(moduleName) { mutableListOf() } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt index ff70035..3d62be8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -41,6 +41,8 @@ object CompletionEngineLight { } suspend fun completeSuspend(text: String, caret: Int): List { + // Ensure stdlib Obj*-defined docs (e.g., String methods) are initialized before registry lookup + StdlibDocsBootstrap.ensure() val prefix = prefixAt(text, caret) val mini = buildMiniAst(text) // Build imported modules as a UNION of MiniAst-derived and textual extraction, always including stdlib @@ -158,7 +160,13 @@ object CompletionEngineLight { fun emitGroup(map: LinkedHashMap>) { for (name in map.keys.sortedBy { it.lowercase() }) { val variants = map[name] ?: continue - val rep = variants.firstOrNull { it is MiniMemberFunDecl } ?: variants.first() + // Prefer a method with a known return type; else any method; else first variant + val rep = + variants.asSequence() + .filterIsInstance() + .firstOrNull { it.returnType != null } + ?: variants.firstOrNull { it is MiniMemberFunDecl } + ?: variants.first() when (rep) { is MiniMemberFunDecl -> { val params = rep.params.joinToString(", ") { it.name } @@ -168,7 +176,11 @@ object CompletionEngineLight { if (ci.name.startsWith(prefix, true)) out += ci } is MiniMemberValDecl -> { - val ci = CompletionItem(name, Kind.Field, typeText = typeOf(rep.type)) + // Prefer a field variant with known type if available + val chosen = variants.asSequence() + .filterIsInstance() + .firstOrNull { it.type != null } ?: rep + val ci = CompletionItem(name, Kind.Field, typeText = typeOf((chosen as MiniMemberValDecl).type)) if (ci.name.startsWith(prefix, true)) out += ci } } @@ -177,18 +189,70 @@ object CompletionEngineLight { emitGroup(directMap) emitGroup(inheritedMap) + + // Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.re(...)) + run { + val already = (directMap.keys + inheritedMap.keys).toMutableSet() + val ext = BuiltinDocRegistry.extensionMethodNamesFor(className) + for (name in ext) { + if (already.contains(name)) continue + 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 ci = CompletionItem(name, Kind.Method, tailText = "(${params})", typeText = typeOf(member.returnType)) + if (ci.name.startsWith(prefix, true)) out += ci + already.add(name) + } + is MiniMemberValDecl -> { + val ci = CompletionItem(name, Kind.Field, typeText = typeOf(member.type)) + if (ci.name.startsWith(prefix, true)) out += ci + already.add(name) + } + } + } else { + // Fallback: emit simple method name without detailed types + val ci = CompletionItem(name, Kind.Method, tailText = "()", typeText = null) + if (ci.name.startsWith(prefix, true)) out += ci + already.add(name) + } + } + } } // --- Inference helpers (text-only, PSI-free) --- private fun guessReceiverClass(text: String, dotPos: Int, imported: List): String? { DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it } - val i = prevNonWs(text, dotPos - 1) + var i = prevNonWs(text, dotPos - 1) if (i >= 0) { when (text[i]) { '"' -> return "String" ']' -> return "List" '}' -> return "Dict" + ')' -> { + // 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() + if (inner.startsWith('"') && inner.endsWith('"')) return "String" + if (inner.startsWith('[') && inner.endsWith(']')) return "List" + if (inner.startsWith('{') && inner.endsWith('}')) return "Dict" + } + } + } } // Numeric literal: decimal/int/hex/scientific var j = i 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 15cf8d4..bcce039 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -13,26 +13,63 @@ object DocLookupUtils { */ fun canonicalImportedModules(mini: MiniScript): List { val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } } - if (raw.isEmpty()) return emptyList() val result = LinkedHashSet() for (name in raw) { val canon = if (name.startsWith("lyng.")) name else "lyng.$name" result.add(canon) } - // Always make stdlib available as a fallback context for common types + // Always make stdlib available as a fallback context for common types, + // even when there are no explicit imports in the file result.add("lyng.stdlib") return result.toList() } fun aggregateClasses(importedModules: List): Map { - val map = LinkedHashMap() + // Collect all class decls by name across modules, then merge duplicates by unioning members and bases. + val buckets = LinkedHashMap>() for (mod in importedModules) { val docs = BuiltinDocRegistry.docsForModule(mod) - docs.filterIsInstance().forEach { cls -> - if (!map.containsKey(cls.name)) map[cls.name] = cls + for (cls in docs.filterIsInstance()) { + buckets.getOrPut(cls.name) { mutableListOf() }.add(cls) } } - return map + + fun mergeClassDecls(name: String, list: List): MiniClassDecl { + if (list.isEmpty()) throw IllegalArgumentException("empty class list for $name") + if (list.size == 1) return list.first() + // Choose a representative for non-merge fields (range/nameStart/bodyRange): take the first + val rep = list.first() + val bases = LinkedHashSet() + val members = LinkedHashMap>() + var doc: MiniDoc? = null + for (c in list) { + bases.addAll(c.bases) + if (doc == null && c.doc != null && c.doc.raw.isNotBlank()) doc = c.doc + for (m in c.members) { + // Group by name to keep overloads together + members.getOrPut(m.name) { mutableListOf() }.add(m) + } + } + // Flatten grouped members back to a list; keep stable name order + val mergedMembers = members.keys.sortedBy { it.lowercase() }.flatMap { members[it] ?: emptyList() } + return MiniClassDecl( + range = rep.range, + name = rep.name, + bases = bases.toList(), + bodyRange = rep.bodyRange, + ctorFields = rep.ctorFields, + classFields = rep.classFields, + doc = doc, + nameStart = rep.nameStart, + members = mergedMembers + ) + } + + val result = LinkedHashMap() + for ((name, list) in buckets) { + result[name] = mergeClassDecls(name, list) + } + return result } fun resolveMemberWithInheritance(importedModules: List, className: String, member: String): Pair? { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt new file mode 100644 index 0000000..240f64c --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/StdlibDocsBootstrap.kt @@ -0,0 +1,26 @@ +/* + * Ensure stdlib Obj*-defined docs (like String methods added via ObjString.addFnDoc) + * are initialized before registry lookups for completion/quick docs. + */ +package net.sergeych.lyng.miniast + +import net.sergeych.lyng.obj.ObjString + +object StdlibDocsBootstrap { + // Simple idempotent guard; races are harmless as initializer side-effects are idempotent + private var ensured = false + + fun ensure() { + if (ensured) return + try { + // Touch core Obj* types whose docs are registered via addFnDoc/addConstDoc + // Accessing .type forces their static initializers to run and register docs. + @Suppress("UNUSED_VARIABLE") + val _string = ObjString.type + } catch (_: Throwable) { + // Best-effort; absence should not break consumers + } finally { + ensured = true + } + } +} diff --git a/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt index 92dccd8..bb2ebaf 100644 --- a/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt +++ b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt @@ -79,6 +79,51 @@ class CompletionEngineLightTest { assertFalse(ns.contains("Path")) } + @Test + fun stringLiteral_re_hasReturnTypeRegex() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib") + val code = """ + import lyng.stdlib + + val s = "abc". + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val reItem = items.firstOrNull { it.name == "re" } + assertTrue(reItem != null, "Expected to find 're' in String members, got: ${items.map { it.name }}") + // Type text should contain ": Regex" + assertTrue(reItem!!.typeText?.contains("Regex") == true, "Expected type text to contain 'Regex', was: ${reItem.typeText}") + } + + @Test + fun stringLiteral_parenthesized_re_hasReturnTypeRegex() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib") + val code = """ + // No imports on purpose; stdlib must still be available + + val s = ("abc"). + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val names = items.map { it.name } + assertTrue(names.isNotEmpty(), "Expected String members for parenthesized literal, got empty list") + val reItem = items.firstOrNull { it.name == "re" } + assertTrue(reItem != null, "Expected to find 're' for parenthesized String literal, got: $names") + assertTrue(reItem!!.typeText?.contains("Regex") == true, "Expected ': Regex' for re(), was: ${reItem.typeText}") + } + + @Test + fun stringLiteral_noImports_stillHasStringMembers() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib") + val code = """ + val s = "super". + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val names = items.map { it.name } + assertTrue(names.isNotEmpty(), "Expected String members without explicit imports, got empty list") + val reItem = items.firstOrNull { it.name == "re" } + assertTrue(reItem != null, "Expected to find 're' without explicit imports, got: $names") + assertTrue(reItem!!.typeText?.contains("Regex") == true, "Expected ': Regex' for re() without imports, was: ${reItem.typeText}") + } + @Test fun shebang_and_fs_import_iterator_after_lines() = runBlocking { TestDocsBootstrap.ensure("lyng.stdlib", "lyng.io.fs") diff --git a/lynglib/stdlib/lyng/root.lyng b/lynglib/stdlib/lyng/root.lyng index 0c6b5ef..0a4ea17 100644 --- a/lynglib/stdlib/lyng/root.lyng +++ b/lynglib/stdlib/lyng/root.lyng @@ -192,6 +192,6 @@ fun Exception.printStackTrace() { } /* Compile this string into a regular expression. */ -fun String.re() { Regex(this) } +fun String.re(): Regex { Regex(this) } \ No newline at end of file