Summary
flushInline closes the boolean-flag inline spans (mark/del/sup — tracked by highlight/strikethrough/superscript) before it drains the inlineOpenStack (em/strong). When an em/strong is opened inside a mark/del/sup span and both reach a block boundary still open, the resulting event stream is crossed (non-LIFO) — it violates the core invariant that every mark pairs with a matching unmark in LIFO order (see CLAUDE.md).
This is pre-existing (the if (highlight) / if (strikethrough) / if (superscript) force-close blocks have always run before the inlineOpenStack drain). It was surfaced during review of #57, whose new "==" case in flushInline's when(buf) participates in the same ordering for trailing-== inputs but does not uniquely cause it.
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 `crossed stream for em inside a boolean-flag span at block end`() = runTest {
// event stream is +mark +em -mark -em (should be -em -mark)
flowOf("==a*b").parse() /* .toList() to inspect */
}
Inputs and their (crossed) event sequences:
| input |
event sequence (+=mark, -=unmark) |
LIFO? |
render output |
==a*b |
+p +mark +em -mark -em -p |
❌ |
==a*b*== |
==a*b== |
+p +mark +em -mark -em -p |
❌ |
==a*b*== |
~~a*b |
+p +del +em -del -em -p |
❌ |
~~a*b*~~ |
^a*b |
+p +sup +em -sup -em -p |
❌ |
^a*b*^ |
For comparison, the reviewer's original example ==*foo== does NOT reproduce — there the == followed immediately by * stays literal (+p +em -em -p, balanced), so it produces ==*foo==* with no crossing.
Root cause
flushInline closes in this order:
when (buf) — resolves the buffered delimiter run (incl. the "==" → unmark("mark") and "~"/"~~" → unmark("del") cases).
if (math) unmark("math")
if (highlight) unmark("mark")
if (superscript) unmark("sup")
if (strikethrough) unmark("del")
while (inlineOpenStack.isNotEmpty()) unmark(frame) — the em/strong drain.
mark/del/sup (boolean flags) and em/strong (inlineOpenStack) are tracked in two separate structures with no shared ordering. When a mark opens first and an em opens inside it, steps 1/3 emit unmark("mark") while the em is still open, and step 6 emits unmark("em") afterward — closing the outer span before the inner one.
Impact
Expected
flushInline should force-close all open inline spans in strict LIFO order regardless of which structure tracks them — i.e. interleave the boolean-flag closes and the inlineOpenStack drain by open-depth. For ==a*b that yields +mark +em … -em -mark (well-nested <mark>a<em>b</em></mark>).
Why it's out of scope for #57
The fix requires restructuring flushInline's close logic to merge the two tracking structures into a single depth-ordered close — a non-trivial change to a hot, invariant-critical path, unrelated to #57's emphasis-delimiter round-trip fixes. Filing separately per reviewer request.
Possible directions
- Give
mark/del/sup/math a position on a unified open-order stack (the cleanest, but a larger refactor — same structural gap noted for the linkLabelOuter* snapshots).
- In
flushInline, before the boolean-flag closes, drain any inlineOpenStack frames that were opened after the boolean-flag span, then close the flag, repeating by depth.
Summary
flushInlinecloses the boolean-flag inline spans (mark/del/sup— tracked byhighlight/strikethrough/superscript) before it drains theinlineOpenStack(em/strong). When anem/strongis opened inside amark/del/supspan and both reach a block boundary still open, the resulting event stream is crossed (non-LIFO) — it violates the core invariant that everymarkpairs with a matchingunmarkin LIFO order (see CLAUDE.md).This is pre-existing (the
if (highlight)/if (strikethrough)/if (superscript)force-close blocks have always run before theinlineOpenStackdrain). It was surfaced during review of #57, whose new"=="case influshInline'swhen(buf)participates in the same ordering for trailing-==inputs but does not uniquely cause it.Reproduction
Inputs and their (crossed) event sequences:
+=mark,-=unmark)==a*b+p +mark +em -mark -em -p==a*b*====a*b==+p +mark +em -mark -em -p==a*b*==~~a*b+p +del +em -del -em -p~~a*b*~~^a*b+p +sup +em -sup -em -p^a*b*^For comparison, the reviewer's original example
==*foo==does NOT reproduce — there the==followed immediately by*stays literal (+p +em -em -p, balanced), so it produces==*foo==*with no crossing.Root cause
flushInlinecloses in this order:when (buf)— resolves the buffered delimiter run (incl. the"=="→unmark("mark")and"~"/"~~"→unmark("del")cases).if (math) unmark("math")if (highlight) unmark("mark")if (superscript) unmark("sup")if (strikethrough) unmark("del")while (inlineOpenStack.isNotEmpty()) unmark(frame)— theem/strongdrain.mark/del/sup(boolean flags) andem/strong(inlineOpenStack) are tracked in two separate structures with no shared ordering. When amarkopens first and anemopens inside it, steps 1/3 emitunmark("mark")while theemis still open, and step 6 emitsunmark("em")afterward — closing the outer span before the inner one.Impact
BlockFrame.Inline, so theas BlockFrame.Inlinecast succeeds — it just writes the wrong delimiter (the popped frame's), garbling the output (==a*b→==a*b*==). Other LIFO-stack consumers of the event stream would mis-nest similarly.Expected
flushInlineshould force-close all open inline spans in strict LIFO order regardless of which structure tracks them — i.e. interleave the boolean-flag closes and theinlineOpenStackdrain by open-depth. For==a*bthat yields+mark +em … -em -mark(well-nested<mark>a<em>b</em></mark>).Why it's out of scope for #57
The fix requires restructuring
flushInline's close logic to merge the two tracking structures into a single depth-ordered close — a non-trivial change to a hot, invariant-critical path, unrelated to #57's emphasis-delimiter round-trip fixes. Filing separately per reviewer request.Possible directions
mark/del/sup/matha position on a unified open-order stack (the cleanest, but a larger refactor — same structural gap noted for thelinkLabelOuter*snapshots).flushInline, before the boolean-flag closes, drain anyinlineOpenStackframes that were opened after the boolean-flag span, then close the flag, repeating by depth.