fix #88 fix #90 plugin with autocompletion

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

No files matched your search

@@ -68,8 +68,14 @@ object BuiltinDocRegistry : BuiltinDocSource {
}
override fun docsForModule(moduleName: String): List<MiniDecl> {
modules[moduleName]?.let { return it }
// Try lazy supplier once
// If module already present but we also have a lazy supplier for it, merge supplier once
modules[moduleName]?.let { existing ->
lazySuppliers.remove(moduleName)?.invoke()?.let { built ->
existing += built
}
return existing
}
// Try lazy supplier once when module is not present
val built = lazySuppliers.remove(moduleName)?.invoke()
if (built != null) {
val list = modules.getOrPut(moduleName) { mutableListOf() }
@@ -41,6 +41,8 @@ object CompletionEngineLight {
}
suspend fun completeSuspend(text: String, caret: Int): List<CompletionItem> {
// Ensure stdlib Obj*-defined docs (e.g., String methods) are initialized before registry lookup
StdlibDocsBootstrap.ensure()
val prefix = prefixAt(text, caret)
val mini = buildMiniAst(text)
// Build imported modules as a UNION of MiniAst-derived and textual extraction, always including stdlib
@@ -158,7 +160,13 @@ object CompletionEngineLight {
fun emitGroup(map: LinkedHashMap<String, MutableList<MiniMemberDecl>>) {
for (name in map.keys.sortedBy { it.lowercase() }) {
val variants = map[name] ?: continue
val rep = variants.firstOrNull { it is MiniMemberFunDecl } ?: variants.first()
// Prefer a method with a known return type; else any method; else first variant
val rep =
variants.asSequence()
.filterIsInstance<MiniMemberFunDecl>()
.firstOrNull { it.returnType != null }
?: variants.firstOrNull { it is MiniMemberFunDecl }
?: variants.first()
when (rep) {
is MiniMemberFunDecl -> {
val params = rep.params.joinToString(", ") { it.name }
@@ -168,7 +176,11 @@ object CompletionEngineLight {
if (ci.name.startsWith(prefix, true)) out += ci
}
is MiniMemberValDecl -> {
val ci = CompletionItem(name, Kind.Field, typeText = typeOf(rep.type))
// Prefer a field variant with known type if available
val chosen = variants.asSequence()
.filterIsInstance<MiniMemberValDecl>()
.firstOrNull { it.type != null } ?: rep
val ci = CompletionItem(name, Kind.Field, typeText = typeOf((chosen as MiniMemberValDecl).type))
if (ci.name.startsWith(prefix, true)) out += ci
}
}
@@ -177,18 +189,70 @@ object CompletionEngineLight {
emitGroup(directMap)
emitGroup(inheritedMap)
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.re(...))
run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className)
for (name in ext) {
if (already.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, className, name)
if (resolved != null) {
when (val member = resolved.second) {
is MiniMemberFunDecl -> {
val params = member.params.joinToString(", ") { it.name }
val ci = CompletionItem(name, Kind.Method, tailText = "(${params})", typeText = typeOf(member.returnType))
if (ci.name.startsWith(prefix, true)) out += ci
already.add(name)
}
is MiniMemberValDecl -> {
val ci = CompletionItem(name, Kind.Field, typeText = typeOf(member.type))
if (ci.name.startsWith(prefix, true)) out += ci
already.add(name)
}
}
} else {
// Fallback: emit simple method name without detailed types
val ci = CompletionItem(name, Kind.Method, tailText = "()", typeText = null)
if (ci.name.startsWith(prefix, true)) out += ci
already.add(name)
}
}
}
}
// --- Inference helpers (text-only, PSI-free) ---
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>): String? {
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it }
val i = prevNonWs(text, dotPos - 1)
var i = prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
'"' -> return "String"
']' -> return "List"
'}' -> return "Dict"
')' -> {
// Parenthesized expression: walk back to matching '(' and inspect the inner expression
var j = i - 1
var depth = 0
while (j >= 0) {
when (text[j]) {
')' -> depth++
'(' -> if (depth == 0) break else depth--
}
j--
}
if (j >= 0 && text[j] == '(') {
val innerS = (j + 1).coerceAtLeast(0)
val innerE = i.coerceAtMost(text.length)
if (innerS < innerE) {
val inner = text.substring(innerS, innerE).trim()
if (inner.startsWith('"') && inner.endsWith('"')) return "String"
if (inner.startsWith('[') && inner.endsWith(']')) return "List"
if (inner.startsWith('{') && inner.endsWith('}')) return "Dict"
}
}
}
}
// Numeric literal: decimal/int/hex/scientific
var j = i
@@ -13,26 +13,63 @@ object DocLookupUtils {
*/
fun canonicalImportedModules(mini: MiniScript): List<String> {
val raw = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
if (raw.isEmpty()) return emptyList()
val result = LinkedHashSet<String>()
for (name in raw) {
val canon = if (name.startsWith("lyng.")) name else "lyng.$name"
result.add(canon)
}
// Always make stdlib available as a fallback context for common types
// Always make stdlib available as a fallback context for common types,
// even when there are no explicit imports in the file
result.add("lyng.stdlib")
return result.toList()
}
fun aggregateClasses(importedModules: List<String>): Map<String, MiniClassDecl> {
val map = LinkedHashMap<String, MiniClassDecl>()
// Collect all class decls by name across modules, then merge duplicates by unioning members and bases.
val buckets = LinkedHashMap<String, MutableList<MiniClassDecl>>()
for (mod in importedModules) {
val docs = BuiltinDocRegistry.docsForModule(mod)
docs.filterIsInstance<MiniClassDecl>().forEach { cls ->
if (!map.containsKey(cls.name)) map[cls.name] = cls
for (cls in docs.filterIsInstance<MiniClassDecl>()) {
buckets.getOrPut(cls.name) { mutableListOf() }.add(cls)
}
}
return map
fun mergeClassDecls(name: String, list: List<MiniClassDecl>): MiniClassDecl {
if (list.isEmpty()) throw IllegalArgumentException("empty class list for $name")
if (list.size == 1) return list.first()
// Choose a representative for non-merge fields (range/nameStart/bodyRange): take the first
val rep = list.first()
val bases = LinkedHashSet<String>()
val members = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
var doc: MiniDoc? = null
for (c in list) {
bases.addAll(c.bases)
if (doc == null && c.doc != null && c.doc.raw.isNotBlank()) doc = c.doc
for (m in c.members) {
// Group by name to keep overloads together
members.getOrPut(m.name) { mutableListOf() }.add(m)
}
}
// Flatten grouped members back to a list; keep stable name order
val mergedMembers = members.keys.sortedBy { it.lowercase() }.flatMap { members[it] ?: emptyList() }
return MiniClassDecl(
range = rep.range,
name = rep.name,
bases = bases.toList(),
bodyRange = rep.bodyRange,
ctorFields = rep.ctorFields,
classFields = rep.classFields,
doc = doc,
nameStart = rep.nameStart,
members = mergedMembers
)
}
val result = LinkedHashMap<String, MiniClassDecl>()
for ((name, list) in buckets) {
result[name] = mergeClassDecls(name, list)
}
return result
}
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String): Pair<String, MiniMemberDecl>? {
@@ -0,0 +1,26 @@
/*
* Ensure stdlib Obj*-defined docs (like String methods added via ObjString.addFnDoc)
* are initialized before registry lookups for completion/quick docs.
*/
package net.sergeych.lyng.miniast
import net.sergeych.lyng.obj.ObjString
object StdlibDocsBootstrap {
// Simple idempotent guard; races are harmless as initializer side-effects are idempotent
private var ensured = false
fun ensure() {
if (ensured) return
try {
// Touch core Obj* types whose docs are registered via addFnDoc/addConstDoc
// Accessing .type forces their static initializers to run and register docs.
@Suppress("UNUSED_VARIABLE")
val _string = ObjString.type
} catch (_: Throwable) {
// Best-effort; absence should not break consumers
} finally {
ensured = true
}
}
}
@@ -79,6 +79,51 @@ class CompletionEngineLightTest {
assertFalse(ns.contains("Path"))
}
@Test
fun stringLiteral_re_hasReturnTypeRegex() = runBlocking {
TestDocsBootstrap.ensure("lyng.stdlib")
val code = """
import lyng.stdlib
val s = "abc".<caret>
""".trimIndent()
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val reItem = items.firstOrNull { it.name == "re" }
assertTrue(reItem != null, "Expected to find 're' in String members, got: ${items.map { it.name }}")
// Type text should contain ": Regex"
assertTrue(reItem!!.typeText?.contains("Regex") == true, "Expected type text to contain 'Regex', was: ${reItem.typeText}")
}
@Test
fun stringLiteral_parenthesized_re_hasReturnTypeRegex() = runBlocking {
TestDocsBootstrap.ensure("lyng.stdlib")
val code = """
// No imports on purpose; stdlib must still be available
val s = ("abc").<caret>
""".trimIndent()
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }
assertTrue(names.isNotEmpty(), "Expected String members for parenthesized literal, got empty list")
val reItem = items.firstOrNull { it.name == "re" }
assertTrue(reItem != null, "Expected to find 're' for parenthesized String literal, got: $names")
assertTrue(reItem!!.typeText?.contains("Regex") == true, "Expected ': Regex' for re(), was: ${reItem.typeText}")
}
@Test
fun stringLiteral_noImports_stillHasStringMembers() = runBlocking {
TestDocsBootstrap.ensure("lyng.stdlib")
val code = """
val s = "super".<caret>
""".trimIndent()
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }
assertTrue(names.isNotEmpty(), "Expected String members without explicit imports, got empty list")
val reItem = items.firstOrNull { it.name == "re" }
assertTrue(reItem != null, "Expected to find 're' without explicit imports, got: $names")
assertTrue(reItem!!.typeText?.contains("Regex") == true, "Expected ': Regex' for re() without imports, was: ${reItem.typeText}")
}
@Test
fun shebang_and_fs_import_iterator_after_lines() = runBlocking {
TestDocsBootstrap.ensure("lyng.stdlib", "lyng.io.fs")
+1 -1
View File
@@ -192,6 +192,6 @@ fun Exception.printStackTrace() {
}
/* Compile this string into a regular expression. */
fun String.re() { Regex(this) }
fun String.re(): Regex { Regex(this) }