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:
@@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user