diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt index 8b12bb4..48b9010 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/annotators/LyngExternalAnnotator.kt @@ -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 putName(d.nameStart, d.name, LyngHighlighterColors.TYPE) } } @@ -167,6 +168,7 @@ class LyngExternalAnnotator : ExternalAnnotator {} } } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt index cc3c216..4127d07 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/completion/LyngCompletionContributor.kt @@ -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? { if (mini == null) return null - val ident = previousIdentifierBeforeDot(text, dotPos) ?: return null - // 1) Local val/var in the file - val valDecl = mini.declarations.filterIsInstance().firstOrNull { it.name == ident } - val typeFromVal = valDecl?.type?.let { simpleClassNameOf(it) } - if (!typeFromVal.isNullOrBlank()) return typeFromVal - // If initializer exists, try to sniff ClassName( - val initR = valDecl?.initRange - if (initR != null) { - val src = mini.range.start.source - val s = src.offsetOf(initR.start) - val e = src.offsetOf(initR.end).coerceAtMost(text.length) - if (s in 0..e && e <= text.length) { - val init = text.substring(s, e) - Regex("([A-Za-z_][A-Za-z0-9_]*)\\s*\\(").find(init)?.let { m -> - val cls = m.groupValues[1] - return cls - } + 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() .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() // 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? { + private fun guessReceiverClass(text: String, dotPos: Int, imported: List, 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().` → detects `lines()` on receiver class `Path` and returns `Iterator`. */ - private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List): String? { + private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List, 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().`. * We extract callee name and resolve it among imported modules' top-level functions. */ - private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List): String? { + private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List, 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().firstOrNull { it.name == callee } if (fn != null) return simpleClassNameOf(fn.returnType) } + + // Also check local declarations + mini?.declarations?.filterIsInstance()?.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().` where `something` type is unknown, but `lines()` commonly returns Iterator. */ - private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List): String? { + private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List, 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 { val result = LinkedHashSet() - 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()) { diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt index 57566c6..2315752 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -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()) { + 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()) { + 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().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) } - // And classes + // And classes/enums docs.filterIsInstance().firstOrNull { it.name == ident }?.let { return renderDeclDoc(it) } + docs.filterIsInstance().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 { val result = LinkedHashSet() - 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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 57be2f2..c4a53cb 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -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() @@ -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) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt index b2c50fa..07b3fb1 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/binding/Binder.kt @@ -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) + } } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt index 20636eb..805abe3 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt @@ -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, Boolean> { + val result = mutableListOf() + 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 (") diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt index 0a4f998..242dd67 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt @@ -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() // 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 { 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")) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt index 44833d9..c3d4b54 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -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().sortedBy { it.name.lowercase() } val classes = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val enums = decls.filterIsInstance().sortedBy { it.name.lowercase() } val vals = decls.filterIsInstance().sortedBy { it.name.lowercase() } funs.forEach { offerDeclAdd(out, prefix, it) } classes.forEach { offerDeclAdd(out, prefix, it) } + 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().sortedBy { it.name.lowercase() } val classes = decls.filterIsInstance().sortedBy { it.name.lowercase() } + val enums = decls.filterIsInstance().sortedBy { it.name.lowercase() } val vals = decls.filterIsInstance().sortedBy { it.name.lowercase() } funs.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } classes.forEach { if (externalAdded < budget) { offerDeclAdd(out, prefix, it); externalAdded++ } } + 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, prefix: String, imported: List, className: String) { - val classes = DocLookupUtils.aggregateClasses(imported) + private fun offerMembersAdd(out: MutableList, prefix: String, imported: List, className: String, mini: MiniScript? = null) { + val classes = DocLookupUtils.aggregateClasses(imported, mini) val visited = mutableSetOf() val directMap = LinkedHashMap>() val inheritedMap = LinkedHashMap>() @@ -242,8 +247,51 @@ object CompletionEngineLight { // --- Inference helpers (text-only, PSI-free) --- - private fun guessReceiverClass(text: String, dotPos: Int, imported: List): String? { - DocLookupUtils.guessClassFromCallBefore(text, dotPos, imported)?.let { return it } + private fun guessReceiverClassViaMini(mini: MiniScript?, text: String, dotPos: Int, imported: List): 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, 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? { + private fun guessReturnClassFromMemberCallBefore(text: String, dotPos: Int, imported: List, 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? { + private fun guessReturnClassFromTopLevelCallBefore(text: String, dotPos: Int, imported: List, 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().firstOrNull { it.name == callee } if (fn != null) return simpleClassNameOf(fn.returnType) } + // Also check local declarations + mini?.declarations?.filterIsInstance()?.firstOrNull { it.name == callee }?.let { return simpleClassNameOf(it.returnType) } return null } - private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List): String? { + private fun guessReturnClassAcrossKnownCallees(text: String, dotPos: Int, imported: List, 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("", text) - val provider = LenientImportProvider.create() - Compiler.compileWithMini(src, provider, sink) - sink.build() - } catch (_: Throwable) { - null + return try { + val src = Source("", 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 { val result = LinkedHashSet() - 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") diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt index bcce039..0e42a42 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/DocLookupUtils.kt @@ -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): Map { + fun aggregateClasses(importedModules: List, localMini: MiniScript? = null): Map { // Collect all class decls by name across modules, then merge duplicates by unioning members and bases. val buckets = LinkedHashMap>() for (mod in importedModules) { @@ -32,6 +49,23 @@ object DocLookupUtils { for (cls in docs.filterIsInstance()) { buckets.getOrPut(cls.name) { mutableListOf() }.add(cls) } + for (en in docs.filterIsInstance()) { + 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 { @@ -72,8 +106,8 @@ object DocLookupUtils { return result } - fun resolveMemberWithInheritance(importedModules: List, className: String, member: String): Pair? { - val classes = aggregateClasses(importedModules) + fun resolveMemberWithInheritance(importedModules: List, className: String, member: String, localMini: MiniScript? = null): Pair? { + val classes = aggregateClasses(importedModules, localMini) fun dfs(name: String, visited: MutableSet): Pair? { 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, member: String): Pair? { - val classes = aggregateClasses(importedModules) + fun findMemberAcrossClasses(importedModules: List, member: String, localMini: MiniScript? = null): Pair? { + 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? { + fun guessClassFromCallBefore(text: String, dotPos: Int, importedModules: List, 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() + // 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 + ) + } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt index b83faea..edd5377 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt @@ -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 = emptyList() ) : MiniDecl +data class MiniEnumDecl( + override val range: MiniRange, + override val name: String, + val entries: List, + 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) } diff --git a/lynglib/src/commonTest/kotlin/MiniAstTest.kt b/lynglib/src/commonTest/kotlin/MiniAstTest.kt index 216a566..294d226 100644 --- a/lynglib/src/commonTest/kotlin/MiniAstTest.kt +++ b/lynglib/src/commonTest/kotlin/MiniAstTest.kt @@ -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().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().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. + """ + 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. + """ + 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") + } } diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt index d132d69..7969a63 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt @@ -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) + } }