diff --git a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json index 644446d..9062cf4 100644 --- a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json +++ b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json @@ -42,7 +42,7 @@ { "name": "constant.numeric.decimal.lyng", "match": "(?(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 role map for top-level vals/vars and parameters val nameRole = HashMap(8) for (d in mini.declarations) when (d) { @@ -253,7 +253,7 @@ class LyngExternalAnnotator : ExternalAnnotator 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 = 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: diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt index 1226ce2..7029bce 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngColorSettingsPage.kt @@ -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? = null + override fun getAdditionalHighlightingTagToDescriptorMap(): MutableMap = mutableMapOf( + "label" to LyngHighlighterColors.LABEL + ) override fun getAttributeDescriptors(): Array = 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), diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt index 4714224..45d7501 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngHighlighterColors.kt @@ -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 + ) } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt index 5e04ed8..2abc9ac 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt @@ -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 diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt index f7341cd..dfb4a66 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngSyntaxHighlighter.kt @@ -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() } diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt index 9cfdd35..5fdaf17 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngTokenTypes.kt @@ -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") } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt index b55c544..ff6e905 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/format/LyngFormatter.kt @@ -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 diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt index 859c911..cc5f3e7 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/format/LyngFormatterTest.kt @@ -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 = """ diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/HighlightMappingTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/HighlightMappingTest.kt index 579f53f..a4a1a04 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/HighlightMappingTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/HighlightMappingTest.kt @@ -25,6 +25,16 @@ class HighlightMappingTest { private fun spansToLabeled(text: String, spans: List): List> = 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" diff --git a/site/src/jsMain/kotlin/HomePage.kt b/site/src/jsMain/kotlin/HomePage.kt index 7f31cbd..9042878 100644 --- a/site/src/jsMain/kotlin/HomePage.kt +++ b/site/src/jsMain/kotlin/HomePage.kt @@ -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) diff --git a/site/src/jsTest/kotlin/LyngHighlightTest.kt b/site/src/jsTest/kotlin/LyngHighlightTest.kt index 1bb9857..1383e09 100644 --- a/site/src/jsTest/kotlin/LyngHighlightTest.kt +++ b/site/src/jsTest/kotlin/LyngHighlightTest.kt @@ -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(">>>").xor(true), "Should not contain prompt marker unless expected") + } + @Test fun highlightsLyngFencedBlock() { val md = """