lynglib: added MiniAST support
lyngweb: better syntax highlighting and code editor
This commit is contained in:
1 parent
28b961d339
commit
ea0ecb1db3
24 files changed
+3095
-218
No files matched your search
@@ -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("<")
|
||||
@@ -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\">​</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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user