Skip to content

LIFO violation closing a del/mark/sup span while an inner span is open (boolean-flag spans are not order-tracked) #62

Description

@morisil

Summary

del (~~), mark (==), and sup (^) are tracked in the parser as
independent boolean flags (strikethrough / highlight / superscript),
not on the ordered inlineOpenStack that carries em / strong. Because their
opening order is not recorded, no close path can tell an inner span from an
outer one
, so closing one of these spans while another inline span (another
boolean flag, or an em/strong) opened after it is still open emits a
crossed, non-LIFO event stream.

The semantic event stream invariant requires every unmark to match the LIFO
top. A crossed stream is a contract violation: the reverse Markdown renderer
pops the wrong frame and writes the wrong delimiter (e.g. ~~foo ^bar^~~); the
HTML renderer produces mis-nested elements.

This is the same family as #58 (crossed parser streams) but a distinct root
cause: #58 is em/strong pairing across a link-label watermark; this is
del/mark/sup not co-closing in opening order with each other or with the
stack.

Reproduction

All of the following parse to crossed (unmark ≠ LIFO top) streams today:

input events rendered (scrambled)
~~foo ^bar~~ +del +sup … -del -sup ~~foo ^bar^~~
==foo ^bar== +mark +sup … -mark -sup ==foo ^bar^==
~~a ^b~~c +del +sup … -del … -sup ~~a ^b^c~~
==a ^b==c +mark +sup … -mark … -sup ==a ^b^c==
~~a *b~~ +del +em … -del -em ~~a *b*~~

(The ~~foo ^bar~~ / ==foo ^bar== at-flush cases were introduced by PR #57's
change to the "~"/"~~" and "==" arms of flushInline's when(buf) block —
pre-PR they emitted the trailing run as literal text and the fixed-order cleanup
block happened to close sup-before-del, which was LIFO-correct. The mid-line
cases — ~~a ^b~~c etc. — and the del-before-em case are pre-existing:
the mid-line ~~/== close at MarkanywhereParser.kt ~6093 and the
fixed-order boolean cleanup in flushInline have the same shape.)

@Test
fun `del span containing an inner sup must close LIFO`() = runTest {
    val events = flowOf("~~foo ^bar~~").parse().toList()
    // EXPECTED LIFO close order: -sup then -del
    // ACTUAL: -del then -sup  (crossed)
}

Why the obvious local fix is wrong

The natural quick fix — in the flushInline "~"/"~~" arm, drain
superscript/highlight before unmark("del") — is incorrect, because it
assumes the other open flags are always inner. They can be outer:
==a ~~b~~ is balanced today (+mark … +del … -del -mark: inner del
closes first, outer mark second). Draining highlight before del
unconditionally would close the outer mark first → a new LIFO violation.
There is no correct localized fix without opening-order information.

Recommended fix

Track all inline spans in a single ordered structure so every close is true
LIFO. The cleanest approach is to push del/mark/sup onto the same ordered
open stack as em/strong (with a discriminator), so:

  • a close walks the stack LIFO, force-closing inner spans first (as
    closeInlineDownTo already does for em/strong);
  • flushInline drains via a single while (stack) loop instead of the
    fixed-order boolean cleanup block;
  • the linkLabelOuter{Strikethrough,Highlight,Superscript} watermark snapshots
    likely become unnecessary (boolean spans would be scoped by
    linkLabelOuterStackDepth like em/strong).

This is a non-trivial refactor touching every del/mark/sup open/close site and
flushInline, with broad test impact — hence a separate issue rather than a
PR #57 hotfix.

Origin

Surfaced by the PR #57 review:
#57 (comment)

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