Allow inline code spans in Markdown link labels - #118
Conversation
Every pass after the first consulted the claimed ranges by scanning the whole array: once per character in scanEscapes and collectDelimiterRuns, once per candidate in scanLinkFamily. buildTree then decided containment by testing each span against every other one. All fine at ordinary densities, ~n^2 when a single paragraph carries hundreds of spans. Both scans are avoidable for the same reason. The passes walk the string left to right and never look back, and claimed ranges never PARTIALLY overlap, so a cursor over the sorted ranges answers "is this claimed?" in amortised constant time. Containment falls out of the same invariant: sorting spans by start ascending and length descending puts every span immediately after the one containing it, so buildTree becomes a single ordered walk. A paragraph of 240 code spans parses in 0.5ms rather than 33ms. 6x the spans now costs ~6x the parse instead of ~30x. Affects every claimed-span construct — code, escapes, links, images, wiki links, inline LaTeX, emphasis, and extension spans. No parse result changes. InlineSpanDensityTests folds the parsed tree of a 4000-input pseudo-random corpus into one fingerprint, recorded on the pre-rewrite parser at this branch's merge base; the scaling assertions fail on that parser at 11.3x-27.8x against an 8x bound. Rebased onto nodes-app#118, which relaxed the invariant this rests on: a link may now contain a claimed code span inside its label, so claimed ranges are disjoint OR properly nested rather than strictly disjoint. Three consequences, all handled here. * ClaimedIndex gains `overlapping`, which enumerates every claimed range meeting a candidate instead of answering yes/no. Only the link case needs it; everything else still short-circuits on the first overlap. It peeks forward from the cursor rather than advancing it, so the left-to-right walk is unaffected and the cost property holds. * `contains` needed no change: a nested range sorts after its container, which already covers it. * The corpus fingerprint was re-recorded on the pre-rewrite parser at the new merge base. It reproduces that parser's trees exactly, which is what makes this a pure performance change on top of nodes-app#118 as well. The scaling bound moved from 12x to 8x. Measured on one machine: worst case after the rewrite is 5.6x (code), best case before it is 11.3x (highlight), so 12x let the two cheapest constructs pass on the OLD parser — the one thing the assertion exists to prevent. 8x is the geometric midpoint of that gap, with ~1.4x of room either side. Closes nodes-app#109 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every pass after the first consulted the claimed ranges by scanning the whole array: once per character in scanEscapes and collectDelimiterRuns, once per candidate in scanLinkFamily. buildTree then decided containment by testing each span against every other one. All fine at ordinary densities, ~n^2 when a single paragraph carries hundreds of spans. Both scans are avoidable for the same reason. The passes walk the string left to right and never look back, and claimed ranges never PARTIALLY overlap, so a cursor over the sorted ranges answers "is this claimed?" in amortised constant time. Containment falls out of the same invariant: sorting spans by start ascending and length descending puts every span immediately after the one containing it, so buildTree becomes a single ordered walk. A paragraph of 240 code spans parses in 0.5ms rather than 33ms. 6x the spans now costs ~6x the parse instead of ~30x. Affects every claimed-span construct — code, escapes, links, images, wiki links, inline LaTeX, emphasis, and extension spans. No parse result changes. InlineSpanDensityTests folds the parsed tree of a 4000-input pseudo-random corpus into one fingerprint, recorded on the pre-rewrite parser at this branch's merge base; the scaling assertions fail on that parser at 11.3x-27.8x against an 8x bound. Rebased onto #118, which relaxed the invariant this rests on: a link may now contain a claimed code span inside its label, so claimed ranges are disjoint OR properly nested rather than strictly disjoint. Three consequences, all handled here. * ClaimedIndex gains `overlapping`, which enumerates every claimed range meeting a candidate instead of answering yes/no. Only the link case needs it; everything else still short-circuits on the first overlap. It peeks forward from the cursor rather than advancing it, so the left-to-right walk is unaffected and the cost property holds. * `contains` needed no change: a nested range sorts after its container, which already covers it. * The corpus fingerprint was re-recorded on the pre-rewrite parser at the new merge base. It reproduces that parser's trees exactly, which is what makes this a pure performance change on top of #118 as well. The scaling bound moved from 12x to 8x. Measured on one machine: worst case after the rewrite is 5.6x (code), best case before it is 11.3x (highlight), so 12x let the two cheapest constructs pass on the OLD parser — the one thing the assertion exists to prevent. 8x is the geometric midpoint of that gap, with ~1.4x of room either side. Closes #109 Co-authored-by: Jason Jobe <box2019@jasonjobe.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Hi — flagging an overlap so it doesn't surprise either of us at merge time, and with something useful attached. I opened #140 yesterday (fixes #109, the quadratic span scans). It rewrites the same two things this PR changes: I tried composing them locally before saying anything, and the result is in your favour. Your rule ports to the cursor in about 10 lines, and all five of your parser tests plus the styler test pass unmodified: /// True when a claimed range intersects `range` and isn't wholly inside
/// `allowed`. Walks only the intersecting ranges, not the whole array.
mutating func overlaps(_ range: NSRange, exceptWhollyInside allowed: NSRange) -> Bool {
advance(to: range.location)
var k = cursor
while k < ranges.count, ranges[k].location < NSMaxRange(range) {
if !rangeContains(allowed, ranges[k]) { return true }
k += 1
}
return false
}Your And the two compose to identical output. #140 carries a differential test that folds the parsed tree of 4000 pseudo-random inputs into one fingerprint. #140+#118 and your branch alone both produce One thing worth knowing either way:
Rebased onto #140 it stays linear, because the cursor only visits the ranges that actually intersect. Even standalone it's a one-line fix — Nice catch on the underlying bug, by the way; |
|
Disregard my note above — I wrote it against a stale view of the repo and posted it after this had already merged, so the offer in it is moot. Apologies for the noise. For anyone finding this later: #140 landed on top and the maintainer rebased it, so |
Summary
Allow Markdown links to contain inline code and escaped punctuation in their labels while preserving the parser's existing overlap protections.
For example, this now parses and styles the code span inside:
Root cause
The inline parser scans code spans and escapes before links so those higher-precedence spans remain opaque. The link pass previously rejected every candidate that overlapped an already claimed span, including spans fully contained within the link label.
As a result, valid label content such as inline code caused the entire Markdown link to remain literal.
Changes
This changes no public API.
Validation
swift test