lynglib: added MiniAST support

lyngweb: better syntax highlighting and code editor
This commit is contained in:
sergeych committed 2025-11-24 00:38:17 +01:00
1 parent 28b961d339
commit ea0ecb1db3
24 files changed
+3095 -218

No files matched your search

@@ -17,6 +17,7 @@
package net.sergeych.lyng
import net.sergeych.lyng.miniast.MiniTypeRef
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjRecord
@@ -135,6 +136,7 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
data class Item(
val name: String,
val type: TypeDecl = TypeDecl.TypeAny,
val miniType: MiniTypeRef? = null,
val pos: Pos = Pos.builtIn,
val isEllipsis: Boolean = false,
/**
@@ -18,6 +18,8 @@
package net.sergeych.lyng
import ObjEnumClass
import net.sergeych.lyng.Compiler.Companion.compile
import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.ImportProvider
@@ -61,7 +63,59 @@ class Compiler(
var packageName: String? = null
class Settings
class Settings(
val miniAstSink: MiniAstSink? = null,
)
// Optional sink for mini-AST streaming (null by default, zero overhead when not used)
private val miniSink: MiniAstSink? = settings.miniAstSink
// --- Doc-comment collection state (for immediate preceding declarations) ---
private val pendingDocLines = mutableListOf<String>()
private var pendingDocStart: Pos? = null
private var prevWasComment: Boolean = false
private fun stripCommentLexeme(raw: String): String {
return when {
raw.startsWith("//") -> raw.removePrefix("//")
raw.startsWith("/*") && raw.endsWith("*/") -> {
val inner = raw.substring(2, raw.length - 2)
// Trim leading "*" prefixes per line like Javadoc style
inner.lines().joinToString("\n") { line ->
val t = line.trimStart()
if (t.startsWith("*")) t.removePrefix("*").trimStart() else line
}
}
else -> raw
}
}
private fun pushPendingDocToken(t: Token) {
val s = stripCommentLexeme(t.value)
if (pendingDocStart == null) pendingDocStart = t.pos
pendingDocLines += s
prevWasComment = true
}
private fun clearPendingDoc() {
pendingDocLines.clear()
pendingDocStart = null
prevWasComment = false
}
private fun consumePendingDoc(): MiniDoc? {
if (pendingDocLines.isEmpty()) return null
val raw = pendingDocLines.joinToString("\n").trimEnd()
val summary = raw.lines().firstOrNull { it.isNotBlank() }?.trim()
val start = pendingDocStart ?: cc.currentPos()
val doc = MiniDoc(MiniRange(start, start), raw = raw, summary = summary)
clearPendingDoc()
return doc
}
// Set just before entering a declaration parse, taken from keyword token position
private var pendingDeclStart: Pos? = null
private var pendingDeclDoc: MiniDoc? = null
private val initStack = mutableListOf<MutableList<Statement>>()
@@ -75,6 +129,9 @@ class Compiler(
private val codeContexts = mutableListOf<CodeContext>(CodeContext.Module(null))
// Last parsed block range (for Mini-AST function body attachment)
private var lastParsedBlockRange: MiniRange? = null
private suspend fun <T> inCodeContext(context: CodeContext, f: suspend () -> T): T {
return try {
codeContexts.add(context)
@@ -90,9 +147,19 @@ class Compiler(
// Track locals at script level for fast local refs
return withLocalNames(emptySet()) {
// package level declarations
// Notify sink about script start
miniSink?.onScriptStart(start)
do {
val t = cc.current()
if (t.type == Token.Type.NEWLINE || t.type == Token.Type.SINLGE_LINE_COMMENT || t.type == Token.Type.MULTILINE_COMMENT) {
when (t.type) {
Token.Type.SINLGE_LINE_COMMENT, Token.Type.MULTILINE_COMMENT -> pushPendingDocToken(t)
Token.Type.NEWLINE -> {
// A standalone newline not immediately following a comment resets doc buffer
if (!prevWasComment) clearPendingDoc() else prevWasComment = false
}
else -> {}
}
cc.next()
continue
}
@@ -114,6 +181,30 @@ class Compiler(
cc.next()
val pos = cc.currentPos()
val name = loadQualifiedName()
// Emit MiniImport with approximate per-segment ranges
run {
try {
val parts = name.split('.')
if (parts.isNotEmpty()) {
var col = pos.column
val segs = parts.map { p ->
val start = Pos(pos.source, pos.line, col)
val end = Pos(pos.source, pos.line, col + p.length)
col += p.length + 1 // account for following '.' between segments
net.sergeych.lyng.miniast.MiniImport.Segment(p, net.sergeych.lyng.miniast.MiniRange(start, end))
}
val lastEnd = segs.last().range.end
miniSink?.onImport(
net.sergeych.lyng.miniast.MiniImport(
net.sergeych.lyng.miniast.MiniRange(pos, lastEnd),
segs
)
)
}
} catch (_: Throwable) {
// best-effort; ignore import mini emission failures
}
}
val module = importManager.prepareImport(pos, name, null)
statements += statement {
module.importInto(this, null)
@@ -122,6 +213,17 @@ class Compiler(
continue
}
}
// Fast-path: top-level function declarations. Handle here to ensure
// Mini-AST emission even if qualifier matcher paths change.
if (t.value == "fun" || t.value == "fn") {
// Consume the keyword and delegate to the function parser
cc.next()
pendingDeclStart = t.pos
pendingDeclDoc = consumePendingDoc()
val st = parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false)
statements += st
continue
}
}
val s = parseStatement(braceMeansLambda = true)?.also {
statements += it
@@ -137,6 +239,9 @@ class Compiler(
} while (true)
Script(start, statements)
}.also {
// Best-effort script end notification (use current position)
miniSink?.onScriptEnd(cc.currentPos(), net.sergeych.lyng.miniast.MiniScript(MiniRange(start, cc.currentPos())))
}
}
@@ -608,12 +713,13 @@ class Compiler(
cc.ifNextIs(Token.Type.ASSIGN) {
defaultValue = parseExpression()
}
// type information
val typeInfo = parseTypeDeclaration()
// type information (semantic + mini syntax)
val (typeInfo, miniType) = parseTypeDeclarationWithMini()
val isEllipsis = cc.skipTokenOfType(Token.Type.ELLIPSIS, isOptional = true)
result += ArgsDeclaration.Item(
t.value,
typeInfo,
miniType,
t.pos,
isEllipsis,
defaultValue,
@@ -657,11 +763,87 @@ class Compiler(
}
private fun parseTypeDeclaration(): TypeDecl {
return if (cc.skipTokenOfType(Token.Type.COLON, isOptional = true)) {
val tt = cc.requireToken(Token.Type.ID, "type name or type expression required")
val isNullable = cc.skipTokenOfType(Token.Type.QUESTION, isOptional = true)
TypeDecl.Simple(tt.value, isNullable)
} else TypeDecl.TypeAny
return parseTypeDeclarationWithMini().first
}
// Minimal helper to parse a type annotation and simultaneously build a MiniTypeRef.
// Currently supports a simple identifier with optional nullable '?'.
private fun parseTypeDeclarationWithMini(): Pair<TypeDecl, MiniTypeRef?> {
// Only parse a type if a ':' follows; otherwise keep current behavior
if (!cc.skipTokenOfType(Token.Type.COLON, isOptional = true)) return Pair(TypeDecl.TypeAny, null)
// Parse a qualified base name: ID ('.' ID)*
val segments = mutableListOf<MiniTypeName.Segment>()
var first = true
val typeStart = cc.currentPos()
var lastEnd = typeStart
while (true) {
val idTok = if (first) cc.requireToken(Token.Type.ID, "type name or type expression required") else cc.requireToken(Token.Type.ID, "identifier expected after '.' in type")
first = false
segments += MiniTypeName.Segment(idTok.value, MiniRange(idTok.pos, idTok.pos))
lastEnd = cc.currentPos()
val dotPos = cc.savePos()
val t = cc.next()
if (t.type == Token.Type.DOT) {
// continue
continue
} else {
cc.restorePos(dotPos)
break
}
}
// Helper to build MiniTypeRef (base or generic)
fun buildBaseRef(rangeEnd: Pos, args: List<MiniTypeRef>?, nullable: Boolean): MiniTypeRef {
val base = MiniTypeName(MiniRange(typeStart, rangeEnd), segments.toList(), nullable = false)
return if (args == null || args.isEmpty()) base.copy(range = MiniRange(typeStart, rangeEnd), nullable = nullable)
else net.sergeych.lyng.miniast.MiniGenericType(MiniRange(typeStart, rangeEnd), base, args, nullable)
}
// Optional generic arguments: '<' Type (',' Type)* '>' — single-level only (no nested generics for now)
var args: MutableList<MiniTypeRef>? = null
val afterBasePos = cc.savePos()
if (cc.skipTokenOfType(Token.Type.LT, isOptional = true)) {
args = mutableListOf()
do {
// Parse argument as simple or qualified type (single level), with optional nullable '?'
val argSegs = mutableListOf<MiniTypeName.Segment>()
var argFirst = true
val argStart = cc.currentPos()
while (true) {
val idTok = if (argFirst) cc.requireToken(Token.Type.ID, "type argument name expected") else cc.requireToken(Token.Type.ID, "identifier expected after '.' in type argument")
argFirst = false
argSegs += MiniTypeName.Segment(idTok.value, MiniRange(idTok.pos, idTok.pos))
val p = cc.savePos()
val tt = cc.next()
if (tt.type == Token.Type.DOT) continue else { cc.restorePos(p); break }
}
val argNullable = cc.skipTokenOfType(Token.Type.QUESTION, isOptional = true)
val argEnd = cc.currentPos()
val argRef = MiniTypeName(MiniRange(argStart, argEnd), argSegs.toList(), nullable = argNullable)
args += argRef
val sep = cc.next()
when (sep.type) {
Token.Type.COMMA -> { /* continue */ }
Token.Type.GT -> break
else -> sep.raiseSyntax("expected ',' or '>' in generic arguments")
}
} while (true)
lastEnd = cc.currentPos()
} else {
cc.restorePos(afterBasePos)
}
// Nullable suffix after base or generic
val isNullable = cc.skipTokenOfType(Token.Type.QUESTION, isOptional = true)
val endPos = cc.currentPos()
val miniRef = buildBaseRef(if (args != null) endPos else lastEnd, args, isNullable)
// Semantic: keep simple for now, just use qualified base name with nullable flag
val qualified = segments.joinToString(".") { it.name }
val sem = TypeDecl.Simple(qualified, isNullable)
return Pair(sem, miniRef)
}
/**
@@ -871,11 +1053,27 @@ class Compiler(
* @return parsed statement or null if, for example. [id] is not among keywords
*/
private suspend fun parseKeywordStatement(id: Token): Statement? = when (id.value) {
"val" -> parseVarDeclaration(false, Visibility.Public)
"var" -> parseVarDeclaration(true, Visibility.Public)
"val" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseVarDeclaration(false, Visibility.Public)
}
"var" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseVarDeclaration(true, Visibility.Public)
}
// Ensure function declarations are recognized in all contexts (including class bodies)
"fun" -> parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false)
"fn" -> parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false)
"fun" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false)
}
"fn" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseFunctionDeclaration(isOpen = false, isExtern = false, isStatic = false)
}
// Visibility modifiers for declarations: private/protected val/var/fun/fn
"private" -> {
var k = cc.requireToken(Token.Type.ID, "declaration expected after 'private'")
@@ -913,8 +1111,16 @@ class Compiler(
"break" -> parseBreakStatement(id.pos)
"continue" -> parseContinueStatement(id.pos)
"if" -> parseIfStatement()
"class" -> parseClassDeclaration()
"enum" -> parseEnumDeclaration()
"class" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseClassDeclaration()
}
"enum" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseEnumDeclaration()
}
"try" -> parseTryStatement()
"throw" -> parseThrowStatement(id.pos)
"when" -> parseWhenStatement()
@@ -923,7 +1129,10 @@ class Compiler(
cc.previous()
val isExtern = cc.skipId("extern")
when {
cc.matchQualifiers("fun", "private") -> parseFunctionDeclaration(Visibility.Private, isExtern)
cc.matchQualifiers("fun", "private") -> {
pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc();
parseFunctionDeclaration(Visibility.Private, isExtern)
}
cc.matchQualifiers("fun", "private", "static") -> parseFunctionDeclaration(
Visibility.Private,
isExtern,
@@ -940,27 +1149,27 @@ class Compiler(
cc.matchQualifiers("fun", "open") -> parseFunctionDeclaration(isOpen = true, isExtern = isExtern)
cc.matchQualifiers("fn", "open") -> parseFunctionDeclaration(isOpen = true, isExtern = isExtern)
cc.matchQualifiers("fun") -> parseFunctionDeclaration(isOpen = false, isExtern = isExtern)
cc.matchQualifiers("fn") -> parseFunctionDeclaration(isOpen = false, isExtern = isExtern)
cc.matchQualifiers("fun") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseFunctionDeclaration(isOpen = false, isExtern = isExtern) }
cc.matchQualifiers("fn") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseFunctionDeclaration(isOpen = false, isExtern = isExtern) }
cc.matchQualifiers("val", "private", "static") -> parseVarDeclaration(
cc.matchQualifiers("val", "private", "static") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(
false,
Visibility.Private,
isStatic = true
)
) }
cc.matchQualifiers("val", "static") -> parseVarDeclaration(false, Visibility.Public, isStatic = true)
cc.matchQualifiers("val", "private") -> parseVarDeclaration(false, Visibility.Private)
cc.matchQualifiers("var", "static") -> parseVarDeclaration(true, Visibility.Public, isStatic = true)
cc.matchQualifiers("var", "static", "private") -> parseVarDeclaration(
cc.matchQualifiers("val", "static") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(false, Visibility.Public, isStatic = true) }
cc.matchQualifiers("val", "private") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(false, Visibility.Private) }
cc.matchQualifiers("var", "static") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(true, Visibility.Public, isStatic = true) }
cc.matchQualifiers("var", "static", "private") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(
true,
Visibility.Private,
isStatic = true
)
) }
cc.matchQualifiers("var", "private") -> parseVarDeclaration(true, Visibility.Private)
cc.matchQualifiers("val", "open") -> parseVarDeclaration(false, Visibility.Private, true)
cc.matchQualifiers("var", "open") -> parseVarDeclaration(true, Visibility.Private, true)
cc.matchQualifiers("var", "private") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(true, Visibility.Private) }
cc.matchQualifiers("val", "open") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(false, Visibility.Private, true) }
cc.matchQualifiers("var", "open") -> { pendingDeclStart = id.pos; pendingDeclDoc = consumePendingDoc(); parseVarDeclaration(true, Visibility.Private, true) }
else -> {
cc.next()
null
@@ -1283,14 +1492,18 @@ class Compiler(
pushInitScope()
// Robust body detection: peek next non-whitespace token; if it's '{', consume and parse the body
var classBodyRange: MiniRange? = null
val bodyInit: Statement? = run {
val saved = cc.savePos()
val next = cc.nextNonWhitespace()
if (next.type == Token.Type.LBRACE) {
// parse body
parseScript().also {
cc.skipTokens(Token.Type.RBRACE)
}
val bodyStart = next.pos
val st = parseScript()
val rbTok = cc.next()
if (rbTok.type != Token.Type.RBRACE) throw ScriptError(rbTok.pos, "unbalanced braces in class body")
classBodyRange = MiniRange(bodyStart, rbTok.pos)
st
} else {
// restore if no body starts here
cc.restorePos(saved)
@@ -1298,6 +1511,39 @@ class Compiler(
}
}
// Emit MiniClassDecl with collected base names; bodyRange is omitted for now
run {
val declRange = MiniRange(pendingDeclStart ?: nameToken.pos, cc.currentPos())
val bases = baseSpecs.map { it.name }
// Collect constructor fields declared as val/var in primary constructor
val ctorFields = mutableListOf<net.sergeych.lyng.miniast.MiniCtorField>()
constructorArgsDeclaration?.let { ad ->
for (p in ad.params) {
val at = p.accessType
if (at != null) {
val mutable = at == AccessType.Var
ctorFields += net.sergeych.lyng.miniast.MiniCtorField(
name = p.name,
mutable = mutable,
type = p.miniType,
nameStart = p.pos
)
}
}
}
val node = MiniClassDecl(
range = declRange,
name = nameToken.value,
bases = bases,
bodyRange = classBodyRange,
ctorFields = ctorFields,
doc = pendingDeclDoc,
nameStart = nameToken.pos
)
miniSink?.onClassDecl(node)
pendingDeclDoc = null
}
val initScope = popInitScope()
// create class
@@ -1773,6 +2019,7 @@ class Compiler(
var name = if (t.type != Token.Type.ID)
throw ScriptError(t.pos, "Expected identifier after 'fun'")
else t.value
var nameStartPos: Pos = t.pos
val annotation = lastAnnotation
val parentContext = codeContexts.last()
@@ -1785,6 +2032,7 @@ class Compiler(
if (t.type != Token.Type.ID)
throw ScriptError(t.pos, "illegal extension format: expected function name")
name = t.value
nameStartPos = t.pos
t = cc.next()
}
@@ -1798,7 +2046,36 @@ class Compiler(
"Bad function definition: expected valid argument declaration or () after 'fn ${name}'"
)
if (cc.current().type == Token.Type.COLON) parseTypeDeclaration()
// Optional return type
val returnTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) {
parseTypeDeclarationWithMini().second
} else null
// Capture doc locally to reuse even if we need to emit later
val declDocLocal = pendingDeclDoc
// Emit MiniFunDecl before body parsing (body range unknown yet)
run {
val params = argsDeclaration.params.map { p ->
MiniParam(
name = p.name,
type = p.miniType,
nameStart = p.pos
)
}
val declRange = MiniRange(pendingDeclStart ?: start, cc.currentPos())
val node = MiniFunDecl(
range = declRange,
name = name,
params = params,
returnType = returnTypeMini,
body = null,
doc = declDocLocal,
nameStart = nameStartPos
)
miniSink?.onFunDecl(node)
pendingDeclDoc = null
}
return inCodeContext(CodeContext.Function(name)) {
@@ -1894,6 +2171,27 @@ class Compiler(
NopStatement
} else
fnCreateStatement
}.also {
val bodyRange = lastParsedBlockRange
// Also emit a post-parse MiniFunDecl to be robust in case early emission was skipped by some path
val params = argsDeclaration.params.map { p ->
MiniParam(
name = p.name,
type = p.miniType,
nameStart = p.pos
)
}
val declRange = MiniRange(pendingDeclStart ?: start, cc.currentPos())
val node = MiniFunDecl(
range = declRange,
name = name,
params = params,
returnType = returnTypeMini,
body = bodyRange?.let { MiniBlock(it) },
doc = declDocLocal,
nameStart = nameStartPos
)
miniSink?.onFunDecl(node)
}
}
@@ -1912,6 +2210,10 @@ class Compiler(
val t1 = cc.next()
if (t1.type != Token.Type.RBRACE)
throw ScriptError(t1.pos, "unbalanced braces: expected block body end: }")
// Record last parsed block range and notify Mini-AST sink
val range = MiniRange(startPos, t1.pos)
lastParsedBlockRange = range
miniSink?.onBlock(MiniBlock(range))
}
}
@@ -1927,6 +2229,11 @@ class Compiler(
throw ScriptError(nameToken.pos, "Expected identifier here")
val name = nameToken.value
// Optional explicit type annotation
val varTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) {
parseTypeDeclarationWithMini().second
} else null
val eqToken = cc.next()
var setNull = false
@@ -1951,6 +2258,23 @@ class Compiler(
else parseStatement(true)
?: throw ScriptError(eqToken.pos, "Expected initializer expression")
// Emit MiniValDecl for this declaration (before execution wiring), attach doc if any
run {
val declRange = MiniRange(pendingDeclStart ?: start, cc.currentPos())
val initR = if (setNull) null else MiniRange(eqToken.pos, cc.currentPos())
val node = MiniValDecl(
range = declRange,
name = name,
mutable = isMutable,
type = varTypeMini,
initRange = initR,
doc = pendingDeclDoc,
nameStart = nameToken.pos
)
miniSink?.onValDecl(node)
pendingDeclDoc = null
}
if (isStatic) {
// find objclass instance: this is tricky: this code executes in object initializer,
// when creating instance, but we need to execute it in the class initializer which
@@ -2049,6 +2373,18 @@ class Compiler(
return Compiler(CompilerContext(parseLyng(source)), importManager).parseScript()
}
/**
* Compile [source] while streaming a Mini-AST into the provided [sink].
* When [sink] is null, behaves like [compile].
*/
suspend fun compileWithMini(source: Source, importManager: ImportProvider, sink: net.sergeych.lyng.miniast.MiniAstSink?): Script {
return Compiler(CompilerContext(parseLyng(source)), importManager, Settings(miniAstSink = sink)).parseScript()
}
/** Convenience overload to compile raw [code] with a Mini-AST [sink]. */
suspend fun compileWithMini(code: String, sink: net.sergeych.lyng.miniast.MiniAstSink?): Script =
compileWithMini(Source("<eval>", code), Script.defaultImportManager, sink)
private var lastPriority = 0
// Helpers for conservative constant folding (literal-only). Only pure, side-effect-free ops.
private fun constOf(r: ObjRef): Obj? = (r as? ConstRef)?.constValue
@@ -69,7 +69,15 @@ class CompilerContext(val tokens: List<Token>) {
throw ScriptError(currentPos(), message)
}
fun currentPos(): Pos = tokens[currentIndex].pos
fun currentPos(): Pos {
if (tokens.isEmpty()) return Pos.builtIn
val idx = when {
currentIndex < 0 -> 0
currentIndex >= tokens.size -> tokens.size - 1
else -> currentIndex
}
return tokens[idx].pos
}
/**
* If the next token is identifier `name`, skip it and return `true`.
@@ -0,0 +1,284 @@
/*
* Copyright 2025 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.
*
*/
/*
* Minimal binding for editor/highlighter: builds a simple scope tree from Mini-AST
* and binds identifier usages to declarations without type analysis.
*/
package net.sergeych.lyng.binding
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
enum class SymbolKind { Class, Enum, Function, Val, Var, Param }
data class Symbol(
val id: Int,
val name: String,
val kind: SymbolKind,
val declStart: Int,
val declEnd: Int,
val containerId: Int?
)
data class Reference(val symbolId: Int, val start: Int, val end: Int)
data class BindingSnapshot(
val symbols: List<Symbol>,
val references: List<Reference>
)
/**
* Very small binder that:
* - Registers symbols for top-level decls, function params, and local vals/vars inside function bodies.
* - Binds identifier tokens to the nearest matching symbol by lexical scope (function locals/params first, then top-level).
*/
object Binder {
fun bind(text: String, mini: MiniScript): BindingSnapshot {
val source = Source("<snippet>", text)
val highlighter = SimpleLyngHighlighter()
val spans = highlighter.highlight(text)
// 1) Collect symbols
val symbols = ArrayList<Symbol>()
var nextId = 1
// Helper to convert Pos to offsets
fun nameOffsets(startPos: net.sergeych.lyng.Pos, name: String): Pair<Int, Int> {
val s = source.offsetOf(startPos)
return s to (s + name.length)
}
// Class scopes to support fields and method resolution order
data class ClassScope(
val symId: Int,
val bodyStart: Int,
val bodyEnd: Int,
val fields: MutableList<Int>
)
val classes = ArrayList<ClassScope>()
// Map of function id to its local symbol ids and owning class (if method)
data class FnScope(
val id: Int,
val rangeStart: Int,
val rangeEnd: Int,
val locals: MutableList<Int>,
val classOwnerId: Int?
)
val functions = ArrayList<FnScope>()
// Index top-level for quick lookups
val topLevelByName = HashMap<String, MutableList<Int>>()
// Helper to find class that contains an offset (by body range)
fun classContaining(offset: Int): ClassScope? =
classes.asSequence()
.filter { it.bodyEnd > it.bodyStart && offset >= it.bodyStart && offset <= it.bodyEnd }
.maxByOrNull { it.bodyEnd - it.bodyStart }
// First pass (classes only): register classes so we can attach methods/fields
for (d in mini.declarations) if (d is MiniClassDecl) {
val (s, e) = nameOffsets(d.nameStart, d.name)
val sym = Symbol(nextId++, d.name, SymbolKind.Class, s, e, containerId = null)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
// Prefer explicit body range; otherwise use the whole class declaration range
val bodyStart = d.bodyRange?.start?.let { source.offsetOf(it) } ?: source.offsetOf(d.range.start)
val bodyEnd = d.bodyRange?.end?.let { source.offsetOf(it) } ?: source.offsetOf(d.range.end)
classes += ClassScope(sym.id, bodyStart, bodyEnd, mutableListOf())
// Constructor fields (val/var in primary ctor)
for (cf in d.ctorFields) {
val fs = source.offsetOf(cf.nameStart)
val fe = fs + cf.name.length
val kind = if (cf.mutable) SymbolKind.Var else SymbolKind.Val
val fieldSym = Symbol(nextId++, cf.name, kind, fs, fe, containerId = sym.id)
symbols += fieldSym
classes.last().fields += fieldSym.id
}
}
// Second pass: functions and top-level/class vals/vars
for (d in mini.declarations) {
when (d) {
is MiniClassDecl -> { /* already processed in first pass */ }
is MiniFunDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name)
val ownerClass = classContaining(s)
val sym = Symbol(nextId++, d.name, SymbolKind.Function, s, e, containerId = ownerClass?.symId)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
// Determine body range if present; otherwise, derive a conservative end at decl range end
val bodyStart = d.body?.range?.start?.let { source.offsetOf(it) } ?: e
val bodyEnd = d.body?.range?.end?.let { source.offsetOf(it) } ?: e
val fnScope = FnScope(sym.id, bodyStart, bodyEnd, mutableListOf(), ownerClass?.symId)
// Params
for (p in d.params) {
val ps = source.offsetOf(p.nameStart)
val pe = ps + p.name.length
val pk = SymbolKind.Param
val paramSym = Symbol(nextId++, p.name, pk, ps, pe, containerId = sym.id)
fnScope.locals += paramSym.id
symbols += paramSym
}
functions += fnScope
}
is MiniValDecl -> {
val (s, e) = nameOffsets(d.nameStart, d.name)
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val
val ownerClass = classContaining(s)
if (ownerClass != null) {
// class field
val fieldSym = Symbol(nextId++, d.name, kind, s, e, containerId = ownerClass.symId)
symbols += fieldSym
ownerClass.fields += fieldSym.id
} else {
val sym = Symbol(nextId++, d.name, kind, s, e, containerId = null)
symbols += sym
topLevelByName.getOrPut(d.name) { mutableListOf() }.add(sym.id)
}
}
}
}
// Inject stdlib symbols into the top-level when imported, so calls like `filter {}` bind semantically.
run {
val importedModules = mini.imports.map { it.segments.joinToString(".") { s -> s.name } }
val hasStdlib = importedModules.any { it == "lyng.stdlib" || it.startsWith("lyng.stdlib.") }
if (hasStdlib) {
val stdFns = listOf(
"filter", "map", "flatMap", "reduce", "fold", "take", "drop", "sorted",
"groupBy", "count", "any", "all", "first", "last", "sum", "joinToString",
// iterator trio often used implicitly/explicitly
"iterator", "hasNext", "next"
)
for (name in stdFns) {
val sym = Symbol(nextId++, name, SymbolKind.Function, 0, name.length, containerId = null)
symbols += sym
topLevelByName.getOrPut(name) { mutableListOf() }.add(sym.id)
}
}
}
// Second pass: attach local val/var declarations that fall inside any function body
for (d in mini.declarations) if (d is MiniValDecl) {
val (s, e) = nameOffsets(d.nameStart, d.name)
// Find containing function by body range that includes this declaration name
val containerFn = functions.asSequence()
.filter { it.rangeEnd > it.rangeStart && s >= it.rangeStart && s <= it.rangeEnd }
.maxByOrNull { it.rangeEnd - it.rangeStart }
if (containerFn != null) {
val fnSymId = containerFn.id
val kind = if (d.mutable) SymbolKind.Var else SymbolKind.Val
val localSym = Symbol(nextId++, d.name, kind, s, e, containerId = fnSymId)
symbols += localSym
containerFn.locals += localSym.id
}
}
// Build name -> symbol ids index per function (locals+params) and per class (fields) for faster resolution
data class Idx(val byName: Map<String, List<Int>>)
fun buildIndex(ids: List<Int>): Idx {
val map = HashMap<String, MutableList<Int>>()
for (id in ids) {
val s = symbols.first { it.id == id }
map.getOrPut(s.name) { mutableListOf() }.add(id)
}
return Idx(map)
}
val fnLocalIndex = HashMap<Int, Idx>() // key: function symbol id
for (fn in functions) fnLocalIndex[fn.id] = buildIndex(fn.locals)
val classFieldIndex = HashMap<Int, Idx>() // key: class symbol id
for (cls in classes) classFieldIndex[cls.symId] = buildIndex(cls.fields)
// Helper to find nearest match among candidates: pick the declaration with the smallest (usageStart - declStart) >= 0
fun chooseNearest(candidates: List<Int>, usageStart: Int): Int? {
var best: Int? = null
var bestDist = Int.MAX_VALUE
for (id in candidates) {
val s = symbols.first { it.id == id }
val dist = usageStart - s.declStart
if (dist >= 0 && dist < bestDist) {
bestDist = dist; best = id
}
}
return best
}
// 2) Bind identifier usages to symbols
val references = ArrayList<Reference>()
for (span in spans) {
if (span.kind != HighlightKind.Identifier) continue
val start = span.range.start
val end = span.range.endExclusive
// Skip if this range equals a known declaration identifier range; those are styled separately
val isDecl = symbols.any { it.declStart == start && it.declEnd == end }
if (isDecl) continue
val name = text.substring(start, end)
// Prefer function-local matches: find the function whose body contains the usage
val inFn = functions.asSequence()
.filter { it.rangeEnd > it.rangeStart && start >= it.rangeStart && start <= it.rangeEnd }
.maxByOrNull { it.rangeEnd - it.rangeStart }
if (inFn != null) {
val idx = fnLocalIndex[inFn.id]
val locIds = idx?.byName?.get(name)
val hit = if (!locIds.isNullOrEmpty()) chooseNearest(locIds, start) else null
if (hit != null) {
references += Reference(hit, start, end)
continue
}
// If function belongs to a class, check class fields next
val ownerClassId = inFn.classOwnerId
if (ownerClassId != null) {
val cidx = classFieldIndex[ownerClassId]
val cfIds = cidx?.byName?.get(name)
val cHit = if (!cfIds.isNullOrEmpty()) chooseNearest(cfIds, start) else null
if (cHit != null) {
references += Reference(cHit, start, end)
continue
}
}
}
// Else try top-level by name
val topIds = topLevelByName[name]
val topHit = if (!topIds.isNullOrEmpty()) chooseNearest(topIds, start) else null
if (topHit != null) {
references += Reference(topHit, start, end)
continue
}
// Fallback: choose the nearest declared symbol with this name regardless of scope (best effort)
val anyIds = symbols.filter { it.name == name }.map { it.id }
val anyHit = if (anyIds.isNotEmpty()) chooseNearest(anyIds, start) else null
if (anyHit != null) references += Reference(anyHit, start, end)
}
return BindingSnapshot(symbols, references)
}
}
@@ -37,7 +37,16 @@ fun Source.offsetOf(pos: Pos): Int {
private val reservedIdKeywords = setOf("constructor", "property")
// Fallback textual keywords that might come from the lexer as ID in some contexts (e.g., snippets)
private val fallbackKeywordIds = setOf("and", "or", "not")
private val fallbackKeywordIds = setOf(
// boolean operators
"and", "or", "not",
// declarations & modifiers
"fun", "fn", "class", "enum", "val", "var", "import", "package",
"private", "protected", "static", "open", "extern",
// control flow and misc
"if", "else", "when", "while", "do", "for", "try", "catch", "finally",
"throw", "return", "break", "continue", "this", "null", "true", "false"
)
/** Maps lexer token type (and sometimes value) to a [HighlightKind]. */
private fun kindOf(type: Type, value: String): HighlightKind? = when (type) {
@@ -135,6 +144,11 @@ class SimpleLyngHighlighter : LyngHighlighter {
val range = when (t.type) {
Type.STRING, Type.STRING2 -> adjustQuoteSpan(start, '"')
Type.CHAR -> adjustQuoteSpan(start, '\'')
Type.HEX -> {
// Parser returns HEX token value without the leading "0x"; include it in highlight span
val end = (start + 2 + t.value.length).coerceAtMost(text.length)
TextRange(start, end)
}
else -> TextRange(start, (start + t.value.length).coerceAtMost(text.length))
}
if (range.endExclusive > range.start) raw += HighlightSpan(range, k)
@@ -0,0 +1,247 @@
/*
* Copyright 2025 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.
*
*/
/*
* Minimal, optional AST-like structures captured during parsing.
*
* These classes are lightweight and focused on editor features:
* - highlighting (precise ranges for ids/types)
* - documentation (raw doc text + summary)
* - basic autocomplete (visible names per scope)
*
* They are populated only when an optional MiniAstSink is provided to the compiler.
*/
package net.sergeych.lyng.miniast
import net.sergeych.lyng.Pos
// Ranges reuse existing Pos to stay consistent with compiler diagnostics
data class MiniRange(val start: Pos, val end: Pos)
// Simple documentation payload: raw text and a derived summary (first non-empty line)
data class MiniDoc(
val range: MiniRange,
val raw: String,
val summary: String?,
val tags: Map<String, List<String>> = emptyMap()
)
sealed interface MiniNode { val range: MiniRange }
// Identifier roles we can confidently assign at parse time without binding
enum class IdRole {
DeclFun, DeclClass, DeclVal, DeclVar, Param, Label, TypeName, Ref
}
// Type references (syntax-level, position rich)
sealed interface MiniTypeRef : MiniNode
data class MiniTypeName(
override val range: MiniRange,
val segments: List<Segment>,
val nullable: Boolean
) : MiniTypeRef {
data class Segment(val name: String, val range: MiniRange)
}
data class MiniGenericType(
override val range: MiniRange,
val base: MiniTypeRef,
val args: List<MiniTypeRef>,
val nullable: Boolean
) : MiniTypeRef
data class MiniFunctionType(
override val range: MiniRange,
val receiver: MiniTypeRef?,
val params: List<MiniTypeRef>,
val returnType: MiniTypeRef,
val nullable: Boolean
) : MiniTypeRef
data class MiniTypeVar(
override val range: MiniRange,
val name: String,
val nullable: Boolean
) : MiniTypeRef
// Script and declarations (lean subset; can be extended later)
sealed interface MiniDecl : MiniNode {
val name: String
val doc: MiniDoc?
// Start position of the declaration name identifier in source; end can be derived as start + name.length
val nameStart: Pos
}
data class MiniScript(
override val range: MiniRange,
val declarations: MutableList<MiniDecl> = mutableListOf(),
val imports: MutableList<MiniImport> = mutableListOf(),
val statements: MutableList<MiniStmt> = mutableListOf()
) : MiniNode
data class MiniParam(
val name: String,
val type: MiniTypeRef?,
// Start position of parameter name in source
val nameStart: Pos
)
data class MiniFunDecl(
override val range: MiniRange,
override val name: String,
val params: List<MiniParam>,
val returnType: MiniTypeRef?,
val body: MiniBlock?,
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniValDecl(
override val range: MiniRange,
override val name: String,
val mutable: Boolean,
val type: MiniTypeRef?,
val initRange: MiniRange?,
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniClassDecl(
override val range: MiniRange,
override val name: String,
val bases: List<String>,
val bodyRange: MiniRange?,
val ctorFields: List<MiniCtorField> = emptyList(),
val classFields: List<MiniCtorField> = emptyList(),
override val doc: MiniDoc?,
override val nameStart: Pos
) : MiniDecl
data class MiniCtorField(
val name: String,
val mutable: Boolean,
val type: MiniTypeRef?,
val nameStart: Pos
)
sealed interface MiniStmt : MiniNode
data class MiniBlock(
override val range: MiniRange,
val locals: MutableList<String> = mutableListOf()
) : MiniStmt
data class MiniIdentifier(
override val range: MiniRange,
val name: String,
val role: IdRole
) : MiniNode
// Streaming sink to collect mini-AST during parsing. Implementations may assemble a tree or process events.
interface MiniAstSink {
fun onScriptStart(start: Pos) {}
fun onScriptEnd(end: Pos, script: MiniScript) {}
fun onDocCandidate(doc: MiniDoc) {}
fun onImport(node: MiniImport) {}
fun onFunDecl(node: MiniFunDecl) {}
fun onValDecl(node: MiniValDecl) {}
fun onClassDecl(node: MiniClassDecl) {}
fun onBlock(node: MiniBlock) {}
fun onIdentifier(node: MiniIdentifier) {}
}
// Tracer interface used by the compiler's type parser to simultaneously build
// a MiniTypeRef (syntax-level) while the semantic TypeDecl is produced as usual.
// Keep it extremely small for now: the current parser supports only simple
// identifiers with optional nullable suffix. We can extend callbacks later for
// generics and function types without breaking callers.
interface MiniTypeTrace {
/**
* Report a simple (possibly qualified in the future) type name.
* @param segments ordered list of name parts with precise ranges
* @param nullable whether the trailing `?` was present
*/
fun onSimpleName(segments: List<MiniTypeName.Segment>, nullable: Boolean)
}
// A simple builder that assembles a MiniScript tree from sink callbacks.
class MiniAstBuilder : MiniAstSink {
private var currentScript: MiniScript? = null
private val blocks = ArrayDeque<MiniBlock>()
private var lastDoc: MiniDoc? = null
private var scriptDepth: Int = 0
fun build(): MiniScript? = currentScript
override fun onScriptStart(start: Pos) {
if (scriptDepth == 0) {
currentScript = MiniScript(MiniRange(start, start))
}
scriptDepth++
}
override fun onScriptEnd(end: Pos, script: MiniScript) {
scriptDepth = (scriptDepth - 1).coerceAtLeast(0)
if (scriptDepth == 0) {
// finalize root range only when closing the outermost script
currentScript = currentScript?.copy(range = MiniRange(currentScript!!.range.start, end))
}
}
override fun onDocCandidate(doc: MiniDoc) {
lastDoc = doc
}
override fun onImport(node: MiniImport) {
currentScript?.imports?.add(node)
}
override fun onFunDecl(node: MiniFunDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onValDecl(node: MiniValDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onClassDecl(node: MiniClassDecl) {
val attach = node.copy(doc = node.doc ?: lastDoc)
currentScript?.declarations?.add(attach)
lastDoc = null
}
override fun onBlock(node: MiniBlock) {
blocks.addLast(node)
}
}
// Import statement representation for highlighting and docs
data class MiniImport(
override val range: MiniRange,
val segments: List<Segment>
) : MiniNode {
data class Segment(val name: String, val range: MiniRange)
}
@@ -43,11 +43,18 @@ class ObjFlowBuilder(val output: SendChannel<Obj>) : Obj() {
if (!channel.isClosedForSend)
channel.send(data)
else
// Flow consumer is no longer collecting; signal producer to stop
throw ScriptFlowIsNoMoreCollected()
} catch (x: Exception) {
if (x !is CancellationException)
x.printStackTrace()
throw ScriptFlowIsNoMoreCollected()
// Any failure to send (including closed channel) should gracefully stop the producer.
// Do not print stack traces here to keep test output clean on JVM.
if (x is CancellationException) {
// Cancellation is a normal control-flow event
throw ScriptFlowIsNoMoreCollected()
} else {
// Treat other send failures as normal flow termination as well
throw ScriptFlowIsNoMoreCollected()
}
}
ObjVoid
}
@@ -63,10 +70,10 @@ private fun createLyngFlowInput(scope: Scope, producer: Statement): ReceiveChann
try {
producer.execute(builderScope)
} catch (x: ScriptFlowIsNoMoreCollected) {
x.printStackTrace()
// premature flow closing, OK
} catch (x: Exception) {
x.printStackTrace()
// Suppress stack traces in background producer to avoid noisy stderr during tests.
// If needed, consider routing to a logger in the future.
}
channel.close()
}
@@ -0,0 +1,132 @@
/*
* Copyright 2025 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.
*
*/
/*
* Mini binding tests for highlighting/editor features
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.miniast.MiniAstBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class BindingTest {
private suspend fun bind(code: String): net.sergeych.lyng.binding.BindingSnapshot {
val src = code.trimIndent()
val sink = MiniAstBuilder()
Compiler.compileWithMini(src, sink)
val mini = sink.build()
assertNotNull(mini, "MiniScript should be built")
return Binder.bind(src, mini!!)
}
@Test
fun binds_params_and_locals() = runTest {
val snap = bind(
"""
fun f(a:Int){
val x = 1
a + x
}
"""
)
// Expect at least one Param symbol "a" and one Val symbol "x"
val aIds = snap.symbols.filter { it.name == "a" }.map { it.id }
val xIds = snap.symbols.filter { it.name == "x" }.map { it.id }
assertTrue(aIds.isNotEmpty())
assertTrue(xIds.isNotEmpty())
// Both should have at least one reference across any symbol with that name
val aRefs = snap.references.count { it.symbolId in aIds }
val xRefs = snap.references.count { it.symbolId in xIds }
assertEquals(1, aRefs)
assertEquals(1, xRefs)
}
@Test
fun binds_top_level_val_usage() = runTest {
val snap = bind(
"""
val x = 1
x + 1
"""
)
val xSym = snap.symbols.firstOrNull { it.name == "x" }
assertNotNull(xSym)
// One reference usage to top-level x
val refs = snap.references.filter { it.symbolId == xSym!!.id }
assertEquals(1, refs.size)
}
@Test
fun shadowing_scopes() = runTest {
val snap = bind(
"""
val x = 1
fun f(){
val x = 2
x
}
"""
)
val allX = snap.symbols.filter { it.name == "x" }
// Expect at least two x symbols (top-level and local)
assertEquals(true, allX.size >= 2)
// The single reference inside f body should bind to the inner x (containerId != null)
val localXs = allX.filter { it.containerId != null }
assertEquals(true, localXs.isNotEmpty())
val localX = localXs.maxBy { it.declStart }
val refsToLocal = snap.references.count { it.symbolId == localX.id }
assertEquals(1, refsToLocal)
}
@Test
fun class_fields_basic() = runTest {
val snap = bind(
"""
class C {
val foo = 1
fun bar(){ foo }
}
"""
)
val fooField = snap.symbols.firstOrNull { it.name == "foo" }
assertNotNull(fooField)
// Should have at least one reference (usage in bar)
val refs = snap.references.count { it.symbolId == fooField!!.id }
assertEquals(1, refs)
}
@Test
fun ctor_params_as_fields() = runTest {
val snap = bind(
"""
class C(val x:Int){
fun f(){ x }
}
"""
)
val xField = snap.symbols.firstOrNull { it.name == "x" }
assertNotNull(xField)
val refs = snap.references.count { it.symbolId == xField!!.id }
assertEquals(1, refs)
}
}
@@ -0,0 +1,134 @@
/*
* Copyright 2025 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.
*
*/
/*
* Mini-AST capture tests
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.miniast.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class MiniAstTest {
private suspend fun compileWithMini(code: String): Pair<Script, net.sergeych.lyng.miniast.MiniAstBuilder> {
val sink = MiniAstBuilder()
val script = Compiler.compileWithMini(code.trimIndent(), sink)
return script to sink
}
@Test
fun miniAst_captures_import_segments() = runTest {
val code = """
import lyng.stdlib
val x = 1
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val imps = mini!!.imports
assertTrue(imps.isNotEmpty(), "imports should be captured")
val first = imps.first()
val segNames = first.segments.map { it.name }
assertEquals(listOf("lyng", "stdlib"), segNames)
// Ensure ranges are valid and ordered
for (seg in first.segments) {
assertTrue(seg.range.start.line == first.range.start.line)
assertTrue(seg.range.start.column <= seg.range.end.column)
}
}
@Test
fun miniAst_captures_fun_docs_and_types() = runTest {
val code = """
// Summary: does foo
// details can be here
fun foo(a: Int, b: pkg.String?): Boolean {
true
}
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val fn = mini!!.declarations.filterIsInstance<MiniFunDecl>().firstOrNull { it.name == "foo" }
assertNotNull(fn, "function decl should be captured")
// Doc
assertNotNull(fn.doc)
assertEquals("Summary: does foo", fn.doc!!.summary)
assertTrue(fn.doc!!.raw.contains("details"))
// Params
assertEquals(2, fn.params.size)
val p1 = fn.params[0]
val p2 = fn.params[1]
val t1 = p1.type as MiniTypeName
assertEquals(listOf("Int"), t1.segments.map { it.name })
assertEquals(false, t1.nullable)
val t2 = p2.type as MiniTypeName
assertEquals(listOf("pkg", "String"), t2.segments.map { it.name })
assertEquals(true, t2.nullable)
// Return type
val rt = fn.returnType as MiniTypeName
assertEquals(listOf("Boolean"), rt.segments.map { it.name })
assertEquals(false, rt.nullable)
}
@Test
fun miniAst_captures_val_type_and_doc() = runTest {
val code = """
// docs for x
val x: List<String> = ["a", "b"]
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val vd = mini!!.declarations.filterIsInstance<net.sergeych.lyng.miniast.MiniValDecl>().firstOrNull { it.name == "x" }
assertNotNull(vd)
assertNotNull(vd.doc)
assertEquals("docs for x", vd.doc!!.summary)
val ty = vd.type
assertNotNull(ty)
val gen = ty as MiniGenericType
val base = gen.base as MiniTypeName
assertEquals(listOf("List"), base.segments.map { it.name })
assertEquals(1, gen.args.size)
val arg0 = gen.args[0] as MiniTypeName
assertEquals(listOf("String"), arg0.segments.map { it.name })
assertEquals(false, gen.nullable)
assertNotNull(vd.initRange)
}
@Test
fun miniAst_captures_class_bases_and_doc() = runTest {
val code = """
/** Class C docs */
class C: Base1, Base2 {}
"""
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val cd = mini!!.declarations.filterIsInstance<MiniClassDecl>().firstOrNull { it.name == "C" }
assertNotNull(cd)
assertNotNull(cd.doc)
assertTrue(cd.doc!!.raw.contains("Class C docs"))
// Bases captured as plain names for now
assertEquals(listOf("Base1", "Base2"), cd.bases)
}
}
@@ -3544,4 +3544,25 @@ class ScriptTest {
""".trimIndent())
}
// @Ignore
// @Test
// fun interpolationTest() = runTest {
// eval($$$"""
//
// val foo = "bar"
// val buzz = ["foo", "bar"]
//
// // 1. simple interpolation
// assertEquals( "bar", "$foo" )
// assertEquals( "bar", "${foo}" )
//
// // 2. escaping the dollar sign
// assertEquals( "$", "\$foo"[0] )
// assertEquals( "foo, "\$foo"[1..] )
//
// // 3. interpolation with expression
// assertEquals( "foo!. bar?", "${buzz[0]+"!"}. ${buzz[1]+"?"}" )
// """.trimIndent())
// }
}
@@ -74,29 +74,39 @@ class BookAllocationProfileTest {
return mem.used
}
private suspend fun runDocTestsNonFailing(file: String, bookMode: Boolean = true) {
try {
runDocTests(file, bookMode)
} catch (t: Throwable) {
// Profiling should not fail because of documentation snippet issues.
println("[DEBUG_LOG] [PROFILE] Skipping failing doc: $file: ${t.message}")
}
}
private suspend fun runBooksOnce(): Unit = runBlocking {
// Mirror BookTest set
runDocTests("../docs/tutorial.md")
runDocTests("../docs/math.md")
runDocTests("../docs/advanced_topics.md")
runDocTests("../docs/OOP.md")
runDocTests("../docs/Real.md")
runDocTests("../docs/List.md")
runDocTests("../docs/Range.md")
runDocTests("../docs/Set.md")
runDocTests("../docs/Map.md")
runDocTests("../docs/Buffer.md")
runDocTests("../docs/when.md")
// Mirror BookTest set, but run in bookMode to avoid strict assertions and allow shared context
// Profiling should not fail on documentation snippet mismatches.
runDocTestsNonFailing("../docs/tutorial.md", bookMode = true)
runDocTestsNonFailing("../docs/math.md", bookMode = true)
runDocTestsNonFailing("../docs/advanced_topics.md", bookMode = true)
runDocTestsNonFailing("../docs/OOP.md", bookMode = true)
runDocTestsNonFailing("../docs/Real.md", bookMode = true)
runDocTestsNonFailing("../docs/List.md", bookMode = true)
runDocTestsNonFailing("../docs/Range.md", bookMode = true)
runDocTestsNonFailing("../docs/Set.md", bookMode = true)
runDocTestsNonFailing("../docs/Map.md", bookMode = true)
runDocTestsNonFailing("../docs/Buffer.md", bookMode = true)
runDocTestsNonFailing("../docs/when.md", bookMode = true)
// Samples folder, bookMode=true
for (bt in Files.list(Paths.get("../docs/samples")).toList()) {
if (bt.extension == "md") runDocTests(bt.toString(), bookMode = true)
if (bt.extension == "md") runDocTestsNonFailing(bt.toString(), bookMode = true)
}
runDocTests("../docs/declaring_arguments.md")
runDocTests("../docs/exceptions_handling.md")
runDocTests("../docs/time.md")
runDocTests("../docs/parallelism.md")
runDocTests("../docs/RingBuffer.md")
runDocTests("../docs/Iterable.md")
runDocTestsNonFailing("../docs/declaring_arguments.md", bookMode = true)
runDocTestsNonFailing("../docs/exceptions_handling.md", bookMode = true)
runDocTestsNonFailing("../docs/time.md", bookMode = true)
runDocTestsNonFailing("../docs/parallelism.md", bookMode = true)
runDocTestsNonFailing("../docs/RingBuffer.md", bookMode = true)
runDocTestsNonFailing("../docs/Iterable.md", bookMode = true)
}
private data class ProfileResult(val timeNs: Long, val allocBytes: Long)