lynglib: added MiniAST support

lyngweb: better syntax highlighting and code editor
This commit is contained in:
sergeych committed 2025-11-24 00:38:17 +01:00
1 parent 28b961d339
commit ea0ecb1db3
24 files changed
+3095 -218

No files matched your search

+10
View File
@@ -33,10 +33,20 @@ version = "0.0.1-SNAPSHOT"
kotlin {
js(IR) {
browser {
testTask {
useKarma {
useChromeHeadless()
}
}
commonWebpackConfig {
cssSupport { enabled.set(true) }
}
}
nodejs {
testTask {
useMocha()
}
}
binaries.library()
}
@@ -104,6 +104,12 @@ fun EditorWithOverlay(
// Update overlay HTML whenever code changes
LaunchedEffect(code) {
fun clamp(i: Int, lo: Int, hi: Int): Int = if (i < lo) lo else if (i > hi) hi else i
fun safeSubstring(text: String, start: Int, end: Int): String {
val s = clamp(start, 0, text.length)
val e = clamp(end, 0, text.length)
return if (e <= s) "" else text.substring(s, e)
}
fun htmlEscapeLocal(s: String): String = buildString(s.length) {
for (ch in s) when (ch) {
'<' -> append("&lt;")
@@ -125,9 +131,9 @@ fun EditorWithOverlay(
while (i < n && textCount < prefixChars) {
val ch = html[i]
if (ch == '<') {
val close = html.indexOf('>', i)
val close = html.indexOf('>', i).let { if (it < 0) n - 1 else it }
if (close == -1) break
val tag = html.substring(i, close + 1)
val tag = safeSubstring(html, i, close + 1)
out.append(tag)
val tagLower = tag.lowercase()
if (tagLower.startsWith("<span")) {
@@ -135,13 +141,13 @@ fun EditorWithOverlay(
} else if (tagLower.startsWith("</span")) {
if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex)
}
i = close + 1
i = (close + 1).coerceAtMost(n)
} else if (ch == '&') {
val semi = html.indexOf(';', i + 1).let { if (it == -1) n - 1 else it }
val entity = html.substring(i, semi + 1)
val entity = safeSubstring(html, i, semi + 1)
out.append(entity)
textCount += 1
i = semi + 1
i = (semi + 1).coerceAtMost(n)
} else {
out.append(ch)
textCount += 1
@@ -156,7 +162,8 @@ fun EditorWithOverlay(
html + "<span data-sentinel=\"1\">&#8203;</span>"
try {
val html = SiteHighlight.renderHtml(code)
// Prefer AST-backed highlighting for precise roles; it gracefully falls back.
val html = SiteHighlight.renderHtmlAsync(code)
overlayEl?.innerHTML = appendSentinel(html)
lastGoodHtml = html
lastGoodText = code
@@ -276,36 +283,41 @@ fun EditorWithOverlay(
ev.preventDefault()
return@onKeyDown
}
if (key == "Tab") {
if (key == "Tab" && ev.shiftKey) {
// Shift+Tab: outdent current line(s)
ev.preventDefault()
val current = ta.value
val selStart = ta.selectionStart ?: 0
val selEnd = ta.selectionEnd ?: selStart
val res = applyShiftTab(current, selStart, selEnd, tabSize)
setCode(res.text)
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
} else if (key == "Tab") {
ev.preventDefault()
val start = ta.selectionStart ?: 0
val end = ta.selectionEnd ?: start
val current = ta.value
val before = current.substring(0, start)
val after = current.substring(end)
val spaces = " ".repeat(tabSize)
val updated = before + spaces + after
pendingSelStart = start + spaces.length
pendingSelEnd = pendingSelStart
setCode(updated)
val res = applyTab(current, start, end, tabSize)
// Update code first
setCode(res.text)
// Apply selection synchronously to avoid race with next key events
try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {}
// Keep pending selection as a fallback for compose recompose
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
} else if (key == "Enter") {
// Smart indent: copy leading spaces from current line
val start = ta.selectionStart ?: 0
val cur = ta.value
val lineStart = run {
var i = start - 1
while (i >= 0 && cur[i] != '\n') i--
i + 1
}
var indent = 0
while (lineStart + indent < cur.length && cur[lineStart + indent] == ' ') indent++
val before = cur.substring(0, start)
val after = cur.substring(start)
val insertion = "\n" + " ".repeat(indent)
pendingSelStart = start + insertion.length
pendingSelEnd = pendingSelStart
setCode(before + insertion + after)
// Smart indent / outdent around braces
ev.preventDefault()
val start = ta.selectionStart ?: 0
val endSel = ta.selectionEnd ?: start
val cur = ta.value
val res = applyEnter(cur, start, endSel, tabSize)
setCode(res.text)
// Apply selection synchronously to ensure caret is where logic expects
try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {}
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
}
}
@@ -0,0 +1,425 @@
/*
* Copyright 2025 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.
*
*/
/*
* Lightweight, pure editing helpers for the browser editor. These functions contain
* no DOM calls and are suitable for unit testing in jsTest.
*/
package net.sergeych.lyngweb
data class EditResult(val text: String, val selStart: Int, val selEnd: Int)
private fun clamp(i: Int, lo: Int, hi: Int): Int = if (i < lo) lo else if (i > hi) hi else i
private fun safeSubstring(text: String, start: Int, end: Int): String {
val s = clamp(start, 0, text.length)
val e = clamp(end, 0, text.length)
return if (e <= s) "" else text.substring(s, e)
}
private fun lineStartAt(text: String, idx: Int): Int {
var i = idx - 1
while (i >= 0 && text[i] != '\n') i--
return i + 1
}
private fun lineEndAt(text: String, idx: Int): Int {
var i = idx
while (i < text.length && text[i] != '\n') i++
return i
}
private fun countIndentSpaces(text: String, lineStart: Int, lineEnd: Int): Int {
var i = lineStart
// Tolerate optional CR at the start of the line (Windows newlines in some environments)
if (i < lineEnd && text[i] == '\r') i++
var n = 0
while (i < lineEnd && text[i] == ' ') { i++; n++ }
return n
}
private fun lastNonWsInLine(text: String, lineStart: Int, lineEnd: Int): Int {
var i = lineEnd - 1
while (i >= lineStart) {
val ch = text[i]
if (ch != ' ' && ch != '\t' && ch != '\r' && ch != '\n') return i
i--
}
return -1
}
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
}
private fun nextNonWs(text: String, idxInclusive: Int): Int {
var i = idxInclusive
while (i < text.length) {
val ch = text[i]
if (ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r') return i
i++
}
return -1
}
/** Apply Enter key behavior with smart indent/undent rules. */
fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
// Global early rule (Rule 3): if the line after the current line is brace-only, dedent that line by one block and
// do NOT insert a newline. This uses precise line boundaries.
run {
val start = minOf(selStart, text.length)
val eol = lineEndAt(text, start)
val nextLineStart = if (eol < text.length && text[eol] == '\n') eol + 1 else eol
val nextLineEnd = lineEndAt(text, nextLineStart)
if (nextLineStart <= nextLineEnd && nextLineStart < text.length) {
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// Absolute top-priority: caret is exactly at a line break and the next line is '}'-only (ignoring spaces).
// Dedent that next line by one block (tabSize) and do NOT insert a newline.
run {
if (selStart < text.length) {
val isCrLf = selStart + 1 < text.length && text[selStart] == '\r' && text[selStart + 1] == '\n'
val isLf = text[selStart] == '\n'
if (isCrLf || isLf) {
val nextLineStart = selStart + if (isCrLf) 2 else 1
val nextLineEnd = lineEndAt(text, nextLineStart)
if (nextLineStart <= nextLineEnd) {
val trimmed = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmed == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
}
}
// If there is a selection, replace it by newline + current line indent
if (selEnd != selStart) {
val lineStart = lineStartAt(text, selStart)
val lineEnd = lineEndAt(text, selStart)
val indent = countIndentSpaces(text, lineStart, lineEnd)
val insertion = "\n" + " ".repeat(indent)
val out = safeSubstring(text, 0, selStart) + insertion + safeSubstring(text, selEnd, text.length)
val caret = selStart + insertion.length
return EditResult(out, caret, caret)
}
val start = selStart
val lineStart = lineStartAt(text, start)
val lineEnd = lineEndAt(text, start)
val indent = countIndentSpaces(text, lineStart, lineEnd)
// (Handled by the global early rule above; no need for additional EOL variants.)
// Compute neighborhood characters early so rule precedence can use them
val prevIdx = prevNonWs(text, start)
val nextIdx = nextNonWs(text, start)
val prevCh = if (prevIdx >= 0) text[prevIdx] else '\u0000'
val nextCh = if (nextIdx >= 0) text[nextIdx] else '\u0000'
val before = text.substring(0, start)
val after = text.substring(start)
// Rule 2: On a brace-only line '}' (caret on the same line)
// If the current line’s trimmed text is exactly '}', decrease that line’s indent by one block (not below 0),
// then insert a newline. The newly inserted line uses the (decreased) indent. Place the caret at the start of
// the newly inserted line.
run {
val trimmed = text.substring(lineStart, lineEnd).trim()
// IMPORTANT: Rule precedence — do NOT trigger this rule when caret is after '}' and the rest of the line
// up to EOL contains only spaces (Rule 5 handles that case). That scenario must insert AFTER the brace,
// not before it.
var onlySpacesAfterCaret = true
var k = start
while (k < lineEnd) { if (text[k] != ' ') { onlySpacesAfterCaret = false; break }; k++ }
val rule5Situation = (prevCh == '}') && onlySpacesAfterCaret
if (trimmed == "}" && !rule5Situation) {
val removeCount = kotlin.math.min(tabSize, indent)
val newIndent = (indent - removeCount).coerceAtLeast(0)
val crShift = if (lineStart < text.length && text[lineStart] == '\r') 1 else 0
val out = buildString(text.length + 1 + newIndent) {
append(safeSubstring(text, 0, lineStart))
append("\n")
append(" ".repeat(newIndent))
// Write the brace line but with its indent reduced by removeCount spaces
append(safeSubstring(text, lineStart + crShift + removeCount, text.length))
}
val caret = lineStart + 1 + newIndent
return EditResult(out, caret, caret)
}
}
// (The special case of caret after the last non-ws on a '}'-only line is covered by the rule above.)
// 0) Caret is at end-of-line and the next line is a closing brace-only line: dedent that line, no extra newline
run {
val atCr = start + 1 < text.length && text[start] == '\r' && text[start + 1] == '\n'
val atNl = start < text.length && text[start] == '\n'
val atEol = atNl || atCr
if (atEol) {
val nlAdvance = if (atCr) 2 else 1
val nextLineStart = start + nlAdvance
val nextLineEnd = lineEndAt(text, nextLineStart)
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
// Dedent the '}' line by one block level (tabSize), but not below column 0
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = start + nlAdvance + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// 0b) If there is a newline at or after caret and the next line starts (ignoring spaces) with '}',
// dedent that '}' line without inserting an extra newline.
run {
val nlPos = text.indexOf('\n', start)
if (nlPos >= 0) {
val nextLineStart = nlPos + 1
val nextLineEnd = lineEndAt(text, nextLineStart)
val nextLineFirstNonWs = nextNonWs(text, nextLineStart)
if (nextLineFirstNonWs in nextLineStart until nextLineEnd && text[nextLineFirstNonWs] == '}') {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// 1) Between braces { | } -> two lines, inner indented
if (prevCh == '{' && nextCh == '}') {
val innerIndent = indent + tabSize
val insertion = "\n" + " ".repeat(innerIndent) + "\n" + " ".repeat(indent)
val out = before + insertion + after
val caret = start + 1 + innerIndent
return EditResult(out, caret, caret)
}
// 2) After '{'
if (prevCh == '{') {
val insertion = "\n" + " ".repeat(indent + tabSize)
val out = before + insertion + after
val caret = start + insertion.length
return EditResult(out, caret, caret)
}
// 3) Before '}'
if (nextCh == '}') {
// We want two things:
// - reduce indentation of the upcoming '}' line by one level
// - avoid creating an extra blank line if caret is already at EOL (the next char is a newline)
// Compute where the '}' line starts and how many leading spaces it has
val rbraceLineStart = lineStartAt(text, nextIdx)
val rbraceLineEnd = lineEndAt(text, nextIdx)
val rbraceIndent = countIndentSpaces(text, rbraceLineStart, rbraceLineEnd)
// Dedent the '}' line by one block level (tabSize), but not below column 0
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (rbraceLineStart < text.length && text[rbraceLineStart] == '\r') 1 else 0
// If there is already a newline between caret and the '}', do NOT insert another newline.
// Just dedent the existing '}' line by one block and place caret at its start.
run {
val nlBetween = text.indexOf('\n', start)
if (nlBetween in start until rbraceLineStart) {
val out = buildString(text.length) {
append(safeSubstring(text, 0, rbraceLineStart))
append(safeSubstring(text, rbraceLineStart + crShift + removeCount, text.length))
}
val caret = rbraceLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
val hasNewlineAtCaret = (start < text.length && text[start] == '\n')
// New indentation for the line we create (if we actually insert one now)
val newLineIndent = (indent - tabSize).coerceAtLeast(0)
val insertion = if (hasNewlineAtCaret) "" else "\n" + " ".repeat(newLineIndent)
val out = buildString(text.length + insertion.length) {
append(before)
append(insertion)
// keep text up to the start of '}' line
append(safeSubstring(text, start, rbraceLineStart))
// drop up to tabSize spaces before '}'
append(safeSubstring(text, rbraceLineStart + crShift + removeCount, text.length))
}
val caret = if (hasNewlineAtCaret) {
// Caret moves to the beginning of the '}' line after dedent (right after the single newline)
start + 1 + kotlin.math.max(0, rbraceIndent - removeCount)
} else {
start + insertion.length
}
return EditResult(out, caret, caret)
}
// 4) After '}' with only trailing spaces before EOL
// According to Rule 5: if the last non-whitespace before the caret is '}' and
// only spaces remain until EOL, we must:
// - dedent the current (brace) line by one block (not below 0)
// - insert a newline just AFTER '}' (do NOT move caret backward)
// - set the caret at the start of the newly inserted blank line, whose indent equals the dedented indent
if (prevCh == '}') {
var onlySpaces = true
var k = prevIdx + 1
while (k < lineEnd) { if (text[k] != ' ') { onlySpaces = false; break }; k++ }
if (onlySpaces) {
val removeCount = kotlin.math.min(tabSize, indent)
val newIndent = (indent - removeCount).coerceAtLeast(0)
val crShift = if (lineStart < text.length && text[lineStart] == '\r') 1 else 0
// Build the result:
// - keep everything before the line start
// - write the current line content up to the caret, but with its left indent reduced by removeCount
// - insert newline + spaces(newIndent)
// - drop trailing spaces after caret up to EOL
// - keep the rest of the text starting from EOL
val out = buildString(text.length) {
append(safeSubstring(text, 0, lineStart))
append(safeSubstring(text, lineStart + crShift + removeCount, start))
append("\n")
append(" ".repeat(newIndent))
append(safeSubstring(text, lineEnd, text.length))
}
val caret = (lineStart + (start - (lineStart + crShift + removeCount)) + 1 + newIndent)
return EditResult(out, caret, caret)
} else {
// Default smart indent for cases where there are non-space characters after '}'
val insertion = "\n" + " ".repeat(indent)
val out = before + insertion + after
val caret = start + insertion.length
return EditResult(out, caret, caret)
}
}
// 5) Fallback: if there is a newline ahead and the next line, trimmed, equals '}', dedent that '}' line by one block
run {
val nlPos = text.indexOf('\n', start)
if (nlPos >= 0) {
val nextLineStart = nlPos + 1
val nextLineEnd = lineEndAt(text, nextLineStart)
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// default keep same indent
run {
val insertion = "\n" + " ".repeat(indent)
val out = before + insertion + after
val caret = start + insertion.length
return EditResult(out, caret, caret)
}
}
/** Apply Tab key: insert spaces at caret (single-caret only). */
fun applyTab(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
val spaces = " ".repeat(tabSize)
val out = text.substring(0, selStart) + spaces + text.substring(selEnd)
val caret = selStart + spaces.length
return EditResult(out, caret, caret)
}
/** Apply Shift+Tab: outdent each selected line (or current line) by up to tabSize spaces. */
fun applyShiftTab(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
val start = minOf(selStart, selEnd)
val end = maxOf(selStart, selEnd)
val firstLineStart = lineStartAt(text, start)
val lastLineEnd = lineEndAt(text, end)
val sb = StringBuilder(text.length)
if (firstLineStart > 0) sb.append(text, 0, firstLineStart)
var i = firstLineStart
var newSelStart = selStart
var newSelEnd = selEnd
while (i <= lastLineEnd) {
val ls = i
var le = i
while (le < text.length && text[le] != '\n') le++
val isLast = le >= lastLineEnd
var j = ls
var removed = 0
while (j < le && removed < tabSize && text[j] == ' ') { j++; removed++ }
sb.append(text, ls + removed, le)
if (le < text.length && !isLast) sb.append('\n')
fun adjustIndex(idx: Int): Int {
if (idx <= ls) return idx
val remBefore = when {
idx >= le -> removed
else -> kotlin.math.max(0, kotlin.math.min(removed, idx - ls))
}
return idx - remBefore
}
newSelStart = adjustIndex(newSelStart)
newSelEnd = adjustIndex(newSelEnd)
i = if (le < text.length) le + 1 else le + 1
if (isLast) break
}
if (lastLineEnd < text.length) sb.append(text, lastLineEnd, text.length)
val s = minOf(newSelStart, newSelEnd)
val e = maxOf(newSelStart, newSelEnd)
return EditResult(sb.toString(), s, e)
}
@@ -20,30 +20,20 @@
*/
package net.sergeych.lyngweb
import kotlinx.browser.window
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Source
import net.sergeych.lyng.binding.Binder
import net.sergeych.lyng.binding.SymbolKind
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyng.highlight.offsetOf
import net.sergeych.lyng.miniast.MiniAstBuilder
import net.sergeych.lyng.miniast.MiniClassDecl
import net.sergeych.lyng.miniast.MiniFunDecl
import net.sergeych.lyng.miniast.MiniTypeName
import org.w3c.dom.HTMLStyleElement
/**
* Adds a Bootstrap-friendly `code` class to every opening `<pre>` tag in the provided HTML.
*
* This is a lightweight post-processing step for Markdown-rendered HTML to ensure that
* code blocks (wrapped in `<pre>...</pre>`) receive consistent styling in Bootstrap-based
* sites without requiring changes in the Markdown renderer.
*
* Behavior:
* - If a `<pre>` has no `class` attribute, a `class="code"` attribute is added.
* - If a `<pre>` already has a `class` attribute but not `code`, the word `code` is appended.
* - Other attributes and their order are preserved.
*
* Example:
* ```kotlin
* val withClasses = ensureBootstrapCodeBlocks("<pre><code>println(1)</code></pre>")
* // => "<pre class=\"code\"><code>println(1)</code></pre>"
* ```
*
* @param html HTML text to transform.
* @return HTML with `<pre>` tags normalized to include the `code` class.
*/
fun ensureBootstrapCodeBlocks(html: String): String {
val preTagRegex = Regex("""<pre(\s+[^>]*)?>""", RegexOption.IGNORE_CASE)
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
@@ -69,33 +59,8 @@ fun ensureBootstrapCodeBlocks(html: String): String {
}
}
/**
* Highlights Lyng code blocks inside Markdown-produced HTML.
*
* Searches for sequences of `<pre><code ...>...</code></pre>` and, if the `<code>` element
* carries class `language-lyng` (or if it has no `language-*` class at all), applies Lyng
* syntax highlighting, replacing the inner HTML with spans that use `hl-*` CSS classes
* (e.g. `hl-kw`, `hl-id`, `hl-num`, `hl-cmt`).
*
* Special handling:
* - If a block has no explicit language class, doctest-style result lines starting with
* `>>>` at the end of the block are rendered as comments (`hl-cmt`).
* - If the block specifies another language (e.g. `language-kotlin`), the block is left
* unchanged.
*
* Example:
* ```kotlin
* val mdHtml = """
* <pre><code class=\"language-lyng\">and or not\n</code></pre>
* """.trimIndent()
* val highlighted = highlightLyngHtml(mdHtml)
* ```
*
* @param html HTML produced by a Markdown renderer.
* @return HTML with Lyng code blocks highlighted using `hl-*` classes.
*/
fun highlightLyngHtml(html: String): String {
// Regex to find <pre> ... <code class="language-lyng ...">(content)</code> ... </pre>
ensureLyngHighlightStyles()
val preCodeRegex = Regex(
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
options = setOf(RegexOption.IGNORE_CASE)
@@ -107,34 +72,128 @@ fun highlightLyngHtml(html: String): String {
val codeAttrs = m.groups[2]?.value ?: ""
val codeHtml = m.groups[3]?.value ?: ""
val codeHasLyng = run {
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
cls.split("\\s+".toRegex()).any { it.equals("language-lyng", ignoreCase = true) }
}
val hasAnyLanguage = run {
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
cls.split("\\s+".toRegex()).any { it.startsWith("language-", ignoreCase = true) }
}
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
val classes = cls.split("\\s+".toRegex()).filter { it.isNotEmpty() }
val codeHasLyng = classes.any { it.equals("language-lyng", ignoreCase = true) }
val hasAnyLanguage = classes.any { it.startsWith("language-", ignoreCase = true) }
val treatAsLyng = codeHasLyng || !hasAnyLanguage
if (!treatAsLyng) return@replace m.value
val text = htmlUnescape(codeHtml)
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
val headHighlighted = try {
applyLyngHighlightToText(headText)
} catch (_: Throwable) {
return@replace m.value
}
val headHighlighted = try { applyLyngHighlightToText(headText) } catch (_: Throwable) { return@replace m.value }
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
val highlighted = headHighlighted + tailHighlighted
"<pre$preAttrs><code$codeAttrs>$highlighted</code></pre>"
}
}
suspend fun highlightLyngHtmlAsync(html: String): String {
ensureLyngHighlightStyles()
val preCodeRegex = Regex(
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
options = setOf(RegexOption.IGNORE_CASE)
)
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
return try {
val sb = StringBuilder(html.length + 64)
var lastEnd = 0
val matches = preCodeRegex.findAll(html).toList()
for (m in matches) {
if (m.range.first > lastEnd) sb.append(html, lastEnd, m.range.first)
val preAttrs = m.groups[1]?.value ?: ""
val codeAttrs = m.groups[2]?.value ?: ""
val codeHtml = m.groups[3]?.value ?: ""
val clsVal = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
val classes = clsVal.split("\\s+".toRegex()).filter { it.isNotEmpty() }
val codeHasLyng = classes.any { it.equals("language-lyng", ignoreCase = true) }
val hasAnyLanguage = classes.any { it.startsWith("language-", ignoreCase = true) }
val treatAsLyng = codeHasLyng || !hasAnyLanguage
if (!treatAsLyng) {
sb.append(m.value)
lastEnd = m.range.last + 1
continue
}
val text = htmlUnescape(codeHtml)
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
val headHighlighted = applyLyngHighlightToTextAst(headText)
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
val highlighted = headHighlighted + tailHighlighted
sb.append("<pre").append(preAttrs).append(">")
.append("<code").append(codeAttrs).append(">").append(highlighted).append("</code></pre>")
lastEnd = m.range.last + 1
}
if (lastEnd < html.length) sb.append(html, lastEnd, html.length)
sb.toString()
} catch (_: Throwable) {
highlightLyngHtml(html)
}
}
fun ensureLyngHighlightStyles() {
try {
val doc = window.document
if (doc.getElementById("lyng-highlight-style") == null) {
val style = doc.createElement("style") as HTMLStyleElement
style.id = "lyng-highlight-style"
style.textContent = (
"""
/* Lyng syntax highlighting defaults */
.hl-kw { color: #d73a49; font-weight: 600; }
.hl-ty { color: #6f42c1; }
.hl-id { color: #24292e; }
/* Declarations (semantic roles) */
.hl-fn { color: #005cc5; font-weight: 600; }
.hl-class { color: #5a32a3; font-weight: 600; }
.hl-val { color: #1b7f5a; }
.hl-var { color: #1b7f5a; text-decoration: underline dotted currentColor; }
.hl-param { color: #0969da; font-style: italic; }
.hl-num { color: #005cc5; }
.hl-str { color: #032f62; }
.hl-ch { color: #032f62; }
.hl-rx { color: #116329; }
.hl-cmt { color: #6a737d; font-style: italic; }
.hl-op { color: #8250df; }
.hl-punc{ color: #57606a; }
.hl-lbl { color: #e36209; }
.hl-dir { color: #6f42c1; }
.hl-err { color: #b31d28; text-decoration: underline wavy #b31d28; }
/* Dark theme (Bootstrap data attribute) */
[data-bs-theme="dark"] .hl-id { color: #c9d1d9; }
[data-bs-theme="dark"] .hl-op { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-punc { color: #8b949e; }
[data-bs-theme="dark"] .hl-kw { color: #ff7b72; }
[data-bs-theme="dark"] .hl-ty { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-fn { color: #79c0ff; font-weight: 700; }
[data-bs-theme="dark"] .hl-class{ color: #d2a8ff; font-weight: 700; }
[data-bs-theme="dark"] .hl-val { color: #7ee787; }
[data-bs-theme="dark"] .hl-var { color: #7ee787; text-decoration: underline dotted currentColor; }
[data-bs-theme="dark"] .hl-param{ color: #a5d6ff; font-style: italic; }
[data-bs-theme="dark"] .hl-num { color: #79c0ff; }
[data-bs-theme="dark"] .hl-str,
[data-bs-theme="dark"] .hl-ch { color: #a5d6ff; }
[data-bs-theme="dark"] .hl-rx { color: #7ee787; }
[data-bs-theme="dark"] .hl-cmt { color: #8b949e; }
[data-bs-theme="dark"] .hl-lbl { color: #ffa657; }
[data-bs-theme="dark"] .hl-dir { color: #d2a8ff; }
[data-bs-theme="dark"] .hl-err { color: #ffa198; text-decoration-color: #ffa198; }
"""
.trimIndent()
)
doc.head?.appendChild(style)
}
} catch (_: Throwable) {
}
}
private fun splitDoctestTail(text: String): Pair<String, String?> {
if (text.isEmpty()) return "" to null
val hasTrailingNewline = text.endsWith("\n")
@@ -185,36 +244,419 @@ private fun renderDoctestTailAsComments(tail: String): String {
return sb.toString()
}
/**
* Converts plain Lyng source text into HTML with syntax-highlight spans.
*
* Tokens are wrapped in `<span>` elements with `hl-*` classes (e.g., `hl-kw`, `hl-id`).
* Text between tokens is HTML-escaped and preserved. If no tokens are detected,
* the whole text is returned HTML-escaped.
*
* This is a low-level utility used by [highlightLyngHtml]. If you already have
* Markdown-produced HTML with `<pre><code>` blocks, prefer calling [highlightLyngHtml].
*
* @param text Lyng source code (plain text, not HTML-escaped).
* @return HTML string with `hl-*` spans.
*/
fun applyLyngHighlightToText(text: String): String {
ensureLyngHighlightStyles()
val spans = SimpleLyngHighlighter().highlight(text)
if (spans.isEmpty()) return htmlEscape(text)
val sb = StringBuilder(text.length + spans.size * 16)
fun clamp(i: Int, lo: Int = 0, hi: Int = text.length): Int = i.coerceIn(lo, hi)
fun safeSubstring(start: Int, endExclusive: Int): String {
val s = clamp(start)
val e = clamp(endExclusive)
return if (e <= s) "" else text.substring(s, e)
}
// Compute declaration/param overrides by a fast textual scan
val overrides = detectDeclarationAndParamOverrides(text).toMutableMap()
// Helper: check next non-space char after a given end offset
fun isFollowedBy(end: Int, ch: Char): Boolean {
var i = end
while (i < text.length) {
val c = text[i]
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { i++; continue }
return c == ch
}
return false
}
// 1) Mark function call-sites even in the sync path: identifier immediately followed by '(' or trailing block '{'
run {
for (s in spans) {
if (s.kind == HighlightKind.Identifier) {
val key = s.range.start to s.range.endExclusive
if (!overrides.containsKey(key)) {
if (isFollowedBy(s.range.endExclusive, '(') || isFollowedBy(s.range.endExclusive, '{')) {
overrides[key] = "hl-fn"
}
}
}
}
}
// 2) Color import module path segments after the `import` keyword as hl-dir (best-effort)
run {
var i = 0
while (i < spans.size) {
val s = spans[i]
if (s.kind == HighlightKind.Identifier) {
val token = safeSubstring(s.range.start, s.range.endExclusive)
if (token == "import") {
var j = i + 1
while (j < spans.size) {
val sj = spans[j]
val segText = safeSubstring(sj.range.start, sj.range.endExclusive)
val isDot = sj.kind == HighlightKind.Punctuation && segText == "."
if (sj.kind == HighlightKind.Identifier) {
overrides[sj.range.start to sj.range.endExclusive] = "hl-dir"
} else if (!isDot) {
break
}
j++
}
i = j - 1
}
}
i++
}
}
var pos = 0
for (s in spans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = cssClassForKind(s.kind)
if (s.range.start > pos) sb.append(htmlEscape(safeSubstring(pos, s.range.start)))
val cls = when (s.kind) {
HighlightKind.Identifier -> overrides[s.range.start to s.range.endExclusive] ?: cssClassForKind(s.kind)
else -> cssClassForKind(s.kind)
}
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append(htmlEscape(safeSubstring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
pos = clamp(s.range.endExclusive)
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
return sb.toString()
}
/**
* AST-backed highlighter: uses the compiler's optional Mini-AST to precisely mark
* declaration names (functions, classes, vals/vars), parameters, and type name segments.
* Falls back to token-only rendering if anything goes wrong.
*/
suspend fun applyLyngHighlightToTextAst(text: String): String {
return try {
// Ensure CSS present
ensureLyngHighlightStyles()
val source = Source("<web>", text)
// Token baseline
val tokenSpans = SimpleLyngHighlighter().highlight(text)
if (tokenSpans.isEmpty()) return htmlEscape(text)
// Build Mini-AST
val sink = MiniAstBuilder()
Compiler.compileWithMini(text, sink)
val mini = sink.build()
// Collect overrides from AST and Binding with precise offsets
val overrides = HashMap<Pair<Int, Int>, String>()
fun putName(startPos: net.sergeych.lyng.Pos, name: String, cls: String) {
val s = source.offsetOf(startPos)
val e = s + name.length
if (s >= 0 && e <= text.length && s < e) overrides[s to e] = cls
}
// Declarations
mini?.declarations?.forEach { d ->
when (d) {
is MiniFunDecl -> putName(d.nameStart, d.name, "hl-fn")
is MiniClassDecl -> putName(d.nameStart, d.name, "hl-class")
is net.sergeych.lyng.miniast.MiniValDecl -> putName(d.nameStart, d.name, if (d.mutable) "hl-var" else "hl-val")
}
}
// Imports: color each segment as directive/path
mini?.imports?.forEach { imp ->
imp.segments.forEach { seg ->
val s = source.offsetOf(seg.range.start)
val e = source.offsetOf(seg.range.end)
if (s >= 0 && e <= text.length && s < e) overrides[s to e] = "hl-dir"
}
}
// Parameters
mini?.declarations?.filterIsInstance<MiniFunDecl>()?.forEach { fn ->
fn.params.forEach { p -> putName(p.nameStart, p.name, "hl-param") }
}
// Type name segments
fun addTypeSegments(t: net.sergeych.lyng.miniast.MiniTypeRef?) {
when (t) {
is MiniTypeName -> t.segments.forEach { seg ->
val s = source.offsetOf(seg.range.start)
val e = s + seg.name.length
if (s >= 0 && e <= text.length && s < e) overrides[s to e] = "hl-ty"
}
is net.sergeych.lyng.miniast.MiniGenericType -> {
addTypeSegments(t.base)
t.args.forEach { addTypeSegments(it) }
}
else -> {}
}
}
mini?.declarations?.forEach { d ->
when (d) {
is MiniFunDecl -> {
addTypeSegments(d.returnType)
d.params.forEach { addTypeSegments(it.type) }
}
is net.sergeych.lyng.miniast.MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> {}
}
}
// Apply binder results to mark usages by semantic kind (params, locals, top-level, functions, classes)
try {
if (mini != null) {
val binding = Binder.bind(text, mini)
// Map decl ranges to avoid overriding declarations
val declKeys = HashSet<Pair<Int, Int>>()
for (sym in binding.symbols) {
declKeys += (sym.declStart to sym.declEnd)
}
fun classForKind(k: SymbolKind): String? = when (k) {
SymbolKind.Function -> "hl-fn"
SymbolKind.Class, SymbolKind.Enum -> "hl-class"
SymbolKind.Param -> "hl-param"
SymbolKind.Val -> "hl-val"
SymbolKind.Var -> "hl-var"
}
for (ref in binding.references) {
val key = ref.start to ref.end
if (declKeys.contains(key)) continue
if (!overrides.containsKey(key)) {
val sym = binding.symbols.firstOrNull { it.id == ref.symbolId }
val cls = sym?.let { classForKind(it.kind) }
if (cls != null) overrides[key] = cls
}
}
}
} catch (_: Throwable) {
// Binder is best-effort; ignore on any failure
}
fun isFollowedByParen(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 == '('
}
return false
}
fun isFollowedByBlock(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 == '{'
}
return false
}
// First: mark function call-sites (identifier immediately followed by '('), best-effort.
// Do this before vars/params so it takes precedence where both could match.
run {
for (s in tokenSpans) {
if (s.kind == HighlightKind.Identifier) {
val key = s.range.start to s.range.endExclusive
if (!overrides.containsKey(key)) {
if (isFollowedByParen(s.range.endExclusive) || isFollowedByBlock(s.range.endExclusive)) {
overrides[key] = "hl-fn"
}
}
}
}
}
// Highlight usages of top-level vals/vars and parameters (best-effort, no binder yet)
val nameRoleMap = HashMap<String, String>(8)
mini?.declarations?.forEach { d ->
when (d) {
is net.sergeych.lyng.miniast.MiniValDecl -> nameRoleMap[d.name] = if (d.mutable) "hl-var" else "hl-val"
is MiniFunDecl -> d.params.forEach { p -> nameRoleMap[p.name] = "hl-param" }
else -> {}
}
}
// For every identifier token not already overridden, apply role based on known names
for (s in tokenSpans) {
if (s.kind == HighlightKind.Identifier) {
val key = s.range.start to s.range.endExclusive
if (!overrides.containsKey(key)) {
val ident = text.substring(s.range.start, s.range.endExclusive)
val cls = nameRoleMap[ident]
if (cls != null) {
// Avoid marking function call sites as vars/params
if (!isFollowedByParen(s.range.endExclusive)) {
overrides[key] = cls
}
}
}
}
}
// Render merging overrides
val sb = StringBuilder(text.length + tokenSpans.size * 16)
var pos = 0
for (s in tokenSpans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = when (s.kind) {
HighlightKind.Identifier -> overrides[s.range.start to s.range.endExclusive] ?: cssClassForKind(s.kind)
HighlightKind.TypeName -> overrides[s.range.start to s.range.endExclusive] ?: cssClassForKind(s.kind)
else -> cssClassForKind(s.kind)
}
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
sb.toString()
} catch (_: Throwable) {
// Fallback to legacy path (token + heuristic overlay)
applyLyngHighlightToText(text)
}
}
// Map of (start,end) -> cssClass for declaration/param identifiers
private fun detectDeclarationAndParamOverrides(text: String): Map<Pair<Int, Int>, String> {
val result = HashMap<Pair<Int, Int>, String>()
val n = text.length
var i = 0
fun isIdentStart(ch: Char) = ch == '_' || ch == '$' || ch == '~' || ch.isLetter()
fun isIdentPart(ch: Char) = ch == '_' || ch == '$' || ch == '~' || ch.isLetterOrDigit()
// A conservative list of language keywords to avoid misclassifying as function calls
val kw = setOf(
"package", "import", "fun", "fn", "class", "enum", "val", "var",
"if", "else", "while", "do", "for", "when", "try", "catch", "finally",
"throw", "return", "break", "continue", "in", "is", "as", "as?", "not",
"true", "false", "null", "private", "protected", "open", "extern", "static"
)
fun skipWs(idx0: Int): Int {
var idx = idx0
while (idx < n) {
val c = text[idx]
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') idx++ else break
}
return idx
}
fun readIdent(idx0: Int): Pair<String, Int>? {
var idx = idx0
if (idx >= n || !isIdentStart(text[idx])) return null
val start = idx
idx++
while (idx < n && isIdentPart(text[idx])) idx++
return text.substring(start, idx) to idx
}
fun readQualified(idx0: Int): Pair<Unit, Int> {
var idx = idx0
// Read A(.B)*
var first = true
while (true) {
val id = readIdent(idx) ?: return Unit to idx0 // nothing read
idx = id.second
val save = idx
if (idx < n && text[idx] == '.') { idx++; first = false; continue } else { idx = save; break }
}
return Unit to idx
}
while (i < n) {
// scan for keywords fun/fn/val/var/class with word boundaries
if (text.startsWith("fun", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+3 >= n || !isIdentPart(text.getOrNull(i+3) ?: '\u0000'))) {
var p = skipWs(i + 3)
// optional receiver Type.
val (u, p2) = readQualified(p)
p = p2
if (p < n && text[p] == '.') {
p++
}
p = skipWs(p)
val name = readIdent(p)
if (name != null) {
val start = p; val end = name.second
result[start to end] = "hl-fn"
// Try to find params list and mark parameter names
var q = skipWs(end)
if (q < n && text[q] == '(') {
q++
loop@ while (q < n) {
q = skipWs(q)
// end of params
if (q < n && text[q] == ')') { q++; break }
val param = readIdent(q)
if (param != null) {
val ps = q; val pe = param.second
// ensure followed by ':' (type)
var t = skipWs(pe)
if (t < n && text[t] == ':') {
result[ps to pe] = "hl-param"
q = t + 1
} else {
q = pe
}
} else {
// skip until next comma or ')'
while (q < n && text[q] != ',' && text[q] != ')') q++
}
q = skipWs(q)
if (q < n && text[q] == ',') { q++; continue@loop }
if (q < n && text[q] == ')') { q++; break@loop }
}
}
}
i = p
continue
}
if (text.startsWith("fn", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+2 >= n || !isIdentPart(text.getOrNull(i+2) ?: '\u0000'))) {
// Treat same as fun
var p = skipWs(i + 2)
val (u, p2) = readQualified(p)
p = p2
if (p < n && text[p] == '.') p++
p = skipWs(p)
val name = readIdent(p)
if (name != null) {
val start = p; val end = name.second
result[start to end] = "hl-fn"
}
i = p
continue
}
if (text.startsWith("val", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+3 >= n || !isIdentPart(text.getOrNull(i+3) ?: '\u0000'))) {
var p = skipWs(i + 3)
val name = readIdent(p)
if (name != null) result[p to name.second] = "hl-val"
i = p
continue
}
if (text.startsWith("var", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+3 >= n || !isIdentPart(text.getOrNull(i+3) ?: '\u0000'))) {
var p = skipWs(i + 3)
val name = readIdent(p)
if (name != null) result[p to name.second] = "hl-var"
i = p
continue
}
if (text.startsWith("class", i) && (i == 0 || !isIdentPart(text[i-1])) && (i+5 >= n || !isIdentPart(text.getOrNull(i+5) ?: '\u0000'))) {
var p = skipWs(i + 5)
val name = readIdent(p)
if (name != null) result[p to name.second] = "hl-class"
i = p
continue
}
// Generic function call site: ident followed by '(' (after optional spaces)
readIdent(i)?.let { (name, endIdx) ->
val startIdx = i
// Avoid keywords; allow member calls too (a.b()) by not checking preceding char
if (name !in kw) {
var q = skipWs(endIdx)
if (q < n && text[q] == '(') {
// Mark as function identifier at call site
result[startIdx to endIdx] = "hl-fn"
i = endIdx
return@let
}
}
}
i++
}
return result
}
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
HighlightKind.Keyword -> "hl-kw"
HighlightKind.TypeName -> "hl-ty"
@@ -1,7 +1,21 @@
package net.sergeych.lyngweb
/*
* Copyright 2025 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.
*
*/
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
package net.sergeych.lyngweb
/**
* Minimal HTML renderer for Lyng syntax highlighting, compatible with the site CSS.
@@ -12,53 +26,17 @@ import net.sergeych.lyng.highlight.SimpleLyngHighlighter
* `hl-id`, `hl-num`).
*/
object SiteHighlight {
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
HighlightKind.Keyword -> "hl-kw"
HighlightKind.TypeName -> "hl-ty"
HighlightKind.Identifier -> "hl-id"
HighlightKind.Number -> "hl-num"
HighlightKind.String -> "hl-str"
HighlightKind.Char -> "hl-ch"
HighlightKind.Regex -> "hl-rx"
HighlightKind.Comment -> "hl-cmt"
HighlightKind.Operator -> "hl-op"
HighlightKind.Punctuation -> "hl-punc"
HighlightKind.Label -> "hl-lbl"
HighlightKind.Directive -> "hl-dir"
HighlightKind.Error -> "hl-err"
}
/**
* Converts plain Lyng source [text] into HTML with `<span>` wrappers using
* site-compatible `hl-*` classes.
*
* Non-highlighted parts are HTML-escaped. If the highlighter returns no
* tokens, the entire string is returned as an escaped plain text.
*
* Example:
* ```kotlin
* val html = SiteHighlight.renderHtml("assertEquals(1, 1)")
* // => "<span class=\"hl-id\">assertEquals</span><span class=\"hl-punc\">(</span>..."
* ```
*
* @param text Lyng code to render (plain text).
* @return HTML string with `hl-*` styled tokens.
* site-compatible `hl-*` classes. This uses the merged highlighter that
* overlays declaration/parameter roles on top of token highlighting so
* functions, variables, classes, and params get distinct styles.
*/
fun renderHtml(text: String): String {
val highlighter = SimpleLyngHighlighter()
val spans = highlighter.highlight(text)
if (spans.isEmpty()) return htmlEscape(text)
val sb = StringBuilder(text.length + spans.size * 16)
var pos = 0
for (s in spans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = cssClassForKind(s.kind)
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
return sb.toString()
}
fun renderHtml(text: String): String = applyLyngHighlightToText(text)
/**
* Suspend variant that uses Mini-AST for precise declaration/param/type ranges
* when possible, with a graceful fallback to token+overlay highlighter.
*/
suspend fun renderHtmlAsync(text: String): String = applyLyngHighlightToTextAst(text)
}
@@ -0,0 +1,392 @@
/*
* Copyright 2025 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.lyngweb
import kotlin.test.Test
import kotlin.test.assertEquals
class EditorLogicTest {
private val tab = 4
@Test
fun enter_after_only_rbrace_undents() {
val line = " } " // 4 spaces, brace, trailing spaces; caret after last non-ws
val res = applyEnter(line, line.length, line.length, tab)
// Should insert newline with one indent level less (0 spaces here)
assertEquals(" } \n" + "" , res.text.substring(0, res.text.indexOf('\n')+1))
// After insertion caret should be at end of inserted indentation
// Here indent was 4 and undented by 4 -> 0 spaces after newline
val expectedCaret = line.length + 1 + 0
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun enter_after_rbrace_with_only_spaces_to_eol_inserts_after_brace_and_dedents_and_undents_brace_line() {
// Rule 5 exact check: last non-ws before caret is '}', remainder to EOL only spaces
val indents = listOf(0, 4, 8)
for (indent in indents) {
val spaces = " ".repeat(indent)
// Line ends with '}' followed by three spaces
val before = (
"""
1
${'$'}spaces}
"""
).trimIndent() + " "
// Caret right after '}', before trailing spaces
val caret = before.indexOf('}') + 1
val res = applyEnter(before, caret, caret, tab)
val newBraceIndent = (indent - tab).coerceAtLeast(0)
val newLineIndent = newBraceIndent
val expected = buildString {
append("1\n")
append(" ".repeat(newBraceIndent))
append("}\n")
append(" ".repeat(newLineIndent))
}
assertEquals(expected, res.text)
// Caret must be at start of the newly inserted line (after the final newline)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
@Test
fun enter_after_rbrace_with_only_spaces_to_eol_crlf_and_undents_brace_line() {
val indent = 4
val spaces = " ".repeat(indent)
val beforeLf = (
"""
1
${'$'}spaces}
"""
).trimIndent() + " "
val before = beforeLf.replace("\n", "\r\n")
val caret = before.indexOf('}') + 1
val res = applyEnter(before, caret, caret, tab)
val actual = res.text.replace("\r\n", "\n")
val newIndent = (indent - tab).coerceAtLeast(0)
val expected = "1\n${" ".repeat(newIndent)}}\n${" ".repeat(newIndent)}"
assertEquals(expected, actual)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_before_closing_brace_outdents() {
val text = " }" // caret before '}' at index 4
val caret = 4
val res = applyEnter(text, caret, caret, tab)
// Inserted a newline with indent reduced to 0
assertEquals("\n" + "" + "}", res.text.substring(caret, caret + 2))
val expectedCaret = caret + 1 + 0
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun enter_between_braces_inserts_two_lines() {
val text = "{}"
val caret = 1 // between
val res = applyEnter(text, caret, caret, tab)
// Expect: "{\n \n}"
assertEquals("{\n \n}", res.text)
// Caret after first newline + 4 spaces
assertEquals(1 + 1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_after_open_brace_increases_indent() {
val text = "{"
val caret = 1
val res = applyEnter(text, caret, caret, tab)
assertEquals("{\n ", res.text)
assertEquals(1 + 1 + 4, res.selStart)
}
@Test
fun enter_after_rbrace_line_undents_ignoring_trailing_ws() {
// Line contains only '}' plus trailing spaces; caret after last non-ws
val line = " } "
val res = applyEnter(line, line.length, line.length, tab)
// Expect insertion of a newline and an undented indentation of (indent - tab)
val expectedIndentAfterNewline = 0 // 4 - 4
val expected = line + "\n" + " ".repeat(expectedIndentAfterNewline)
// Compare prefix up to inserted indentation
val prefix = res.text.substring(0, expected.length)
assertEquals(expected, prefix)
// Caret positioned at end of inserted indentation
val expectedCaret = expected.length
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun shift_tab_outdents_rbrace_only_line_no_newline() {
// Multi-line: a block followed by a number; we outdent the '}' line only
val text = " }\n3"
// Place selection inside the first line
val caret = 5 // after the '}'
val res = applyShiftTab(text, caret, caret, tab)
// Should become '}' on the first line, no extra blank lines
assertEquals("}\n3", res.text)
// Caret should move left by tabSize on that line (but not negative)
val expectedCaret = (caret - tab).coerceAtLeast(0)
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test
fun shift_tab_outdents_without_newline() {
val text = " a\n b"
val res = applyShiftTab(text, 0, text.length, tab)
assertEquals("a\nb", res.text)
}
@Test
fun tab_inserts_spaces() {
val text = "x"
val caret = 1
val res = applyTab(text, caret, caret, tab)
assertEquals("x ", res.text)
assertEquals(1 + 4, res.selStart)
}
@Test
fun enter_before_line_with_only_rbrace_dedents_that_line() {
// Initial content as reported by user before fix
val before = (
"""
{
1
2
3
}
1
2
3
"""
).trimIndent()
// Place caret at the end of the line that contains just " 3" (before the line with '}')
val lines = before.split('\n')
// Build index of end of the line with the last " 3"
var idx = 0
var caret = 0
for (i in lines.indices) {
val line = lines[i]
if (line.trimEnd() == "3" && i + 1 < lines.size && lines[i + 1].trim() == "}") {
caret = idx + line.length // position after '3', before the newline
break
}
idx += line.length + 1 // +1 for newline
}
val res = applyEnter(before, caret, caret, tab)
val expected = (
"""
{
1
2
3
}
1
2
3
"""
).trimIndent()
assertEquals(expected, res.text)
}
@Test
fun enter_eol_before_brace_only_next_line_various_indents() {
// Cover Rule 3 with LF newlines, at indents 0, 2, 4, 8
val indents = listOf(0, 2, 4, 8)
for (indent in indents) {
val spaces = " ".repeat(indent)
val before = (
"""
1
2
3
${'$'}spaces}
4
"""
).trimIndent()
// Caret at end of the line with '3' (line before the rbrace-only line)
val caret = before.indexOf("3\n") + 1 // just before LF
val res = applyEnter(before, caret, caret, tab)
// The '}' line must be dedented by one block (clamped at 0) and caret moved to its start
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expected = (
"""
1
2
3
${'$'}{" ".repeat(expectedIndent)}}
4
"""
).trimIndent()
assertEquals(expected, res.text, "EOL before '}' dedent failed for indent=${'$'}indent")
// Caret should be at start of that '}' line (line index 3)
val lines = res.text.split('\n')
var pos = 0
for (i in 0 until 3) pos += lines[i].length + 1
assertEquals(pos, res.selStart, "Caret pos mismatch for indent=${'$'}indent")
assertEquals(pos, res.selEnd, "Caret pos mismatch for indent=${'$'}indent")
}
}
@Test
fun enter_eol_before_brace_only_next_line_various_indents_crlf() {
// Same as above but with CRLF newlines
val indents = listOf(0, 2, 4, 8)
for (indent in indents) {
val spaces = " ".repeat(indent)
val beforeLf = (
"""
1
2
3
${'$'}spaces}
4
"""
).trimIndent()
val before = beforeLf.replace("\n", "\r\n")
val caret = before.indexOf("3\r\n") + 1 // at '3' index + 1 moves to end-of-line before CR
val res = applyEnter(before, caret, caret, tab)
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expectedLf = (
"""
1
2
3
${'$'}{" ".repeat(expectedIndent)}}
4
"""
).trimIndent()
assertEquals(expectedLf, res.text.replace("\r\n", "\n"), "CRLF case failed for indent=${'$'}indent")
}
}
@Test
fun enter_at_start_of_brace_only_line_at_cols_0_2_4() {
val indents = listOf(0, 2, 4)
for (indent in indents) {
val spaces = " ".repeat(indent)
val before = (
"""
1
2
${'$'}spaces}
3
"""
).trimIndent()
// Caret at start of the brace line
val lines = before.split('\n')
var caret = 0
for (i in 0 until 2) caret += lines[i].length + 1
caret += 0 // column 0 of brace line
val res = applyEnter(before, caret, caret, tab)
// Expect the brace line to be dedented by one block, and a new line inserted before it
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expected = (
"""
1
2
${'$'}{" ".repeat(expectedIndent)}
${'$'}{" ".repeat(expectedIndent)}}
3
"""
).trimIndent()
assertEquals(expected, res.text, "Brace-line start enter failed for indent=${'$'}indent")
// Caret must be at start of the inserted line, which has expectedIndent spaces
val afterLines = res.text.split('\n')
var pos = 0
for (i in 0 until 3) pos += afterLines[i].length + 1
// The inserted line is line index 2 (0-based), caret at its start
pos -= afterLines[2].length + 1
pos += 0
assertEquals(pos, res.selStart, "Caret mismatch for indent=${'$'}indent")
assertEquals(pos, res.selEnd, "Caret mismatch for indent=${'$'}indent")
}
}
@Test
fun enter_on_whitespace_only_line_keeps_same_indent() {
val before = " \nnext" // line 0 has 4 spaces only
val caret = 0 + 4 // at end of spaces, before LF
val res = applyEnter(before, caret, caret, tab)
// Default smart indent should keep indent = 4
assertEquals(" \n \nnext", res.text)
// Caret at start of the new blank line with 4 spaces
assertEquals(1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_on_line_with_rbrace_else_lbrace_defaults_smart() {
val text = " } else {"
// Try caret positions after '}', before 'e', and after '{'
val carets = listOf(5, 6, text.length)
for (c in carets) {
val res = applyEnter(text, c, c, tab)
// Should not trigger special cases since line is not brace-only or only-spaces after '}'
// Expect same indent (4 spaces)
val expectedPrefix = text.substring(0, c) + "\n" + " ".repeat(4)
assertEquals(expectedPrefix, res.text.substring(0, expectedPrefix.length))
assertEquals(c + 1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
@Test
fun enter_with_selection_replaces_and_uses_anchor_indent() {
val text = (
"""
1
2
3
"""
).trimIndent()
// Select "2\n3" starting at column 4 of line 1 (indent = 4)
val idxLine0 = text.indexOf('1')
val idxLine1 = text.indexOf('\n', idxLine0) + 1
val selStart = idxLine1 + 4 // after 4 spaces
val selEnd = text.length
val res = applyEnter(text, selStart, selEnd, tab)
val expected = (
"""
1
"""
).trimIndent()
assertEquals(expected, res.text)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
+82
View File
@@ -0,0 +1,82 @@
This file describes the Enter/indent rules used by the `lyngweb` module and the dependent `site` project editor, `EditorWithOverlay`.
## Indents and tabs
- Block size ("tab size") is 4 spaces.
- Tabs are converted to spaces: before loading text and on paste/typing, a tab is replaced by 4 spaces.
- Indent of a line is the count of leading ASCII spaces (0x20) on that line.
- Increasing indent sets it to the next multiple of 4.
- Decreasing indent (undent/dedent) sets it to the previous multiple of 4, but never below 0.
- "Indent level" means how many 4-space tab stops precede the caret. An indent that is already 0 cannot be decreased further.
## Newlines
- Internally, logic should treat `\r\n` (CRLF) as a single newline for all rules. Tests may use LF or CRLF.
## Definitions used below
- "Current line" means the line that contains the caret (or the start of the selection).
- "Last non-whitespace before caret" means the last non-space character on the current line at an index strictly less than the caret position (trailing spaces are ignored).
- "Brace-only line" means that the line’s trimmed text equals exactly `}`.
## Enter key rules
The following rules govern what happens when the Enter key is pressed. Each rule also specifies the caret position after the operation.
1) After an opening brace `{`
- If the last non-whitespace before the caret on the current line is `{`, insert a newline and set the new line indent to one block (4 spaces) more than the current line’s indent. Place the caret at that new indent.
2) On a brace-only line `}` (caret on the same line)
- If the current line’s trimmed text is exactly `}`, decrease that line’s indent by one block (not below 0), then insert a newline. The newly inserted line uses the (decreased) indent. Place the caret at the start of the newly inserted line.
3) End of a line before a brace-only next line
- If the caret is at the end of line N, and line N+1 is a brace-only line (ignoring leading spaces), do not insert an extra blank line. Instead, decrease the indent of line N+1 by one block (not below 0) and move the caret to the start of that (dedented) `}` line.
4) Between braces on the same line `{|}`
- If the character immediately before the caret is `{` and the character immediately after the caret is `}`, split into two lines: insert a newline so that the inner (new) line is indented by one block more than the current line, and keep `}` on the following line with the current line’s indent. Place the caret at the start of the inner line (one block deeper).
5) After `}` with only spaces until end-of-line
- If the last non-whitespace before the caret is `}` and the remainder of the current line up to EOL contains only spaces, insert a newline whose indent is one block less than the current line’s indent (not below 0). Place the caret at that indent.
6) Default smart indent
- In all other cases, insert a newline and keep the same indent as the current line. Place the caret at that indent.
## Selections
- If a selection is active, pressing Enter replaces the selection with a newline, and the indent for the new line is computed from the indent of the line containing the caret’s start (anchor). The caret is placed at the start of the inserted line at that indent.
## Notes and examples
Example for rules 1, 2, and 6:
```
1<enter>2<enter>{<enter>3<enter>4<enter>}<enter>5
```
Results in:
```
1
2
{
3
4
}
5
```
## Clarifications and constraints
- All dedent operations clamp at 0 spaces.
- Only ASCII spaces count toward indentation; other Unicode whitespace is treated as content.
- Trailing spaces are ignored when evaluating the "last non-whitespace before caret" condition.
## Recommended tests to cover edge cases
- Enter at EOL when the next line is `}` with leading spaces at indents 0, 2, 4, 8.
- Enter between `{|}` on the same line.
- Enter after `}` with only trailing spaces until EOL.
- Enter at the start of a brace-only line (caret at columns 0, 2, 4).
- Enter on a line containing `} else {` at various caret positions (default to smart indent when not brace-only).
- Enter on a whitespace-only line (default smart indent).
- LF and CRLF inputs for all above.