Repository navigation
LIFO violation closing a del/mark/sup span while an inner span is open (boolean-flag spans are not order-tracked) #62
Description
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
closeInlineDownToalready does forem/strong); flushInlinedrains via a singlewhile (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
linkLabelOuterStackDepthlikeem/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