Skip to content

Allow inline code spans in Markdown link labels - #118

Merged
luca-chen198 merged 1 commit into
nodes-app:mainfrom
yukihiratype2:fix/link-code-span-overlap
Aug 5, 2026
Merged

Allow inline code spans in Markdown link labels#118
luca-chen198 merged 1 commit into
nodes-app:mainfrom
yukihiratype2:fix/link-code-span-overlap

Conversation

@yukihiratype2

@yukihiratype2 yukihiratype2 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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:

[`App`](/tmp/App.swift:56)

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

  • Permit previously claimed spans only when they are fully contained within a Markdown link's label.
  • Treat links as containers when constructing the inline syntax tree.
  • Continue rejecting partial overlaps and spans that cross a label boundary.
  • Keep Markdown-looking content inside code spans opaque.
  • Add parser coverage for:
    • inline code inside a link label
    • multiple code spans in one label
    • escaped punctuation in a label
    • a code span crossing the label boundary
    • link-looking text inside code
  • Add a styling regression test confirming linked inline code receives both code and link attributes.

This changes no public API.

Validation

  • swift test
    • 306 tests passed across 54 suites

@yukihiratype2
yukihiratype2 marked this pull request as ready for review August 3, 2026 13:14
@luca-chen198
luca-chen198 merged commit 1a2bd74 into nodes-app:main Aug 5, 2026
1 check passed
luca-chen198 pushed a commit to wildthink/swift-markdown-engine that referenced this pull request Aug 6, 2026
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>
luca-chen198 pushed a commit that referenced this pull request Aug 6, 2026
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>
@wildthink

wildthink commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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: Span.containerContent and scanLinkFamily's overlap check. The patches don't apply to each other, so whichever lands second needs a rebase.

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 containerContent change turns out to be unnecessary on top of #140. You extend it to links so that label-nested spans are suppressed at top level. #140 replaces the pairwise containment test with an ordered walk that already skips anything nested inside the span it just consumed — I'd written that step defensively, believing nothing was ever nested inside a non-emphasis span. Your PR makes it real, and it already does the right thing. The children still come from reparse(textRange) exactly as you have it, so containerContent can be deleted outright.

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 b74649ffbbbe237a — the same trees on every input, not just on the six tests. That's the strongest evidence I could give you that the rebase is behaviour-preserving.

One thing worth knowing either way: hasDisallowedClaimedOverlap builds an array with claimed.filter { … } for every candidate at every position, where the old code was a short-circuiting contains. On current main that measures as:

main this PR
links, 40 spans in a paragraph 0.59ms 1.05ms
links, 240 spans 9.31ms 12.77ms

Rebased onto #140 it stays linear, because the cursor only visits the ranges that actually intersect. Even standalone it's a one-line fix — contains(where:) instead of filter, checking containment inline — if #140 doesn't land first.

Nice catch on the underlying bug, by the way; [`App`](path) staying literal is exactly the kind of thing that reads as broken. Happy to hand you the rebased version as a patch or a PR into your branch, or to rebase mine onto yours if @luca-chen198 would rather take this one first — genuinely no preference on ordering, just flagging that the second one through is cheap either way.

@wildthink

Copy link
Copy Markdown
Contributor

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 containerContent is gone and the label-nesting rule now lives in ClaimedIndex.overlapping. Behaviour here is unchanged — the 4000-input corpus fingerprints identically before and after that rewrite.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants