fix #88 fix #90 plugin with autocompletion

This commit is contained in:
Sergey Chernov 2025-12-08 03:28:27 +01:00
parent c35d684df1
commit 708b908415
9 changed files with 415 additions and 27 deletions

View File

@ -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`.

View File

@ -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

View File

@ -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
}

View File

@ -68,8 +68,14 @@ object BuiltinDocRegistry : BuiltinDocSource {
}
override fun docsForModule(moduleName: String): List<MiniDecl> {
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() }

View File

@ -41,6 +41,8 @@ object CompletionEngineLight {
}
suspend fun completeSuspend(text: String, caret: Int): List<CompletionItem> {
// 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<String, MutableList<MiniMemberDecl>>) {
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<MiniMemberFunDecl>()
.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<MiniMemberValDecl>()
.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>): 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

View File

@ -13,26 +13,63 @@ object DocLookupUtils {
*/
fun canonicalImportedModules(mini: MiniScript): List<String> {
val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
if (raw.isEmpty()) return emptyList()
val result = LinkedHashSet<String>()
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<String>): Map<String, MiniClassDecl> {
val map = LinkedHashMap<String, MiniClassDecl>()
// Collect all class decls by name across modules, then merge duplicates by unioning members and bases.
val buckets = LinkedHashMap<String, MutableList<MiniClassDecl>>()
for (mod in importedModules) {
val docs = BuiltinDocRegistry.docsForModule(mod)
docs.filterIsInstance<MiniClassDecl>().forEach { cls ->
if (!map.containsKey(cls.name)) map[cls.name] = cls
for (cls in docs.filterIsInstance<MiniClassDecl>()) {
buckets.getOrPut(cls.name) { mutableListOf() }.add(cls)
}
}
return map
fun mergeClassDecls(name: String, list: List<MiniClassDecl>): 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<String>()
val members = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
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<String, MiniClassDecl>()
for ((name, list) in buckets) {
result[name] = mergeClassDecls(name, list)
}
return result
}
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String): Pair<String, MiniMemberDecl>? {

View File

@ -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
}
}
}

View File

@ -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".<caret>
""".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").<caret>
""".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".<caret>
""".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")

View File

@ -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) }