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)
Summary
del(~~),mark(==), andsup(^) are tracked in the parser asindependent boolean flags (
strikethrough/highlight/superscript),not on the ordered
inlineOpenStackthat carriesem/strong. Because theiropening 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 acrossed, non-LIFO event stream.
The semantic event stream invariant requires every
unmarkto match the LIFOtop. A crossed stream is a contract violation: the reverse Markdown renderer
pops the wrong frame and writes the wrong delimiter (e.g.
~~foo ^bar^~~); theHTML renderer produces mis-nested elements.
This is the same family as #58 (crossed parser streams) but a distinct root
cause: #58 is
em/strongpairing across a link-label watermark; this isdel/mark/supnot co-closing in opening order with each other or with thestack.
Reproduction
All of the following parse to crossed (
unmark≠ LIFO top) streams today:~~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'schange to the
"~"/"~~"and"=="arms offlushInline'swhen(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~~cetc. — and thedel-before-emcase are pre-existing:the mid-line
~~/==close atMarkanywhereParser.kt~6093 and thefixed-order boolean cleanup in
flushInlinehave the same shape.)Why the obvious local fix is wrong
The natural quick fix — in the
flushInline"~"/"~~"arm, drainsuperscript/highlightbeforeunmark("del")— is incorrect, because itassumes the other open flags are always inner. They can be outer:
==a ~~b~~is balanced today (+mark … +del … -del -mark: innerdelcloses first, outer
marksecond). Draininghighlightbeforedelunconditionally would close the outer
markfirst → 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/suponto the same orderedopen stack as
em/strong(with a discriminator), so:closeInlineDownToalready does forem/strong);flushInlinedrains via a singlewhile (stack)loop instead of thefixed-order boolean cleanup block;
linkLabelOuter{Strikethrough,Highlight,Superscript}watermark snapshotslikely 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 aPR #57 hotfix.
Origin
Surfaced by the PR #57 review:
#57 (comment)