plugin: autocomplete for enums and members, better support for local symbols; fixed bug in space normalization
This commit is contained in:
1 parent
22f6c149db
commit
fac58675d5
12 files changed
+581
-187
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user