plugin: autocomplete for enums and members, better support for local symbols; fixed bug in space normalization

This commit is contained in:
Sergey Chernov 2026-01-02 16:03:57 +01:00
parent 22f6c149db
commit fac58675d5
12 changed files with 581 additions and 187 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -117,6 +117,7 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
d.name,
if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE
)
is MiniEnumDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE)
}
}
@ -167,6 +168,7 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
d.ctorFields.forEach { addTypeSegments(it.type) }
d.classFields.forEach { addTypeSegments(it.type) }
}
is MiniEnumDecl -> {}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -124,10 +124,10 @@ class LyngCompletionContributor : CompletionContributor() {
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)
guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported, mini)
if (inferred != null) {
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Fallback inferred receiver/return class='$inferred' — offering its members")
@ -159,6 +159,8 @@ class LyngCompletionContributor : CompletionContributor() {
.withInsertHandler(ParenInsertHandler)
Kind.Class_ -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Class)
Kind.Enum -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Enum)
Kind.Value -> LookupElementBuilder.create(ci.name)
.withIcon(AllIcons.Nodes.Field)
.let { b -> if (!ci.typeText.isNullOrBlank()) b.withTypeText(ci.typeText, true) else b }
@ -179,16 +181,16 @@ class LyngCompletionContributor : CompletionContributor() {
val inferredClass =
guessReturnClassFromMemberCallBeforeMini(mini, text, memberDotPos, imported)
?: guessReceiverClassViaMini(mini, text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
?: guessReturnClassFromMemberCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassFromTopLevelCallBefore(text, memberDotPos, imported, mini)
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported, mini)
?: guessReceiverClass(text, memberDotPos, imported, mini)
if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMemberNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
for (name in ext) {
if (existing.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, inferredClass, name)
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, inferredClass, name, mini)
if (resolved != null) {
when (val member = resolved.second) {
is MiniMemberFunDecl -> {
@ -267,7 +269,8 @@ class LyngCompletionContributor : CompletionContributor() {
.withIcon(kindIcon)
.withTypeText(typeOf(d.type), true)
}
else -> LookupElementBuilder.create(name)
is MiniEnumDecl -> LookupElementBuilder.create(name)
.withIcon(AllIcons.Nodes.Enum)
}
emit(builder)
}
@ -294,9 +297,7 @@ class LyngCompletionContributor : CompletionContributor() {
, 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)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (DEBUG_COMPLETION) {
val keys = classes.keys.joinToString(", ")
log.info("[LYNG_DEBUG] offerMembers: imported=${imported} classes=[${keys}] target=${className}")
@ -553,32 +554,51 @@ class LyngCompletionContributor : CompletionContributor() {
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
}
val i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0) return null
val wordRange = TextCtx.wordRangeAt(text, i + 1) ?: return null
val ident = text.substring(wordRange.startOffset, wordRange.endOffset)
// 1) Global declarations in current file (val/var/fun/class/enum)
val d = mini.declarations.firstOrNull { it.name == ident }
if (d != null) {
return when (d) {
is MiniClassDecl -> d.name
is MiniEnumDecl -> d.name
is MiniValDecl -> simpleClassNameOf(d.type)
is MiniFunDecl -> simpleClassNameOf(d.returnType)
}
}
// 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
simpleClassNameOf(paramType)?.let { return it }
// 3) Recursive chaining: Base.ident.
val dotBefore = TextCtx.findDotLeft(text, wordRange.startOffset)
if (dotBefore != null) {
val receiverClass = guessReceiverClassViaMini(mini, text, dotBefore, imported)
?: guessReceiverClass(text, dotBefore, imported)
if (receiverClass != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, ident, mini)
if (resolved != null) {
val rt = when (val m = resolved.second) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
return simpleClassNameOf(rt)
}
}
}
// 4) Check if it's a known class (static access)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
return null
}
@ -631,7 +651,7 @@ class LyngCompletionContributor : CompletionContributor() {
}
}
// Else fallback to registry-based resolution (covers imported classes)
return DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee)?.second?.let { m ->
return DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee, mini)?.second?.let { m ->
val rt = when (m) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
@ -651,7 +671,7 @@ class LyngCompletionContributor : CompletionContributor() {
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_]*))?")
val funRe = Regex("^\\s*fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?", RegexOption.MULTILINE)
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()
@ -659,7 +679,7 @@ class LyngCompletionContributor : CompletionContributor() {
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_]*))?")
val valRe = Regex("^\\s*(val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?::\\s*([A-Za-z_][A-Za-z0-9_]*))?", RegexOption.MULTILINE)
for (m in valRe.findAll(body)) {
val kind = m.groupValues.getOrNull(1) ?: continue
val name = m.groupValues.getOrNull(2) ?: continue
@ -669,9 +689,9 @@ class LyngCompletionContributor : CompletionContributor() {
return map
}
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>): String? {
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
// 1) Try call-based: ClassName(...).
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it }
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported, mini)?.let { return it }
// 2) Literal heuristics based on the immediate char before '.'
var i = TextCtx.prevNonWs(text, dotPos - 1)
@ -706,6 +726,32 @@ class LyngCompletionContributor : CompletionContributor() {
}
}
}
// If it's an identifier, check if it's a known class (static access) or chain
val wordRange = TextCtx.wordRangeAt(text, i + 1)
if (wordRange != null) {
val ident = text.substring(wordRange.startOffset, wordRange.endOffset)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
// Chaining without MiniAst
val dotBefore = TextCtx.findDotLeft(text, wordRange.startOffset)
if (dotBefore != null) {
val base = guessReceiverClass(text, dotBefore, imported, mini)
if (base != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, base, ident, mini)
if (resolved != null) {
val rt = when (val m = resolved.second) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
return simpleClassNameOf(rt)
}
}
}
}
// Numeric literal: support decimal, hex (0x..), and scientific notation (1e-3)
var j = i
var hasDigits = false
@ -764,7 +810,7 @@ class LyngCompletionContributor : CompletionContributor() {
* 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? {
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0) return null
// We expect a call just before the dot, i.e., ')' ... '.'
@ -795,9 +841,9 @@ class LyngCompletionContributor : CompletionContributor() {
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
val receiverClass = guessReceiverClass(text, prevDot, imported, mini) ?: return null
// Resolve the callee as a member of receiver class, including inheritance
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee) ?: return null
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee, mini) ?: return null
val member = resolved.second
val returnType = when (member) {
is MiniMemberFunDecl -> member.returnType
@ -811,7 +857,7 @@ class LyngCompletionContributor : CompletionContributor() {
* 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? {
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// Walk back to matching '('
@ -845,6 +891,10 @@ class LyngCompletionContributor : CompletionContributor() {
val fn = decls.asSequence().filterIsInstance<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
// Also check local declarations
mini?.declarations?.filterIsInstance<MiniFunDecl>()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) }
return null
}
@ -853,7 +903,7 @@ class LyngCompletionContributor : CompletionContributor() {
* 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? {
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = TextCtx.prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
// Walk back to matching '('
@ -877,7 +927,7 @@ class LyngCompletionContributor : CompletionContributor() {
if (start >= end) return null
val callee = text.substring(start, end)
// Try cross-class resolution
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee) ?: return null
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee, mini) ?: return null
val member = resolved.second
val returnType = when (member) {
is MiniMemberFunDecl -> member.returnType
@ -949,7 +999,7 @@ class LyngCompletionContributor : CompletionContributor() {
// 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_]*)*)")
val re = Regex("^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)", RegexOption.MULTILINE)
re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -110,7 +110,66 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
}
}
}
// 3) Member-context resolution first (dot immediately before identifier): handle literals and calls
// 3) usages in current file via Binder (resolves local variables, parameters, and classes)
if (haveMini) {
try {
val binding = net.sergeych.lyng.binding.Binder.bind(text, mini)
val ref = binding.references.firstOrNull { offset in it.start until it.end }
if (ref != null) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
if (sym != null) {
// Find local declaration that matches this symbol
val ds = mini.declarations.firstOrNull { decl ->
val s = source.offsetOf(decl.nameStart)
decl.name == sym.name && s == sym.declStart
}
if (ds != null) return renderDeclDoc(ds)
// Check parameters
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
for (p in fn.params) {
val s = source.offsetOf(p.nameStart)
if (p.name == sym.name && s == sym.declStart) {
return renderParamDoc(fn, p)
}
}
}
// Check class members (fields/functions)
for (cls in mini.declarations.filterIsInstance<MiniClassDecl>()) {
for (m in cls.members) {
val s = source.offsetOf(m.nameStart)
if (m.name == sym.name && s == sym.declStart) {
return when (m) {
is MiniMemberFunDecl -> renderMemberFunDoc(cls.name, m)
is MiniMemberValDecl -> renderMemberValDoc(cls.name, m)
is MiniInitDecl -> null
}
}
}
for (cf in cls.ctorFields) {
val s = source.offsetOf(cf.nameStart)
if (cf.name == sym.name && s == sym.declStart) {
// Render as a member val
val mv = MiniMemberValDecl(
range = MiniRange(cf.nameStart, cf.nameStart), // dummy
name = cf.name,
mutable = cf.mutable,
type = cf.type,
doc = null,
nameStart = cf.nameStart
)
return renderMemberValDoc(cls.name, mv)
}
}
}
}
}
} catch (e: Throwable) {
if (DEBUG_LOG) log.warn("[LYNG_DEBUG] QuickDoc: local binder resolution failed: ${e.message}")
}
}
// 4) Member-context resolution first (dot immediately before identifier): handle literals and calls
run {
val dotPos = TextCtx.findDotLeft(text, idRange.startOffset)
?: TextCtx.findDotLeft(text, offset)
@ -225,8 +284,9 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
}
// Also allow values/consts
docs.filterIsInstance<MiniValDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
// And classes
// And classes/enums
docs.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
docs.filterIsInstance<MiniEnumDecl>().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) }
}
// Defensive fallback: if nothing found and it's a well-known stdlib function, render minimal inline docs
if (ident == "println" || ident == "print") {
@ -320,7 +380,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
*/
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_]*)*)")
val re = Regex("^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)", RegexOption.MULTILINE)
re.findAll(text).forEach { m ->
val raw = m.groupValues.getOrNull(1)?.trim().orEmpty()
if (raw.isNotEmpty()) {
@ -373,8 +433,8 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
val title = when (d) {
is MiniFunDecl -> "function ${d.name}${signatureOf(d)}"
is MiniClassDecl -> "class ${d.name}"
is MiniEnumDecl -> "enum ${d.name} { ${d.entries.joinToString(", ")} }"
is MiniValDecl -> if (d.mutable) "var ${d.name}${typeOf(d.type)}" else "val ${d.name}${typeOf(d.type)}"
else -> d.name
}
// Show full detailed documentation, not just the summary
val raw = d.doc?.raw

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -1750,6 +1750,9 @@ class Compiler(
}
private fun parseEnumDeclaration(): Statement {
val startPos = pendingDeclStart ?: cc.currentPos()
val doc = pendingDeclDoc ?: consumePendingDoc()
pendingDeclDoc = null
val nameToken = cc.requireToken(Token.Type.ID)
// so far only simplest enums:
val names = mutableListOf<String>()
@ -1777,6 +1780,16 @@ class Compiler(
}
} while (true)
miniSink?.onEnumDecl(
MiniEnumDecl(
range = MiniRange(startPos, cc.currentPos()),
name = nameToken.value,
entries = names,
doc = doc,
nameStart = nameToken.pos
)
)
return statement {
ObjEnumClass.createSimpleEnum(nameToken.value, names).also {
addItem(nameToken.value, false, it, recordType = ObjRecord.Type.Enum)

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -25,10 +25,7 @@ import net.sergeych.lyng.Source
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniClassDecl
import net.sergeych.lyng.miniast.MiniFunDecl
import net.sergeych.lyng.miniast.MiniScript
import net.sergeych.lyng.miniast.MiniValDecl
import net.sergeych.lyng.miniast.*
enum class SymbolKind { Class, Enum, Function, Val, Var, Param }
@ -160,6 +157,12 @@ object Binder {
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
}
}
is MiniEnumDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name)
val sym = Symbol(nextId++, d.name, SymbolKind.Enum, s, e, containerId = null)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
}
}
}

