added label syntax support and highlighting

This commit is contained in:
Sergey Chernov 2026-01-06 02:37:10 +01:00
parent 660a80a26b
commit 26b8370b01
12 changed files with 122 additions and 19 deletions

View File

@ -42,7 +42,7 @@
{ "name": "constant.numeric.decimal.lyng", "match": "(?<![A-Za-z_])(?:[0-9][0-9_]*)\\.(?:[0-9_]+)(?:[eE][+-]?[0-9_]+)?|(?<![A-Za-z_])(?:[0-9][0-9_]*)(?:[eE][+-]?[0-9_]+)?" }
]
},
"annotations": { "patterns": [ { "name": "entity.name.label.at.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*:" }, { "name": "storage.modifier.annotation.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*" } ] },
"annotations": { "patterns": [ { "name": "entity.name.label.at.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*" } ] },
"mapLiterals": {
"patterns": [
{
@ -74,7 +74,7 @@
}
]
},
"labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*:" } ] },
"labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*@" } ] },
"directives": { "patterns": [ { "name": "meta.directive.lyng", "match": "^\\s*#[_A-Za-z][_A-Za-z0-9]*" } ] },
"declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(fun|fn)\\s+(?:([\\p{L}_][\\p{L}\\p{N}_]*)\\.)?([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "keyword.declaration.lyng" }, "2": { "name": "entity.name.type.lyng" }, "3": { "name": "entity.name.function.lyng" } } }, { "name": "meta.type.declaration.lyng", "match": "\\b(?:class|enum|interface|object)(?:\\s+([\\p{L}_][\\p{L}\\p{N}_]*))?", "captures": { "1": { "name": "entity.name.type.lyng" } } }, { "name": "meta.variable.declaration.lyng", "match": "\\b(val|var)\\s+(?:([\\p{L}_][\\p{L}\\p{N}_]*)\\.)?([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "keyword.declaration.lyng" }, "2": { "name": "entity.name.type.lyng" }, "3": { "name": "variable.other.declaration.lyng" } } } ] },
"keywords": { "patterns": [ { "name": "keyword.control.lyng", "match": "\\b(?:if|else|when|while|do|for|try|catch|finally|throw|return|break|continue)\\b" }, { "name": "keyword.declaration.lyng", "match": "\\b(?:fun|fn|class|enum|interface|val|var|import|package|constructor|property|abstract|override|open|closed|extern|private|protected|static|get|set|object|init|by)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\bnot\\s+(?:in|is)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\b(?:and|or|not|in|is|as|as\\?)\\b" } ] },

View File

