From c35d684df18a37f2e9f75b21c6fb9ff5700a06b1 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 7 Dec 2025 23:05:58 +0100 Subject: [PATCH] started autocompletion in the plugin --- CHANGELOG.md | 8 + README.md | 22 + .../completion/LyngCompletionContributor.kt | 819 ++++++++++++++++++ .../idea/docs/LyngDocumentationProvider.kt | 3 +- .../idea/settings/LyngFormatterSettings.kt | 6 + .../LyngFormatterSettingsConfigurable.kt | 9 +- .../sergeych/lyng/idea/util/DocsBootstrap.kt | 44 + .../net/sergeych/lyng/idea/util/TextCtx.kt | 60 ++ .../src/main/resources/META-INF/plugin.xml | 3 + .../completion/LyngCompletionMemberTest.kt | 121 +++ lynglib/build.gradle.kts | 6 + .../lyng/miniast/BuiltinDocRegistry.kt | 16 + .../lyng/miniast/CompletionEngineLight.kt | 381 ++++++++ .../lyng/miniast/CompletionEngineLightTest.kt | 102 +++ .../lyng/miniast/TestDocsBootstrap.kt | 33 + 15 files changed, 1631 insertions(+), 2 deletions(-) create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/DocsBootstrap.kt create mode 100644 lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/TextCtx.kt create mode 100644 lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionMemberTest.kt create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt create mode 100644 lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt create mode 100644 lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/TestDocsBootstrap.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aea380..c9328d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ### Unreleased +- IDEA plugin: Lightweight autocompletion (experimental) + - Global completion: local declarations, in‑scope parameters, imported modules, and stdlib symbols. + - Member completion: after a dot, suggests only members of the inferred receiver type (incl. chained calls like `Path(".." ).lines().` → `Iterator` methods). No global identifiers appear after a dot. + - Inheritance-aware: direct class members first, then inherited (e.g., `List` includes `Collection`/`Iterable` methods). + - 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). + - Language: Named arguments and named splats - New call-site syntax for named arguments using colon: `name: value`. - Positional arguments must come before named; positionals after a named argument inside parentheses are rejected. diff --git a/README.md b/README.md index fbf78ba..23a85f9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,28 @@ scope.eval("sumOf(1,2,3)") // <- 6 ``` Note that the scope stores all changes in it so you can make calls on a single scope to preserve state between calls. +## IntelliJ IDEA plugin: Lightweight autocompletion (experimental) + +The IDEA plugin provides a fast, lightweight BASIC completion for Lyng code (IntelliJ IDEA 2024.3+). + +What it does: +- Global suggestions: in-scope parameters, same-file declarations (functions/classes/vals), imported modules, and stdlib symbols. +- Member completion after dot: offers only members of the inferred receiver type. It works for chained calls like `Path(".." ).lines().` (suggests `Iterator` methods), and for literals like `"abc".` (String methods) or `[1,2,3].` (List/Iterable methods). +- Inheritance-aware: shows direct class members first, then inherited. For example, `List` also exposes common `Collection`/`Iterable` methods. +- Static/namespace members: `Name.` lists only static members when `Name` is a known class or container (e.g., `Math`). +- Performance: suggestions are capped; prefix filtering is early; parsing is cached; computation is cancellation-friendly. + +What it does NOT do (yet): +- No heavy resolve or project-wide indexing. It’s best-effort, driven by a tiny MiniAst + built-in docs registry. +- No control/data-flow type inference. + +Enable/disable: +- Settings | Lyng Formatter → "Enable Lyng autocompletion (experimental)" (default: ON). + +Tips: +- After a dot, globals are intentionally suppressed (e.g., `lines().Path` is not valid), only the receiver’s members are suggested. +- If completion seems sparse, make sure related modules are imported (e.g., `import lyng.io.fs` so that `Path` and its methods are known). + ## Why? Designed to add scripting to kotlin multiplatform application in easy and efficient way. This is attempt to achieve what Lua is for C/++. 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 new file mode 100644 index 0000000..9c0e7d0 --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt @@ -0,0 +1,819 @@ +/* + * Lightweight BASIC completion for Lyng, MVP version. + * Uses MiniAst (best-effort) + BuiltinDocRegistry to suggest symbols. + */ +package net.sergeych.lyng.idea.completion + +import com.intellij.codeInsight.completion.* +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 +import com.intellij.util.ProcessingContext +import kotlinx.coroutines.runBlocking +import net.sergeych.lyng.Compiler +import net.sergeych.lyng.Source +import net.sergeych.lyng.highlight.offsetOf +import net.sergeych.lyng.idea.LyngLanguage +import net.sergeych.lyng.idea.settings.LyngFormatterSettings +import net.sergeych.lyng.idea.util.DocsBootstrap +import net.sergeych.lyng.idea.util.IdeLenientImportProvider +import net.sergeych.lyng.idea.util.TextCtx +import net.sergeych.lyng.miniast.* + +class LyngCompletionContributor : CompletionContributor() { + init { + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(LyngLanguage), + Provider + ) + } + + private object Provider : CompletionProvider() { + private val log = Logger.getInstance(LyngCompletionContributor::class.java) + private const val DEBUG_COMPLETION = true + + override fun addCompletions( + parameters: CompletionParameters, + context: ProcessingContext, + result: CompletionResultSet + ) { + // Ensure external/bundled docs are registered (e.g., lyng.io.fs with Path) + DocsBootstrap.ensure() + val file: PsiFile = parameters.originalFile + if (file.language != LyngLanguage) return + // Feature toggle: allow turning completion off from settings + val settings = LyngFormatterSettings.getInstance(file.project) + if (!settings.enableLyngCompletionExperimental) return + val document: Document = file.viewProvider.document ?: return + val text = document.text + val caret = parameters.offset.coerceIn(0, text.length) + + val prefix = TextCtx.prefixAt(text, caret) + val withPrefix = result.withPrefixMatcher(prefix).caseInsensitive() + + // Emission with cap + val cap = 200 + var added = 0 + val emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit = { le -> + if (added < cap) { + withPrefix.addElement(le) + added++ + } + } + + // Determine if we are in member context (dot before caret or before word start) + val wordRange = TextCtx.wordRangeAt(text, caret) + val memberDotPos = (wordRange?.let { TextCtx.findDotLeft(text, it.startOffset) }) + ?: TextCtx.findDotLeft(text, caret) + if (DEBUG_COMPLETION) { + log.info("[LYNG_DEBUG] Completion: caret=$caret prefix='${prefix}' memberDotPos=${memberDotPos} file='${file.name}'") + } + + // Build MiniAst (cached) for both global and member contexts to enable local class/val inference + val mini = buildMiniAstCached(file, text) + + // Delegate computation to the shared engine to keep behavior in sync with tests + val engineItems = try { + runBlocking { CompletionEngineLight.completeSuspend(text, caret) } + } catch (t: Throwable) { + if (DEBUG_COMPLETION) log.warn("[LYNG_DEBUG] Engine completion failed: ${t.message}") + emptyList() + } + if (DEBUG_COMPLETION) { + val preview = engineItems.take(10).joinToString { it.name } + log.info("[LYNG_DEBUG] Engine items: count=${engineItems.size} preview=[${preview}]") + } + + // If we are in member context and the engine produced nothing, try a guarded local fallback + if (memberDotPos != null && engineItems.isEmpty()) { + if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback: engine returned 0 in member context; trying local inference") + // Build imported modules from text (lenient) + stdlib; avoid heavy MiniAst here + val fromText = extractImportsFromText(text) + val imported = LinkedHashSet().apply { + fromText.forEach { add(it) } + add("lyng.stdlib") + }.toList() + + // Try inferring return/receiver class around the dot + val inferred = + // Prefer MiniAst-based inference (return type from member call or receiver type) + 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 (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) + return + } else { + if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback could not infer class; keeping list empty (no globals after dot)") + return + } + } + + // In global context, add params in scope first (engine does not include them) + if (memberDotPos == null && mini != null) { + offerParamsInScope(emit, mini, text, caret) + } + + // Render engine items + for (ci in engineItems) { + val builder = when (ci.kind) { + Kind.Function -> LookupElementBuilder.create(ci.name) + .withIcon(AllIcons.Nodes.Function) + .let { b -> if (!ci.tailText.isNullOrBlank()) b.withTailText(ci.tailText, true) else b } + .let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b } + .withInsertHandler(ParenInsertHandler) + Kind.Method -> LookupElementBuilder.create(ci.name) + .withIcon(AllIcons.Nodes.Method) + .let { b -> if (!ci.tailText.isNullOrBlank()) b.withTailText(ci.tailText, true) else b } + .let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b } + .withInsertHandler(ParenInsertHandler) + Kind.Class_ -> LookupElementBuilder.create(ci.name) + .withIcon(AllIcons.Nodes.Class) + Kind.Value -> LookupElementBuilder.create(ci.name) + .withIcon(AllIcons.Nodes.Field) + .let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b } + Kind.Field -> LookupElementBuilder.create(ci.name) + .withIcon(AllIcons.Nodes.Field) + .let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b } + } + emit(builder) + } + // 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") + val fromText = extractImportsFromText(text) + val imported = LinkedHashSet().apply { + fromText.forEach { add(it) } + add("lyng.stdlib") + }.toList() + val inferred = + 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 (inferred != null) { + if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Enrichment inferred class='$inferred' — offering its members") + offerMembers(emit, imported, inferred, sourceText = text, mini = mini) + } + } + return + } + + private fun offerDecl(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, d: MiniDecl) { + val name = d.name + val builder = when (d) { + is MiniFunDecl -> { + val params = d.params.joinToString(", ") { it.name } + val ret = typeOf(d.returnType) + val tail = "(${params})" + LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Function) + .withTailText(tail, true) + .withTypeText(ret, true) + .withInsertHandler(ParenInsertHandler) + } + is MiniClassDecl -> LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Class) + is MiniValDecl -> { + val kindIcon = if (d.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field + LookupElementBuilder.create(name) + .withIcon(kindIcon) + .withTypeText(typeOf(d.type), true) + } + else -> LookupElementBuilder.create(name) + } + emit(builder) + } + + private object ParenInsertHandler : InsertHandler { + override fun handleInsert(context: InsertionContext, item: com.intellij.codeInsight.lookup.LookupElement) { + val doc = context.document + val tailOffset = context.tailOffset + val nextChar = doc.charsSequence.getOrNull(tailOffset) + if (nextChar != '(') { + doc.insertString(tailOffset, "()") + context.editor.caretModel.moveToOffset(tailOffset + 1) + } + } + } + + // --- Member completion helpers --- + + private fun offerMembers( + emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, + imported: List, + className: String, + staticOnly: Boolean = false + , sourceText: String, + mini: MiniScript? = null + ) { + // Ensure modules are seeded in the registry (triggers lazy stdlib build too) + for (m in imported) BuiltinDocRegistry.docsForModule(m) + val classes = DocLookupUtils.aggregateClasses(imported) + if (DEBUG_COMPLETION) { + val keys = classes.keys.joinToString(", ") + log.info("[LYNG_DEBUG] offerMembers: imported=${imported} classes=[${keys}] target=${className}") + } + val visited = mutableSetOf() + // Collect separated to keep tiers: direct first, then inherited + val directMap = LinkedHashMap>() + val inheritedMap = LinkedHashMap>() + + // 0) Prefer locally-declared class members (same-file) when available + val localClass = mini?.declarations?.filterIsInstance()?.firstOrNull { it.name == className } + if (localClass != null) { + for (m in localClass.members) { + val list = directMap.getOrPut(m.name) { mutableListOf() } + list.add(m) + } + // If MiniAst didn't populate members (empty), try to scan class body text for member signatures + if (localClass.members.isEmpty()) { + val scanned = scanLocalClassMembersFromText(mini, text = sourceText, cls = localClass) + if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for class ${localClass.name}: found ${scanned.size} members -> ${scanned.keys}") + for ((name, sig) in scanned) { + when (sig.kind) { + "fun" -> { + val builder = LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Method) + .withTailText("(" + (sig.params?.joinToString(", ") ?: "") + ")", true) + .let { b -> sig.typeText?.let { b.withTypeText(": $it", true) } ?: b } + .withInsertHandler(ParenInsertHandler) + emit(builder) + } + "val", "var" -> { + val builder = LookupElementBuilder.create(name) + .withIcon(if (sig.kind == "var") AllIcons.Nodes.Variable else AllIcons.Nodes.Field) + .let { b -> sig.typeText?.let { b.withTypeText(": $it", true) } ?: b } + emit(builder) + } + } + } + } + } + + fun addMembersOf(clsName: String, tierDirect: Boolean) { + val cls = classes[clsName] ?: return + val target = if (tierDirect) directMap else inheritedMap + for (m in cls.members) { + if (staticOnly) { + // Filter only static members in namespace/static context + when (m) { + is MiniMemberFunDecl -> if (!m.isStatic) continue + is MiniMemberValDecl -> if (!m.isStatic) continue + } + } + val list = target.getOrPut(m.name) { mutableListOf() } + list.add(m) + } + // Then inherited + for (base in cls.bases) { + if (visited.add(base)) addMembersOf(base, false) + } + } + + visited.add(className) + addMembersOf(className, true) + if (DEBUG_COMPLETION) { + log.info("[LYNG_DEBUG] offerMembers: direct=${directMap.size} inherited=${inheritedMap.size} for ${className}") + } + + // If the docs model lacks explicit bases for some core container classes, + // conservatively supplement with preferred parents to expose common ops. + fun supplementPreferredBases(receiver: String) { + // Preference/known lineage map kept tiny and safe + val extras = when (receiver) { + "List" -> listOf("Collection", "Iterable") + "Array" -> listOf("Collection", "Iterable") + // In practice, many high-level ops users expect on iteration live on Iterable. + // For editor assistance, expose Iterable ops for Iterator receivers too. + "Iterator" -> listOf("Iterable") + else -> emptyList() + } + for (base in extras) { + if (visited.add(base)) addMembersOf(base, false) + } + } + supplementPreferredBases(className) + + 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() + when (rep) { + is MiniMemberFunDecl -> { + val params = rep.params.joinToString(", ") { it.name } + val ret = typeOf(rep.returnType) + val extra = list.count { it is MiniMemberFunDecl } - 1 + val overloads = if (extra > 0) " (+$extra overloads)" else "" + val tail = "(${params})$overloads" + val icon = AllIcons.Nodes.Method + val builder = LookupElementBuilder.create(name) + .withIcon(icon) + .withTailText(tail, true) + .withTypeText(ret, true) + .withInsertHandler(ParenInsertHandler) + emit(builder) + } + is MiniMemberValDecl -> { + val icon = if (rep.mutable) AllIcons.Nodes.Variable else AllIcons.Nodes.Field + val builder = LookupElementBuilder.create(name) + .withIcon(icon) + .withTypeText(typeOf(rep.type), true) + emit(builder) + } + } + } + } + + // Emit what we have first + emitGroup(directMap) + emitGroup(inheritedMap) + + // If suggestions are suspiciously sparse for known container classes, + // try to conservatively supplement using a curated list resolved via docs registry. + val totalSuggested = directMap.size + inheritedMap.size + val isContainer = className in setOf("Iterator", "Iterable", "Collection", "List", "Array") + if (isContainer && totalSuggested < 3) { + if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Supplementing members for $className; had=$totalSuggested") + val common = when (className) { + "Iterator" -> listOf( + "hasNext", "next", "forEach", "map", "filter", "take", "drop", "toList", "count", "any", "all" + ) + else -> listOf( + // Iterable/Collection/List/Array common ops + "size", "isEmpty", "map", "flatMap", "filter", "first", "last", "contains", + "any", "all", "count", "forEach", "toList", "toSet" + ) + } + val already = (directMap.keys + inheritedMap.keys).toMutableSet() + for (name in common) { + if (name in already) continue + // Try resolve across classes first to get types/params; if it fails, emit a synthetic safe suggestion. + val resolved = DocLookupUtils.findMemberAcrossClasses(imported, name) + if (resolved != null) { + val member = resolved.second + when (member) { + 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) + } + is MiniMemberValDecl -> { + val builder = LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Field) + .withTypeText(typeOf(member.type), true) + emit(builder) + already.add(name) + } + } + } else { + // Synthetic fallback: method without detailed params/types to improve UX in absence of docs + val isProperty = name in setOf("size", "length") + val builder = if (isProperty) { + LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Field) + } else { + LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Method) + .withTailText("()", true) + .withInsertHandler(ParenInsertHandler) + } + emit(builder) + already.add(name) + } + } + } + + // Supplement with stdlib extension 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 + val builder = LookupElementBuilder.create(name) + .withIcon(AllIcons.Nodes.Method) + .withTailText("()", true) + .withInsertHandler(ParenInsertHandler) + emit(builder) + already.add(name) + } + } + } + + // --- MiniAst-based inference helpers --- + + private fun previousIdentifierBeforeDot(text: String, dotPos: Int): String? { + var i = dotPos - 1 + // skip whitespace + while (i >= 0 && text[i].isWhitespace()) i-- + val end = i + 1 + while (i >= 0 && TextCtx.isIdentChar(text[i])) i-- + val start = i + 1 + return if (start < end) text.substring(start, end) else null + } + + private fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List): String? { + if (mini == null) return null + val ident = previousIdentifierBeforeDot(text, dotPos) ?: return null + // 1) Local val/var in the file + val valDecl = mini.declarations.filterIsInstance().firstOrNull { it.name == ident } + val typeFromVal = valDecl?.type?.let { simpleClassNameOf(it) } + if (!typeFromVal.isNullOrBlank()) return typeFromVal + // If initializer exists, try to sniff ClassName( + val initR = valDecl?.initRange + if (initR != null) { + val src = mini.range.start.source + val s = src.offsetOf(initR.start) + val e = src.offsetOf(initR.end).coerceAtMost(text.length) + if (s in 0..e && e <= text.length) { + val init = text.substring(s, e) + Regex("([A-Za-z_][A-Za-z0-9_]*)\\s*\\(").find(init)?.let { m -> + val cls = m.groupValues[1] + return cls + } + } + } + // 2) Parameters in any function (best-effort without scope mapping) + val paramType = mini.declarations.filterIsInstance() + .asSequence() + .flatMap { it.params.asSequence() } + .firstOrNull { it.name == ident }?.type + val typeFromParam = simpleClassNameOf(paramType) + if (!typeFromParam.isNullOrBlank()) return typeFromParam + return null + } + + private fun guessReturnClassFromMemberCallBeforeMini(mini: MiniScript?, text: String, dotPos: Int, imported: List): String? { + if (mini == null) return null + var i = TextCtx.prevNonWs(text, dotPos - 1) + if (i < 0 || text[i] != ')') return null + // back to matching '(' + i-- + var depth = 0 + while (i >= 0) { + when (text[i]) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && TextCtx.isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + // Ensure member call: dot before callee + var k = start - 1 + while (k >= 0 && text[k].isWhitespace()) k-- + if (k < 0 || text[k] != '.') return null + val prevDot = k + // Resolve receiver class via MiniAst (ident like `x`) + val receiverClass = guessReceiverClassViaMini(mini, text, prevDot, imported) ?: return null + // If receiver class is a locally declared class, resolve member on it + val localClass = mini.declarations.filterIsInstance().firstOrNull { it.name == receiverClass } + if (localClass != null) { + val mm = localClass.members.firstOrNull { it.name == callee } + if (mm != null) { + val rt = when (mm) { + is MiniMemberFunDecl -> mm.returnType + is MiniMemberValDecl -> mm.type + else -> null + } + return simpleClassNameOf(rt) + } else { + // Try to scan class body text for method signature and extract return type + val sigs = scanLocalClassMembersFromText(mini, text, localClass) + if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Local scan for return type in ${receiverClass}.${callee}: candidates=${sigs.keys}") + val sig = sigs[callee] + if (sig != null && sig.typeText != null) return sig.typeText + } + } + // Else fallback to registry-based resolution (covers imported classes) + return DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee)?.second?.let { m -> + val rt = when (m) { + is MiniMemberFunDecl -> m.returnType + is MiniMemberValDecl -> m.type + } + simpleClassNameOf(rt) + } + } + + private data class ScannedSig(val kind: String, val params: List?, val typeText: String?) + + private fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map { + val src = mini.range.start.source + val start = src.offsetOf(cls.bodyRange?.start ?: cls.range.start) + val end = src.offsetOf(cls.bodyRange?.end ?: cls.range.end).coerceAtMost(text.length) + if (start !in 0..end) return emptyMap() + val body = text.substring(start, end) + val map = LinkedHashMap() + // fun name(params): Type + val funRe = Regex("(?m)^\\s*fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?") + for (m in funRe.findAll(body)) { + val name = m.groupValues.getOrNull(1) ?: continue + val params = m.groupValues.getOrNull(2)?.split(',')?.mapNotNull { it.trim().takeIf { it.isNotEmpty() } } ?: emptyList() + val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() } + map[name] = ScannedSig("fun", params, type) + } + // val/var name: Type + val valRe = Regex("(?m)^\\s*(val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?") + for (m in valRe.findAll(body)) { + val kind = m.groupValues.getOrNull(1) ?: continue + val name = m.groupValues.getOrNull(2) ?: continue + val type = m.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() } + map.putIfAbsent(name, ScannedSig(kind, null, type)) + } + return map + } + + private fun guessReceiverClass(text: String, dotPos: Int, imported: List): String? { + // 1) Try call-based: ClassName(...). + DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it } + + // 2) Literal heuristics based on the immediate char before '.' + val i = TextCtx.prevNonWs(text, dotPos - 1) + if (i >= 0) { + when (text[i]) { + '"' -> { + // Either regular or triple-quoted string; both map to String + return "String" + } + ']' -> return "List" // very rough heuristic + '}' -> return "Dict" // map/dictionary literal heuristic + } + // Numeric literal: support decimal, hex (0x..), and scientific notation (1e-3) + var j = i + var hasDigits = false + var hasDot = false + var hasExp = false + // Walk over digits, letters for hex, dots, and exponent markers + while (j >= 0) { + val ch = text[j] + if (ch.isDigit()) { hasDigits = true; j-- ; continue } + if (ch == '.') { hasDot = true; j-- ; continue } + if (ch == 'e' || ch == 'E') { hasExp = true; j-- ; // optional sign directly before digits + if (j >= 0 && (text[j] == '+' || text[j] == '-')) j-- + continue + } + if (ch in listOf('x','X')) { // part of 0x prefix + j-- + continue + } + if (ch == 'a' || ch == 'b' || ch == 'c' || ch == 'd' || ch == 'f' || + ch == 'A' || ch == 'B' || ch == 'C' || ch == 'D' || ch == 'F') { + // hex digit in 0x... + j-- + continue + } + break + } + // Now check for 0x/0X prefix + val k = j + val isHex = k >= 1 && text[k] == '0' && (text[k+1] == 'x' || text[k+1] == 'X') + if (hasDigits) { + return if (isHex) "Int" else if (hasDot || hasExp) "Real" else "Int" + } + } + return null + } + + /** + * Try to infer the class of the return value of the member call immediately before the dot. + * Example: `Path(".." ).lines().` → detects `lines()` on receiver class `Path` and returns `Iterator`. + */ + private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List): String? { + var i = TextCtx.prevNonWs(text, dotPos - 1) + if (i < 0) return null + // We expect a call just before the dot, i.e., ')' ... '.' + if (text[i] != ')') return null + // Walk back to matching '(' + i-- + var depth = 0 + while (i >= 0) { + val ch = text[i] + when (ch) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + // Identify callee identifier just before '(' + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && TextCtx.isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + // Ensure it's a member call (there must be a dot immediately before the callee, ignoring spaces) + var k = start - 1 + while (k >= 0 && text[k].isWhitespace()) k-- + if (k < 0 || text[k] != '.') return null + val prevDot = k + // Infer receiver class at the previous dot + val receiverClass = guessReceiverClass(text, prevDot, imported) ?: return null + // Resolve the callee as a member of receiver class, including inheritance + val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee) ?: return null + val member = resolved.second + val returnType = when (member) { + is MiniMemberFunDecl -> member.returnType + is MiniMemberValDecl -> member.type + } + return simpleClassNameOf(returnType) + } + + /** + * Infer return class of a top-level call right before the dot: e.g., `files().`. + * We extract callee name and resolve it among imported modules' top-level functions. + */ + private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List): String? { + var i = TextCtx.prevNonWs(text, dotPos - 1) + if (i < 0 || text[i] != ')') return null + // Walk back to matching '(' + i-- + var depth = 0 + while (i >= 0) { + val ch = text[i] + when (ch) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + // Extract callee ident before '(' + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && TextCtx.isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + // If it's a member call, bail out (handled in member-call inference) + var k = start - 1 + while (k >= 0 && text[k].isWhitespace()) k-- + if (k >= 0 && text[k] == '.') return null + + // Resolve top-level function in imported modules + for (mod in imported) { + val decls = BuiltinDocRegistry.docsForModule(mod) + val fn = decls.asSequence().filterIsInstance().firstOrNull { it.name == callee } + if (fn != null) return simpleClassNameOf(fn.returnType) + } + return null + } + + /** + * Fallback: if we can at least extract a callee name before the dot and it exists across common classes, + * derive its return type using cross-class lookup (Iterable/Iterator/List preference). This ignores the receiver. + * Example: `something.lines().` where `something` type is unknown, but `lines()` commonly returns Iterator. + */ + private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List): String? { + var i = TextCtx.prevNonWs(text, dotPos - 1) + if (i < 0 || text[i] != ')') return null + // Walk back to matching '(' + i-- + var depth = 0 + while (i >= 0) { + val ch = text[i] + when (ch) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + // Extract callee ident before '(' + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && TextCtx.isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + // Try cross-class resolution + val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee) ?: return null + val member = resolved.second + val returnType = when (member) { + is MiniMemberFunDecl -> member.returnType + is MiniMemberValDecl -> member.type + } + return simpleClassNameOf(returnType) + } + + /** Convert a MiniTypeRef to a simple class name as used by docs (e.g., Iterator from Iterator). */ + private fun simpleClassNameOf(t: MiniTypeRef?): String? = when (t) { + null -> null + is MiniTypeName -> t.segments.lastOrNull()?.name + is MiniGenericType -> simpleClassNameOf(t.base) + is MiniFunctionType -> null + is MiniTypeVar -> null + } + + private fun buildMiniAst(text: String): MiniScript? { + return try { + val sink = MiniAstBuilder() + val provider = IdeLenientImportProvider.create() + val src = Source("", text) + runBlocking { Compiler.compileWithMini(src, provider, sink) } + sink.build() + } catch (_: Throwable) { + null + } + } + + // Cached per PsiFile by document modification stamp + private val MINI_KEY = Key.create("lyng.mini.cache") + private val STAMP_KEY = Key.create("lyng.mini.cache.stamp") + + private fun buildMiniAstCached(file: PsiFile, text: String): MiniScript? { + val doc = file.viewProvider.document ?: return null + val stamp = doc.modificationStamp + val prevStamp = file.getUserData(STAMP_KEY) + val cached = file.getUserData(MINI_KEY) + if (cached != null && prevStamp != null && prevStamp == stamp) return cached + val built = buildMiniAst(text) + // Cache even null? avoid caching failures; only cache non-null + if (built != null) { + file.putUserData(MINI_KEY, built) + file.putUserData(STAMP_KEY, stamp) + } + return built + } + + private fun offerParamsInScope(emit: (com.intellij.codeInsight.lookup.LookupElement) -> Unit, mini: MiniScript, text: String, caret: Int) { + val src = mini.range.start.source + // Find function whose body contains caret or whose whole range contains caret + val fns = mini.declarations.filterIsInstance() + for (fn in fns) { + val start = src.offsetOf(fn.range.start) + val end = src.offsetOf(fn.range.end).coerceAtMost(text.length) + if (caret in start..end) { + for (p in fn.params) { + val builder = LookupElementBuilder.create(p.name) + .withIcon(AllIcons.Nodes.Variable) + .withTypeText(typeOf(p.type), true) + emit(builder) + } + return + } + } + } + + // Lenient textual import extractor (duplicated from QuickDoc privately) + private fun extractImportsFromText(text: String): List { + val result = LinkedHashSet() + val re = Regex("(?m)^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)") + re.findAll(text).forEach { m -> + val raw = m.groupValues.getOrNull(1)?.trim().orEmpty() + if (raw.isNotEmpty()) { + val canon = if (raw.startsWith("lyng.")) raw else "lyng.$raw" + result.add(canon) + } + } + return result.toList() + } + + private fun typeOf(t: MiniTypeRef?): String { + return when (t) { + null -> "" + is MiniTypeName -> t.segments.lastOrNull()?.name?.let { ": $it" } ?: "" + is MiniGenericType -> { + val base = typeOf(t.base).removePrefix(": ") + val args = t.args.joinToString(",") { typeOf(it).removePrefix(": ") } + ": ${base}<${args}>" + } + is MiniFunctionType -> ": (fn)" + is MiniTypeVar -> ": ${t.name}" + } + } + } +} 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 ccfd9a9..e8d3418 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 @@ -31,6 +31,7 @@ import net.sergeych.lyng.Source import net.sergeych.lyng.highlight.offsetOf import net.sergeych.lyng.idea.LyngLanguage import net.sergeych.lyng.idea.util.IdeLenientImportProvider +import net.sergeych.lyng.idea.util.TextCtx import net.sergeych.lyng.miniast.* /** @@ -52,7 +53,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { // Determine caret/lookup offset from the element range val offset = originalElement?.textRange?.startOffset ?: element.textRange.startOffset - val idRange = wordRangeAt(text, offset) ?: run { + val idRange = TextCtx.wordRangeAt(text, offset) ?: run { log.info("[LYNG_DEBUG] QuickDoc: no word at offset=$offset in ${file.name}") return null } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt index d71a8f4..48416b1 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettings.kt @@ -50,6 +50,8 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo var offerLyngTypoQuickFixes: Boolean = true, // Per-project learned words (do not flag again) var learnedWords: MutableSet = mutableSetOf(), + // Experimental: enable Lyng autocompletion (can be disabled if needed) + var enableLyngCompletionExperimental: Boolean = true, ) private var myState: State = State() @@ -116,6 +118,10 @@ class LyngFormatterSettings(private val project: Project) : PersistentStateCompo get() = myState.learnedWords set(value) { myState.learnedWords = value } + var enableLyngCompletionExperimental: Boolean + get() = myState.enableLyngCompletionExperimental + set(value) { myState.enableLyngCompletionExperimental = value } + companion object { @JvmStatic fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java) diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt index dd512df..da46b79 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/settings/LyngFormatterSettingsConfigurable.kt @@ -38,6 +38,7 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur private var debugShowSpellFeedCb: JCheckBox? = null private var showTyposGreenCb: JCheckBox? = null private var offerQuickFixesCb: JCheckBox? = null + private var enableCompletionCb: JCheckBox? = null override fun getDisplayName(): String = "Lyng Formatter" @@ -57,6 +58,7 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur debugShowSpellFeedCb = JCheckBox("Debug: show spell-feed ranges (weak warnings)") showTyposGreenCb = JCheckBox("Show Lyng typos with green underline (TYPO styling)") offerQuickFixesCb = JCheckBox("Offer Lyng typo quick fixes (Replace…, Add to dictionary) without Spell Checker") + enableCompletionCb = JCheckBox("Enable Lyng autocompletion (experimental)") // Tooltips / short help spacingCb?.toolTipText = "Applies minimal, safe spacing (e.g., around commas/operators, control-flow parens)." @@ -71,6 +73,7 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur debugShowSpellFeedCb?.toolTipText = "Show the exact ranges we feed to spellcheckers (ids/comments/strings) as weak warnings." showTyposGreenCb?.toolTipText = "Render Lyng typos using the platform's green TYPO underline instead of generic warnings." offerQuickFixesCb?.toolTipText = "Provide lightweight Replace… and Add to dictionary quick-fixes without requiring the legacy Spell Checker." + enableCompletionCb?.toolTipText = "Turn on/off the lightweight Lyng code completion (BASIC)." p.add(spacingCb) p.add(wrappingCb) p.add(reindentClosedBlockCb) @@ -84,6 +87,7 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur p.add(debugShowSpellFeedCb) p.add(showTyposGreenCb) p.add(offerQuickFixesCb) + p.add(enableCompletionCb) panel = p reset() return p @@ -103,7 +107,8 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur grazieLiteralsAsCommentsCb?.isSelected != s.grazieTreatLiteralsAsComments || debugShowSpellFeedCb?.isSelected != s.debugShowSpellFeed || showTyposGreenCb?.isSelected != s.showTyposWithGreenUnderline || - offerQuickFixesCb?.isSelected != s.offerLyngTypoQuickFixes + offerQuickFixesCb?.isSelected != s.offerLyngTypoQuickFixes || + enableCompletionCb?.isSelected != s.enableLyngCompletionExperimental } override fun apply() { @@ -121,6 +126,7 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur s.debugShowSpellFeed = debugShowSpellFeedCb?.isSelected == true s.showTyposWithGreenUnderline = showTyposGreenCb?.isSelected == true s.offerLyngTypoQuickFixes = offerQuickFixesCb?.isSelected == true + s.enableLyngCompletionExperimental = enableCompletionCb?.isSelected == true } override fun reset() { @@ -138,5 +144,6 @@ class LyngFormatterSettingsConfigurable(private val project: Project) : Configur debugShowSpellFeedCb?.isSelected = s.debugShowSpellFeed showTyposGreenCb?.isSelected = s.showTyposWithGreenUnderline offerQuickFixesCb?.isSelected = s.offerLyngTypoQuickFixes + enableCompletionCb?.isSelected = s.enableLyngCompletionExperimental } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/DocsBootstrap.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/DocsBootstrap.kt new file mode 100644 index 0000000..852510c --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/DocsBootstrap.kt @@ -0,0 +1,44 @@ +/* + * Ensure external/bundled docs are registered in BuiltinDocRegistry + * so completion/quickdoc can resolve things like lyng.io.fs.Path. + */ +package net.sergeych.lyng.idea.util + +import com.intellij.openapi.diagnostic.Logger +import net.sergeych.lyng.idea.docs.FsDocsFallback + +object DocsBootstrap { + private val log = Logger.getInstance(DocsBootstrap::class.java) + @Volatile private var ensured = false + + fun ensure() { + if (ensured) return + synchronized(this) { + if (ensured) return + val loaded = tryLoadExternal() || trySeedFallback() + if (loaded) ensured = true else ensured = true // mark done to avoid repeated attempts + } + } + + private fun tryLoadExternal(): Boolean = try { + val cls = Class.forName("net.sergeych.lyngio.docs.FsBuiltinDocs") + val m = cls.getMethod("ensure") + m.invoke(null) + log.info("[LYNG_DEBUG] DocsBootstrap: external docs loaded: net.sergeych.lyngio.docs.FsBuiltinDocs.ensure() OK") + true + } catch (_: Throwable) { + false + } + + private fun trySeedFallback(): Boolean = try { + val seeded = FsDocsFallback.ensureOnce() + if (seeded) { + log.info("[LYNG_DEBUG] DocsBootstrap: external docs not found; seeded plugin fallback for lyng.io.fs") + } else { + log.info("[LYNG_DEBUG] DocsBootstrap: external docs not found; no fallback seeded") + } + seeded + } catch (_: Throwable) { + false + } +} diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/TextCtx.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/TextCtx.kt new file mode 100644 index 0000000..d9f9a1a --- /dev/null +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/util/TextCtx.kt @@ -0,0 +1,60 @@ +/* + * Shared tiny, PSI-free text helpers for Lyng editor features (Quick Doc, Completion). + */ +package net.sergeych.lyng.idea.util + +import com.intellij.openapi.util.TextRange + +object TextCtx { + fun prefixAt(text: String, offset: Int): String { + val off = offset.coerceIn(0, text.length) + var i = (off - 1).coerceAtLeast(0) + while (i >= 0 && isIdentChar(text[i])) i-- + val start = i + 1 + return if (start in 0..text.length && start <= off) text.substring(start, off) else "" + } + + fun wordRangeAt(text: String, offset: Int): TextRange? { + if (text.isEmpty()) return null + val off = offset.coerceIn(0, text.length) + var s = off + var e = off + while (s > 0 && isIdentChar(text[s - 1])) s-- + while (e < text.length && isIdentChar(text[e])) e++ + return if (s < e) TextRange(s, e) else null + } + + fun findDotLeft(text: String, offset: Int): Int? { + var i = (offset - 1).coerceAtLeast(0) + while (i >= 0 && text[i].isWhitespace()) i-- + return if (i >= 0 && text[i] == '.') i else null + } + + fun previousWordBefore(text: String, offset: Int): String? { + var i = prevNonWs(text, (offset - 1).coerceAtLeast(0)) + // Skip trailing identifier at caret if inside word + while (i >= 0 && isIdentChar(text[i])) i-- + i = prevNonWs(text, i) + if (i < 0) return null + val end = i + 1 + while (i >= 0 && isIdentChar(text[i])) i-- + val start = i + 1 + return if (start < end) text.substring(start, end) else null + } + + fun hasDotBetween(text: String, start: Int, end: Int): Boolean { + if (start >= end) return false + val s = start.coerceAtLeast(0) + val e = end.coerceAtMost(text.length) + for (i in s until e) if (text[i] == '.') return true + return false + } + + fun prevNonWs(text: String, start: Int): Int { + var i = start.coerceAtMost(text.length - 1) + while (i >= 0 && text[i].isWhitespace()) i-- + return i + } + + fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit() +} diff --git a/lyng-idea/src/main/resources/META-INF/plugin.xml b/lyng-idea/src/main/resources/META-INF/plugin.xml index 3fc3c26..4f85666 100644 --- a/lyng-idea/src/main/resources/META-INF/plugin.xml +++ b/lyng-idea/src/main/resources/META-INF/plugin.xml @@ -62,6 +62,9 @@ + + + diff --git a/lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionMemberTest.kt b/lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionMemberTest.kt new file mode 100644 index 0000000..698840b --- /dev/null +++ b/lyng-idea/src/test/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionMemberTest.kt @@ -0,0 +1,121 @@ +package net.sergeych.lyng.idea.completion + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import net.sergeych.lyng.idea.util.DocsBootstrap +import net.sergeych.lyng.miniast.BuiltinDocRegistry +import net.sergeych.lyng.miniast.DocLookupUtils +import net.sergeych.lyng.miniast.MiniClassDecl + +class LyngCompletionMemberTest : BasePlatformTestCase() { + + override fun getTestDataPath(): String = "" + + private fun complete(code: String): List { + myFixture.configureByText("test.lyng", code) + val items = myFixture.completeBasic() + return myFixture.lookupElementStrings ?: emptyList() + } + + private fun ensureDocs(imports: List) { + // Make sure external/bundled docs like lyng.io.fs are registered + DocsBootstrap.ensure() + // Touch modules to force stdlib lazy load and optional modules + for (m in imports) BuiltinDocRegistry.docsForModule(m) + } + + private fun aggregateMemberNames(className: String, imported: List): Set { + val classes = DocLookupUtils.aggregateClasses(imported) + val visited = mutableSetOf() + val result = linkedSetOf() + fun dfs(name: String) { + val cls: MiniClassDecl = classes[name] ?: return + for (m in cls.members) result.add(m.name) + if (!visited.add(name)) return + for (b in cls.bases) dfs(b) + } + dfs(className) + // Conservative supplementation mirroring contributor behavior + when (className) { + "List" -> listOf("Collection", "Iterable").forEach { dfs(it) } + "Array" -> listOf("Collection", "Iterable").forEach { dfs(it) } + } + return result + } + + fun test_NoGlobalsAfterDot_IteratorFromLines() { + val code = """ + import lyng.io.fs + import lyng.stdlib + + val files = Path("../..").lines(). + """.trimIndent() + val imported = listOf("lyng.io.fs", "lyng.stdlib") + ensureDocs(imported) + + val items = complete(code) + // Must not propose globals after dot + assertFalse(items.contains("Path")) + assertFalse(items.contains("Array")) + assertFalse(items.contains("String")) + + // Should contain a reasonable subset of Iterator members + val expected = aggregateMemberNames("Iterator", imported) + // At least one expected member must appear + val intersection = expected.intersect(items.toSet()) + assertTrue("Expected Iterator members, but got: $items", intersection.isNotEmpty()) + } + + fun test_IteratorAfterLines_WithPrefix() { + val code = """ + import lyng.io.fs + import lyng.stdlib + + val files = Path("../..").lines().t + """.trimIndent() + val imported = listOf("lyng.io.fs", "lyng.stdlib") + ensureDocs(imported) + + val items = complete(code) + // Must not propose globals after dot even with prefix + assertFalse(items.contains("Path")) + assertFalse(items.contains("Array")) + assertFalse(items.contains("String")) + + // All suggestions should start with the typed prefix (case-insensitive) + assertTrue(items.all { it.startsWith("t", ignoreCase = true) }) + + // Some Iterator member starting with 't' should be present (e.g., toList) + val expected = aggregateMemberNames("Iterator", imported).filter { it.startsWith("t", true) }.toSet() + if (expected.isNotEmpty()) { + val intersection = expected.intersect(items.toSet()) + assertTrue("Expected Iterator members with prefix 't', got: $items", intersection.isNotEmpty()) + } else { + // If registry has no 't*' members, at least suggestions should not be empty + assertTrue(items.isNotEmpty()) + } + } + + fun test_ListLiteral_MembersWithInherited() { + val code = """ + import lyng.stdlib + + val x = [1,2,3]. + """.trimIndent() + val imported = listOf("lyng.stdlib") + ensureDocs(imported) + + val items = complete(code) + // Must not propose globals after dot + assertFalse(items.contains("Array")) + assertFalse(items.contains("String")) + assertFalse(items.contains("Path")) + + // Expect members from List plus parents (Collection/Iterable) + val expected = aggregateMemberNames("List", imported) + val intersection = expected.intersect(items.toSet()) + assertTrue("Expected List/Collection/Iterable members, got: $items", intersection.isNotEmpty()) + + // Heuristic: we expect more than a couple of items (not just size/toList) + assertTrue("Too few member suggestions after list literal: $items", items.size >= 3) + } +} diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 4bc5866..84e2c68 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -107,6 +107,12 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } + val jvmTest by getting { + dependencies { + // Allow tests to load external docs like lyng.io.fs via registrar + implementation(project(":lyngio")) + } + } } } 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 a3b026a..dabcfb3 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt @@ -97,6 +97,22 @@ object BuiltinDocRegistry : BuiltinDocSource { init { registerLazy("lyng.stdlib") { buildStdlibDocs() } } + + /** + * List names of extension-like methods defined for [className] in the stdlib text (`root.lyng`). + * We do a lightweight regex scan like: `fun ClassName.methodName(` and collect distinct names. + */ + fun extensionMethodNamesFor(className: String): List { + val src = try { rootLyng } catch (_: Throwable) { null } ?: return emptyList() + val out = LinkedHashSet() + // Match lines like: fun String.trim(...) + val re = Regex("(?m)^\\s*fun\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\(") + re.findAll(src).forEach { m -> + val name = m.groupValues.getOrNull(1)?.trim() + if (!name.isNullOrEmpty()) out.add(name) + } + return out.toList() + } } // ---------------- Builders ---------------- diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt new file mode 100644 index 0000000..ff70035 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -0,0 +1,381 @@ +/* + * Pure-Kotlin, PSI-free completion engine used for isolated tests and non-IDE harnesses. + * Mirrors the IntelliJ MVP logic: MiniAst + BuiltinDocRegistry + lenient imports. + */ +package net.sergeych.lyng.miniast +import net.sergeych.lyng.Compiler +import net.sergeych.lyng.Script +import net.sergeych.lyng.Source +import net.sergeych.lyng.pacman.ImportProvider + +/** Minimal completion item description (IDE-agnostic). */ +data class CompletionItem( + val name: String, + val kind: Kind, + val tailText: String? = null, + val typeText: String? = null, +) + +enum class Kind { Function, Class_, Value, Method, Field } + +/** + * Platform-free, lenient import provider that never fails on unknown packages. + * Used to allow MiniAst parsing even when external modules are not present at runtime. + */ +class LenientImportProvider private constructor(root: net.sergeych.lyng.Scope) : ImportProvider(root) { + override suspend fun createModuleScope(pos: net.sergeych.lyng.Pos, packageName: String): net.sergeych.lyng.ModuleScope = + net.sergeych.lyng.ModuleScope(this, pos, packageName) + + companion object { + fun create(): LenientImportProvider = LenientImportProvider(Script.defaultImportManager.rootScope) + } +} + +object CompletionEngineLight { + + suspend fun completeAtMarkerSuspend(textWithCaret: String, marker: String = ""): List { + val idx = textWithCaret.indexOf(marker) + require(idx >= 0) { "Caret marker '$marker' not found" } + val text = textWithCaret.replace(marker, "") + return completeSuspend(text, idx) + } + + suspend fun completeSuspend(text: String, caret: Int): List { + val prefix = prefixAt(text, caret) + val mini = buildMiniAst(text) + // Build imported modules as a UNION of MiniAst-derived and textual extraction, always including stdlib + run { + // no-op block to keep local scope tidy + } + val fromMini: List = mini?.let { DocLookupUtils.canonicalImportedModules(it) } ?: emptyList() + val fromText: List = extractImportsFromText(text) + val imported: List = LinkedHashSet().apply { + fromMini.forEach { add(it) } + fromText.forEach { add(it) } + add("lyng.stdlib") + }.toList() + + val cap = 200 + val out = ArrayList(64) + + // Member context detection: dot immediately before caret or before current word start + val word = wordRangeAt(text, caret) + val memberDot = findDotLeft(text, word?.first ?: caret) + if (memberDot != null) { + // 0) Try chained member call return type inference + guessReturnClassFromMemberCallBefore(text, memberDot, imported)?.let { cls -> + offerMembersAdd(out, prefix, imported, cls) + return out + } + // 0a) Top-level call before dot + guessReturnClassFromTopLevelCallBefore(text, memberDot, imported)?.let { cls -> + offerMembersAdd(out, prefix, imported, cls) + return out + } + // 0b) Across-known-callees (Iterable/Iterator/List preference) + guessReturnClassAcrossKnownCallees(text, memberDot, imported)?.let { cls -> + offerMembersAdd(out, prefix, imported, cls) + return out + } + // 1) Receiver inference fallback + guessReceiverClass(text, memberDot, imported)?.let { cls -> + offerMembersAdd(out, prefix, imported, cls) + return out + } + // In member context and unknown receiver/return type: show nothing (no globals after dot) + return out + } + + // Global identifiers: params > local decls > imported > stdlib; Functions > Classes > Values; alphabetical + mini?.let { m -> + val decls = m.declarations + val funs = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val classes = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val vals = decls.filterIsInstance().sortedBy { it.name.lowercase() } + funs.forEach { offerDeclAdd(out, prefix, it) } + classes.forEach { offerDeclAdd(out, prefix, it) } + vals.forEach { offerDeclAdd(out, prefix, it) } + } + + // Imported and builtin + val (nonStd, std) = imported.partition { it != "lyng.stdlib" } + val order = nonStd + std + val emptyPrefixThrottle = prefix.isBlank() + var externalAdded = 0 + val budget = if (emptyPrefixThrottle) 100 else Int.MAX_VALUE + for (mod in order) { + val decls = BuiltinDocRegistry.docsForModule(mod) + val funs = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val classes = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val vals = decls.filterIsInstance().sortedBy { it.name.lowercase() } + funs.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } + classes.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } + vals.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } + if (out.size >= cap || externalAdded >= budget) break + } + + return out + } + + // --- Emission helpers --- + + private fun offerDeclAdd(out: MutableList, prefix: String, d: MiniDecl) { + fun add(ci: CompletionItem) { if (ci.name.startsWith(prefix, true)) out += ci } + when (d) { + is MiniFunDecl -> { + val params = d.params.joinToString(", ") { it.name } + val tail = "(${params})" + add(CompletionItem(d.name, Kind.Function, tailText = tail, typeText = typeOf(d.returnType))) + } + is MiniClassDecl -> add(CompletionItem(d.name, Kind.Class_)) + is MiniValDecl -> add(CompletionItem(d.name, Kind.Value, typeText = typeOf(d.type))) + else -> add(CompletionItem(d.name, Kind.Value)) + } + } + + private fun offerMembersAdd(out: MutableList, prefix: String, imported: List, className: String) { + val classes = DocLookupUtils.aggregateClasses(imported) + val visited = mutableSetOf() + val directMap = LinkedHashMap>() + val inheritedMap = LinkedHashMap>() + + fun addMembersOf(name: String, direct: Boolean) { + val cls = classes[name] ?: return + val target = if (direct) directMap else inheritedMap + for (m in cls.members) target.getOrPut(m.name) { mutableListOf() }.add(m) + for (b in cls.bases) if (visited.add(b)) addMembersOf(b, false) + } + + visited.add(className) + addMembersOf(className, true) + + // Conservative supplements for containers + when (className) { + "List" -> listOf("Collection", "Iterable").forEach { if (visited.add(it)) addMembersOf(it, false) } + "Array" -> listOf("Collection", "Iterable").forEach { if (visited.add(it)) addMembersOf(it, false) } + } + + 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() + when (rep) { + is MiniMemberFunDecl -> { + val params = rep.params.joinToString(", ") { it.name } + val extra = variants.count { it is MiniMemberFunDecl } - 1 + val ov = if (extra > 0) " (+$extra overloads)" else "" + val ci = CompletionItem(name, Kind.Method, tailText = "(${params})$ov", typeText = typeOf(rep.returnType)) + if (ci.name.startsWith(prefix, true)) out += ci + } + is MiniMemberValDecl -> { + val ci = CompletionItem(name, Kind.Field, typeText = typeOf(rep.type)) + if (ci.name.startsWith(prefix, true)) out += ci + } + } + } + } + + emitGroup(directMap) + emitGroup(inheritedMap) + } + + // --- 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) + if (i >= 0) { + when (text[i]) { + '"' -> return "String" + ']' -> return "List" + '}' -> return "Dict" + } + // Numeric literal: decimal/int/hex/scientific + var j = i + var hasDigits = false + var hasDot = false + var hasExp = false + while (j >= 0) { + val ch = text[j] + if (ch.isDigit()) { hasDigits = true; j--; continue } + if (ch == '.') { hasDot = true; j--; continue } + if (ch == 'e' || ch == 'E') { hasExp = true; j--; if (j >= 0 && (text[j] == '+' || text[j] == '-')) j--; continue } + if (ch in listOf('x','X','a','b','c','d','f','A','B','C','D','F')) { j--; continue } + break + } + if (hasDigits) return if (hasDot || hasExp) "Real" else "Int" + } + return null + } + + private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List): String? { + var i = prevNonWs(text, dotPos - 1) + if (i < 0 || text[i] != ')') return null + i-- + var depth = 0 + while (i >= 0) { + when (text[i]) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + var k = start - 1 + while (k >= 0 && text[k].isWhitespace()) k-- + if (k < 0 || text[k] != '.') return null + val prevDot = k + val receiverClass = guessReceiverClass(text, prevDot, imported) ?: return null + val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee) ?: return null + val member = resolved.second + val ret = when (member) { + is MiniMemberFunDecl -> member.returnType + is MiniMemberValDecl -> member.type + } + return simpleClassNameOf(ret) + } + + private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List): String? { + var i = prevNonWs(text, dotPos - 1) + if (i < 0 || text[i] != ')') return null + i-- + var depth = 0 + while (i >= 0) { + when (text[i]) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + var k = start - 1 + while (k >= 0 && text[k].isWhitespace()) k-- + if (k >= 0 && text[k] == '.') return null // was a member call + for (mod in imported) { + val decls = BuiltinDocRegistry.docsForModule(mod) + val fn = decls.asSequence().filterIsInstance().firstOrNull { it.name == callee } + if (fn != null) return simpleClassNameOf(fn.returnType) + } + return null + } + + private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List): String? { + var i = prevNonWs(text, dotPos - 1) + if (i < 0 || text[i] != ')') return null + i-- + var depth = 0 + while (i >= 0) { + when (text[i]) { + ')' -> depth++ + '(' -> if (depth == 0) break else depth-- + } + i-- + } + if (i < 0 || text[i] != '(') return null + var j = i - 1 + while (j >= 0 && text[j].isWhitespace()) j-- + val end = j + 1 + while (j >= 0 && isIdentChar(text[j])) j-- + val start = j + 1 + if (start >= end) return null + val callee = text.substring(start, end) + val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee) ?: return null + val member = resolved.second + val ret = when (member) { + is MiniMemberFunDecl -> member.returnType + is MiniMemberValDecl -> member.type + } + return simpleClassNameOf(ret) + } + + private fun simpleClassNameOf(t: MiniTypeRef?): String? = when (t) { + null -> null + is MiniTypeName -> t.segments.lastOrNull()?.name + is MiniGenericType -> simpleClassNameOf(t.base) + is MiniFunctionType -> null + is MiniTypeVar -> null + } + + // --- MiniAst and small utils --- + + private suspend fun buildMiniAst(text: String): MiniScript? = try { + val sink = MiniAstBuilder() + val src = Source("", text) + val provider = LenientImportProvider.create() + Compiler.compileWithMini(src, provider, sink) + sink.build() + } catch (_: Throwable) { + null + } + + private fun typeOf(t: MiniTypeRef?): String = when (t) { + null -> "" + is MiniTypeName -> t.segments.lastOrNull()?.name?.let { ": $it" } ?: "" + is MiniGenericType -> { + val base = typeOf(t.base).removePrefix(": ") + val args = t.args.joinToString(",") { typeOf(it).removePrefix(": ") } + ": ${base}<${args}>" + } + is MiniFunctionType -> ": (fn)" + is MiniTypeVar -> ": ${t.name}" + } + + // Note: we intentionally skip "params in scope" in the isolated engine to avoid PSI/offset mapping. + + // Text helpers + private fun prefixAt(text: String, offset: Int): String { + val off = offset.coerceIn(0, text.length) + var i = (off - 1).coerceAtLeast(0) + while (i >= 0 && isIdentChar(text[i])) i-- + val start = i + 1 + return if (start in 0..text.length && start <= off) text.substring(start, off) else "" + } + + private fun wordRangeAt(text: String, offset: Int): Pair? { + if (text.isEmpty()) return null + val off = offset.coerceIn(0, text.length) + var s = off + var e = off + while (s > 0 && isIdentChar(text[s - 1])) s-- + while (e < text.length && isIdentChar(text[e])) e++ + return if (s < e) s to e else null + } + + private fun findDotLeft(text: String, offset: Int): Int? { + var i = (offset - 1).coerceAtLeast(0) + while (i >= 0 && text[i].isWhitespace()) i-- + return if (i >= 0 && text[i] == '.') i else null + } + + private fun prevNonWs(text: String, start: Int): Int { + var i = start.coerceAtMost(text.length - 1) + while (i >= 0 && text[i].isWhitespace()) i-- + return i + } + + private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit() + + private fun extractImportsFromText(text: String): List { + val result = LinkedHashSet() + val re = Regex("(?m)^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)") + re.findAll(text).forEach { m -> + val raw = m.groupValues.getOrNull(1)?.trim().orEmpty() + if (raw.isNotEmpty()) result.add(if (raw.startsWith("lyng.")) raw else "lyng.$raw") + } + return result.toList() + } +} diff --git a/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt new file mode 100644 index 0000000..92dccd8 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt @@ -0,0 +1,102 @@ +package net.sergeych.lyng.miniast + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CompletionEngineLightTest { + + private fun names(items: List): List = items.map { it.name } + + @Test + fun iteratorAfterLines_noGlobals() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib", "lyng.io.fs") + val code = """ + import lyng.io.fs + import lyng.stdlib + + val files = Path("../..").lines(). + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val ns = names(items) + // No globals after a dot + assertFalse(ns.contains("Path"), "Should not contain global 'Path' after dot") + assertFalse(ns.contains("Array"), "Should not contain global 'Array' after dot") + assertFalse(ns.contains("String"), "Should not contain global 'String' after dot") + // Should have some iterator members (at least non-empty) + assertTrue(ns.isNotEmpty(), "Iterator members should be suggested") + } + + @Test + fun iteratorAfterLines_withPrefix() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib", "lyng.io.fs") + val code = """ + import lyng.io.fs + import lyng.stdlib + + val files = Path("../..").lines().t + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val ns = names(items) + // No globals after a dot even with prefix + assertFalse(ns.contains("Path")) + assertFalse(ns.contains("Array")) + assertFalse(ns.contains("String")) + // All start with 't' + assertTrue(ns.all { it.startsWith("t", ignoreCase = true) }, "All suggestions should respect prefix 't'") + } + + @Test + fun listLiteral_membersAndInherited() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib") + val code = """ + import lyng.stdlib + + val x = [1,2,3]. + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val ns = names(items) + // No globals after a dot + assertFalse(ns.contains("Array")) + assertFalse(ns.contains("String")) + assertFalse(ns.contains("Path")) + // Expect more than a couple of items (not just one) + assertTrue(ns.size >= 3, "Too few member suggestions after list literal: $ns") + } + + @Test + fun stringLiteral_members() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib") + val code = """ + import lyng.stdlib + + val s = "abc". + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val ns = names(items) + assertTrue(ns.isNotEmpty(), "String members should be suggested") + assertFalse(ns.contains("Path")) + } + + @Test + fun shebang_and_fs_import_iterator_after_lines() = runBlocking { + TestDocsBootstrap.ensure("lyng.stdlib", "lyng.io.fs") + val code = """ + #!/bin/env lyng + + import lyng.io.fs + import lyng.stdlib + + val files = Path("../..").lines(). + """.trimIndent() + val items = CompletionEngineLight.completeAtMarkerSuspend(code) + val ns = names(items) + // Should not contain globals after dot + assertFalse(ns.contains("Path")) + assertFalse(ns.contains("Array")) + assertFalse(ns.contains("String")) + // Should contain some iterator members + assertTrue(ns.isNotEmpty(), "Iterator members should be suggested after lines() with shebang present") + } +} diff --git a/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/TestDocsBootstrap.kt b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/TestDocsBootstrap.kt new file mode 100644 index 0000000..7f45871 --- /dev/null +++ b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/TestDocsBootstrap.kt @@ -0,0 +1,33 @@ +package net.sergeych.lyng.miniast + +/** Ensure docs modules are loaded for tests (stdlib + optional lyngio). */ +object TestDocsBootstrap { + @Volatile private var ensured = false + + fun ensure(vararg modules: String) { + if (ensured) return + synchronized(this) { + if (ensured) return + // Touch stdlib to seed lazy docs + BuiltinDocRegistry.docsForModule("lyng.stdlib") + // Try to load external fs docs registrar reflectively + val ok = try { + val cls = Class.forName("net.sergeych.lyngio.docs.FsBuiltinDocs") + val m = cls.getMethod("ensure") + m.invoke(null) + true + } catch (_: Throwable) { false } + if (!ok) { + // Minimal fallback for lyng.io.fs (Path with lines(): Iterator) + BuiltinDocRegistry.moduleReplace("lyng.io.fs") { + classDoc(name = "Path", doc = "Filesystem path class.") { + method(name = "lines", doc = "Iterate file as lines of text.", returns = TypeGenericDoc(type("lyng.Iterator"), listOf(type("lyng.String")))) + method(name = "exists", doc = "Whether the path exists.", returns = type("lyng.Bool")) + } + valDoc(name = "Path", doc = "Path class", type = type("Path")) + } + } + ensured = true + } + } +}