From 31fac1a73c7a10a780822a0f7d5fce75acef67c1 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 30 Apr 2026 09:50:10 +0300 Subject: [PATCH] site: fixed .md/lyng code display --- docs/lyng.io.http.server.md | 2 +- .../lyng/highlight/SimpleLyngHighlighter.kt | 26 ++++++++++++-- .../lyng/highlight/HighlightMappingTest.kt | 35 ++++++++++++++++++- site/src/jsTest/kotlin/LyngHighlightTest.kt | 24 ++++++++++++- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/docs/lyng.io.http.server.md b/docs/lyng.io.http.server.md index 8c5dfed..63cbb8c 100644 --- a/docs/lyng.io.http.server.md +++ b/docs/lyng.io.http.server.md @@ -63,7 +63,7 @@ server.get("/") { } body { h3 { +"Service is running" } - p { +("Path: ${request.path}" } + p { +"Path: ${request.path}" } } } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt index 9feb887..9d0447d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt @@ -117,6 +117,28 @@ private fun mergeAdjacent(spans: List): List { return out } +/** + * The parser expands interpolated strings into expression-like token streams whose + * synthetic tokens share source positions with the original string. Keep the + * widest source span at each offset and drop nested overlaps so renderers can + * rely on the public non-overlapping span contract. + */ +private fun removeOverlappingSpans(spans: List): List { + if (spans.size < 2) return spans + val sorted = spans.sortedWith( + compareBy { it.range.start } + .thenByDescending { it.range.endExclusive - it.range.start } + ) + val out = ArrayList(sorted.size) + var coveredUntil = -1 + for (span in sorted) { + if (span.range.start < coveredUntil) continue + out += span + coveredUntil = span.range.endExclusive + } + return out +} + /** Simple highlighter using the existing Lyng lexer (no incremental support yet). */ class SimpleLyngHighlighter : LyngHighlighter { override fun highlight(text: String): List { @@ -170,8 +192,8 @@ class SimpleLyngHighlighter : LyngHighlighter { val overridden = applyEnumConstantHeuristics(text, src, tokens, raw) // Adjust single-line comment spans to extend till EOL to compensate for lexer offset/length quirks val adjusted = extendSingleLineCommentsToEol(text, overridden) - // Spans are in order; merge adjacent of the same kind for compactness - return mergeAdjacent(adjusted) + // Normalize spans, then merge adjacent spans of the same kind for compactness. + return mergeAdjacent(removeOverlappingSpans(adjusted)) } } 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 a4a1a04..0b4e9e0 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/HighlightMappingTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/HighlightMappingTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * 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. @@ -18,6 +18,7 @@ package net.sergeych.lyng.highlight import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class HighlightMappingTest { @@ -72,6 +73,38 @@ class HighlightMappingTest { assertTrue(labeled.any { it.first == "\"s\"" && it.second == HighlightKind.String }) } + @Test + fun interpolatedStringSpansDoNotOverlap() { + val text = """p { +"Path: ${'$'}{request.path}" }""" + val spans = SimpleLyngHighlighter().highlight(text) + spans.zipWithNext().forEach { (a, b) -> + assertTrue( + a.range.endExclusive <= b.range.start, + "Highlight spans must not overlap: $a then $b in $spans" + ) + } + assertTrue( + spansToLabeled(text, spans).any { + it.first == "\"Path: ${'$'}{request.path}\"" && it.second == HighlightKind.String + } + ) + } + + @Test + fun interpolatedStringRenderingDoesNotDuplicateText() { + val text = """p { +"Path: ${'$'}{request.path}" }""" + val rendered = buildString { + var pos = 0 + for (span in SimpleLyngHighlighter().highlight(text)) { + if (span.range.start > pos) append(text.substring(pos, span.range.start)) + append(text.substring(span.range.start, span.range.endExclusive)) + pos = span.range.endExclusive + } + if (pos < text.length) append(text.substring(pos)) + } + assertEquals(text, rendered) + } + @Test fun commentsHighlighted() { val text = "// line\n/* block */" diff --git a/site/src/jsTest/kotlin/LyngHighlightTest.kt b/site/src/jsTest/kotlin/LyngHighlightTest.kt index 1383e09..016a17e 100644 --- a/site/src/jsTest/kotlin/LyngHighlightTest.kt +++ b/site/src/jsTest/kotlin/LyngHighlightTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * 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. @@ -16,6 +16,7 @@ */ import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -76,4 +77,25 @@ class LyngHighlightTest { // the '<' should be escaped in HTML assertTrue(html.contains("<"), "Expected escaped < inside highlighted HTML: $html") } + + @Test + fun rendersInterpolatedStringOnce() { + val md = """ + ```lyng + p { +"Path: ${'$'}{request.path}" } + ``` + """.trimIndent() + val html = renderMarkdown(md) + + assertEquals( + 1, + Regex("Path:").findAll(html).count(), + "Rendered markdown duplicated string content: $html" + ) + assertEquals( + 1, + Regex("request\\.path").findAll(html).count(), + "Rendered markdown duplicated interpolation content: $html" + ) + } }