started autocompletion in the plugin

This commit is contained in:
Sergey Chernov 2025-12-07 23:05:58 +01:00
parent 678cfbf45e
commit c35d684df1
15 changed files with 1631 additions and 2 deletions

View File

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

View File

@ -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/++.

View File

@ -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<CompletionParameters>() {
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<String>().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<String>().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<com.intellij.codeInsight.lookup.LookupElement> {
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<String>,
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<String>()
// Collect separated to keep tiers: direct first, then inherited
val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
val inheritedMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
// 0) Prefer locally-declared class members (same-file) when available
val localClass = mini?.declarations?.filterIsInstance<MiniClassDecl>()?.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<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()
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>): 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<MiniValDecl>().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<MiniFunDecl>()
.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>): 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<MiniClassDecl>().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<String>?, val typeText: String?)
private fun scanLocalClassMembersFromText(mini: MiniScript, text: String, cls: MiniClassDecl): Map<String, ScannedSig> {
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<String, ScannedSig>()
// 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>): 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().<caret>` detects `lines()` on receiver class `Path` and returns `Iterator`.
*/
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>): 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().<caret>`.
* We extract callee name and resolve it among imported modules' top-level functions.
*/
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>): 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<MiniFunDecl>().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().<caret>` where `something` type is unknown, but `lines()` commonly returns Iterator<String>.
*/
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>): 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<String>). */
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("<ide>", 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<MiniScript>("lyng.mini.cache")
private val STAMP_KEY = Key.create<Long>("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<MiniFunDecl>()
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<String> {
val result = LinkedHashSet<String>()
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}"
}
}
}
}

View File

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

View File

@ -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<String> = 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)

View File

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

View File

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

View File

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

View File

@ -62,6 +62,9 @@
<!-- Quick documentation provider bound to Lyng language -->
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
<!-- Basic code completion (MVP) -->
<completion.contributor language="Lyng" implementationClass="net.sergeych.lyng.idea.completion.LyngCompletionContributor"/>
<!-- Comment toggling support -->
<lang.commenter language="Lyng" implementationClass="net.sergeych.lyng.idea.comment.LyngCommenter"/>

View File

@ -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<String> {
myFixture.configureByText("test.lyng", code)
val items = myFixture.completeBasic()
return myFixture.lookupElementStrings ?: emptyList()
}
private fun ensureDocs(imports: List<String>) {
// 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<String>): Set<String> {
val classes = DocLookupUtils.aggregateClasses(imported)
val visited = mutableSetOf<String>()
val result = linkedSetOf<String>()
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().<caret>
""".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<caret>
""".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].<caret>
""".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)
}
}

View File

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

View File

@ -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<String> {
val src = try { rootLyng } catch (_: Throwable) { null } ?: return emptyList()
val out = LinkedHashSet<String>()
// 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 ----------------

View File

@ -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 = "<caret>"): List<CompletionItem> {
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<CompletionItem> {
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<String> = mini?.let { DocLookupUtils.canonicalImportedModules(it) } ?: emptyList()
val fromText: List<String> = extractImportsFromText(text)
val imported: List<String> = LinkedHashSet<String>().apply {
fromMini.forEach { add(it) }
fromText.forEach { add(it) }
add("lyng.stdlib")
}.toList()
val cap = 200
val out = ArrayList<CompletionItem>(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<MiniFunDecl>().sortedBy { it.name.lowercase() }
val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() }
val vals = decls.filterIsInstance<MiniValDecl>().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<MiniFunDecl>().sortedBy { it.name.lowercase() }
val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() }
val vals = decls.filterIsInstance<MiniValDecl>().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<CompletionItem>, 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<CompletionItem>, prefix: String, imported: List<String>, className: String) {
val classes = DocLookupUtils.aggregateClasses(imported)
val visited = mutableSetOf<String>()
val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
val inheritedMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
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<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()
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>): 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>): 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>): 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<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
return null
}
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>): 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("<engine>", 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<Int, Int>? {
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<String> {
val result = LinkedHashSet<String>()
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()
}
}

View File

@ -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<CompletionItem>): List<String> = 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().<caret>
""".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<caret>
""".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].<caret>
""".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".<caret>
""".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().<caret>
""".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")
}
}

View File

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