Summary
Rendering certain Markdown where an emphasis span wraps an inline link whose label contains a lone, matching emphasis delimiter hard-crashes the reverse renderer (Flow<SemanticEvent>.asMarkdown()) with a ClassCastException.
The root cause is in the parser: it emits an unbalanced / crossed (non-LIFO) event stream for this shape — the * inside the link label pairs with the outer * (opened before the [), so Unmark(em) is emitted while the Link frame is still open. The renderer assumes well-nested (LIFO) marks and casts the popped frame to BlockFrame.Inline, which throws when the popped frame is actually a BlockFrame.Link.
This is independent of PR #57 (the renderer never escapes label content against outer emphasis, before or after that PR) — it is a pre-existing latent bug, surfaced while writing tests for that PR.
Reproduction
import com.xemantic.markanywhere.parse.parse
import com.xemantic.markanywhere.render.renderMarkdown
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@Test
fun `reproduce crossed-stream crash`() = runTest {
// throws java.lang.ClassCastException
flowOf("*x [a*b](u) y*").parse().renderMarkdown()
}
Minimal trigger string: *x [a*b](u) y*
(An <em> wrapping a link [a*b](u) whose label text contains a single * that matches the outer *.)
Actual behavior
java.lang.ClassCastException: class com.xemantic.markanywhere.render.BlockFrame$Link
cannot be cast to class com.xemantic.markanywhere.render.BlockFrame$Inline
at com.xemantic.markanywhere.render.MarkdownRenderingKt$asMarkdown$1$1.emit(MarkdownRendering.kt:916)
MarkdownRendering.kt:916 is the Unmark handler for inline emphasis:
"strong", "em", "del", "mark", "sup" -> {
writeRaw((frame as BlockFrame.Inline).delimiter) // <- frame is actually a Link
refreshActiveDelimiters()
}
Root cause — the parser emits a crossed stream
flowOf("*x [a*b](u) y*").parse() produces (mergeAdjacentText’d):
Mark(p)
Mark(em) <- em opens (the leading *)
Text(x )
Mark(a, href=u) <- link opens INSIDE em
Text(a)
Unmark(em) <- em CLOSES while the link is still open ❌ crossed / non-LIFO
Text(b)
Unmark(a) <- link closes
Text( y*)
Unmark(p)
The * between a and b inside the label [a*b] pairs with the outer em's opening *, emitting Unmark(em) before Unmark(a). The semantic event stream invariant (every mark paired with a matching unmark in LIFO order — see CLAUDE.md) is violated, so any consumer that maintains an open-element stack breaks. The renderer is one such consumer; simplifyHtml/renderHtml and the HTML renderer would mis-nest too.
The parser already has the machinery to prevent this: linkLabelOuterStackDepth is the watermark that scopes label-internal emphasis so an inner delimiter cannot pair with a frame opened outside the label (it is what makes [*foo*](/uri) and [**bold**](u) work). For a lone, unpaired delimiter inside the label that happens to match an outer open emphasis, that watermark is not being honored on the close path — the inner * is allowed to close the outer em.
Notes:
*[a*](u)* does not crash (the inner a* stays literal text — the label-local resolution keeps it as content), so the trigger needs the specific [a*b] shape with text on both sides of the inner delimiter plus the matching outer span.
[a*b](u) on its own renders [a*b*](u) (the lone label * eagerly opens an em that force-closes) — also arguably wrong, but it does not crash; the crash needs the outer em to cross.
Expected behavior
The parser must never emit a crossed/unbalanced stream. For *x [a*b](u) y* the lone label * should stay literal label content (scoped below linkLabelOuterStackDepth), yielding the well-nested:
Mark(em) Text(x ) Mark(a) Text(a*b) Unmark(a) Text( y) Unmark(em)
→ <em>x <a href="u">a*b</a> y</em> → re-renders to *x [a*b](u) y* (a stable fixpoint).
Suggested direction
- Primary (parser): honor
linkLabelOuterStackDepth on the emphasis close path so a delimiter run inside a label cannot close an emphasis frame opened before the label — keep it as literal label content instead (the lone-delimiter analogue of the label-local close already done in closeLabelLocalEmphasisRun).
- Defense in depth (renderer): the
Unmark emphasis handler could guard blockStack.last() is BlockFrame.Inline and fall back (e.g. drop / emit literally) instead of a hard cast, so a malformed stream degrades rather than throwing. This would also protect against any other source of unbalanced streams. (A hard crash on parseable input is the more serious symptom.)
Severity
A hard crash (ClassCastException) on plausible, well-formed Markdown input (emphasis around a link whose text contains the same delimiter). Affects the reverse renderer and likely any LIFO-stack consumer of the event stream.
Summary
Rendering certain Markdown where an emphasis span wraps an inline link whose label contains a lone, matching emphasis delimiter hard-crashes the reverse renderer (
Flow<SemanticEvent>.asMarkdown()) with aClassCastException.The root cause is in the parser: it emits an unbalanced / crossed (non-LIFO) event stream for this shape — the
*inside the link label pairs with the outer*(opened before the[), soUnmark(em)is emitted while theLinkframe is still open. The renderer assumes well-nested (LIFO) marks and casts the popped frame toBlockFrame.Inline, which throws when the popped frame is actually aBlockFrame.Link.This is independent of PR #57 (the renderer never escapes label content against outer emphasis, before or after that PR) — it is a pre-existing latent bug, surfaced while writing tests for that PR.
Reproduction
Minimal trigger string:
*x [a*b](u) y*(An
<em>wrapping a link[a*b](u)whose label text contains a single*that matches the outer*.)Actual behavior
MarkdownRendering.kt:916is theUnmarkhandler for inline emphasis:Root cause — the parser emits a crossed stream
flowOf("*x [a*b](u) y*").parse()produces (mergeAdjacentText’d):The
*betweenaandbinside the label[a*b]pairs with the outer em's opening*, emittingUnmark(em)beforeUnmark(a). The semantic event stream invariant (everymarkpaired with a matchingunmarkin LIFO order — see CLAUDE.md) is violated, so any consumer that maintains an open-element stack breaks. The renderer is one such consumer;simplifyHtml/renderHtmland the HTML renderer would mis-nest too.The parser already has the machinery to prevent this:
linkLabelOuterStackDepthis the watermark that scopes label-internal emphasis so an inner delimiter cannot pair with a frame opened outside the label (it is what makes[*foo*](/uri)and[**bold**](u)work). For a lone, unpaired delimiter inside the label that happens to match an outer open emphasis, that watermark is not being honored on the close path — the inner*is allowed to close the outer em.Notes:
*[a*](u)*does not crash (the innera*stays literal text — the label-local resolution keeps it as content), so the trigger needs the specific[a*b]shape with text on both sides of the inner delimiter plus the matching outer span.[a*b](u)on its own renders[a*b*](u)(the lone label*eagerly opens an em that force-closes) — also arguably wrong, but it does not crash; the crash needs the outer em to cross.Expected behavior
The parser must never emit a crossed/unbalanced stream. For
*x [a*b](u) y*the lone label*should stay literal label content (scoped belowlinkLabelOuterStackDepth), yielding the well-nested:→
<em>x <a href="u">a*b</a> y</em>→ re-renders to*x [a*b](u) y*(a stable fixpoint).Suggested direction
linkLabelOuterStackDepthon the emphasis close path so a delimiter run inside a label cannot close an emphasis frame opened before the label — keep it as literal label content instead (the lone-delimiter analogue of the label-local close already done incloseLabelLocalEmphasisRun).Unmarkemphasis handler could guardblockStack.last() is BlockFrame.Inlineand fall back (e.g. drop / emit literally) instead of a hard cast, so a malformed stream degrades rather than throwing. This would also protect against any other source of unbalanced streams. (A hard crash on parseable input is the more serious symptom.)Severity
A hard crash (
ClassCastException) on plausible, well-formed Markdown input (emphasis around a link whose text contains the same delimiter). Affects the reverse renderer and likely any LIFO-stack consumer of the event stream.