View File

@ -48,11 +48,6 @@ object LyngFormatter {
}
}
fun codePart(s: String): String {
val idx = s.indexOf("//")
return if (idx >= 0) s.substring(0, idx) else s
}
fun indentOf(level: Int, continuation: Int): String =
// Always produce spaces; tabs are not allowed in resulting code
" ".repeat(level * config.indentSize + continuation)
@ -69,10 +64,10 @@ object LyngFormatter {
}
for ((i, rawLine) in lines.withIndex()) {
val line = rawLine
val trimmedLine = line.trim()
val code = codePart(line)
val (parts, nextInBlockComment) = splitIntoParts(rawLine, inBlockComment)
val code = parts.filter { it.type == PartType.Code }.joinToString("") { it.text }
val trimmedStart = code.dropWhile { it == ' ' || it == '\t' }
val trimmedLine = rawLine.trim()
// Compute effective indent level for this line
var effectiveLevel = blockLevel
@ -93,15 +88,7 @@ object LyngFormatter {
if (applyAwaiting) effectiveLevel += 1
val firstChar = trimmedStart.firstOrNull()
// Do not apply continuation on a line that starts with a closer ')' or ']'
val startsWithCloser = firstChar == ')' || firstChar == ']'
// Kotlin-like rule: continuation persists while inside parentheses; it applies
// even on the line that starts with ')'. For brackets, do not apply on the ']' line itself.
// Continuation rules:
// - For brackets: one-shot continuation for first element after '[', and while inside brackets
// (except on the ']' line) continuation equals one unit.
// - For parentheses: continuation depth scales with nested level; e.g., inside two nested
// parentheses lines get 2 * continuationIndentSize. No continuation on a line that starts with ')'.
// While inside parentheses, continuation applies scaled by nesting level
val parenContLevels = if (parenBalance > 0 && firstChar != ')') parenBalance else 0
val continuation = when {
// One-shot continuation when previous line ended with '[' to align first element
@ -120,8 +107,8 @@ object LyngFormatter {
}
// Replace leading whitespace with the exact target indent; but keep fully blank lines truly empty
val contentStart = line.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) line.length else it }
var content = line.substring(contentStart)
val contentStart = rawLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) rawLine.length else it }
var content = rawLine.substring(contentStart)
// Collapse spaces right after an opening '[' to avoid "[ 1"; make it "[1"
if (content.startsWith("[")) {
content = "[" + content.drop(1).trimStart()
@ -135,11 +122,7 @@ object LyngFormatter {
}
// Determine base indent using structural level and continuation only (spaces only)
val indentString = indentOf(effectiveLevel, continuation)
if (content.isEmpty()) {
// preserve truly blank line as empty to avoid trailing spaces on empty lines
// (also keeps continuation blocks visually clean)
// do nothing, just append nothing; newline will be appended below if needed
} else {
if (content.isNotEmpty()) {
sb.append(indentString).append(content)
}
@ -147,34 +130,12 @@ object LyngFormatter {
if (i < lines.lastIndex) sb.append('\n')
// Update balances using this line's code content
if (!inBlockComment) {
val startIdx = code.indexOf("/*")
if (startIdx >= 0) {
val endIdx = code.indexOf("*/", startIdx + 2)
if (endIdx < 0) {
inBlockComment = true
// Process code before /*
val before = code.substring(0, startIdx)
for (ch in before) updateBalances(ch)
} else {
// Block comment starts and ends on the same line
val before = code.substring(0, startIdx)
val after = code.substring(endIdx + 2)
for (ch in before) updateBalances(ch)
for (ch in after) updateBalances(ch)
}
} else {
for (ch in code) updateBalances(ch)
}
} else {
val endIdx = line.indexOf("*/")
if (endIdx >= 0) {
inBlockComment = false
val after = line.substring(endIdx + 2)
val codeAfter = codePart(after)
for (ch in codeAfter) updateBalances(ch)
for (part in parts) {
if (part.type == PartType.Code) {
for (ch in part.text) updateBalances(ch)
}
}
inBlockComment = nextInBlockComment
// Update awaitingSingleIndent based on current line
if (applyAwaiting && trimmedStart.isNotEmpty()) {
@ -189,70 +150,41 @@ object LyngFormatter {
}
// Prepare one-shot bracket continuation if the current line ends with '['
// (first element line gets continuation even before balances update propagate).
val endsWithBracket = code.trimEnd().endsWith("[")
// Reset one-shot flag after we used it on this line
if (prevBracketContinuation) prevBracketContinuation = false
// Set for the next iteration if current line ends with '['
// Record whether THIS line ends with an opening '[' so the NEXT line gets a one-shot
// continuation indent for the first element.
if (endsWithBracket) {
// One-shot continuation for the very next line
prevBracketContinuation = true
} else {
// Reset the one-shot flag if the previous line didn't end with '['
// Reset the one-shot flag if it was used or if line doesn't end with '['
prevBracketContinuation = false
}
}
return sb.toString()
}
/** Full format. Currently performs indentation only; spacing/wrapping can be added later. */
fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Phase 1: indentation
val indented = reindent(text, config)
if (!config.applySpacing && !config.applyWrapping) return indented
// Phase 2: minimal, safe spacing (PSI-free). Skip block comments completely and
// only apply spacing to the part before '//' on each line.
// Phase 2: minimal, safe spacing (PSI-free).
val lines = indented.split('\n')
val out = StringBuilder(indented.length)
var inBlockComment = false
for ((i, rawLine) in lines.withIndex()) {
var line = rawLine
if (config.applySpacing) {
if (inBlockComment) {
// Pass-through until we see the end of the block comment on some line
val end = line.indexOf("*/")
if (end >= 0) {
inBlockComment = false
}
} else {
// If this line opens a block comment, apply spacing only before the opener
val startIdx = line.indexOf("/*")
val endIdx = line.indexOf("*/")
if (startIdx >= 0 && (endIdx < 0 || endIdx < startIdx)) {
val before = line.substring(0, startIdx)
val after = line.substring(startIdx)
val commentIdx = before.indexOf("//")
val code = if (commentIdx >= 0) before.substring(0, commentIdx) else before
val tail = if (commentIdx >= 0) before.substring(commentIdx) else ""
val spaced = applyMinimalSpacing(code)
line = (spaced + tail) + after
inBlockComment = true
val (parts, nextInBlockComment) = splitIntoParts(rawLine, inBlockComment)
val sb = StringBuilder()
for (part in parts) {
if (part.type == PartType.Code) {
sb.append(applyMinimalSpacingRules(part.text))
} else {
// Normal code line: respect single-line comments
val commentIdx = line.indexOf("//")
if (commentIdx >= 0) {
val code = line.substring(0, commentIdx)
val tail = line.substring(commentIdx)
val spaced = applyMinimalSpacing(code)
line = spaced + tail
} else {
line = applyMinimalSpacing(line)
}
sb.append(part.text)
}
}
line = sb.toString()
inBlockComment = nextInBlockComment
}
out.append(line.trimEnd())
if (i < lines.lastIndex) out.append('\n')
@ -409,7 +341,80 @@ object LyngFormatter {
}
}
private fun applyMinimalSpacing(code: String): String {
private enum class PartType { Code, StringLiteral, BlockComment, LineComment }
private data class Part(val text: String, val type: PartType)
/**
* Split a line into parts: code, string literals, and comments.
* Tracks [inBlockComment] state across lines.
*/
private fun splitIntoParts(
text: String,
inBlockCommentInitial: Boolean
): Pair<List<Part>, Boolean> {
val result = mutableListOf<Part>()
var i = 0
var last = 0
var inBlockComment = inBlockCommentInitial
var inString = false
var quoteChar = ' '
while (i < text.length) {
if (inBlockComment) {
if (text.startsWith("*/", i)) {
result.add(Part(text.substring(last, i + 2), PartType.BlockComment))
inBlockComment = false
i += 2
last = i
} else {
i++
}
} else if (inString) {
if (text[i] == quoteChar) {
var escapeCount = 0
var j = i - 1
while (j >= 0 && text[j] == '\\') {
escapeCount++
j--
}
if (escapeCount % 2 == 0) {
inString = false
result.add(Part(text.substring(last, i + 1), PartType.StringLiteral))
last = i + 1
}
}
i++
} else {
if (text.startsWith("//", i)) {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
result.add(Part(text.substring(i), PartType.LineComment))
last = text.length
break
} else if (text.startsWith("/*", i)) {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
inBlockComment = true
last = i
i += 2
} else if (text[i] == '"' || text[i] == '\'') {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
inString = true
quoteChar = text[i]
last = i
i++
} else {
i++
}
}
}
if (last < text.length) {
val leftover = text.substring(last)
val type = if (inBlockComment) PartType.BlockComment else PartType.Code
result.add(Part(leftover, type))
}
return result to inBlockComment
}
private fun applyMinimalSpacingRules(code: String): String {
var s = code
// Ensure space before '(' for control-flow keywords
s = s.replace(Regex("\\b(if|for|while)\\("), "$1 (")

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -113,7 +113,7 @@ object BuiltinDocRegistry : BuiltinDocSource {
val src = try { rootLyng } catch (_: Throwable) { null } ?: return emptyList()
val out = LinkedHashSet<String>()
// Match lines like: fun String.trim(...) or val Int.isEven = ...
val re = Regex("(?m)^\\s*(?:fun|val|var)\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\b")
val re = Regex("^\\s*(?:fun|val|var)\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\b", RegexOption.MULTILINE)
re.findAll(src).forEach { m ->
val name = m.groupValues.getOrNull(1)?.trim()
if (!name.isNullOrEmpty()) out.add(name)
@ -604,6 +604,11 @@ private fun buildStdlibDocs(): List<MiniDecl> {
method(name = "printStackTrace", doc = StdlibInlineDocIndex.methodDoc("Exception", "printStackTrace") ?: "Print this exception and its stack trace to standard output.")
}
mod.classDoc(name = "Enum", doc = StdlibInlineDocIndex.classDoc("Enum") ?: "Base class for all enums.") {
method(name = "name", doc = "Returns the name of this enum constant.", returns = type("lyng.String"))
method(name = "ordinal", doc = "Returns the ordinal of this enum constant.", returns = type("lyng.Int"))
}
mod.classDoc(name = "String", doc = StdlibInlineDocIndex.classDoc("String") ?: "String helpers.") {
// Only include inline-source method here; Kotlin-embedded methods are now documented via DocHelpers near definitions.
method(name = "re", doc = StdlibInlineDocIndex.methodDoc("String", "re") ?: "Compile this string into a regular expression.", returns = type("lyng.Regex"))

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -33,7 +33,7 @@ data class CompletionItem(
val typeText: String? = null,
)
enum class Kind { Function, Class_, Value, Method, Field }
enum class Kind { Function, Class_, Enum, Value, Method, Field }
/**
* Platform-free, lenient import provider that never fails on unknown packages.
@ -82,23 +82,23 @@ object CompletionEngineLight {
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)
guessReturnClassFromMemberCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
return out
}
// 0a) Top-level call before dot
guessReturnClassFromTopLevelCallBefore(text, memberDot, imported)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls)
guessReturnClassFromTopLevelCallBefore(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
return out
}
// 0b) Across-known-callees (Iterable/Iterator/List preference)
guessReturnClassAcrossKnownCallees(text, memberDot, imported)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls)
guessReturnClassAcrossKnownCallees(text, memberDot, imported, mini)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
return out
}
// 1) Receiver inference fallback
guessReceiverClass(text, memberDot, imported)?.let { cls ->
offerMembersAdd(out, prefix, imported, cls)
(guessReceiverClassViaMini(mini, text, memberDot, imported) ?: guessReceiverClass(text, memberDot, imported, mini))?.let { cls ->
offerMembersAdd(out, prefix, imported, cls, mini)
return out
}
// In member context and unknown receiver/return type: show nothing (no globals after dot)
@ -110,9 +110,11 @@ object CompletionEngineLight {
val decls = m.declarations
val funs = decls.filterIsInstance<MiniFunDecl>().sortedBy { it.name.lowercase() }
val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() }
val enums = decls.filterIsInstance<MiniEnumDecl>().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) }
enums.forEach { offerDeclAdd(out, prefix, it) }
vals.forEach { offerDeclAdd(out, prefix, it) }
}
@ -126,9 +128,11 @@ object CompletionEngineLight {
val decls = BuiltinDocRegistry.docsForModule(mod)
val funs = decls.filterIsInstance<MiniFunDecl>().sortedBy { it.name.lowercase() }
val classes = decls.filterIsInstance<MiniClassDecl>().sortedBy { it.name.lowercase() }
val enums = decls.filterIsInstance<MiniEnumDecl>().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++ } }
enums.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
}
@ -147,13 +151,14 @@ object CompletionEngineLight {
add(CompletionItem(d.name, Kind.Function, tailText = tail, typeText = typeOf(d.returnType)))
}
is MiniClassDecl -> add(CompletionItem(d.name, Kind.Class_))
is MiniEnumDecl -> add(CompletionItem(d.name, Kind.Enum))
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)
private fun offerMembersAdd(out: MutableList<CompletionItem>, prefix: String, imported: List<String>, className: String, mini: MiniScript? = null) {
val classes = DocLookupUtils.aggregateClasses(imported, mini)
val visited = mutableSetOf<String>()
val directMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
val inheritedMap = LinkedHashMap<String, MutableList<MiniMemberDecl>>()
@ -242,8 +247,51 @@ object CompletionEngineLight {
// --- 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 }
private fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List<String>): String? {
if (mini == null) return null
val i = prevNonWs(text, dotPos - 1)
if (i < 0) return null
val wordRange = wordRangeAt(text, i + 1) ?: return null
val ident = text.substring(wordRange.first, wordRange.second)
// 1) Global declarations in current file (val/var/fun/class/enum)
val d = mini.declarations.firstOrNull { it.name == ident }
if (d != null) {
return when (d) {
is MiniClassDecl -> d.name
is MiniEnumDecl -> d.name
is MiniValDecl -> simpleClassNameOf(d.type)
is MiniFunDecl -> simpleClassNameOf(d.returnType)
}
}
// 2) Recursive chaining: Base.ident.
val dotBefore = findDotLeft(text, wordRange.first)
if (dotBefore != null) {
val receiverClass = guessReceiverClassViaMini(mini, text, dotBefore, imported)
?: guessReceiverClass(text, dotBefore, imported, mini)
if (receiverClass != null) {
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, ident, mini)
if (resolved != null) {
val rt = when (val m = resolved.second) {
is MiniMemberFunDecl -> m.returnType
is MiniMemberValDecl -> m.type
else -> null
}
return simpleClassNameOf(rt)
}
}
}
// 3) Check if it's a known class (static access)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
return null
}
private fun guessReceiverClass(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported, mini)?.let { return it }
var i = prevNonWs(text, dotPos - 1)
if (i >= 0) {
when (text[i]) {
@ -304,12 +352,16 @@ object CompletionEngineLight {
return ident
}
}
// 4) Check if it's a known class (static access)
val classes = DocLookupUtils.aggregateClasses(imported, mini)
if (classes.containsKey(ident)) return ident
}
}
return null
}
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>): String? {
private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
@ -333,8 +385,8 @@ object CompletionEngineLight {
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 receiverClass = guessReceiverClass(text, prevDot, imported, mini) ?: return null
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, receiverClass, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
@ -344,7 +396,7 @@ object CompletionEngineLight {
return simpleClassNameOf(ret)
}
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>): String? {
private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
@ -372,10 +424,12 @@ object CompletionEngineLight {
val fn = decls.asSequence().filterIsInstance<MiniFunDecl>().firstOrNull { it.name == callee }
if (fn != null) return simpleClassNameOf(fn.returnType)
}
// Also check local declarations
mini?.declarations?.filterIsInstance<MiniFunDecl>()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) }
return null
}
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>): String? {
private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List<String>, mini: MiniScript? = null): String? {
var i = prevNonWs(text, dotPos - 1)
if (i < 0 || text[i] != ')') return null
i--
@ -395,7 +449,7 @@ object CompletionEngineLight {
val start = j + 1
if (start >= end) return null
val callee = text.substring(start, end)
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee) ?: return null
val resolved = DocLookupUtils.findMemberAcrossClasses(imported, callee, mini) ?: return null
val member = resolved.second
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
@ -415,14 +469,16 @@ object CompletionEngineLight {
// --- MiniAst and small utils ---
private suspend fun buildMiniAst(text: String): MiniScript? = try {
private suspend fun buildMiniAst(text: String): MiniScript? {
val sink = MiniAstBuilder()
val src = Source("<engine>", text)
val provider = LenientImportProvider.create()
Compiler.compileWithMini(src, provider, sink)
sink.build()
} catch (_: Throwable) {
null
return try {
val src = Source("<engine>", text)
val provider = LenientImportProvider.create()
Compiler.compileWithMini(src, provider, sink)
sink.build()
} catch (_: Throwable) {
sink.build()
}
}
private fun typeOf(t: MiniTypeRef?): String = when (t) {
@ -474,7 +530,7 @@ object CompletionEngineLight {
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_]*)*)")
val re = Regex("^\\s*import\\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)", RegexOption.MULTILINE)
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")

View File

@ -1,3 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/*
* Shared QuickDoc lookup helpers reusable outside the IDEA plugin.
*/
@ -24,7 +41,7 @@ object DocLookupUtils {
return result.toList()
}
fun aggregateClasses(importedModules: List<String>): Map<String, MiniClassDecl> {
fun aggregateClasses(importedModules: List<String>, localMini: MiniScript? = null): Map<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) {
@ -32,6 +49,23 @@ object DocLookupUtils {
for (cls in docs.filterIsInstance<MiniClassDecl>()) {
buckets.getOrPut(cls.name) { mutableListOf() }.add(cls)
}
for (en in docs.filterIsInstance<MiniEnumDecl>()) {
buckets.getOrPut(en.name) { mutableListOf() }.add(en.toSyntheticClass())
}
}
// Add local declarations
localMini?.declarations?.forEach { d ->
when (d) {
is MiniClassDecl -> {
buckets.getOrPut(d.name) { mutableListOf() }.add(d)
}
is MiniEnumDecl -> {
val syn = d.toSyntheticClass()
buckets.getOrPut(d.name) { mutableListOf() }.add(syn)
}
else -> {}
}
}
fun mergeClassDecls(name: String, list: List<MiniClassDecl>): MiniClassDecl {
@ -72,8 +106,8 @@ object DocLookupUtils {
return result
}
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules)
fun resolveMemberWithInheritance(importedModules: List<String>, className: String, member: String, localMini: MiniScript? = null): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules, localMini)
fun dfs(name: String, visited: MutableSet<String>): Pair<String, MiniMemberDecl>? {
val cls = classes[name] ?: return null
cls.members.firstOrNull { it.name == member }?.let { return name to it }
@ -86,12 +120,12 @@ object DocLookupUtils {
return dfs(className, mutableSetOf())
}
fun findMemberAcrossClasses(importedModules: List<String>, member: String): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules)
fun findMemberAcrossClasses(importedModules: List<String>, member: String, localMini: MiniScript? = null): Pair<String, MiniMemberDecl>? {
val classes = aggregateClasses(importedModules, localMini)
// Preferred order for ambiguous common ops
val preference = listOf("Iterable", "Iterator", "List")
for (name in preference) {
resolveMemberWithInheritance(importedModules, name, member)?.let { return it }
resolveMemberWithInheritance(importedModules, name, member, localMini)?.let { return it }
}
for ((name, cls) in classes) {
cls.members.firstOrNull { it.name == member }?.let { return name to it }
@ -104,7 +138,7 @@ object DocLookupUtils {
* We walk left from the dot, find a matching `)` and then the identifier immediately before the `(`.
* If that identifier matches a known class in any of the imported modules, return it.
*/
fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List<String>): String? {
fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List<String>, localMini: MiniScript? = null): String? {
var i = (dotPos - 1).coerceAtLeast(0)
// Skip spaces
while (i >= 0 && text[i].isWhitespace()) i++
@ -134,9 +168,32 @@ object DocLookupUtils {
if (start >= end) return null
val name = text.substring(start, end)
// Validate against imported classes
val classes = aggregateClasses(importedModules)
val classes = aggregateClasses(importedModules, localMini)
return if (classes.containsKey(name)) name else null
}
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
private fun MiniEnumDecl.toSyntheticClass(): MiniClassDecl {
val staticMembers = mutableListOf<MiniMemberDecl>()
// entries: List
staticMembers.add(MiniMemberValDecl(range, "entries", false, null, null, nameStart, isStatic = true))
// valueOf(name: String): Enum
staticMembers.add(MiniMemberFunDecl(range, "valueOf", listOf(MiniParam("name", null, nameStart)), null, null, nameStart, isStatic = true))
// Also add each entry as a static member (const)
for (entry in entries) {
staticMembers.add(MiniMemberValDecl(range, entry, false, MiniTypeName(range, listOf(MiniTypeName.Segment(name, range)), false), null, nameStart, isStatic = true))
}
return MiniClassDecl(
range = range,
name = name,
bases = listOf("Enum"),
bodyRange = null,
doc = doc,
nameStart = nameStart,
members = staticMembers
)
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -137,6 +137,14 @@ data class MiniClassDecl(
val members: List<MiniMemberDecl> = emptyList()
) : MiniDecl
data class MiniEnumDecl(
override val range: MiniRange,
override val name: String,
val entries: List<String>,
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniCtorField(
val name: String,
val mutable: Boolean,
@ -206,6 +214,7 @@ interface MiniAstSink {
fun onValDecl(node: MiniValDecl) {}
fun onInitDecl(node: MiniInitDecl) {}
fun onClassDecl(node: MiniClassDecl) {}
fun onEnumDecl(node: MiniEnumDecl) {}
fun onBlock(node: MiniBlock) {}
fun onIdentifier(node: MiniIdentifier) {}
@ -275,6 +284,12 @@ class MiniAstBuilder : MiniAstSink {
lastDoc = null
}
override fun onEnumDecl(node: MiniEnumDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onBlock(node: MiniBlock) {
blocks.addLast(node)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -131,4 +131,78 @@ class MiniAstTest {
// Bases captured as plain names for now
assertEquals(listOf("Base1", "Base2"), cd.bases)
}
@Test
fun miniAst_captures_enum_entries_and_doc() = runTest {
val code = """
/** Enum E docs */
enum E {
A,
B,
C
}
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val ed = mini!!.declarations.filterIsInstance<MiniEnumDecl>().firstOrNull { it.name == "E" }
assertNotNull(ed)
assertNotNull(ed.doc)
assertTrue(ed.doc!!.raw.contains("Enum E docs"))
assertEquals(listOf("A", "B", "C"), ed.entries)
assertEquals("E", ed.name)
}
@Test
fun enum_to_synthetic_class_members() = runTest {
val code = """
enum MyEnum { V1, V2 }
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
// I'll check via aggregateClasses by mocking the registry or just checking it includes Enum base.
val stdlib = BuiltinDocRegistry.docsForModule("lyng.stdlib")
val enumBase = stdlib.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == "Enum" }
assertNotNull(enumBase, "Enum base class should be in stdlib")
assertTrue(enumBase.members.any { it.name == "name" })
assertTrue(enumBase.members.any { it.name == "ordinal" })
// Check if aggregateClasses handles enums from local MiniScript
val classes = DocLookupUtils.aggregateClasses(listOf("lyng.stdlib"), mini)
val myEnum = classes["MyEnum"]
assertNotNull(myEnum, "Local enum should be aggregated as a class")
assertEquals(listOf("Enum"), myEnum.bases)
assertTrue(myEnum.members.any { it.name == "entries" }, "Should have entries")
assertTrue(myEnum.members.any { it.name == "valueOf" }, "Should have valueOf")
assertTrue(myEnum.members.any { it.name == "V1" }, "Should have V1")
assertTrue(myEnum.members.any { it.name == "V2" }, "Should have V2")
}
@Test
fun complete_enum_members() = runTest {
val code = """
enum MyEnum { V1, V2 }
val x = MyEnum.V1.<caret>
"""
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }.toSet()
assertTrue(names.contains("name"), "Should contain name from Enum base")
assertTrue(names.contains("ordinal"), "Should contain ordinal from Enum base")
}
@Test
fun complete_enum_class_members() = runTest {
val code = """
enum MyEnum { V1, V2 }
val x = MyEnum.<caret>
"""
val items = CompletionEngineLight.completeAtMarkerSuspend(code)
val names = items.map { it.name }.toSet()
assertTrue(names.contains("entries"), "Should contain entries")
assertTrue(names.contains("valueOf"), "Should contain valueOf")
assertTrue(names.contains("V1"), "Should contain V1")
assertTrue(names.contains("V2"), "Should contain V2")
}
}

View File

@ -676,4 +676,58 @@ class LyngFormatterTest {
// Idempotent
assertEquals(expected, LyngFormatter.reindent(out, cfg))
}
@Test
fun stringLiterals_areNotNormalized() {
val src = """
val s = "a=b"
val s2 = "a + b"
val s3 = "a - b"
val s4 = "a,b"
val s5 = "if(x){}"
""".trimIndent()
val cfg = LyngFormatConfig(applySpacing = true)
val formatted = LyngFormatter.format(src, cfg)
assertEquals(src, formatted, "String literals should not be changed by space normalization")
}
@Test
fun mixedCodeAndStrings_normalization() {
val src = "val x=1+\"a+b\"+2"
val expected = "val x = 1 + \"a+b\" + 2"
val cfg = LyngFormatConfig(applySpacing = true)
val formatted = LyngFormatter.format(src, cfg)
assertEquals(expected, formatted)
}
@Test
fun reindent_ignoresBracesInStrings() {
val src = """
fun test() {
val s = "{"
val s2 = "}"
}
""".trimIndent()
val cfg = LyngFormatConfig(indentSize = 4)
val formatted = LyngFormatter.reindent(src, cfg)
// If it fails, s2 will be indented differently
assertEquals(src, formatted, "Braces in strings should not affect indentation")
}
@Test
fun blockComments_spacing_robustness() {
val src = "val x=1/*a=b*/+2"
val expected = "val x = 1/*a=b*/ + 2"
val cfg = LyngFormatConfig(applySpacing = true)
val formatted = LyngFormatter.format(src, cfg)
assertEquals(expected, formatted)
}
}