feat(markdown): support variable-length code fences and nested codeblocks (#1003)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-08-28 20:27:17 +03:00
committed by GitHub
co-authored by M57
parent 7a2c453f58
commit db3f8c736e
2 changed files with 126 additions and 7 deletions
@@ -694,15 +694,15 @@ internal fun parseBlocks(src: String): List<MdBlock> {
continue continue
} }
val codeFence = parseCodeFenceStart(line)
when { when {
line.startsWith("```") -> { codeFence != null -> {
val lang = line.removePrefix("```").trim().ifBlank { null } val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") }
if (end != null) { if (end != null) {
blocks.add( blocks.add(
MdBlock.Code( MdBlock.Code(
code = lines.subList(i + 1, end).joinToString("\n"), code = lines.subList(i + 1, end).joinToString("\n"),
language = lang, language = codeFence.language,
), ),
) )
i = end + 1 i = end + 1
@@ -710,7 +710,7 @@ internal fun parseBlocks(src: String): List<MdBlock> {
blocks.add( blocks.add(
MdBlock.Code( MdBlock.Code(
code = lines.subList(i + 1, lines.size).joinToString("\n"), code = lines.subList(i + 1, lines.size).joinToString("\n"),
language = lang, language = codeFence.language,
), ),
) )
i = lines.size i = lines.size
@@ -1023,12 +1023,45 @@ private fun splitRow(line: String): List<String> {
return trimmed.split('|').map { it.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( private fun isDefListStart(
lines: List<String>, lines: List<String>,
i: Int, i: Int,
): Boolean { ): Boolean {
val l = lines[i].trim() 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 (LIST_ITEM_LINE_RE.matches(lines[i])) return false
if (i + 1 >= lines.size) return false if (i + 1 >= lines.size) return false
return lines[i + 1].trim().startsWith(":") return lines[i + 1].trim().startsWith(":")
@@ -1050,7 +1083,7 @@ private fun fallthroughToParagraph(
while ( while (
i < lines.size && i < lines.size &&
lines[i].isNotBlank() && lines[i].isNotBlank() &&
!lines[i].startsWith("```") && parseCodeFenceStart(lines[i]) == null &&
!isValidHeading(lines[i]) && !isValidHeading(lines[i]) &&
!lines[i].startsWith(">") && !lines[i].startsWith(">") &&
!LIST_ITEM_LINE_RE.matches(lines[i]) && !LIST_ITEM_LINE_RE.matches(lines[i]) &&
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -452,4 +453,89 @@ class MarkdownTextFeatureTest {
assertEquals(2, o16.index) assertEquals(2, o16.index)
assertEquals(0, o16.level) 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"))
}
} }