Add backtick string literals and formatter support

This commit is contained in:
sergeych committed 2026-04-13 23:08:18 +03:00
1 parent e925195495
commit fada848907
15 files changed
+273 -32

No files matched your search

@@ -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 == '\'') {
@@ -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" }
@@ -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]
@@ -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
@@ -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) {
@@ -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(
@@ -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'"))
}
}
@@ -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)