@ -94,6 +94,16 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
val out = ArrayList<Span>(256)
fun isFollowedByParenOrBlock(rangeEnd: Int): Boolean {
var i = rangeEnd
while (i < text.length) {
val ch = text[i]
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { i++; continue }
return ch == '(' || ch == '{'
}
return false
}
fun putRange(start: Int, end: Int, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
if (start in 0..end && end <= text.length && start < end) out += Span(start, end, key)
}
@ -207,16 +217,6 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
fun isFollowedByParenOrBlock(rangeEnd: Int): Boolean {
var i = rangeEnd
while (i < text.length) {
val ch = text[i]
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { i++; continue }
return ch == '(' || ch == '{'
}
return false
}
// Build simple name -> role map for top-level vals/vars and parameters
val nameRole = HashMap<String, com.intellij.openapi.editor.colors.TextAttributesKey>(8)
for (d in mini.declarations) when (d) {
@ -253,7 +253,7 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
if (e is com.intellij.openapi.progress.ProcessCanceledException) throw e
}
// Add annotation coloring using token highlighter (treat @Label as annotation)
// Add annotation/label coloring using token highlighter
run {
val tokens = try { SimpleLyngHighlighter().highlight(text) } catch (_: Throwable) { emptyList() }
for (s in tokens) if (s.kind == HighlightKind.Label) {
@ -261,8 +261,29 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
val end = s.range.endExclusive
if (start in 0..end && end <= text.length && start < end) {
val lexeme = try { text.substring(start, end) } catch (_: Throwable) { null }
if (lexeme != null && lexeme.startsWith("@")) {
putRange(start, end, LyngHighlighterColors.ANNOTATION)
if (lexeme != null) {
// Heuristic: if it starts with @ and follows a control keyword, it's likely a label
// Otherwise if it starts with @ it's an annotation.
// If it ends with @ it's a loop label.
when {
lexeme.endsWith("@") -> putRange(start, end, LyngHighlighterColors.LABEL)
lexeme.startsWith("@") -> {
// Try to see if it's an exit label
val prevNonWs = prevNonWs(text, start)
val prevWord = if (prevNonWs >= 0) {
var wEnd = prevNonWs + 1
var wStart = prevNonWs
while (wStart > 0 && text[wStart - 1].isLetter()) wStart--
text.substring(wStart, wEnd)
} else null
if (prevWord in setOf("return", "break", "continue") || isFollowedByParenOrBlock(end)) {
putRange(start, end, LyngHighlighterColors.LABEL)
} else {
putRange(start, end, LyngHighlighterColors.ANNOTATION)
}
}
}
}
}
}
@ -364,6 +385,16 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
private val CACHE_KEY: Key<Result> = Key.create("LYNG_SEMANTIC_CACHE")
}
private fun prevNonWs(text: String, idxExclusive: Int): Int {
var i = idxExclusive - 1
while (i >= 0) {
val ch = text[i]
if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') return i
i--
}
return -1
}
/**
* Make the error highlight a bit wider than a single character so it is easier to see and click.
* Strategy:

View File

@ -43,10 +43,15 @@ class LyngColorSettingsPage : ColorSettingsPage {
}
var counter = 0
counter = counter + 1
outer@ while (counter < 10) {
if (counter == 5) return@outer
counter = counter + 1
}
""".trimIndent()
override fun getAdditionalHighlightingTagToDescriptorMap(): MutableMap<String, TextAttributesKey>? = null
override fun getAdditionalHighlightingTagToDescriptorMap(): MutableMap<String, TextAttributesKey> = mutableMapOf(
"label" to LyngHighlighterColors.LABEL
)
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = arrayOf(
AttributesDescriptor("Keyword", LyngHighlighterColors.KEYWORD),
@ -58,6 +63,7 @@ class LyngColorSettingsPage : ColorSettingsPage {
AttributesDescriptor("Punctuation", LyngHighlighterColors.PUNCT),
// Semantic
AttributesDescriptor("Annotation (semantic)", LyngHighlighterColors.ANNOTATION),
AttributesDescriptor("Label (semantic)", LyngHighlighterColors.LABEL),
AttributesDescriptor("Variable (semantic)", LyngHighlighterColors.VARIABLE),
AttributesDescriptor("Value (semantic)", LyngHighlighterColors.VALUE),
AttributesDescriptor("Function (semantic)", LyngHighlighterColors.FUNCTION),

View File

@ -82,4 +82,9 @@ object LyngHighlighterColors {
val ENUM_CONSTANT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_ENUM_CONSTANT", DefaultLanguageHighlighterColors.STATIC_FIELD
)
// Labels (label@ or @label used as exit target)
val LABEL: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
"LYNG_LABEL", DefaultLanguageHighlighterColors.LABEL
)
}

View File

@ -133,10 +133,24 @@ class LyngLexer : LexerBase() {
return
}
// Identifier / keyword
// Labels / Annotations: @label or label@
if (ch == '@') {
i++
while (i < endOffset && (buffer[i].isIdentifierPart())) i++
myTokenEnd = i
myTokenType = LyngTokenTypes.LABEL
return
}
if (ch.isIdentifierStart()) {
i++
while (i < endOffset && buffer[i].isIdentifierPart()) i++
if (i < endOffset && buffer[i] == '@') {
i++
myTokenEnd = i
myTokenType = LyngTokenTypes.LABEL
return
}
myTokenEnd = i
val text = buffer.subSequence(myTokenStart, myTokenEnd).toString()
myTokenType = if (text in keywords) LyngTokenTypes.KEYWORD else LyngTokenTypes.IDENTIFIER

View File

@ -33,6 +33,7 @@ class LyngSyntaxHighlighter : SyntaxHighlighter {
LyngTokenTypes.BLOCK_COMMENT -> pack(LyngHighlighterColors.BLOCK_COMMENT)
LyngTokenTypes.PUNCT -> pack(LyngHighlighterColors.PUNCT)
LyngTokenTypes.IDENTIFIER -> pack(LyngHighlighterColors.IDENTIFIER)
LyngTokenTypes.LABEL -> pack(LyngHighlighterColors.LABEL)
else -> emptyArray()
}

View File

@ -29,6 +29,7 @@ object LyngTokenTypes {
val NUMBER = LyngTokenType("NUMBER")
val KEYWORD = LyngTokenType("KEYWORD")
val IDENTIFIER = LyngTokenType("IDENTIFIER")
val LABEL = LyngTokenType("LABEL")
val PUNCT = LyngTokenType("PUNCT")
val BAD_CHAR = LyngTokenType("BAD_CHAR")
}

View File

@ -477,7 +477,7 @@ private fun splitIntoParts(
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 (")
s = s.replace(Regex("\\b(if|for|while|return|break|continue)\\("), "$1 (")
// Space before '{' for control-flow headers only (avoid function declarations)
s = s.replace(Regex("\\b(if|for|while)(\\s*\\([^)]*\\))\\s*\\{"), "$1$2 {")
s = s.replace(Regex("\\belse\\s+if(\\s*\\([^)]*\\))\\s*\\{"), "else if$1 {")
@ -498,6 +498,8 @@ private fun applyMinimalSpacingRules(code: String): String {
s = s.replace(Regex("(\\bcatch\\s*\\([^)]*\\))\\s*\\{"), "$1 {")
// Ensure space before '(' for catch parameter
s = s.replace(Regex("\\bcatch\\("), "catch (")
// Remove space between control keyword and label: return @label -> return@label
s = s.replace(Regex("\\b(return|break|continue)\\s+(@[\\p{L}_][\\p{L}\\p{N}_]*)"), "$1$2")
// Remove spaces just inside parentheses/brackets: "( a )" -> "(a)"
s = s.replace(Regex("\\(\\s+"), "(")
// Do not strip leading indentation before a closing bracket/paren on its own line

View File

@ -21,6 +21,15 @@ import kotlin.test.assertEquals
class LyngFormatterTest {
@Test
fun labelFormatting() {
val src = "return @label; break @outer; continue @inner"
val expected = "return@label; break@outer; continue@inner"
val cfg = LyngFormatConfig(applySpacing = true)
val out = LyngFormatter.format(src, cfg)
assertEquals(expected, out)
}
@Test
fun reindent_simpleFunction() {
val src = """

View File

@ -25,6 +25,16 @@ class HighlightMappingTest {
private fun spansToLabeled(text: String, spans: List<HighlightSpan>): List<Pair<String, HighlightKind>> =
spans.map { text.substring(it.range.start, it.range.endExclusive) to it.kind }
@Test
fun returnAndExits() {
val text = "return 42; break@outer null"
val spans = SimpleLyngHighlighter().highlight(text)
val labeled = spansToLabeled(text, spans)
assertTrue(labeled.any { it.first == "return" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "break" && it.second == HighlightKind.Keyword })
assertTrue(labeled.any { it.first == "@outer" && it.second == HighlightKind.Label })
}
@Test
fun keywordsAndIdentifiers() {
val text = "a and b or not c"

View File

@ -77,6 +77,14 @@ fun HomePage() {
// Create, transform, and verify — the Lyng way
import lyng.stdlib
fun findFirstPositive(list) {
list.forEach {
if (it > 0) return@findFirstPositive it
}
null
}
assertEquals(42, findFirstPositive([-1, 42, -5]))
val data = 1..5 // or [1,2,3,4,5]
val evens2 = data.filter { it % 2 == 0 }.map { it * it }
assertEquals([4, 16], evens2)

View File

@ -20,6 +20,22 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LyngHighlightTest {
@Test
fun highlightsReturnAndLabels() {
val md = """
```lyng
return 42
break@outer null
return@fn val
```
""".trimIndent()
val html = renderMarkdown(md)
assertTrue(html.contains("hl-kw"), "Expected keyword class for 'return': $html")
assertTrue(html.contains("hl-lbl") || html.contains("hl-ann"), "Expected label/annotation class for @outer/@fn: $html")
assertTrue(html.contains("&gt;&gt;&gt;").xor(true), "Should not contain prompt marker unless expected")
}
@Test
fun highlightsLyngFencedBlock() {
val md = """