Skip to content

flushInline closes boolean-flag spans (mark/del/sup) before draining inlineOpenStack — crossed/non-LIFO event stream for em inside ==…== / ~~…~~ / ^…^ #60

Description

@morisil

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:

  1. when (buf) — resolves the buffered delimiter run (incl. the "=="unmark("mark") and "~"/"~~"unmark("del") cases).
  2. if (math) unmark("math")
  3. if (highlight) unmark("mark")
  4. if (superscript) unmark("sup")
  5. if (strikethrough) unmark("del")
  6. 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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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