Compare commits

..

3 Commits

28 changed files with 444 additions and 49 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ debug.log
/compile_metadata_output.txt
test_output*.txt
/site/src/version-template/lyng-version.js
/bugs/

View File

@ -15,15 +15,16 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
## 2. Lexical Syntax
- Comments: `// line`, `/* block */`.
- Strings: `"..."` (supports escapes). Multiline string content is normalized by indentation logic.
- Supported escapes: `\n`, `\r`, `\t`, `\"`, `\\`, `\uXXXX` (4 hex digits).
- Strings: `"..."` or `` `...` `` (supports escapes). Multiline string content is normalized by indentation logic.
- Shared escapes: `\n`, `\r`, `\t`, `\\`, `\uXXXX` (4 hex digits).
- Delimiter escapes: `\"` inside `"..."`, ``\` `` inside `` `...` ``.
- Unicode escapes use exactly 4 hex digits (for example: `"\u0416"` -> `Ж`).
- Unknown `\x` escapes in strings are preserved literally as two characters (`\` and `x`).
- String interpolation is supported:
- identifier form: `"$name"`
- expression form: `"${expr}"`
- escaped dollar: `"\$"` and `"$$"` both produce literal `$`.
- `\\$x` means backslash + interpolated `x`.
- identifier form: `"$name"` or `` `$name` ``
- expression form: `"${expr}"` or `` `${expr}` ``
- escaped dollar: `"\$"`, `"$$"`, `` `\$` ``, and `` `$$` `` all produce literal `$`.
- `\\$x` means backslash + interpolated `x` in either delimiter form.
- Per-file opt-out is supported via leading comment directive:
- `// feature: interpolation: off`
- with this directive, `$...` stays literal text.

View File

@ -1654,15 +1654,27 @@ The type for the character objects is `Char`.
### String literal escapes
Lyng string literals can use either double quotes or backticks:
val a = "hello"
val b = `hello`
assert(a == b)
| escape | ASCII value |
|--------|-----------------------|
| \n | 0x10, newline |
| \r | 0x13, carriage return |
| \t | 0x07, tabulation |
| \\ | \ slash character |
| \" | " double quote |
| \uXXXX | unicode code point |
Delimiter-specific escapes:
| form | escape | value |
|--------|--------|------------------|
| `"..."` | \" | " double quote |
| `` `...` `` | \` | ` backtick |
Unicode escape form is exactly 4 hex digits, e.g. `"\u263A"` -> `☺`.
Other `\c` combinations, where c is any char except mentioned above, are left intact, e.g.:
@ -1695,10 +1707,15 @@ Example:
val name = "Lyng"
assertEquals("hello, Lyng!", "hello, $name!")
assertEquals("hello, Lyng!", `hello, $name!`)
assertEquals("sum=3", "sum=${1+2}")
assertEquals("sum=3", `sum=${1+2}`)
assertEquals("\$name", "\$name")
assertEquals("\$name", "$$name")
assertEquals("\$name", `\$name`)
assertEquals("\$name", `$$name`)
assertEquals("\\Lyng", "\\$name")
assertEquals("\\Lyng", `\\$name`)
>>> void
Interpolation and `printf`-style formatting can be combined when needed:

View File

