refactor(markdown): extract MarkdownModels and MarkdownParser

Split AST block models and zero-Compose Markdown block parser into
ui/chat/markdown/ package. Wire into MarkdownText.
This commit is contained in:
Hy4ri committed 2026-09-11 18:19:47 +03:00
1 parent a83f4725a2
commit 70f331b931
5 files changed
+649 -644

No files matched your search

@@ -61,15 +61,16 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.FnNote
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath
import com.m57.hermescontrol.util.BidiUtils
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr
@@ -508,32 +509,6 @@ fun MarkdownText(
}
}
/**
* Matches a Markdown image: `![alt](uri)`. The `uri` may be an http(s) URL
* (gateway-served media the phone can reach), a `data:image/...;base64,...`
* data URL (e.g. agent-delivered inline media), or any other resolvable model
* string (Coil handles all three). Bare `![]()` with empty uri is skipped.
*/
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
private fun tryParseImage(line: String): MdBlock.Image? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
private fun tryParseVideo(line: String): MdBlock.Video? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
val ext = uri.substringAfterLast('.', "").lowercase()
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
}
return null
}
@Composable
private fun MarkdownInlineText(
text: String,
@@ -702,531 +677,6 @@ private fun tableTextAlign(align: TableAlign?): TextAlign? =
else -> null
}
internal sealed interface InlineMathSegment {
data class Text(
val value: String,
) : InlineMathSegment
data class Math(
val latex: String,
) : InlineMathSegment
}
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
internal fun splitInlineMath(text: String): List<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
var plainStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '`' && !text.isEscaped(i)) {
val codeEnd = text.indexOf('`', i + 1)
i = if (codeEnd == -1) text.length else codeEnd + 1
continue
}
if (text.startsWith("\\(", i)) {
val end = text.indexOf("\\)", i + 2)
if (end != -1 && end > i + 2) {
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
i = end + 2
plainStart = i
continue
}
}
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
i++
continue
}
var end = i + 1
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
i++
continue
}
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
i = end + 1
plainStart = i
}
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
}
private fun String.isEscaped(index: Int): Boolean {
var slashes = 0
var i = index - 1
while (i >= 0 && this[i] == '\\') {
slashes++
i--
}
return slashes % 2 == 1
}
private fun tryParseDisplayMath(
lines: List<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
val first = lines[start].trim()
if (first.startsWith("\\[")) {
if (first.length > 4 && first.endsWith("\\]")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "\\[") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
if (!first.startsWith("$$")) return null
if (first.length > 4 && first.endsWith("$$")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "$$") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
/**
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
*/
internal fun parseBlocks(src: String): List<MdBlock> {
val lines = src.lines()
val blocks = mutableListOf<MdBlock>()
val footnotes = mutableListOf<Footnote>()
var i = 0
while (i < lines.size) {
val line = lines[i]
// Footnote definition: [^id]: text (collected, not rendered inline)
val fnMatch = FN_DEF_RE.matchAt(line, 0)
if (fnMatch != null) {
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
i++
continue
}
val displayMath = tryParseDisplayMath(lines, i)
if (displayMath != null) {
blocks.add(displayMath.first)
i = displayMath.second
continue
}
val codeFence = parseCodeFenceStart(line)
when {
codeFence != null -> {
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
if (end != null) {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"),
language = codeFence.language,
),
)
i = end + 1
} else {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = codeFence.language,
),
)
i = lines.size
}
}
line.isBlank() -> {
i++
}
isHorizontalRule(line) -> {
blocks.add(MdBlock.Hr)
i++
}
// Heading: requires a space after the '#' run so "#572" stays a paragraph
isValidHeading(line) -> {
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
i++
}
isTableStart(lines, i) -> {
val (header, alignments, body) = parseTable(lines, i)
blocks.add(MdBlock.Table(header, alignments, body))
i += body.size + 2
}
line.startsWith(">") -> {
val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim())
i++
}
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
}
// Definition list: term line followed by one+ ": definition" lines
isDefListStart(lines, i) -> {
val term = line.trim()
val defs = mutableListOf<String>()
i++
while (i < lines.size && lines[i].trim().startsWith(":")) {
defs.add(lines[i].trim().removePrefix(":").trim())
i++
}
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
}
LIST_ITEM_LINE_RE.matches(line) -> {
val (listBlocks, nextIndex) = parseList(lines, i)
blocks.addAll(listBlocks)
i = nextIndex
}
// Standalone Markdown image: ![alt](uri) on its own line.
// Inline images inside a paragraph are left as-is (rendered as text)
// to keep scope tight; standalone is the common agent-media case.
line.isNotBlank() && tryParseVideo(line) != null -> {
blocks.add(tryParseVideo(line)!!)
i++
}
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
}
else -> {
i = fallthroughToParagraph(lines, i, blocks)
}
}
}
if (footnotes.isNotEmpty()) {
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
}
return blocks
}
private sealed interface ParsedItem {
val indent: Int
val continuationLines: MutableList<String>
data class Bullet(
override val indent: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Task(
override val indent: Int,
val checked: Boolean,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Ordered(
override val indent: Int,
val rawNumber: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
}
private fun computeIndent(prefix: String): Int {
var count = 0
for (ch in prefix) {
if (ch == '\t') {
count += 4 - (count % 4)
} else {
count++
}
}
return count
}
private fun parseList(
lines: List<String>,
startIndex: Int,
): Pair<List<MdBlock>, Int> {
val items = mutableListOf<ParsedItem>()
var j = startIndex
while (j < lines.size) {
val line = lines[j]
val taskMatch = TASK_LINE_RE.matchEntire(line)
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
if (taskMatch != null) {
val indent = computeIndent(taskMatch.groupValues[1])
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
val text = taskMatch.groupValues[3]
items.add(ParsedItem.Task(indent, checked, text))
j++
} else if (bulletMatch != null) {
val indent = computeIndent(bulletMatch.groupValues[1])
val text = bulletMatch.groupValues[2]
items.add(ParsedItem.Bullet(indent, text))
j++
} else if (orderedMatch != null) {
val indent = computeIndent(orderedMatch.groupValues[1])
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
val text = orderedMatch.groupValues[3]
items.add(ParsedItem.Ordered(indent, num, text))
j++
} else if (line.isBlank()) {
// Check if there is a following list item
var lookahead = j + 1
while (lookahead < lines.size && lines[lookahead].isBlank()) {
lookahead++
}
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
j = lookahead
} else {
break
}
} else {
// Check if this is a continuation line for the previous list item
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
items.last().continuationLines.add(line.trim())
j++
} else {
break
}
}
}
if (items.isEmpty()) {
return emptyList<MdBlock>() to j
}
val blocks = mutableListOf<MdBlock>()
// Track state of list items and their relative nesting depth using an indent stack
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
var lastItemWasOrdered = false
var currentSequenceNumber = 1
var lastLevel = -1
for (item in items) {
// Resolve level using the indent stack
val level: Int
if (indentStack.isEmpty() || item.indent == 0) {
indentStack.clear()
indentStack.add(item.indent)
level = 0
} else if (item.indent > indentStack.last()) {
indentStack.add(item.indent)
level = indentStack.size - 1
} else if (item.indent == indentStack.last()) {
level = indentStack.size - 1
} else {
// Unwind stack to the matching or nearest smaller indent
while (indentStack.size > 1 && indentStack.last() > item.indent) {
indentStack.removeAt(indentStack.size - 1)
}
level = indentStack.size - 1
}
val fullText =
if (item.continuationLines.isEmpty()) {
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
} else {
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
base + " " + item.continuationLines.joinToString(" ")
}
when (item) {
is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Task -> {
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Ordered -> {
val indexToUse =
if (level == lastLevel && lastItemWasOrdered) {
if (item.rawNumber > currentSequenceNumber) {
currentSequenceNumber = item.rawNumber
} else {
currentSequenceNumber++
}
currentSequenceNumber
} else {
currentSequenceNumber = item.rawNumber
currentSequenceNumber
}
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
lastItemWasOrdered = true
lastLevel = level
}
}
}
return blocks to j
}
private fun isHorizontalRule(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.length < 3) return false
val c = trimmed[0]
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
}
private fun isTableStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i]
if (!l.contains('|')) return false
if (i + 1 >= lines.size) return false
return isTableSeparator(lines[i + 1])
}
private fun isTableSeparator(line: String): Boolean {
val t = line.trim().trim('|')
if (t.isEmpty()) return false
// every cell must be only dashes/colons/spaces, with at least one dash
return t.split('|').all { cell ->
val c = cell.trim()
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
}
}
private fun parseTable(
lines: List<String>,
i: Int,
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
val header = splitRow(lines[i])
val alignments =
splitRow(lines[i + 1]).map { cell ->
val c = cell.trim()
when {
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
c.endsWith(":") -> TableAlign.RIGHT
c.startsWith(":") -> TableAlign.LEFT
else -> TableAlign.LEFT
}
}
val body = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
body.add(splitRow(lines[j]))
j++
}
return Triple(header, alignments, body)
}
private fun splitRow(line: String): List<String> {
val trimmed = line.trim().trim('|')
return trimmed.split('|').map { it.trim() }
}
private data class CodeFenceInfo(
val fenceChar: Char,
val fenceLength: Int,
val language: String?,
)
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
val trimmed = line.trimStart()
if (trimmed.length < 3) return null
val firstChar = trimmed[0]
if (firstChar != '`' && firstChar != '~') return null
val count = trimmed.takeWhile { it == firstChar }.length
if (count < 3) return null
val rest = trimmed.substring(count).trim()
if (firstChar == '`' && rest.contains('`')) return null
return CodeFenceInfo(
fenceChar = firstChar,
fenceLength = count,
language = rest.ifBlank { null },
)
}
private fun isCodeFenceEnd(
line: String,
fence: CodeFenceInfo,
): Boolean {
val trimmed = line.trimStart()
val count = trimmed.takeWhile { it == fence.fenceChar }.length
if (count < fence.fenceLength) return false
val rest = trimmed.substring(count).trim()
return rest.isEmpty()
}
private fun isDefListStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i].trim()
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
if (i + 1 >= lines.size) return false
return lines[i + 1].trim().startsWith(":")
}
private fun isValidHeading(line: String): Boolean {
if (!line.startsWith("#")) return false
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
return level < line.length && line[level] == ' '
}
private fun fallthroughToParagraph(
lines: List<String>,
start: Int,
blocks: MutableList<MdBlock>,
): Int {
var i = start
val para = mutableListOf<String>()
while (
i < lines.size &&
lines[i].isNotBlank() &&
parseCodeFenceStart(lines[i]) == null &&
!isValidHeading(lines[i]) &&
!lines[i].startsWith(">") &&
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
!isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) &&
!isDefListStart(lines, i) &&
tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null
) {
para.add(lines[i])
i++
}
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
return i
}
/**
* Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***,
* ~~strike~~, ==highlight==, ^sup^, ~sub~, <kbd>keys</kbd>, [^ref] footnotes, [text](url) and
@@ -1549,94 +999,6 @@ private fun parseInlineSource(
}
}
internal sealed interface MdBlock {
data class Code(
val code: String,
val language: String? = null,
) : MdBlock
data class Math(
val latex: String,
) : MdBlock
data class Heading(
val level: Int,
val text: String,
) : MdBlock
data class Bullet(
val text: String,
val level: Int = 0,
) : MdBlock
data class Task(
val checked: Boolean,
val text: String,
val level: Int = 0,
) : MdBlock
data class Ordered(
val index: Int,
val text: String,
val level: Int = 0,
) : MdBlock
data class Image(
val uri: String,
val alt: String = "",
) : MdBlock
data class Video(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock
data class Paragraph(
val text: String,
) : MdBlock
data class Table(
val header: List<String>,
val alignments: List<TableAlign>,
val rows: List<List<String>>,
) : MdBlock
object Hr : MdBlock
data class DefList(
val items: List<DefItem>,
) : MdBlock
data class Footnotes(
val notes: List<FnNote>,
) : MdBlock
}
internal data class DefItem(
val term: String,
val definitions: List<String>,
)
internal data class Footnote(
val id: String,
val text: String,
)
internal data class FnNote(
val id: String,
val text: String,
)
internal enum class TableAlign {
LEFT,
CENTER,
RIGHT,
}
private fun resolveImageUrl(uri: String): String {
val trimmed = uri.trim()
if (trimmed.isBlank()) return uri
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.chat.markdown
sealed interface MdBlock {
data class Code(
val code: String,
val language: String? = null,
) : MdBlock
data class Math(
val latex: String,
) : MdBlock
data class Heading(
val level: Int,
val text: String,
) : MdBlock
data class Bullet(
val text: String,
val level: Int = 0,
) : MdBlock
data class Task(
val checked: Boolean,
val text: String,
val level: Int = 0,
) : MdBlock
data class Ordered(
val index: Int,
val text: String,
val level: Int = 0,
) : MdBlock
data class Image(
val uri: String,
val alt: String = "",
) : MdBlock
data class Video(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock
data class Paragraph(
val text: String,
) : MdBlock
data class Table(
val header: List<String>,
val alignments: List<TableAlign>,
val rows: List<List<String>>,
) : MdBlock
object Hr : MdBlock
data class DefList(
val items: List<DefItem>,
) : MdBlock
data class Footnotes(
val notes: List<FnNote>,
) : MdBlock
}
data class DefItem(
val term: String,
val definitions: List<String>,
)
data class Footnote(
val id: String,
val text: String,
)
data class FnNote(
val id: String,
val text: String,
)
enum class TableAlign {
LEFT,
CENTER,
RIGHT,
}
sealed interface InlineMathSegment {
data class Text(
val value: String,
) : InlineMathSegment
data class Math(
val latex: String,
) : InlineMathSegment
}
@@ -0,0 +1,539 @@
package com.m57.hermescontrol.ui.chat.markdown
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""")
private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
private val IMAGE_RE = Regex("""^!\[([^\]]*)\]\(([^)\s]+)\s*\)""")
/** Splits `$…$` without treating escaped dollars or inline-code contents as math. */
fun splitInlineMath(text: String): List<InlineMathSegment> {
val segments = mutableListOf<InlineMathSegment>()
var plainStart = 0
var i = 0
while (i < text.length) {
if (text[i] == '`' && !text.isEscaped(i)) {
val codeEnd = text.indexOf('`', i + 1)
i = if (codeEnd == -1) text.length else codeEnd + 1
continue
}
if (text.startsWith("\\(", i)) {
val end = text.indexOf("\\)", i + 2)
if (end != -1 && end > i + 2) {
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 2, end)))
i = end + 2
plainStart = i
continue
}
}
if (text[i] != '$' || text.isEscaped(i) || text.getOrNull(i + 1) == '$') {
i++
continue
}
var end = i + 1
while (end < text.length && (text[end] != '$' || text.isEscaped(end))) end++
if (end >= text.length || end == i + 1 || text[end - 1].isWhitespace()) {
i++
continue
}
if (plainStart < i) segments.add(InlineMathSegment.Text(text.substring(plainStart, i)))
segments.add(InlineMathSegment.Math(text.substring(i + 1, end)))
i = end + 1
plainStart = i
}
if (plainStart < text.length) segments.add(InlineMathSegment.Text(text.substring(plainStart)))
return segments.ifEmpty { listOf(InlineMathSegment.Text(text)) }
}
private fun String.isEscaped(index: Int): Boolean {
var slashes = 0
var i = index - 1
while (i >= 0 && this[i] == '\\') {
slashes++
i--
}
return slashes % 2 == 1
}
private fun tryParseDisplayMath(
lines: List<String>,
start: Int,
): Pair<MdBlock.Math, Int>? {
val first = lines[start].trim()
if (first.startsWith("\\[")) {
if (first.length > 4 && first.endsWith("\\]")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "\\[") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "\\]" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
if (!first.startsWith("$$")) return null
if (first.length > 4 && first.endsWith("$$")) {
val latex = first.substring(2, first.length - 2).trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to start + 1 }
}
if (first != "$$") return null
val end = (start + 1 until lines.size).firstOrNull { lines[it].trim() == "$$" } ?: return null
val latex = lines.subList(start + 1, end).joinToString("\n").trim()
return latex.takeIf { it.isNotEmpty() }?.let { MdBlock.Math(it) to end + 1 }
}
/**
* Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first;
* everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs.
*/
fun parseBlocks(src: String): List<MdBlock> {
val lines = src.lines()
val blocks = mutableListOf<MdBlock>()
val footnotes = mutableListOf<Footnote>()
var i = 0
while (i < lines.size) {
val line = lines[i]
// Footnote definition: [^id]: text (collected, not rendered inline)
val fnMatch = FN_DEF_RE.matchAt(line, 0)
if (fnMatch != null) {
footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2]))
i++
continue
}
val displayMath = tryParseDisplayMath(lines, i)
if (displayMath != null) {
blocks.add(displayMath.first)
i = displayMath.second
continue
}
val codeFence = parseCodeFenceStart(line)
when {
codeFence != null -> {
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
if (end != null) {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"),
language = codeFence.language,
),
)
i = end + 1
} else {
blocks.add(
MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = codeFence.language,
),
)
i = lines.size
}
}
line.isBlank() -> {
i++
}
isHorizontalRule(line) -> {
blocks.add(MdBlock.Hr)
i++
}
// Heading: requires a space after the '#' run so "#572" stays a paragraph
isValidHeading(line) -> {
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
blocks.add(MdBlock.Heading(level, line.substring(level).trim()))
i++
}
isTableStart(lines, i) -> {
val (header, alignments, body) = parseTable(lines, i)
blocks.add(MdBlock.Table(header, alignments, body))
i += body.size + 2
}
line.startsWith(">") -> {
val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim())
i++
}
blocks.add(MdBlock.Quote(quote.joinToString("\n")))
}
// Definition list: term line followed by one+ ": definition" lines
isDefListStart(lines, i) -> {
val term = line.trim()
val defs = mutableListOf<String>()
i++
while (i < lines.size && lines[i].trim().startsWith(":")) {
defs.add(lines[i].trim().removePrefix(":").trim())
i++
}
blocks.add(MdBlock.DefList(listOf(DefItem(term, defs))))
}
LIST_ITEM_LINE_RE.matches(line) -> {
val (listBlocks, nextIndex) = parseList(lines, i)
blocks.addAll(listBlocks)
i = nextIndex
}
// Standalone Markdown image: ![alt](uri) on its own line.
line.isNotBlank() && tryParseVideo(line) != null -> {
blocks.add(tryParseVideo(line)!!)
i++
}
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
}
else -> {
i = fallthroughToParagraph(lines, i, blocks)
}
}
}
if (footnotes.isNotEmpty()) {
blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) }))
}
return blocks
}
private sealed interface ParsedItem {
val indent: Int
val continuationLines: MutableList<String>
data class Bullet(
override val indent: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Task(
override val indent: Int,
val checked: Boolean,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
data class Ordered(
override val indent: Int,
val rawNumber: Int,
val text: String,
override val continuationLines: MutableList<String> = mutableListOf(),
) : ParsedItem
}
private fun computeIndent(prefix: String): Int {
var count = 0
for (ch in prefix) {
if (ch == '\t') {
count += 4 - (count % 4)
} else {
count++
}
}
return count
}
private fun parseList(
lines: List<String>,
startIndex: Int,
): Pair<List<MdBlock>, Int> {
val items = mutableListOf<ParsedItem>()
var j = startIndex
while (j < lines.size) {
val line = lines[j]
val taskMatch = TASK_LINE_RE.matchEntire(line)
val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null
val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null
if (taskMatch != null) {
val indent = computeIndent(taskMatch.groupValues[1])
val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true)
val text = taskMatch.groupValues[3]
items.add(ParsedItem.Task(indent, checked, text))
j++
} else if (bulletMatch != null) {
val indent = computeIndent(bulletMatch.groupValues[1])
val text = bulletMatch.groupValues[2]
items.add(ParsedItem.Bullet(indent, text))
j++
} else if (orderedMatch != null) {
val indent = computeIndent(orderedMatch.groupValues[1])
val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1
val text = orderedMatch.groupValues[3]
items.add(ParsedItem.Ordered(indent, num, text))
j++
} else if (line.isBlank()) {
// Check if there is a following list item
var lookahead = j + 1
while (lookahead < lines.size && lines[lookahead].isBlank()) {
lookahead++
}
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
j = lookahead
} else {
break
}
} else {
// Check if this is a continuation line for the previous list item
val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' })
if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) {
items.last().continuationLines.add(line.trim())
j++
} else {
break
}
}
}
if (items.isEmpty()) {
return emptyList<MdBlock>() to j
}
val blocks = mutableListOf<MdBlock>()
// Track state of list items and their relative nesting depth using an indent stack
val indentStack = mutableListOf<Int>() // maps level (index) -> base indent
var lastItemWasOrdered = false
var currentSequenceNumber = 1
var lastLevel = -1
for (item in items) {
// Resolve level using the indent stack
val level: Int
if (indentStack.isEmpty() || item.indent == 0) {
indentStack.clear()
indentStack.add(item.indent)
level = 0
} else if (item.indent > indentStack.last()) {
indentStack.add(item.indent)
level = indentStack.size - 1
} else if (item.indent == indentStack.last()) {
level = indentStack.size - 1
} else {
// Unwind stack to the matching or nearest smaller indent
while (indentStack.size > 1 && indentStack.last() > item.indent) {
indentStack.removeAt(indentStack.size - 1)
}
level = indentStack.size - 1
}
val fullText =
if (item.continuationLines.isEmpty()) {
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
} else {
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
base + " " + item.continuationLines.joinToString(" ")
}
when (item) {
is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Task -> {
blocks.add(MdBlock.Task(item.checked, fullText, level = level))
lastItemWasOrdered = false
lastLevel = level
}
is ParsedItem.Ordered -> {
val indexToUse =
if (level == lastLevel && lastItemWasOrdered) {
if (item.rawNumber > currentSequenceNumber) {
currentSequenceNumber = item.rawNumber
} else {
currentSequenceNumber++
}
currentSequenceNumber
} else {
currentSequenceNumber = item.rawNumber
currentSequenceNumber
}
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
lastItemWasOrdered = true
lastLevel = level
}
}
}
return blocks to j
}
private fun isHorizontalRule(line: String): Boolean {
val trimmed = line.trim()
if (trimmed.length < 3) return false
val c = trimmed[0]
return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c }
}
private fun isTableStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i]
if (!l.contains('|')) return false
if (i + 1 >= lines.size) return false
return isTableSeparator(lines[i + 1])
}
private fun isTableSeparator(line: String): Boolean {
val t = line.trim().trim('|')
if (t.isEmpty()) return false
// every cell must be only dashes/colons/spaces, with at least one dash
return t.split('|').all { cell ->
val c = cell.trim()
c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' }
}
}
private fun parseTable(
lines: List<String>,
i: Int,
): Triple<List<String>, List<TableAlign>, List<List<String>>> {
val header = splitRow(lines[i])
val alignments =
splitRow(lines[i + 1]).map { cell ->
val c = cell.trim()
when {
c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER
c.endsWith(":") -> TableAlign.RIGHT
c.startsWith(":") -> TableAlign.LEFT
else -> TableAlign.LEFT
}
}
val body = mutableListOf<List<String>>()
var j = i + 2
while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) {
body.add(splitRow(lines[j]))
j++
}
return Triple(header, alignments, body)
}
private fun splitRow(line: String): List<String> {
val trimmed = line.trim().trim('|')
return trimmed.split('|').map { it.trim() }
}
private data class CodeFenceInfo(
val fenceChar: Char,
val fenceLength: Int,
val language: String?,
)
private fun parseCodeFenceStart(line: String): CodeFenceInfo? {
val trimmed = line.trimStart()
if (trimmed.length < 3) return null
val firstChar = trimmed[0]
if (firstChar != '`' && firstChar != '~') return null
val count = trimmed.takeWhile { it == firstChar }.length
if (count < 3) return null
val rest = trimmed.substring(count).trim()
if (firstChar == '`' && rest.contains('`')) return null
return CodeFenceInfo(
fenceChar = firstChar,
fenceLength = count,
language = rest.ifBlank { null },
)
}
private fun isCodeFenceEnd(
line: String,
fence: CodeFenceInfo,
): Boolean {
val trimmed = line.trimStart()
val count = trimmed.takeWhile { it == fence.fenceChar }.length
if (count < fence.fenceLength) return false
val rest = trimmed.substring(count).trim()
return rest.isEmpty()
}
private fun isDefListStart(
lines: List<String>,
i: Int,
): Boolean {
val l = lines[i].trim()
if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false
if (LIST_ITEM_LINE_RE.matches(lines[i])) return false
if (i + 1 >= lines.size) return false
return lines[i + 1].trim().startsWith(":")
}
private fun isValidHeading(line: String): Boolean {
if (!line.startsWith("#")) return false
val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6)
return level < line.length && line[level] == ' '
}
private fun tryParseImage(line: String): MdBlock.Image? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
private fun tryParseVideo(line: String): MdBlock.Video? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
val ext = uri.substringAfterLast('.', "").lowercase()
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
}
return null
}
private fun fallthroughToParagraph(
lines: List<String>,
start: Int,
blocks: MutableList<MdBlock>,
): Int {
var i = start
val para = mutableListOf<String>()
while (
i < lines.size &&
lines[i].isNotBlank() &&
parseCodeFenceStart(lines[i]) == null &&
!isValidHeading(lines[i]) &&
!lines[i].startsWith(">") &&
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
!isHorizontalRule(lines[i]) &&
!isTableStart(lines, i) &&
!isDefListStart(lines, i) &&
tryParseDisplayMath(lines, i) == null &&
FN_DEF_RE.matchAt(lines[i], 0) == null
) {
para.add(lines[i])
i++
}
if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n")))
return i
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test
import kotlin.system.measureTimeMillis
@@ -11,6 +11,10 @@ import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.FnNote
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import com.m57.hermescontrol.util.BidiUtils
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse