Skip to content

Hard crash (ClassCastException) rendering emphasis around a link whose label contains a matching delimiter — parser emits a crossed event stream #58

Description

@morisil

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

  1. 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).
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions