From db3f8c736ee922783b3d5ee2ae12c47b5dd6de54 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:27:17 +0300 Subject: [PATCH] feat(markdown): support variable-length code fences and nested codeblocks (#1003) Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 47 ++++++++-- .../ui/chat/MarkdownTextFeatureTest.kt | 86 +++++++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index f76cca5f..34c68070 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -694,15 +694,15 @@ internal fun parseBlocks(src: String): List { continue } + val codeFence = parseCodeFenceStart(line) when { - line.startsWith("```") -> { - val lang = line.removePrefix("```").trim().ifBlank { null } - val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") } + 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 = lang, + language = codeFence.language, ), ) i = end + 1 @@ -710,7 +710,7 @@ internal fun parseBlocks(src: String): List { blocks.add( MdBlock.Code( code = lines.subList(i + 1, lines.size).joinToString("\n"), - language = lang, + language = codeFence.language, ), ) i = lines.size @@ -1023,12 +1023,45 @@ private fun splitRow(line: String): List { 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, i: Int, ): Boolean { val l = lines[i].trim() - if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || l.startsWith("```")) return false + 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(":") @@ -1050,7 +1083,7 @@ private fun fallthroughToParagraph( while ( i < lines.size && lines[i].isNotBlank() && - !lines[i].startsWith("```") && + parseCodeFenceStart(lines[i]) == null && !isValidHeading(lines[i]) && !lines[i].startsWith(">") && !LIST_ITEM_LINE_RE.matches(lines[i]) && diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 0139b0d2..032c9db4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.searchHighlightColors import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -452,4 +453,89 @@ class MarkdownTextFeatureTest { assertEquals(2, o16.index) assertEquals(0, o16.level) } + + // 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES + @Test + fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() { + val md = + """ + ````markdown + Here is a nested block: + ```python + def foo(): + print("hello") + ``` + End of inner block. + ```` + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals("Should parse as single code block", 1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertEquals("markdown", codeBlock.language) + val expectedInner = + """ + Here is a nested block: + ```python + def foo(): + print("hello") + ``` + End of inner block. + """.trimIndent() + assertEquals(expectedInner, codeBlock.code) + } + + @Test + fun testNestedCodeBlock_fiveBackticksWrappingFourAndThree() { + val md = + """ + ````` + ````markdown + ```python + print("deep") + ``` + ```` + ````` + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals(1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertNull("Language should be null when omitted", codeBlock.language) + assertTrue(codeBlock.code.contains("````markdown")) + assertTrue(codeBlock.code.contains("```python")) + } + + @Test + fun testTildeCodeBlock_parses() { + val md = + """ + ~~~json + {"key": "value"} + ~~~ + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals(1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertEquals("json", codeBlock.language) + assertEquals("{\"key\": \"value\"}", codeBlock.code) + } + + @Test + fun testTildeCodeBlock_wrappingBackticks() { + val md = + """ + ~~~~ + ```python + def foo(): pass + ``` + ~~~~ + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals(1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertTrue(codeBlock.code.contains("```python")) + } }