@ -310,7 +310,7 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() {
// Try literal and call-based receiver inference around the dot
val i = TextCtx.prevNonWs(text, dotPos - 1)
val className: String? = when {
i >= 0 && text[i] == '"' -> "String"
i >= 0 && (text[i] == '"' || text[i] == '`') -> "String"
i >= 0 && text[i] == ']' -> "List"
i >= 0 && text[i] == '}' -> "Dict"
i >= 0 && text[i] == ')' -> {

View File

@ -24,6 +24,7 @@ import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.impl.source.codeStyle.PreFormatProcessor
import net.sergeych.lyng.format.LyngFormatConfig
import net.sergeych.lyng.format.LyngFormatter
import net.sergeych.lyng.format.LyngStringDelimiterPolicy
import net.sergeych.lyng.idea.LyngLanguage
/**
@ -170,6 +171,7 @@ class LyngPreFormatProcessor : PreFormatProcessor {
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
applySpacing = true,
applyWrapping = false,
stringDelimiterPolicy = LyngStringDelimiterPolicy.PreferFewerEscapes,
)
val r = if (runFullFileIndent) currentLocalRange() else workingRangeLocal.intersection(currentLocalRange()) ?: currentLocalRange()
val text = doc.getText(r)
@ -189,6 +191,7 @@ class LyngPreFormatProcessor : PreFormatProcessor {
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
applySpacing = settings.enableSpacing,
applyWrapping = true,
stringDelimiterPolicy = LyngStringDelimiterPolicy.PreferFewerEscapes,
)
val r = if (runFullFileIndent) currentLocalRange() else workingRangeLocal.intersection(currentLocalRange()) ?: currentLocalRange()
val text = doc.getText(r)

View File

@ -101,8 +101,8 @@ class LyngLexer : LexerBase() {
return
}
// String "..." or '...' with simple escape handling
if (ch == '"' || ch == '\'') {
// String "...", `...`, or '...' with simple escape handling
if (ch == '"' || ch == '\'' || ch == '`') {
val quote = ch
i++
while (i < endOffset) {

View File

@ -0,0 +1,58 @@
/*
* 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.
*
*/
package net.sergeych.lyng.idea.highlight
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
class LyngLexerBacktickStringTest {
@Test
fun backtickStringGetsStringTokenAndColor() {
val lexer = LyngLexer()
val source = """val json = `{"name":"lyng","doc":"use \`quotes\`"}`"""
lexer.start(source, 0, source.length, 0)
val tokens = mutableListOf<Pair<String, String>>()
while (lexer.tokenType != null) {
val tokenText = source.substring(lexer.tokenStart, lexer.tokenEnd)
tokens += lexer.tokenType.toString() to tokenText
lexer.advance()
}
assertEquals(
listOf(
"KEYWORD" to "val",
"WHITESPACE" to " ",
"IDENTIFIER" to "json",
"WHITESPACE" to " ",
"PUNCT" to "=",
"WHITESPACE" to " ",
"STRING" to "`{\"name\":\"lyng\",\"doc\":\"use \\`quotes\\`\"}`"
),
tokens
)
val highlighter = LyngSyntaxHighlighter()
assertArrayEquals(
arrayOf(LyngHighlighterColors.STRING),
highlighter.getTokenHighlights(LyngTokenTypes.STRING)
)
}
}

View File

@ -420,6 +420,7 @@ private class Fmt : CoreCliktCommand(name = "fmt") {
val cfg = net.sergeych.lyng.format.LyngFormatConfig(
applySpacing = enableSpacing,
applyWrapping = enableWrapping,
stringDelimiterPolicy = net.sergeych.lyng.format.LyngStringDelimiterPolicy.PreferFewerEscapes,
)
var anyChanged = false

View File

@ -146,18 +146,27 @@ class CliLocalModuleImportRegressionJvmTest {
val headers = Map<String, String>()
fn startListen(port, host) {
var eager = Bravo()
eager.doSomething()
tcpServer = Net.tcpListen(port, host)
// println("tcpServer.isOpen: " + tcpServer.isOpen()) // historical workaround; should not be needed
println("tcpServer.isOpen: " + tcpServer.isOpen())
launch {
try {
while (true) {
println("wait for accept...")
val tcpSocket = tcpServer.accept()
println("var bravo = Bravo()")
var bravo = Bravo()
println("bravo.doSomething()...")
bravo.doSomething()
println("bravo.doSomething()... OK")
tcpSocket.close()
break
}
} catch (e) {
println("ERR [Alpha.startListen]: '", e, "'")
} finally {
println("FIN [Alpha.startListen]")
tcpServer.close()
}
}
@ -259,6 +268,7 @@ class CliLocalModuleImportRegressionJvmTest {
delay(50)
val socket = Net.tcpConnect("127.0.0.1", $port)
println("send ping...")
socket.writeUtf8("ping")
socket.flush()
socket.close()
@ -269,8 +279,10 @@ class CliLocalModuleImportRegressionJvmTest {
val result = runCli(mainFile.toString())
assertTrue(result.err.isBlank(), result.err)
assertFalse(result.out.contains("ERR [Alpha.startListen]"), result.out)
assertFalse(result.out.contains("module capture 'Bravo'"), result.out)
assertTrue(result.out.contains("Bravo.doSomething"), result.out)
assertTrue(result.out.contains("bravo.doSomething()... OK"), result.out)
assertEquals(2, Regex("Bravo\\.doSomething").findAll(result.out).count(), result.out)
} finally {
root.toFile().deleteRecursively()
}

View File

@ -17,4 +17,5 @@
package net.sergeych.lyng.obj
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null

View File

@ -2086,7 +2086,7 @@ class Compiler(
return null
}
if (scopeSeedNames.contains(name)) {
val isModuleSlot = modulePlan != null && slotLoc.scopeId == modulePlan.id
val isModuleSlot = resolvesToModuleSeedSlot(name, slotLoc)
if (!isModuleSlot || useScopeSlots) return null
}
recordCaptureSlot(name, slotLoc)
@ -2105,6 +2105,24 @@ class Compiler(
)
}
private fun resolvesToModuleSeedSlot(name: String, slotLoc: SlotLocation): Boolean {
val modulePlan = moduleSlotPlan() ?: return false
var current: SlotLocation? = slotLoc
val visitedScopeIds = HashSet<Int>()
while (current != null && visitedScopeIds.add(current.scopeId)) {
if (current.scopeId == modulePlan.id) {
return true
}
val owner = capturePlanStack
.firstOrNull { it.slotPlan.id == current.scopeId }
?.captureOwners
?.get(name)
?: return false
current = owner
}
return false
}
private fun captureSlotRef(name: String, pos: Pos): ObjRef? {
if (capturePlanStack.isEmpty()) return null
if (name == "this") return null

View File

@ -361,7 +361,7 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
Token(":", from, Token.Type.COLON)
}
'"' -> loadStringTokens(from)
'"', '`' -> loadStringTokens(from, ch)
in digitsSet -> {
pos.back()
@ -550,11 +550,11 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
return fixed.joinToString("\n")
}
private fun loadStringToken(): Token {
private fun loadStringToken(delimiter: Char): Token {
val start = currentPos
val sb = StringBuilder()
var newlineDetected = false
while (currentChar != '"') {
while (currentChar != delimiter) {
if (pos.end) throw ScriptError(start, "unterminated string started there")
when (currentChar) {
'\\' -> {
@ -572,8 +572,8 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
sb.append('\t'); pos.advance()
}
'"' -> {
sb.append('"'); pos.advance()
delimiter -> {
sb.append(delimiter); pos.advance()
}
'\\' -> {
@ -615,8 +615,8 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
data class Expr(val tokens: List<Token>, val pos: Pos) : StringChunk
}
private fun loadStringTokens(startQuotePos: Pos): Token {
if (!interpolationEnabled) return loadStringToken()
private fun loadStringTokens(startQuotePos: Pos, delimiter: Char): Token {
if (!interpolationEnabled) return loadStringToken(delimiter)
val tokenPos = currentPos
val chunks = mutableListOf<StringChunk>()
@ -631,7 +631,7 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
}
}
while (currentChar != '"') {
while (currentChar != delimiter) {
if (pos.end) throw ScriptError(startQuotePos, "unterminated string started there")
when (currentChar) {
'\\' -> {
@ -649,8 +649,8 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
literal.append('\t'); pos.advance()
}
'"' -> {
literal.append('"'); pos.advance()
delimiter -> {
literal.append(delimiter); pos.advance()
}
'\\' -> {
@ -788,8 +788,8 @@ private class Parser(fromPos: Pos, private val interpolationEnabled: Boolean = t
var depth = 1
while (!pos.end) {
val ch = currentChar
if (ch == '"') {
appendQuoted(out, '"')
if (ch == '"' || ch == '`') {
appendQuoted(out, ch)
continue
}
if (ch == '\'') {

View File

@ -33,6 +33,17 @@ data class Pos(val source: Source, val line: Int, val column: Int) {
if( end ) "EOF"
else if( line >= 0 ) source.lines[line] else "<no line information>"
val currentLineTrimmedStart: String get() = currentLine.trimStart()
val currentLineIndentWidth: Int
get() {
val lineText = currentLine
val firstNonWhitespace = lineText.indexOfFirst { !it.isWhitespace() }
return if (firstNonWhitespace >= 0) firstNonWhitespace else 0
}
val visualColumn: Int get() = (column - currentLineIndentWidth).coerceAtLeast(0)
val end: Boolean get() = line >= source.lines.size
companion object {

View File

@ -25,14 +25,15 @@ open class ScriptError(val pos: Pos, val errorMessage: String, cause: Throwable?
"""
$pos: Error: $errorMessage
${pos.currentLine}
${if( pos.column >= 0 ) "-".repeat(pos.column) + "^" else ""}
${pos.currentLineTrimmedStart}
${if( pos.column >= 0 ) "-".repeat(pos.visualColumn) + "^" else ""}
""".trimIndent(),
cause
)
class ScriptFlowIsNoMoreCollected: Exception()
class ExecutionError(val errorObject: Obj, pos: Pos, message: String) : ScriptError(pos, message)
class ExecutionError(val errorObject: Obj, pos: Pos, message: String, cause: Throwable? = null) :
ScriptError(pos, message, cause)
class ImportException(pos: Pos, message: String) : ScriptError(pos, message)

View File

@ -3650,11 +3650,14 @@ class BytecodeCompiler(
}
private fun compileIndexRef(ref: IndexRef): CompiledValue? {
val indexPos = refPosOrCurrent(ref.targetRef)
setPos(indexPos)
val receiver = compileRefWithFallback(ref.targetRef, null, Pos.builtIn) ?: return null
val elementSlotType = indexElementSlotType(receiver.slot, ref.targetRef)
val dst = allocSlot()
if (!ref.optionalRef) {
val index = compileRefWithFallback(ref.indexRef, null, Pos.builtIn) ?: return null
setPos(indexPos)
if (elementSlotType == SlotType.INT && index.type == SlotType.INT) {
builder.emit(Opcode.GET_INDEX_INT, receiver.slot, index.slot, dst)
updateSlotType(dst, SlotType.INT)

View File

@ -43,9 +43,10 @@ class CmdVm {
}
break
} catch (e: Throwable) {
if (!frame.handleException(e)) {
val throwable = frame.normalizeThrowable(e)
if (!frame.handleException(throwable)) {
frame.cancelIterators()
throw e
throw throwable
}
}
}
@ -4432,6 +4433,19 @@ class CmdFrame(
return scope
}
suspend fun normalizeThrowable(t: Throwable): Throwable {
if (t is ExecutionError || t is ReturnException || t is LoopBreakContinueException) return t
val parentScope = ensureScope()
val pos = (t as? ScriptError)?.pos ?: currentErrorPos() ?: parentScope.pos
val throwScope = parentScope.createChildScope(pos = pos)
val message = when (t) {
is ScriptError -> t.errorMessage
else -> t.message ?: t.toString()
}
val errorObject = ObjUnknownException(throwScope, message).apply { getStackTrace() }
return ExecutionError(errorObject, pos, message, t)
}
suspend fun handleException(t: Throwable): Boolean {
val handler = tryStack.lastOrNull() ?: return false
vmIterDebug {

View File

@ -16,6 +16,11 @@
*/
package net.sergeych.lyng.format
enum class LyngStringDelimiterPolicy {
Preserve,
PreferFewerEscapes,
}
/**
* Formatting configuration for Lyng source code.
* Defaults are Kotlin-like.
@ -28,6 +33,7 @@ data class LyngFormatConfig(
val applySpacing: Boolean = false,
val applyWrapping: Boolean = false,
val trailingComma: Boolean = false,
val stringDelimiterPolicy: LyngStringDelimiterPolicy = LyngStringDelimiterPolicy.Preserve,
) {
init {
require(indentSize > 0) { "indentSize must be > 0" }

View File

@ -274,7 +274,9 @@ object LyngFormatter {
fun format(text: String, config: LyngFormatConfig = LyngFormatConfig()): String {
// Phase 1: indentation
val indented = reindent(text, config)
if (!config.applySpacing && !config.applyWrapping) return indented
if (!config.applySpacing && !config.applyWrapping &&
config.stringDelimiterPolicy == LyngStringDelimiterPolicy.Preserve
) return indented
// Phase 2: minimal, safe spacing (PSI-free).
val lines = indented.split('\n')
@ -286,14 +288,27 @@ object LyngFormatter {
val (parts, nextInBlockComment) = splitIntoParts(rawLine, inBlockComment)
val sb = StringBuilder()
for (part in parts) {
if (part.type == PartType.Code) {
sb.append(applyMinimalSpacingRules(part.text))
} else {
sb.append(part.text)
val normalizedPart = when (part.type) {
PartType.Code -> if (config.applySpacing) applyMinimalSpacingRules(part.text) else part.text
PartType.StringLiteral -> applyStringLiteralPolicy(part.text, config.stringDelimiterPolicy)
else -> part.text
}
sb.append(normalizedPart)
}
line = sb.toString()
inBlockComment = nextInBlockComment
} else if (config.stringDelimiterPolicy != LyngStringDelimiterPolicy.Preserve) {
val (parts, nextInBlockComment) = splitIntoParts(rawLine, inBlockComment)
line = buildString(rawLine.length) {
for (part in parts) {
append(
if (part.type == PartType.StringLiteral) {
applyStringLiteralPolicy(part.text, config.stringDelimiterPolicy)
} else part.text
)
}
}
inBlockComment = nextInBlockComment
}
out.append(line.trimEnd())
if (i < lines.lastIndex) out.append('\n')
@ -463,6 +478,84 @@ object LyngFormatter {
private enum class PartType { Code, StringLiteral, BlockComment, LineComment }
private data class Part(val text: String, val type: PartType)
private fun applyStringLiteralPolicy(text: String, policy: LyngStringDelimiterPolicy): String {
if (policy == LyngStringDelimiterPolicy.Preserve) return text
if (text.length < 2) return text
val delimiter = text.first()
if (delimiter != '"' && delimiter != '`') return text
if (text.last() != delimiter) return text
val other = if (delimiter == '"') '`' else '"'
val rewritten = rewriteStringLiteralDelimiter(text, other) ?: return text
return when (policy) {
LyngStringDelimiterPolicy.Preserve -> text
LyngStringDelimiterPolicy.PreferFewerEscapes -> {
val currentCost = delimiterEscapeCost(text, delimiter)
val rewrittenCost = delimiterEscapeCost(rewritten, other)
if (rewrittenCost < currentCost) rewritten else text
}
}
}
private fun delimiterEscapeCost(text: String, delimiter: Char): Int {
var cost = 0
var i = 1
while (i < text.length - 1) {
val ch = text[i]
if (ch == '\\' && i + 1 < text.length - 1) {
val next = text[i + 1]
if (next == delimiter) cost++
i += 2
continue
}
if (ch == delimiter) cost++
i++
}
return cost
}
private fun rewriteStringLiteralDelimiter(text: String, targetDelimiter: Char): String? {
if (text.length < 2) return null
val sourceDelimiter = text.first()
if ((sourceDelimiter != '"' && sourceDelimiter != '`') || text.last() != sourceDelimiter) return null
if (sourceDelimiter == targetDelimiter) return text
val body = StringBuilder(text.length + 8)
var i = 1
val end = text.length - 1
while (i < end) {
val ch = text[i]
if (ch == '\\' && i + 1 < end) {
val next = text[i + 1]
when {
next == sourceDelimiter -> {
if (sourceDelimiter == targetDelimiter) body.append('\\').append(targetDelimiter)
else body.append(next)
i += 2
}
next == targetDelimiter -> {
body.append('\\').append('\\').append('\\').append(targetDelimiter)
i += 2
}
else -> {
body.append(ch).append(next)
i += 2
}
}
continue
}
if (ch == targetDelimiter) {
body.append('\\').append(targetDelimiter)
} else {
body.append(ch)
}
i++
}
return buildString(body.length + 2) {
append(targetDelimiter)
append(body)
append(targetDelimiter)
}
}
/**
* Split a line into parts: code, string literals, and comments.
* Tracks [inBlockComment] state across lines.
@ -514,7 +607,7 @@ private fun splitIntoParts(
inBlockComment = true
last = i
i += 2
} else if (text[i] == '"' || text[i] == '\'') {
} else if (text[i] == '"' || text[i] == '\'' || text[i] == '`') {
if (i > last) result.add(Part(text.substring(last, i), PartType.Code))
inString = true
quoteChar = text[i]

View File

@ -143,7 +143,10 @@ class SimpleLyngHighlighter : LyngHighlighter {
val k = kindOf(t.type, t.value) ?: continue
val start0 = src.offsetOf(t.pos)
val range = when (t.type) {
Type.STRING, Type.STRING2 -> adjustQuoteSpan(start0, '"')
Type.STRING, Type.STRING2 -> {
val quote = text.getOrNull(start0)?.takeIf { it == '"' || it == '`' } ?: '"'
adjustQuoteSpan(start0, quote)
}
Type.CHAR -> adjustQuoteSpan(start0, '\'')
Type.HEX -> {
// Parser returns HEX token value without the leading "0x"; include it in highlight span

View File

@ -530,7 +530,7 @@ object DocLookupUtils {
var inString = false
while (i < text.length) {
val ch = text[i]
if (ch == '"' && (i == 0 || text[i - 1] != '\\')) {
if ((ch == '"' || ch == '`') && (i == 0 || text[i - 1] != '\\')) {
inString = !inString
}
if (!inString && ch == '/' && i + 1 < text.length) {

View File

@ -106,16 +106,18 @@ open class ObjException(
val pos = s.pos
if (pos != lastPos && !pos.currentLine.isEmpty()) {
if (lastPos == null || (lastPos.source != pos.source || lastPos.line != pos.line)) {
val sourceLine = pos.currentLineTrimmedStart
val visualColumn = pos.visualColumn
val fallback =
ObjString("#${pos.source.objSourceName}:${pos.line+1}:${pos.column+1}: ${pos.currentLine}")
ObjString("#${pos.source.objSourceName}:${pos.line+1}:${visualColumn+1}: $sourceLine")
if (maybeCls != null) {
try {
result.list += maybeCls.callWithArgs(
scope,
pos.source.objSourceName,
ObjInt(pos.line.toLong()),
ObjInt(pos.column.toLong()),
ObjString(pos.currentLine)
ObjInt(visualColumn.toLong()),
ObjString(sourceLine)
)
} catch (e: Throwable) {
// Fallback textual entry if StackTraceEntry fails to instantiate

View File

@ -4395,6 +4395,30 @@ class ScriptTest {
)
}
@Test
fun backtickStringsMatchRegularStringSemantics() = runTest {
val d = '$'
eval(
"""
val name = "Lyng"
val simple = `hello, $d{name}`
assertEquals("hello, Lyng", simple)
assertEquals("{\"name\":\"Lyng\"}", `{"name":"Lyng"}`)
assertEquals("use the `code` style", `use the \`code\` style`)
assertEquals("\\\"", `\"`)
assertEquals("\"", `"`)
assertEquals(
"first\n\"second\"\nthird",
`
first
"second"
third
`
)
""".trimIndent()
)
}
@Test
fun testInlineArrayLiteral() = runTest {
eval(
@ -5237,6 +5261,35 @@ class ScriptTest {
)
}
@Test
fun testUnexpectedThrowablePreservesThrowSiteStackTrace() = runTest {
val caught = evalNamed(
"baderrorstack", """
fun boom() {
val arr = [10, 20, 30]
arr[10]
}
try {
boom()
"unreachable"
} catch (e) {
e
}
""".trimIndent()
)
val trace = caught.getLyngExceptionMessageWithStackTrace()
assertContains(trace, "\n at baderrorstack:3:1: arr[10]")
assertContains(trace, "\n at baderrorstack:6:1: boom()")
assertFalse(trace.contains("catch (e)"))
}
@Test
fun testScriptErrorMessageTrimsSourceIndent() = runTest {
val x = ScriptError(Pos(Source("trimraw", " arr[10]"), 0, 4), "boom")
assertContains(x.message!!, "\narr[10]\n")
assertFalse(x.message!!.contains("\n arr[10]\n"))
}
@Test
fun testMapIteralAmbiguity() = runTest {
eval(

View File

@ -31,6 +31,13 @@ class UnicodeEscapeTest {
assertEquals("", token.value)
}
@Test
fun parserDecodesUnicodeEscapeInBacktickStringLiteral() {
val token = parseLyng("`\\u263A`".toSource()).first()
assertEquals(Token.Type.STRING, token.type)
assertEquals("", token.value)
}
@Test
fun parserDecodesUnicodeEscapeInCharLiteral() {
val token = parseLyng("'\\u263A'".toSource()).first()
@ -55,6 +62,7 @@ class UnicodeEscapeTest {
@Test
fun evalDecodesUnicodeEscapes() = runTest {
assertEquals(ObjString(""), eval("\"\\u263A\""))
assertEquals(ObjString(""), eval("`\\u263A`"))
assertEquals(ObjChar('☺'), eval("'\\u263A'"))
}
}

View File

@ -22,6 +22,8 @@ import net.sergeych.lyng.bridge.globalBinder
import net.sergeych.lyng.obj.ObjVoid
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PrintlnOverrideTest {
@ -84,4 +86,46 @@ class PrintlnOverrideTest {
assertEquals(listOf("gb top level", "gb inside function"), output)
}
@Test
fun testExceptionPrintStackTraceFormatsPrimaryFrameBlock() = runTest {
val scope = Script.newScope()
val output = mutableListOf<String>()
scope.globalBinder().bindGlobalFun("println") {
val sb = StringBuilder()
for (i in 0 until args.size) {
if (i > 0) sb.append(" ")
sb.append(string(i))
}
output.add(sb.toString())
ObjVoid
}
scope.eval(
"""
fun boom() {
val arr = [10, 20, 30]
var a = 10
var b = arr[a]
b
}
try {
boom()
} catch (e) {
e.printStackTrace()
}
""".trimIndent()
)
assertTrue(output.isNotEmpty())
assertEquals(
"IndexOutOfBoundsException: Index 10 out of bounds for length 3 at eval:4:9:",
output[0]
)
assertEquals("var b = arr[a]", output[1])
assertEquals("--------^", output[2])
assertTrue(output.size >= 4)
assertFalse(output.any { it == " at eval:4:9: var b = arr[a]" })
}
}

View File

@ -18,9 +18,36 @@ package net.sergeych.lyng.format
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LyngFormatterTest {
@Test
fun preferBackticksForQuoteHeavyStrings() {
val src = "val json = \"{\\\"name\\\":\\\"lyng\\\",\\\"kind\\\":\\\"lang\\\"}\""
val cfg = LyngFormatConfig(
applySpacing = true,
stringDelimiterPolicy = LyngStringDelimiterPolicy.PreferFewerEscapes
)
val out = LyngFormatter.format(src, cfg)
assertEquals("""val json = `{"name":"lyng","kind":"lang"}`""", out)
assertEquals(out, LyngFormatter.format(out, cfg))
}
@Test
fun preserveStringsWhenAlternativeWouldNotHelp() {
val src = "val sample = \"use `ticks` and keep \\` literal\""
val cfg = LyngFormatConfig(
stringDelimiterPolicy = LyngStringDelimiterPolicy.PreferFewerEscapes
)
val out = LyngFormatter.format(src, cfg)
assertEquals(src, out)
}
@Test
fun labelFormatting() {
val src = "return @label; break @outer; continue @inner"
@ -79,9 +106,9 @@ class LyngFormatterTest {
val formatted = LyngFormatter.format(src, LyngFormatConfig(applyWrapping = true, maxLineLength = 40, continuationIndentSize = 4))
// Ensure the string literal remains intact
kotlin.test.assertTrue(formatted.contains(arg2), "String literal must be preserved")
assertTrue(formatted.contains(arg2), "String literal must be preserved")
// Ensure end-of-line comment remains
kotlin.test.assertTrue(formatted.contains("// end comment"), "EOL comment must be preserved")
assertTrue(formatted.contains("// end comment"), "EOL comment must be preserved")
// Idempotency
val formatted2 = LyngFormatter.format(formatted, LyngFormatConfig(applyWrapping = true, maxLineLength = 40, continuationIndentSize = 4))
assertEquals(formatted, formatted2)

View File

@ -17,4 +17,5 @@
package net.sergeych.lyng.obj
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null

View File

@ -17,4 +17,5 @@
package net.sergeych.lyng.obj
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null

View File

@ -441,9 +441,25 @@ static fun List<T>.fill(size: Int, block: (Int)->T): List<T> {
/* Print this exception and its stack trace to standard output. */
fun Exception.printStackTrace(): void {
println(this)
if( stackTrace.size == 0 ) {
println(this)
return
}
val first = stackTrace[0] as StackTraceEntry
var arrow = "^"
for( i in 0..<first.column ) {
arrow = "-" + arrow
}
println("%s: %s at %s:"(this::class.className, message, first.at))
println(first.sourceString)
println(arrow)
var skipFirst = true
for( entry in stackTrace ) {
println("\tat "+entry.toString())
if( skipFirst ) {
skipFirst = false
} else {
println(" at " + entry.toString())
}
}
}