Moves footnote [^id] badge substitution from BlockHTMLEmitter.renderInline (raw-source split) into InlineHTMLRenderer.Walker.visitText (post-parse), so a reference inside a code span stays literal. Fifth review round; PR #326, merge base a972145.
Verdict: Needs fixes. The core refactor is right and its four stated fixes are confirmed working, but the escape filter added in round 2 misclassifies footnote tokens whenever an earlier [^id]-shaped occurrence was consumed by a non-Text node. Two of the three findings below are outright regressions against origin/main, proven by running both versions of the renderer side by side.
word[^1] more keeps its trailing text (T-1945); emphasis spanning a badge survives; the separator space is preserved.DocumentSourceMapInvariantTests spans — before[^1] after maps to 0+6="before" and 10+6=" after", exactly as the new assertions state. These were corrections, not loosenings.[^id] and the reference starts the next Text node. Regression against origin/main.[^1]) is badged where main left it literal, on a path measured quadratic (14.66x vs main's 3.84x) — a different shape from the entity row already filed under T-1966.<a> for a reference in link text is invalid HTML but strictly better than main (which destroyed the link).FootnotePreprocessor.scanLineForReferences still counts \[^1] as a reference the renderer now refuses to badge (display-number skew); MarkdownBlock.searchableText and SearchStateFeeder still scan raw source, so a code-span-only reference contributes a search match with no badge in its block.xcodebuild test could not be completed on this machine — three attempts died with "The test runner hung before establishing connection" after 11 minutes, with 6 concurrent xcodebuild processes present. Verification was done by compiling the renderer into a standalone harness instead; no claim here rests on a test run I did not get.Needs fixes
The structural move is correct and a genuine simplification: one Document(parsing:) per block instead of M+1, one coordinate space, rebased(_:by:) deleted, and T-1716 / T-1945 / emphasis-spanning-a-badge / the swallowed separator space all confirmed fixed by execution. The six updated DocumentSourceMapInvariantTests assertions are confirmed genuine corrections — every expected span matches the renderer's actual output unit-for-unit.
What blocks the push is the escape filter, not the move. footnoteScanEnd — the anchor the whole classification heuristic rests on — is advanced only by decisions made inside visitText. Any [^id]-shaped source consumed by a different node (visitInlineCode, visitImage, visitLink) is invisible to it, so when a token sits at the start of its Text node the scan restarts from the stale anchor and adopts the earlier occurrence's spelling. Executed, against both HEAD and a verbatim transcription of origin/main's renderInline:
`\[\^1\]`[^1] — the live reference loses its badge (0 badges; main emits 1).`[^1]`\[\^1\] — the escaped reference is badged, which is precisely the T-1716 defect class this branch exists to remove. Also reachable through image alt text and link destinations.[^1] — an entity-spelled reference is badged where main renders it literally, and that path is quadratic: 14.66x for 4x the input against main's 3.84x, above the branch's own 8x ceiling.All three are one root-cause family and all three need production changes plus order-asserting tests. Per the review brief those were reported rather than applied. Nothing else found is blocking.
d9475cd Fix T-1716: Footnote badges no longer replace references inside inline code 53ddc2e Fix review finding: escaped footnote references badged as chrome 47187a6 Merge remote-tracking branch 'origin/main' into T-1716/bugfix-footnote-badge-inline-code e84e6a4 T-1716 review: make the footnote scan linear and anchor it on classified tokens working-tree No fixes applied in this review Prism turns [^1] in a document into a small tappable numbered badge that opens a footnote. Until now it found those markers by searching the raw markdown text and cutting the text into pieces around them, before handing the pieces to the markdown parser.
That meant it could not tell the difference between a real footnote marker and one an author had deliberately written to be shown — for example inside backticks, `[^1]`, which markdown says means "display this literally". So a tutorial paragraph explaining footnote syntax got its example replaced by a badge.
This branch moves the substitution to after parsing, at the point where the parser has already labelled every piece of text as either prose or code. Code is never substituted, so the example survives.
Cutting the text into pieces caused three more problems that all disappear with the same change:
Escaping. Markdown has a second way to display a marker literally: put backslashes in front of it, \[\^1\]. The parser removes those backslashes, so by the time the renderer sees the text it looks identical to a real marker. To tell them apart the renderer looks back at the original document text, which still has the backslashes.
Where the review found trouble. That "look back at the original text" step keeps a bookmark of how far it has read. The bookmark only moves when the step itself makes a decision — it does not move for markers the parser routed into code spans, image captions or link addresses. So if one of those sits just before a real marker, the step looks at the wrong one and copies its answer. Running the code showed this both ways: a real footnote can lose its badge, and an escaped one can wrongly get a badge.
BlockHTMLEmitter.renderInline is now a five-line delegate: one InlineHTMLRenderer.render call over the block's whole inline source, runs appended verbatim. Gone with the segment loop are the M+1 Document(parsing:) calls per block, the M+1 Array(source.utf16) conversions, the utf16Offset(in:) per match, and the rebased(_:by:) helper T-1876 added purely to undo the coordinate damage the split caused. Net −78 lines in the emitter, +228 in the renderer.
Substitution now lives in InlineHTMLRenderer.Walker.visitText. Because the AST routes a code span to visitInlineCode, a reference inside backticks never reaches visitText and is literal by construction — the same rule FootnotePreprocessor.scanLineForReferences already applied on the definition side.
Two independent per-node bounds. lastEnd ends the visible text already emitted and advances only when a badge consumes a token; classifiedEnd ends the text whose source spelling has been decided and advances on both outcomes. Deriving the source scan from classifiedEnd rather than lastEnd is what keeps a run of literal tokens linear — measuring from lastEnd made the scanned slice re-span every earlier occurrence, rebuilt per token. Confirmed linear here: 800 escaped references 3.04 ms, 3,200 11.54 ms — 3.79x for 4x the input.
A second Walker-scoped anchor. footnoteScanEnd is separate from the run cursor because the cursor tracks mapped text: text holding an escaped reference is not verbatim in the source, so the cursor stalls behind it, and if a later verbatim coincidence is found it can jump ahead of the token being judged. Round 4 changed the anchor from max(cursor, footnoteScanEnd) to footnoteScanEnd alone, which fixed the alternating-pair swap.
Escape-tolerant matching. matchAllowingEscapes accepts \ + ASCII punctuation in place of any token unit and reports whether any escape was used; isEscapedBackslash walks the preceding backslash run so \\[^1] stays live. It is tested last in the condition chain so a long backslash run does not pay the backward walk at every position.
Runs after a badge now include the separator space (" after" at offset 10, not "after" at 11) — real rendered text the old per-segment Document(parsing:) discarded. Six source-map assertions and two live-selection assertions were updated for it; the run invariant (a run's rendered text equals its source span unit-for-unit) still holds, and every updated span was verified against the renderer's actual output.
The cost of moving downstream of the parser is that the parser has also already consumed backslash escapes and decoded HTML entities, so the renderer must reconstruct the source spelling by searching. That reconstruction is where all three blocking findings live.
sourceSpellsLiveReference answers "was this token spelled live or escaped?" by scanning the source forward from sourcePosition(past: precedingText, from: footnoteScanEnd) and taking whichever spelling matches first. Its correctness rests on one invariant, stated in the doc comment as "advances by exactly one occurrence per decision" and "the only monotonic one": that footnoteScanEnd is at or past the end of every [^id]-shaped source occurrence already accounted for.
The code does not uphold it. footnoteScanEnd is mutated in exactly one place — inside sourceSpellsLiveReference, i.e. only for occurrences that reached visitText. Occurrences the AST routed elsewhere consume source without moving it: visitInlineCode → appendVisible (advances cursor only), visitImage (image.plainText, no cursor advance at all), visitLink (destination never located). There are also two exits that decide a token without advancing the anchor: the unresolvable-identifier continue (safe — no occurrence was consumed) and the found-in-neither-spelling fallback, which returns true after walking to sourceUTF16.count.
Both HEAD's InlineHTMLRenderer and a verbatim transcription of origin/main's renderInline (segment loop, rebased, emitter footnoteBadge) were compiled against the project's own Markdown.o and run on the same inputs:
`\[\^1\]`[^1] — HEAD 0 badges, main 1. visitInlineCode consumes the escaped spelling at source 1..8 leaving the anchor at 0; the following Text node is "[^1]" so precedingText is empty and sourcePosition returns 0 unchanged; the scan finds the code span's escaped occurrence and returns false.`[^1]`\[\^1\] — HEAD 1 badge on the escaped reference (text "[^1]1"), main 1 badge with a broken code span. Same mechanism, opposite polarity.![[^1]](x.png)\[\^1\] and [t](http://x/[^1])\[\^1\] — HEAD badges the escaped reference in both.See [^1] here and cite[^1]. — HEAD 2 badges, main 1. cmark's handle_entity decodes the entity and cmark_consolidate_text_nodes merges the result, so the token reaches visitText as a bare [^1] that exists in the source in neither spelling; the fallback treats it as live.The trigger condition is narrow but not adversarial: an earlier unclassified [^id]-shaped occurrence with the opposite spelling, plus a token whose precedingText is empty (immediately after any inline markup) or does not locate verbatim.
The amortisation argument is sound where the anchor advances (Σ|precedingText| ≤ N, footnoteScanEnd monotone and bounded by N). The found-in-neither exit breaks it twice over: the scan walks to sourceUTF16.count without advancing the anchor, then appendFootnoteBadge's locate(units, from: cursor) also fails and also does not advance cursor — Θ(M·N) each. Measured on [^1] repeated: HEAD 48.74 ms at 800 and 714.43 ms at 3,200 (14.66x), against main's 1.19 ms and 4.59 ms (3.84x). This is distinct from the three quadratics the report audits: its entity row measures an entity between escaped references, quadratic on the merge base too and filed under T-1966. This shape is linear on main.
Nested anchors. [label[^1]](url) now emits <a href><span>label</span><a class="prism-footnote-badge">1</a></a>, confirmed. Invalid HTML — the parser closes the outer anchor early — but strictly better than main, which split the raw source and destroyed the link into literal brackets. Runs are unaffected (the implicit close reparents no spans).
Three owners of one question. "Is this [^id] a live reference?" is now answered independently by FootnotePreprocessor.scanLineForReferences (backtick-aware, backslash-blind), MarkdownBlock.searchableText / SearchStateFeeder.referencedFootnoteIdentifiers (raw regex, neither), and the Walker (both). Before this branch the render agreed with the other two by sharing their naive scan — that agreement was on a wrong rendering, so the drift is the price of the fix, not a reason to revert it. It does need filing: \[^1] consumes a display number nothing points at, and a code-span-only reference contributes a search match whose badge lives in another block (partly T-1853).
Zero-length runs. Text containing an escape or a decoded entity does not locate verbatim, so appendVisible records length 0 anchored at the cursor and selection notes over it are not anchorable. Pre-existing on both paths and documented in the report.
prism/Services/WebRendering/InlineHTMLRenderer.swift
Why it matters. This is the fix. Because the AST routes a code span to visitInlineCode, a reference inside backticks can never reach visitText, so it is literal by construction rather than by a scanner that has to re-derive backtick rules. One parse and one cursor per block also removes the three fragment-level divergences the split caused (indented code block, unbalanced emphasis, stripped leading whitespace).
What to look at. InlineHTMLRenderer.swift:189-246
prism/Services/WebRendering/InlineHTMLRenderer.swift
Why it matters. BLOCKING. Moving downstream of the parser means the parser has already consumed the backslashes, so an escaped reference is indistinguishable from a live one by parsed text alone and the source must be re-consulted. The heuristic ('whichever spelling occurs first from here') is sound only if the anchor is past every occurrence already accounted for — and the anchor is advanced only by decisions made inside visitText, so occurrences consumed by visitInlineCode / visitImage / visitLink are invisible to it. Executed: `\[\^1\]`[^1] loses its badge (main emits one), and `[^1]`\[\^1\] badges the escaped reference.
What to look at. InlineHTMLRenderer.swift:248-292 (anchor at :106, mutated only at :284)
prism/Services/WebRendering/InlineHTMLRenderer.swift
Why it matters. BLOCKING. The comment justifies this exit as 'the source reaching this renderer was rewritten upstream', but cmark decodes HTML entity references and consolidates the resulting text nodes, so [^1] routinely arrives as a bare [^1] that exists in the source in neither spelling. Executed: HEAD emits a badge where origin/main renders literal text. The exit also advances neither footnoteScanEnd nor (via the failed locate in appendFootnoteBadge) cursor, so cost is Theta(M*N) twice: 48.74 ms at 800 references and 714.43 ms at 3,200 — 14.66x for 4x input, against main's 3.84x and above the branch's own 8x ceiling.
What to look at. InlineHTMLRenderer.swift:288-291 and :370-379
prism/Services/WebRendering/InlineHTMLRenderer.swift
Why it matters. Round 4's performance fix, and it holds up. Separating classifiedEnd (advances on both outcomes) from lastEnd (advances only on a badge) means the slice handed to the classifier spans one gap at a time instead of re-spanning every earlier occurrence. Measured at HEAD: 800 escaped references 3.04 ms, 3,200 11.54 ms — 3.79x for 4x the input.
What to look at. InlineHTMLRenderer.swift:207-245
prism/Services/WebRendering/BlockHTMLEmitter.swift
Why it matters. T-1876 added rebased(_:by:) purely to shift segment-local run offsets back into block coordinates. With one render call there is one coordinate space, so the correction is unnecessary rather than merely relocated — the T-1941 item 1 it was created for is now moot. Verified: no references to rebased remain anywhere in the target or tests.
What to look at. BlockHTMLEmitter.swift:691-723 (was 691-780)
prismTests/WebRendering/DocumentSourceMapTests.swift
Why it matters. The highest-risk kind of test edit — changing expectations in the same commit as the behaviour. Each updated span was therefore re-derived by execution rather than read: before[^1] after produces runs 0+6="before" and 10+6=" after", and the alpha/beta/gamma, unresolved-then-resolved, footnote-at-start, adjacent-footnotes and astral cases all match their new expectations exactly. These are corrections: the space is real rendered text that the old per-segment Document(parsing:) discarded, and the run invariant still holds.
What to look at. DocumentSourceMapTests.swift:160-284, WebSelectionNoteTests.swift:232-303
Only the parser knows whether [^id] is prose or code. Substituting downstream of it makes the code-span case correct by construction and removes the per-segment re-parse that caused T-1945, the emphasis breakage and the swallowed space. The rejected alternative — teaching the emitter's scan about backticks — would have re-implemented fence-length matching outside the parser and fixed none of the other three.
Necessary consequence of the move: swift-markdown resolves \[\^1\] before visitText sees it, so parsed text alone cannot distinguish it from a live reference. The source still can. This also upholds the walker's existing contract that rendered text is mapped only where it appears verbatim — an escaped reference is not verbatim, so chrome must not claim its source units.
Round 4. cursor tracks mapped text, not classified tokens; text holding an escaped reference is not verbatim, so the cursor both stalls behind such text and, on a later verbatim coincidence, can end up ahead of the token being judged. In \[\^1\] [^1] repeated, that swapped a live and an escaped reference from the third pair onward while keeping the badge count identical.
The review found the replacement anchor is still not monotonic over all source occurrences — only over those visitText decided — which is finding 1.
One budget at one input size cannot separate linear from quadratic. Measuring 800 against 3,200 references with an 8x ceiling leaves 2x headroom above linear and 2x below quadratic, which is deliberately wide given this repo's timing-test flakiness (T-1541) and concurrent builds. Precedent: T-1655, reused in T-1877.
Worth noting the ceiling does its job — the entity shape this review measured comes in at 14.66x and would fail it, if a fixture existed for that shape.
The space is real rendered text; the old path dropped it from the output entirely. Keeping it preserves the run invariant (rendered text equals source span unit-for-unit) and makes the rendering more faithful, at the cost of updating six source-map assertions and giving two live-selection tests a startOffset.
Fixing them means changing locate, the shared source-mapping primitive every block type depends on and whose forward-search semantics T-1876's assertions rest on. Measured quadratic on the merge base too, and each got roughly 2x faster from this change. Filed as T-1966.
The new quadratic in finding 3 is not one of these: it is linear on main (3.84x) and quadratic only on the branch (14.66x).
Follows the caller. It has exactly one caller (Walker.appendFootnoteBadge) in the same file, and Swift allows private static on the outer type to be reached from a nested type, so private would compile. Widening to internal is unmotivated but harmless.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| blocker | InlineHTMLRenderer.swift:106, 248-292 — stale classification anchor | A live footnote reference LOSES its badge when an earlier [^id]-shaped source occurrence was consumed by a non-Text node with the opposite spelling. `\[\^1\]`[^1] renders 0 badges at HEAD; origin/main renders 1. Root cause: footnoteScanEnd is mutated only inside sourceSpellsLiveReference, so source consumed by visitInlineCode (via appendVisible), visitImage (image.plainText, no cursor advance at all) and visitLink (destination never located) never moves it. When the token starts its Text node, precedingText is empty, sourcePosition returns the stale anchor unchanged, and the scan matches the earlier occurrence and adopts ITS spelling. Verified by compiling both HEAD's InlineHTMLRenderer and a verbatim transcription of origin/main's renderInline against the project's own Markdown.o and running them on identical input. This is a regression against origin/main. | REPORTED, not fixed — needs a production change plus order-asserting tests, which the review brief excludes. Two shapes worth considering: advance footnoteScanEnd from every node that consumes source (visitInlineCode already knows the span locate returned), or replace the archaeology with a single O(N) pre-pass over sourceUTF16 collecting every (start, length, escaped) occurrence and have visitText consume that list in document order — the latter also makes the found-in-neither case a list-exhausted check and deletes sourcePosition/matchAllowingEscapes from the per-token path. Suggested regression tests: `\[\^1\]`[^1], `[^1]`\[\^1\], ![[^1]](x.png)\[\^1\] and [t](http://x/[^1])\[\^1\], asserting rendered text in order. |
| blocker | InlineHTMLRenderer.swift:248-292 — same anchor, opposite polarity | The mirror of the above BADGES AN ESCAPED REFERENCE — the exact defect class T-1716 exists to remove, reached through adjacency instead of backticks. `[^1]`\[\^1\] renders text "[^1]1" with 1 badge at HEAD; the correct output is "[^1][^1]" with 0 badges. Also reproduced through image alt text (![[^1]](x.png)\[\^1\]) and a link destination ([t](http://x/[^1])\[\^1\]), both of which badge the escaped reference. Executed, same harness. Prose variants with text between the two occurrences are all correct (`[^1]`, but cite[^1] normally. / `[^1]` [^1] / Write `[^1]` and \[\^1\] and cite[^1].), so the trigger is specifically an empty or non-locatable precedingText. | REPORTED, not fixed. Same root cause and same fix as finding 1; listed separately because it is a re-introduction of the ticket's own defect rather than a regression against main, and because it is what makes finding 1 more than a cosmetic ordering issue. |
| blocker | InlineHTMLRenderer.swift:288-291, 370-379 — found-in-neither-spelling fallback | An entity-spelled reference is badged where origin/main renders it literally, and the path is quadratic. cmark decodes HTML entity references (handle_entity) and merges the fragments (cmark_consolidate_text_nodes), so 'See [^1] here and cite[^1].' reaches visitText as a bare [^1] that exists in the source in neither spelling. HEAD emits 2 badges; main emits 1 and keeps the entity-spelled one as literal text. The fallback also advances neither footnoteScanEnd nor, via the failed locate in appendFootnoteBadge, cursor — so both scans restart from the same position for every subsequent token. Measured on '[^1] ' repeated: HEAD 48.74 ms at 800 and 714.43 ms at 3,200 (14.66x for 4x input), against main's 1.19 ms and 4.59 ms (3.84x). This is NOT the entity row already audited in the report and filed under T-1966: that row measures an entity BETWEEN escaped references and is quadratic on the merge base too. This shape is linear on main. | REPORTED, not fixed. Note the stopgap of setting footnoteScanEnd = sourceUTF16.count on failure restores linearity but would then misclassify a subsequent escaped reference, so it must not be taken without a test. The report's own 8x growth ceiling would catch this shape if a fixture existed for it; the comment at :289-291 also needs correcting, since entity decoding is standard CommonMark rather than a source 'rewritten upstream'. |
| major | FootnotePreprocessor.swift:305-345 vs InlineHTMLRenderer.swift:234 — divergent notions of a live reference | scanLineForReferences is backtick-aware but backslash-blind: it steps over the leading \ one character at a time and then prefix-matches [^1], so \[^1] is counted as a reference. The renderer now refuses to badge it (pinned by partiallyEscapedReferenceIsNotBadged). Consequence: \[^1] consumes a display number nothing points at, so a document whose only reference to [^1] is escaped that way makes the next footnote render as badge '2' with no '1' anywhere, and searchableText appends that definition's content to a block with no badge to highlight. Confirmed by reading both scanners; \[\^1\], [\^1] and [^1\] do not diverge (the pattern requires a literal [^ pair). Before this branch the render shared the naive scan and agreed — on a wrong rendering. | REPORTED, not fixed. Needs a Transit ticket. The doc comment at InlineHTMLRenderer.swift:190-197 and the new CLAUDE.md sentence both claim the rendering now agrees with scanLineForReferences, which is true for backticks and false for \[^1]; that claim should be qualified whichever way the ticket goes. |
| minor | MarkdownBlock.swift:944-958 and SearchStateFeeder.swift:202-214 — raw-source footnote scans | Both still match FootnoteData.referencePattern against raw block source, so a reference inside a code span counts toward the block's searchable text and lands in matchedFootnoteIds even though the block now has no badge. prism-search.js resolves the badge with a document-wide querySelector, so the match navigates to that identifier's badge in a different block rather than failing outright — which is the already-filed T-1853 shape. Note the narrower case is self-consistent: a reference occurring ONLY inside a code span never enters referenceOrder, so buildFootnoteData drops it and definition(for:) returns nil on both sides. | REPORTED, not fixed. Lower severity than it first appears because of the document-wide selector and because the fully-protected case is consistent. Belongs in the same ticket as the preprocessor divergence: one owner for 'is this [^id] live?' rather than three. |
| minor | CHANGELOG.md — [Unreleased] / Fixed, T-1716 entry | Two clauses describe intra-branch states as user-visible fixes. 'a paragraph holding a long run of escaped references no longer takes seconds to appear: 3,200 of them went from ten seconds to two hundredths of a second' — no released build ever took ten seconds, because main's raw-source regex never matches \[\^1\] and so never split (measured on main: 3.84x growth, 4.59 ms at 3,200). Likewise 'previously, from the third onward, a real reference could show as plain text while an escaped one became a badge' describes the round-3 code, not main, where escaped references were badged uniformly. The entry is also one ~200-word paragraph covering four tickets. | REPORTED, not fixed — left for the author since rewording user-facing release notes is a judgement call and the file feeds prism-release-prep. Suggested: drop both 'previously' clauses (they belong in the bugfix report, which already has them) and split the remaining behaviours into separate bullets. |
| minor | InlineHTMLRenderer.swift:266, 272-280 — doc comments overstate the invariant | The comments assert footnoteScanEnd 'advances by exactly one occurrence per decision' and is 'the only monotonic one', and that the scan starts 'from this token's source position'. All three overstate: it does not advance on the found-in-neither exit, does not advance on the unresolvable-identifier path (correctly, but that is a second exception the wording denies), is not monotonic over occurrences consumed by other nodes (findings 1-2), and the start position is footnoteScanEnd + spelling(precedingText), an approximation. A maintainer will trust a guarantee the code does not give — which is how findings 1-2 survived four review rounds. | REPORTED, not fixed. Restate as the weaker true property and name both non-advancing exits. Best done together with the fix for findings 1-3, since the correct wording depends on which shape that takes. |
| minor | InlineHTMLRenderer.swift:216-220, 272-280, 326-331, 190-202; BlockHTMLEmitter.swift:694-714 — review history in production comments | 99 of the 209 added lines in InlineHTMLRenderer.swift are comment, which is defensible for this code — but several blocks narrate the review rather than the behaviour: benchmark numbers with 'review round 3 measured...', nine lines justifying why an earlier version of this branch was wrong, six lines on predicate ordering, and a 21-line doc comment on the now five-line renderInline whose middle paragraph describes the deleted implementation. All of this already lives in specs/bugfixes/footnote-badge-inline-code/report.md. | REPORTED, not fixed (comment-only, but adjacent to the code findings 1-3 will change, so better done in the same pass). Keep the T-1941 caveat in renderInline's comment — that one is still live — and replace the benchmark narration with a pointer to InlineFootnoteScanPerformanceTests. |
| nit | InlineHTMLRenderer.swift:72, 271, 84/109 | footnoteBadge is internal with one same-file caller (private static is reachable from a nested type). The guard !units.isEmpty at :271 is unreachable — the regex requires at least one identifier character — and its default (treat as escaped) is the opposite of the other can't-tell default at :291 (treat as live), which reads as an inconsistency. sourceScalarsView (:84, assigned :109) is stored and never read; confirmed pre-existing on origin/main at :72/:90, so out of scope but sitting in the struct this diff extends. | REPORTED, not fixed. |
| nit | prismTests/WebRendering — duplicated helpers, stale helper name | footnoteData(_ identifiers: [String]) added at BlockHTMLEmitterTests.swift:563-571 is byte-identical to copies in DocumentSourceMapTests.swift:142-150 and WebSelectionNoteTests.swift:225-233; the natural home is BlockHTMLEmitterTestSupport in the same file, which the new suite already uses for emit. badgeCount is factored into a helper in InlineFootnoteScanTests.swift:83-85 but repeated inline three times in BlockHTMLEmitterTests.swift. resolveWholeRun in WebSelectionNoteTests.swift no longer resolves the whole run now that it takes a startOffset. | REPORTED, not fixed — test-file changes are excluded by the review brief. |
| nit | Build warnings | The macOS build emits one warning, repeated across compilation units: "main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context; this is an error in the Swift 6 language mode". ImageDimension is not touched by this diff, so the warning cannot originate here; it is pre-existing on main. iOS builds with zero warnings. | REPORTED as informational — not attributable to this branch, worth its own chore ticket before Swift 6 mode. |
Click to expand.
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 8c16327..990c4d6 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -64,6 +64,18 @@ nonisolated struct InlineHTMLRenderer { return Result(html: renderer.html, runs: renderer.runs) } + // MARK: - Footnote badge chrome++ /// An inert footnote badge: pill chrome with an open action (Req 7.1/10.2). The badge+ /// text is the display number, not document-derived prose. Tapping dispatches+ /// linkActivated for `prism://footnote/{id}` via the bridge.+ static func footnoteBadge(identifier: String, displayNumber: Int) -> String {+ let id = HTMLEscaping.escapeAttribute(identifier)+ return "<a class=\"prism-footnote-badge\" data-prism-chrome"+ + " data-prism-footnote=\"\(id)\" href=\"\(PrismLinkRoute.footnote(id))\""+ + " role=\"button\">\(displayNumber)</a>"+ }+ // MARK: - Walker /// Walks the inline AST, emitting HTML and tracking the source cursor in UTF-16 units.@@ -86,6 +98,13 @@ nonisolated struct InlineHTMLRenderer { private var openRunSourceLength: Int = 0 private var openRunHTML: String = "" + /// Monotonic source position for footnote-token decisions: the end of the last+ /// `[^id]`-shaped source occurrence already classified as live or escaped. `cursor`+ /// cannot serve, because it tracks mapped text rather than classified tokens and text+ /// whose source spelling is not verbatim (an escaped reference) drags it off — see+ /// `sourceSpellsLiveReference`.+ private var footnoteScanEnd: Int = 0+ init(source: String, footnotes: FootnoteData, nextRunID: Int) { self.sourceScalarsView = source self.sourceUTF16 = Array(source.utf16)@@ -168,7 +187,195 @@ nonisolated struct InlineHTMLRenderer { // MARK: Inline leaf nodes mutating func visitText(_ text: Text) {- appendVisible(text.string)+ // Footnote references become inert badge chrome, but ONLY where the markdown+ // parser produced a Text node. Reaching them here (rather than by splitting the+ // block source before parsing) is what makes the rendering agree with+ // `FootnotePreprocessor.scanLineForReferences`, which skips backtick-delimited+ // spans: a `[^id]` inside a code span arrives via `visitInlineCode`, never here,+ // so it stays literal text (T-1716). It also keeps one cursor over one parse, so+ // emphasis spanning a reference survives and no segment re-parse can promote a+ // fragment to a block node and drop it (T-1945).+ //+ // Inline code is only one of CommonMark's two ways to display `[^id]` literally.+ // The other is backslash escaping, and that one DOES arrive here as a Text node+ // because the parser has already consumed the backslashes, so it is filtered+ // below against the source spelling (`sourceSpellsLiveReference`).+ guard !footnotes.isEmpty else {+ appendVisible(text.string)+ return+ }+ let string = text.string+ // Two independent bounds over this node's parsed text:+ //+ // - `lastEnd` ends the visible text already emitted. It advances only when a+ // badge consumes a token, so a token left literal stays inside the following+ // visible segment.+ // - `classifiedEnd` ends the text whose source spelling has already been decided.+ // It advances on BOTH outcomes, badged or literal.+ //+ // The source scan is measured from `classifiedEnd`, not `lastEnd`. Measuring it+ // from `lastEnd` made a run of literal tokens quadratic: the slice handed to+ // `sourceSpellsLiveReference` re-spanned every earlier occurrence and was rebuilt+ // and re-scanned from scratch for each one (review round 3 measured 800 escaped+ // references at 0.61s and 3,200 at 9.83s — 16x for 4x the input).+ var lastEnd = string.startIndex+ var classifiedEnd = string.startIndex+ for match in string.matches(of: FootnoteData.referencePattern) {+ // An unresolvable identifier is not a reference: leave BOTH bounds where they+ // are so the literal `[^id]` text stays inside the next visible segment. No+ // source occurrence was classified either, so the scan bound must not move.+ guard let definition = footnotes.definition(for: String(match.1)) else { continue }+ let token = String(match.0)+ let precedingText = String(string[classifiedEnd..<match.range.lowerBound])+ // Nor is a reference the author backslash-escaped: the literal `[^id]` text+ // likewise stays inside the next visible segment (T-1716). Its source+ // occurrence HAS been classified, though, so `classifiedEnd` advances either+ // way — the scan never revisits it.+ let isLive = sourceSpellsLiveReference(token, after: precedingText)+ classifiedEnd = match.range.upperBound+ guard isLive else { continue }+ appendVisible(String(string[lastEnd..<match.range.lowerBound]))+ appendFootnoteBadge(+ token: token,+ identifier: definition.identifier,+ displayNumber: definition.displayNumber+ )+ lastEnd = match.range.upperBound+ }+ appendVisible(String(string[lastEnd...]))+ }++ /// Whether the `[^id]` token carried by the parsed text is spelled as a LIVE+ /// reference in the block source, rather than one the author neutralised with+ /// backslash escapes (`\[\^1\]`, `\[^1]`, `[\^1]`, `[^1\]`).+ ///+ /// swift-markdown resolves backslash escapes while parsing, so an escaped reference+ /// reaches `visitText` as a Text node whose `.string` holds a bare `[^id]` —+ /// indistinguishable from a real reference by the parsed text alone (T-1716). The+ /// block source is still available here and still carries the backslashes, so the+ /// decision is made against it: scanning forward from this token's source position,+ /// whichever spelling occurs FIRST — literal or escaped — is the one it came from.+ ///+ /// This upholds the same contract as the rest of the walker: rendered text is only+ /// mapped where it appears verbatim in the source. An escaped reference is not+ /// verbatim, so it must not be replaced by chrome claiming those source units.+ ///+ /// `precedingText` is this node's text between the previously classified token and+ /// this one; the scan starts past it so a later occurrence in the same node is judged+ /// on its own spelling instead of re-matching an earlier one. Deciding a token also+ /// advances `footnoteScanEnd` past the source occurrence it consumed.+ private mutating func sourceSpellsLiveReference(+ _ token: String, after precedingText: String+ ) -> Bool {+ let units = Array(token.utf16)+ guard !units.isEmpty else { return false }+ // Classification walks the source in its own order, anchored on `footnoteScanEnd`+ // alone: every candidate is matched at or after the end of the previously+ // classified occurrence, then past the source spelling of the text between them.+ // The run `cursor` is deliberately NOT consulted. It tracks mapped text, not+ // classified tokens, and text holding an escaped reference is not verbatim in the+ // source — so the cursor both stalls behind such text and, when a later verbatim+ // coincidence is found for it, can end up ahead of the very token being judged.+ // Either way it is the wrong anchor; `footnoteScanEnd` advances by exactly one+ // occurrence per decision and is the only monotonic one.+ var index = sourcePosition(past: precedingText, from: footnoteScanEnd)+ while index < sourceUTF16.count {+ if let match = matchAllowingEscapes(units, at: index) {+ footnoteScanEnd = index + match.length+ return !match.escaped+ }+ index += 1+ }+ // Present in neither spelling — the source reaching this renderer was rewritten+ // upstream. Keep the established behaviour and treat it as a reference.+ return true+ }++ /// The source position just past the units that spell `text`, starting at `index`.+ ///+ /// Two steps, cheapest first. `text` normally begins exactly at `index` — right after+ /// the previously classified occurrence — so consuming it in place, allowing the same+ /// backslash escapes a token match allows, settles it in one pass over `text` with no+ /// search at all. Consuming in place is what keeps a run of ESCAPED references linear:+ /// their separators are escaped too, so a verbatim search misses every time and would+ /// rescan the source once per reference. Only when the text does not start at `index`+ /// (inline markup between the two, an entity reference, …) does it fall back to+ /// searching forward for the text verbatim, and to `index` itself when even that+ /// fails — the same fallback the scan has always used.+ private func sourcePosition(past text: String, from index: Int) -> Int {+ let units = Array(text.utf16)+ guard !units.isEmpty else { return index }+ if let match = matchAllowingEscapes(units, at: index) {+ return index + match.length+ }+ if let start = locate(units, from: index) {+ return start + units.count+ }+ return index+ }++ /// Matches `units` at `index` in the source, accepting a CommonMark backslash escape+ /// (`\` + ASCII punctuation) in place of any unit. Returns the source units consumed+ /// and whether at least one escape was used, or nil when the units do not match.+ private func matchAllowingEscapes(+ _ units: [UInt16], at index: Int+ ) -> (length: Int, escaped: Bool)? {+ var sourceIndex = index+ var escaped = false+ for unit in units {+ guard sourceIndex < sourceUTF16.count else { return nil }+ // `isEscapedBackslash` walks backwards over the preceding backslash run, so it+ // is tested LAST: the cheap unit comparisons in front of it reject almost every+ // position, which keeps a long run of backslashes in the source from costing+ // that backward walk at every one of its positions. Pure predicates, so the+ // order affects only cost, not the outcome.+ if sourceUTF16[sourceIndex] == 0x5C, // backslash+ isASCIIPunctuationUnit(unit),+ sourceIndex + 1 < sourceUTF16.count,+ sourceUTF16[sourceIndex + 1] == unit,+ !isEscapedBackslash(at: sourceIndex) {+ escaped = true+ sourceIndex += 2+ continue+ }+ guard sourceUTF16[sourceIndex] == unit else { return nil }+ sourceIndex += 1+ }+ return (sourceIndex - index, escaped)+ }++ /// Whether the backslash at `index` is itself escaped (an odd-length run of+ /// backslashes precedes it), making it a literal backslash in the rendered text+ /// rather than an escape marker — so `\\[^1]` is a live reference, not an escaped one.+ private func isEscapedBackslash(at index: Int) -> Bool {+ var preceding = 0+ var scan = index - 1+ while scan >= 0, sourceUTF16[scan] == 0x5C {+ preceding += 1+ scan -= 1+ }+ return !preceding.isMultiple(of: 2)+ }++ /// CommonMark's escapable set: only ASCII punctuation may follow a backslash to+ /// stand for itself, so `\1` is a literal backslash and not an escape of `1`.+ private func isASCIIPunctuationUnit(_ unit: UInt16) -> Bool {+ (0x21...0x2F).contains(unit) || (0x3A...0x40).contains(unit)+ || (0x5B...0x60).contains(unit) || (0x7B...0x7E).contains(unit)+ }++ /// Emits a footnote badge and advances the source cursor past the `[^id]` token it+ /// replaces. The badge is chrome: the run is closed first so the token's source+ /// units fall inside no run and a selection can never land on it (Decision 8).+ mutating func appendFootnoteBadge(token: String, identifier: String, displayNumber: Int) {+ closeRun()+ let units = Array(token.utf16)+ if let start = locate(units, from: cursor) {+ cursor = start + units.count+ }+ html += InlineHTMLRenderer.footnoteBadge(+ identifier: identifier, displayNumber: displayNumber+ ) } mutating func visitInlineCode(_ inlineCode: InlineCode) {
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 796aa58..75c2b6a 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -691,90 +691,33 @@ nonisolated enum BlockHTMLEmitter { // MARK: - Inline rendering + footnote badges - /// Renders inline markdown to HTML, accumulating the block's runs into `context` and- /// substituting resolvable `[^id]` references with inert footnote badges.+ /// Renders inline markdown to HTML, accumulating the block's runs into `context`.+ ///+ /// Resolvable `[^id]` references become inert footnote badges, but that substitution+ /// happens INSIDE `InlineHTMLRenderer` (on the Text nodes the markdown parser produced),+ /// not by splitting this source string beforehand. Splitting used to badge references+ /// the parser had classified as inline code (T-1716), break emphasis that spanned a+ /// reference, and drop a segment that re-parsed as an indented code block (T-1945).+ /// Rendering the whole string in one parse also means every run is already in this+ /// string's coordinate space — no rebasing needed (T-1876/T-1673). /// /// When `recordRuns` is `false` the emitted HTML is byte-identical (run IDs are still /// allocated so the `data-prism-run` attributes match), but the runs are NOT appended to /// `context.blockRuns`. A selection landing on an unrecorded run resolves to no source /// map entry and is declined rather than mis-anchored (Decision 8, safe-by-decline). This /// is used by `renderInnerBlock` (nested blocks) and a blockquote's non-first children.+ ///+ /// Note the runs are block-relative only when this is handed the block's own source+ /// (paragraph, heading, blockquote first child, top-level details summary).+ /// `renderListMarkup` (item content), `tableHTML` (header/cell) and `emitDetails`'+ /// recursive NESTED summary hand it a substring and record the result into the+ /// enclosing block's runs, so those stay sub-span-local — tracked as T-1941. static func renderInline(_ source: String, context: Context, recordRuns: Bool = true) -> String {- let footnotes = context.footnotes- // Footnote references are replaced with badge chrome BEFORE inline rendering by- // splitting the source on resolvable [^id] tokens. Each text segment renders- // through the inline renderer (carrying run mapping); each badge is inert chrome.- if footnotes.isEmpty {- let result = InlineHTMLRenderer.render(- source: source, footnotes: footnotes, runIDAllocator: &context.nextRunID- )- if recordRuns { context.blockRuns.append(contentsOf: result.runs) }- return result.html- }- var html = ""- var lastEnd = source.startIndex- for match in source.matches(of: FootnoteData.referencePattern) {- let identifier = String(match.1)- guard let definition = footnotes.definition(for: identifier) else { continue }- // The inline renderer maps runs against the string it is handed, so a segment's- // offsets are segment-local. They are rebased by the segment's UTF-16 start in- // the WHOLE string passed to renderInline — which skips every preceding [^id]- // token — because the source map's consumers (selection notes, relocation,- // inline-note export) index into that string, not into a segment (T-1876/T-1673).- // Note this restores block coordinates only when renderInline was handed the- // block's own source (paragraph, heading, blockquote first child, top-level- // details summary). renderListMarkup (item content), tableHTML (header/cell)- // and emitDetails' recursive NESTED summary all hand it a substring and record- // the result into the enclosing block's runs, so those stay sub-span-local —- // tracked as T-1941.- let segmentStart = lastEnd.utf16Offset(in: source)- let segment = String(source[lastEnd..<match.range.lowerBound])- if !segment.isEmpty {- let result = InlineHTMLRenderer.render(- source: segment, footnotes: .empty, runIDAllocator: &context.nextRunID- )- if recordRuns {- context.blockRuns.append(contentsOf: rebased(result.runs, by: segmentStart))- }- html += result.html- }- html += footnoteBadge(identifier: identifier, displayNumber: definition.displayNumber)- lastEnd = match.range.upperBound- }- let tailStart = lastEnd.utf16Offset(in: source)- let tail = String(source[lastEnd...])- if !tail.isEmpty {- let result = InlineHTMLRenderer.render(- source: tail, footnotes: .empty, runIDAllocator: &context.nextRunID- )- if recordRuns {- context.blockRuns.append(contentsOf: rebased(result.runs, by: tailStart))- }- html += result.html- }- return html- }-- /// Shifts segment-local run offsets into the block's full source coordinate space.- private static func rebased(- _ runs: [DocumentSourceMap.Run], by offset: Int- ) -> [DocumentSourceMap.Run] {- guard offset != 0 else { return runs }- return runs.map {- DocumentSourceMap.Run(- runID: $0.runID, sourceStart: $0.sourceStart + offset, length: $0.length- )- }- }-- /// An inert footnote badge: pill chrome with an open action (Req 7.1/10.2). The- /// badge text is the display number, not document-derived prose. Tapping dispatches- /// linkActivated for `prism://footnote/{id}` via the bridge.- private static func footnoteBadge(identifier: String, displayNumber: Int) -> String {- let id = HTMLEscaping.escapeAttribute(identifier)- return "<a class=\"prism-footnote-badge\" data-prism-chrome"- + " data-prism-footnote=\"\(id)\" href=\"\(PrismLinkRoute.footnote(id))\""- + " role=\"button\">\(displayNumber)</a>"+ let result = InlineHTMLRenderer.render(+ source: source, footnotes: context.footnotes, runIDAllocator: &context.nextRunID+ )+ if recordRuns { context.blockRuns.append(contentsOf: result.runs) }+ return result.html } // MARK: - Image src rewriting (Req 8.3)
diff --git a/prismTests/WebRendering/BlockHTMLEmitterTests.swift b/prismTests/WebRendering/BlockHTMLEmitterTests.swiftindex 984cef9..362d175 100644--- a/prismTests/WebRendering/BlockHTMLEmitterTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterTests.swift@@ -549,3 +549,148 @@ struct BlockHTMLEmitterTotalityTests { } } }++// MARK: - Footnote badge substitution respects inline markup (T-1716 / T-1945)++/// The emitter must only turn `[^id]` into badge chrome where the markdown parser would+/// treat it as document text. `FootnotePreprocessor.scanLineForReferences` already skips+/// backtick-delimited spans, so a reference inside inline code is literal text and must+/// render as such (T-1716). Badge substitution also must not corrupt the surrounding+/// inline markup or drop trailing text (T-1945).+struct BlockHTMLEmitterFootnoteBadgeTests {++ /// A `FootnoteData` with one definition per supplied identifier, numbered in order.+ private func footnoteData(_ identifiers: [String]) -> FootnoteData {+ var definitions: [String: FootnoteDefinition] = [:]+ for (index, identifier) in identifiers.enumerated() {+ definitions[identifier] = FootnoteDefinition(+ identifier: identifier, displayNumber: index + 1, content: "def \(identifier)"+ )+ }+ return FootnoteData(definitions: definitions, referenceOrder: identifiers)+ }++ @Test("A resolvable reference inside inline code stays literal text (T-1716)")+ func referenceInsideInlineCodeIsNotBadged() {+ // `[^1]` is defined because it is referenced elsewhere in the document, but here it+ // sits inside a code span, so it must render as literal code text with no badge.+ let source = "Write `[^1]` to cite."+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ #expect(!doc.html.contains("prism-footnote-badge"),+ "inline code must protect [^1] from badge substitution: \(doc.html)")+ #expect(doc.html.contains("<code>"))+ #expect(doc.normalisedText.contains("Write [^1] to cite."),+ "literal reference text must survive: \(doc.normalisedText)")+ }++ @Test("Inline code is protected while a reference outside it is still badged")+ func codeSpanProtectedButPlainReferenceBadged() {+ let source = "Cite[^1] but write `[^1]` verbatim."+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ let badges = doc.html.components(separatedBy: "prism-footnote-badge").count - 1+ #expect(badges == 1, "exactly one badge expected, got \(badges): \(doc.html)")+ #expect(doc.html.contains("<code>"))+ #expect(doc.normalisedText.contains("[^1]"),+ "the code-span reference must remain visible: \(doc.normalisedText)")+ }++ @Test("Text after a reference followed by 4+ spaces is not dropped (T-1945)")+ func referenceFollowedByIndentKeepsTrailingText() {+ let source = "word[^1] more"+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ #expect(doc.html.contains("prism-footnote-badge"))+ #expect(doc.normalisedText.contains("more"),+ "trailing text must not be dropped: \(doc.normalisedText)")+ }++ @Test("Emphasis spanning a footnote reference stays emphasis, not literal asterisks")+ func emphasisSpanningReferenceIsPreserved() {+ let source = "A *em [^1] end* tail"+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ #expect(doc.html.contains("<em>"), "emphasis must survive the badge: \(doc.html)")+ #expect(!doc.normalisedText.contains("*"),+ "no literal asterisks should leak: \(doc.normalisedText)")+ }++ // MARK: Backslash-escaped references (T-1716, second literal-display mechanism)++ /// Backslash escaping is the other CommonMark way to display `[^id]` literally, and+ /// swift-markdown consumes the backslashes before the walker sees the Text node — so+ /// the parsed text is indistinguishable from a live reference and the renderer must+ /// consult the block source to tell them apart.+ @Test("A fully escaped reference outside a code span stays literal text")+ func escapedReferenceIsNotBadged() {+ let source = #"See \[\^1\] here."#+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ #expect(!doc.html.contains("prism-footnote-badge"),+ "backslash escaping must protect [^1] from badge substitution: \(doc.html)")+ #expect(doc.normalisedText.contains("See [^1] here."),+ "the escaped reference must render literally: \(doc.normalisedText)")+ }++ @Test("A partially escaped reference stays literal text", arguments: [+ #"See \[^1] here."#,+ #"See [\^1] here."#,+ #"See [^1\] here."#+ ])+ func partiallyEscapedReferenceIsNotBadged(source: String) {+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ #expect(!doc.html.contains("prism-footnote-badge"),+ "one escape is enough to neutralise the reference: \(doc.html)")+ #expect(doc.normalisedText.contains("See [^1] here."),+ "the escaped reference must render literally: \(doc.normalisedText)")+ }++ @Test("An escaped reference stays literal while a live one in the same block is badged")+ func escapedReferenceCoexistsWithLiveReference() {+ // Same identifier both ways: the id resolves, so the escaped occurrence can only be+ // distinguished from the live one by its source spelling.+ let source = #"Cite[^1] but write \[\^1\] verbatim."#+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ let badges = doc.html.components(separatedBy: "prism-footnote-badge").count - 1+ #expect(badges == 1, "exactly one badge expected, got \(badges): \(doc.html)")+ #expect(doc.normalisedText.contains("write [^1] verbatim."),+ "the escaped reference must remain visible: \(doc.normalisedText)")+ }++ @Test("A live reference before an escaped one in the same block is still badged")+ func liveReferenceAfterEscapedReferenceIsBadged() {+ let source = #"Write \[\^1\] then cite[^1] properly."#+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ let badges = doc.html.components(separatedBy: "prism-footnote-badge").count - 1+ #expect(badges == 1, "exactly one badge expected, got \(badges): \(doc.html)")+ #expect(doc.normalisedText.contains("Write [^1] then cite"),+ "the escaped reference must remain visible: \(doc.normalisedText)")+ }++ @Test("An escaped reference inside a code span renders its backslashes literally")+ func escapedReferenceInsideCodeSpanStaysLiteral() {+ // Inside a code span backslashes are not escapes, so the source text survives+ // verbatim — backslashes included — and there is still no badge.+ let source = ##"Write `\[\^1\]` to cite."##+ let doc = BlockHTMLEmitterTestSupport.emit(+ [.paragraph(markdown: source)], footnotes: footnoteData(["1"])+ )+ #expect(!doc.html.contains("prism-footnote-badge"),+ "a code span must protect the escaped reference too: \(doc.html)")+ #expect(doc.html.contains("<code>"))+ #expect(doc.normalisedText.contains(#"\[\^1\]"#),+ "code spans keep backslashes verbatim: \(doc.normalisedText)")+ }+}
diff --git a/prismTests/WebRendering/InlineFootnoteScanTests.swift b/prismTests/WebRendering/InlineFootnoteScanTests.swiftnew file mode 100644index 0000000..9e22fa6--- /dev/null+++ b/prismTests/WebRendering/InlineFootnoteScanTests.swift@@ -0,0 +1,253 @@+//+// InlineFootnoteScanTests.swift+// prismTests+//+// Many-reference-per-text-node coverage for the footnote badge scan in+// `InlineHTMLRenderer.Walker.visitText` (T-1716).+//+// Every other footnote-badge test puts at most two `[^id]`-shaped matches in one text+// node, so nothing pinned what happens when one node holds dozens or thousands of them —+// neither the cost per occurrence nor the classification. Both were wrong:+//+// - Cost: the scan derived its starting point from the last BADGED token rather than the+// last CLASSIFIED one, so a run of tokens left literal made the scanned text re-span+// every earlier occurrence and be rebuilt and re-searched from scratch per token —+// quadratic in the token count (review round 3; 800 escaped references took 0.61s,+// 3,200 took 9.83s).+// - Classification: the scan also consulted the run cursor, which tracks mapped text+// rather than classified tokens. From the third match onward in an alternating run it+// sat ahead of the token being judged, and a live reference and an escaped one swapped+// places — invisible to a badge count, which is why these tests assert the rendered+// text in order.+//+// Covers:+// - A long run of escaped references: all literal, no badges+// - A long run of live references: all badged+// - An alternating mix of both spellings of the SAME identifier, asserted in order+// - The monotonic-anchor property at scale: a live reference after many escaped ones is+// still badged (it must not re-match an earlier escaped occurrence), and vice versa+// - Growth guard: quadrupling the reference count must not cost ~16x+//++import Foundation+import Testing+@testable import prism++// MARK: - Fixtures++/// Builds single-paragraph documents holding `count` occurrences of one resolvable+/// reference. swift-markdown consolidates the pieces a backslash escape splits, so the+/// whole run reaches `visitText` as ONE `Text` node — which is what makes these documents+/// exercise the per-node scan rather than one match per node.+private enum InlineFootnoteScanFixture {++ /// `count` escaped references, each `\[\^1\]`, separated by spaces.+ static func escapedRun(count: Int) -> String {+ "Start " + String(repeating: #"\[\^1\] "#, count: count) + "end."+ }++ /// `count` live references, each `[^1]`, separated by spaces.+ static func liveRun(count: Int) -> String {+ "Start " + String(repeating: "[^1] ", count: count) + "end."+ }++ /// `pairs` repetitions of an escaped reference followed by a live one, same identifier.+ static func alternatingRun(pairs: Int) -> String {+ "Start " + String(repeating: #"\[\^1\] [^1] "#, count: pairs) + "end."+ }++ /// `count` escaped references, then one live reference.+ static func escapedRunThenLive(count: Int) -> String {+ "Start " + String(repeating: #"\[\^1\] "#, count: count) + "[^1] end."+ }++ /// One live reference, then `count` escaped ones.+ static func liveThenEscapedRun(count: Int) -> String {+ "Start [^1] " + String(repeating: #"\[\^1\] "#, count: count) + "end."+ }++ /// A `FootnoteData` defining the single identifier every fixture references.+ static var footnotes: FootnoteData {+ FootnoteData(+ definitions: [+ "1": FootnoteDefinition(identifier: "1", displayNumber: 1, content: "def 1")+ ],+ referenceOrder: ["1"]+ )+ }++ static func emit(_ source: String) -> EmittedDocument {+ BlockHTMLEmitterTestSupport.emit([.paragraph(markdown: source)], footnotes: footnotes)+ }++ static func badgeCount(_ document: EmittedDocument) -> Int {+ document.html.components(separatedBy: "prism-footnote-badge").count - 1+ }++ /// Occurrences of the literal reference text in the rendered visible text.+ static func literalReferenceCount(_ document: EmittedDocument) -> Int {+ BlockHTMLEmitterTestSupport.normalisedText(document.html)+ .components(separatedBy: "[^1]").count - 1+ }+}++// MARK: - Classification with many references in one node++@Suite("Inline footnote scan — many references in one text node")+struct InlineFootnoteScanTests {++ private let runLength = 200++ /// The rendered text every fixture is judged against, badge display numbers included:+ /// counting badges alone cannot tell a correct rendering from one that swapped a live+ /// reference for an escaped one, which is exactly what the pre-fix scan did once a node+ /// held more than two matches (a live reference rendered literally and an escaped one+ /// badged, so the totals still matched).+ private func renderedText(_ document: EmittedDocument) -> String {+ BlockHTMLEmitterTestSupport.normalisedText(document.html)+ }++ @Test("Every reference in a long escaped run stays literal text")+ func longEscapedRunIsAllLiteral() {+ let document = InlineFootnoteScanFixture.emit(+ InlineFootnoteScanFixture.escapedRun(count: runLength)+ )+ #expect(InlineFootnoteScanFixture.badgeCount(document) == 0,+ "no escaped reference may be badged")+ #expect(InlineFootnoteScanFixture.literalReferenceCount(document) == runLength,+ "all \(runLength) escaped references must render literally")+ let expected = "Start " + String(repeating: "[^1] ", count: runLength) + "end."+ #expect(renderedText(document).hasPrefix(expected),+ "rendered text diverged: \(renderedText(document).prefix(80))")+ }++ @Test("Every reference in a long live run is badged")+ func longLiveRunIsAllBadged() {+ let document = InlineFootnoteScanFixture.emit(+ InlineFootnoteScanFixture.liveRun(count: runLength)+ )+ #expect(InlineFootnoteScanFixture.badgeCount(document) == runLength,+ "all \(runLength) live references must be badged")+ #expect(InlineFootnoteScanFixture.literalReferenceCount(document) == 0,+ "no literal reference text may leak")+ // Each badge renders as its display number, so the run reads "1 1 1 …".+ let expected = "Start " + String(repeating: "1 ", count: runLength) + "end."+ #expect(renderedText(document).hasPrefix(expected),+ "rendered text diverged: \(renderedText(document).prefix(80))")+ }++ @Test("An alternating run classifies each occurrence on its own spelling")+ func alternatingRunClassifiesEachOccurrenceIndependently() {+ // The identifier resolves, so the only thing separating the two halves of each pair is+ // the source spelling. Asserting the interleaving — literal, badge, literal, badge —+ // rather than only the totals is what makes this test meaningful: pre-fix the totals+ // came out right at some sizes while the third pair onward had them swapped.+ let pairs = runLength+ let document = InlineFootnoteScanFixture.emit(+ InlineFootnoteScanFixture.alternatingRun(pairs: pairs)+ )+ #expect(InlineFootnoteScanFixture.badgeCount(document) == pairs,+ "exactly the \(pairs) live references must be badged")+ #expect(InlineFootnoteScanFixture.literalReferenceCount(document) == pairs,+ "exactly the \(pairs) escaped references must stay literal")+ let expected = "Start " + String(repeating: "[^1] 1 ", count: pairs) + "end."+ #expect(renderedText(document).hasPrefix(expected),+ "escaped and live references must alternate in order, got \(renderedText(document).prefix(80))")+ }++ @Test("A live reference after a long escaped run is still badged")+ func liveReferenceAfterLongEscapedRunIsBadged() {+ // The monotonic scan anchor (`footnoteScanEnd`) exists for this: text holding an+ // escaped reference does not locate verbatim in the source, so the run cursor stalls+ // behind it and the live reference would otherwise re-match an earlier escaped+ // occurrence and lose its badge. At runLength occurrences, an anchor that failed to+ // advance on the skipped path would be visible immediately.+ let document = InlineFootnoteScanFixture.emit(+ InlineFootnoteScanFixture.escapedRunThenLive(count: runLength)+ )+ #expect(InlineFootnoteScanFixture.badgeCount(document) == 1,+ "exactly one badge expected")+ #expect(InlineFootnoteScanFixture.literalReferenceCount(document) == runLength,+ "the escaped run must stay literal")+ let expected = "Start " + String(repeating: "[^1] ", count: runLength) + "1 end."+ #expect(renderedText(document).hasPrefix(expected),+ "the badge must land on the last reference, got \(renderedText(document).suffix(40))")+ }++ @Test("A long escaped run after a live reference leaves that badge alone")+ func longEscapedRunAfterLiveReferenceKeepsOneBadge() {+ let document = InlineFootnoteScanFixture.emit(+ InlineFootnoteScanFixture.liveThenEscapedRun(count: runLength)+ )+ #expect(InlineFootnoteScanFixture.badgeCount(document) == 1,+ "exactly one badge expected")+ #expect(InlineFootnoteScanFixture.literalReferenceCount(document) == runLength,+ "the escaped run must stay literal")+ let expected = "Start 1 " + String(repeating: "[^1] ", count: runLength) + "end."+ #expect(renderedText(document).hasPrefix(expected),+ "the badge must land on the first reference, got \(renderedText(document).prefix(40))")+ }+}++// MARK: - Growth guard++@Suite("Inline footnote scan performance")+struct InlineFootnoteScanPerformanceTests {++ private func fastestElapsed(runs: Int, of body: () -> Void) -> Duration {+ (0..<runs).map { _ in+ let start = ContinuousClock.now+ body()+ return ContinuousClock.now - start+ }.min()!+ }++ private func milliseconds(_ duration: Duration) -> Double {+ Double(duration.components.seconds) * 1000.0+ + Double(duration.components.attoseconds) / 1_000_000_000_000_000.0+ }++ @Test("The footnote scan does not grow quadratically with escaped references per node")+ func testEscapedReferenceScanDoesNotGrowQuadratically() {+ // Deliberately a growth-ratio assertion, not an absolute budget: one budget at one+ // input size cannot distinguish a linear scan from a quadratic one (the naming lesson+ // from T-1655, applied again in T-1877). Quadrupling the reference count costs ~4x+ // when linear and ~16x when quadratic, so the 8x ceiling sits with 2x headroom above+ // linear and 2x below quadratic — deliberately wide given this repo's history of+ // timing-test flakiness (T-1541) and that other builds may run concurrently.+ //+ // Escaped references are the worst case: every one of them is classified and then+ // skipped, which is the path that used to leave the scan's starting point behind.+ let small = InlineFootnoteScanFixture.escapedRun(count: 800)+ let large = InlineFootnoteScanFixture.escapedRun(count: 3_200)++ // Warm up so first-call costs do not land on the smaller measurement and inflate the+ // ratio; the growth signal is what matters, not the absolute numbers.+ for _ in 0..<2 {+ _ = InlineFootnoteScanFixture.emit(small)+ }++ let smallElapsed = fastestElapsed(runs: 3) { _ = InlineFootnoteScanFixture.emit(small) }+ let largeElapsed = fastestElapsed(runs: 3) { _ = InlineFootnoteScanFixture.emit(large) }++ let smallMs = milliseconds(smallElapsed)+ let largeMs = milliseconds(largeElapsed)+ let ratio = largeMs / smallMs+ let ratioText = String(format: "%.2f", ratio)++ print("Escaped footnote scan — 800 refs: \(smallElapsed), 3,200 refs: \(largeElapsed), "+ + "ratio: \(ratioText)")++ // Correctness alongside the timing: the stress document must still render every+ // escaped reference literally, with no badge.+ let document = InlineFootnoteScanFixture.emit(large)+ #expect(InlineFootnoteScanFixture.badgeCount(document) == 0)+ #expect(InlineFootnoteScanFixture.literalReferenceCount(document) == 3_200)++ let failureMessage = "Quadrupling the reference count should cost roughly 4x, not ~16x"+ + " — got \(ratioText)x (\(smallElapsed) -> \(largeElapsed))"+ #expect(smallMs > 0,+ "Baseline measurement must be resolvable to make the ratio meaningful")+ #expect(ratio < 8.0, Comment(rawValue: failureMessage))+ }+}
diff --git a/prismTests/WebRendering/DocumentSourceMapTests.swift b/prismTests/WebRendering/DocumentSourceMapTests.swiftindex f47f478..70c9741 100644--- a/prismTests/WebRendering/DocumentSourceMapTests.swift+++ b/prismTests/WebRendering/DocumentSourceMapTests.swift@@ -157,12 +157,18 @@ struct DocumentSourceMapInvariantTests { return String(decoding: units[run.sourceStart..<end], as: UTF16.self) } + // T-1673/T-1876: runs either side of a footnote badge must stay in FULL block source+ // coordinates, or a selection after the badge anchors near the block start.+ //+ // T-1716 moved badge substitution into `InlineHTMLRenderer`, so there is now a single+ // cursor over a single parse rather than a re-parse per split segment. The expected+ // spans below therefore INCLUDE the whitespace that separates a badge from the text+ // after it: the old per-segment `Document(parsing:)` stripped that leading whitespace+ // (dropping it from the rendered output entirely), whereas the walker now renders it.+ // The run invariant is unchanged — a run's rendered text equals its source span.+ @Test("Runs around a resolved footnote stay ordered, non-overlapping and block-relative") func footnoteRunsStayInBlockCoordinates() {- // T-1673/T-1876: the emitter splits inline source around resolvable [^id] tokens- // and renders each segment independently. Each segment's runs must be rebased by- // the segment's UTF-16 start in the FULL block source (including the skipped- // footnote token), or a selection after the badge anchors near the block start. let source = "before[^1] after" let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"])) #expect(runs.count >= 2, "text either side of the badge should map to separate runs")@@ -179,11 +185,11 @@ struct DocumentSourceMapInvariantTests { #expect(runs.first?.sourceStart == 0) #expect(sourceSpan(runs[0], in: source) == "before") - // The post-badge run starts at "after"'s offset in the FULL block source (11),- // not at its offset within the trailing segment (1).- let expectedStart = (source as NSString).range(of: "after").location+ // The post-badge run starts at " after"'s offset in the FULL block source (10),+ // not at its offset within the text following the badge (0).+ let expectedStart = (source as NSString).range(of: " after").location #expect(runs.last?.sourceStart == expectedStart)- #expect(sourceSpan(runs[runs.count - 1], in: source) == "after")+ #expect(sourceSpan(runs[runs.count - 1], in: source) == " after") } @Test("Runs after multiple resolved footnotes stay in full-block source coordinates")@@ -199,8 +205,8 @@ struct DocumentSourceMapInvariantTests { } // Each run's mapped span is the text it actually renders. let spans = runs.compactMap { sourceSpan($0, in: source) }- #expect(spans == ["alpha", "beta", "gamma"])- #expect(runs.last?.sourceStart == (source as NSString).range(of: "gamma").location)+ #expect(spans == ["alpha", " beta", " gamma"])+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " gamma").location) } @Test("An unresolved footnote reference leaves the run spans block-relative")@@ -220,45 +226,45 @@ struct DocumentSourceMapInvariantTests { @Test("An unresolved reference before a resolved one keeps its literal text in the segment") func unresolvedThenResolvedFootnoteComposes() {- // The emitter's loop `continue`s on an unresolvable reference without advancing- // lastEnd, so [^missing]'s literal text must stay inside the segment that ends at- // the NEXT resolvable token — and the tail rebase must still land on "c".+ // The renderer's loop `continue`s on an unresolvable reference without advancing+ // lastEnd, so [^missing]'s literal text must stay inside the visible segment that+ // ends at the NEXT resolvable token — and the tail must still land on " c". let source = "a[^missing] b[^1] c" let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"])) #expect(runs.first?.sourceStart == 0) #expect(sourceSpan(runs[0], in: source) == "a[^missing] b")- #expect(runs.last?.sourceStart == (source as NSString).range(of: "c").location)- #expect(sourceSpan(runs[runs.count - 1], in: source) == "c")+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " c").location)+ #expect(sourceSpan(runs[runs.count - 1], in: source) == " c") } @Test("A footnote at the very start leaves the tail run in block coordinates") func footnoteAtStartKeepsTailInBlockCoordinates() {- // The pre-badge segment is empty and skipped, so the tail rebase is the only thing- // keeping the map correct.+ // The pre-badge segment is empty and skipped, so the cursor advance over the token+ // is the only thing keeping the map correct. let source = "[^1] after" let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))- #expect(runs.last?.sourceStart == (source as NSString).range(of: "after").location)- #expect(sourceSpan(runs[runs.count - 1], in: source) == "after")+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " after").location)+ #expect(sourceSpan(runs[runs.count - 1], in: source) == " after") } @Test("Adjacent footnotes with no text between keep the tail run in block coordinates") func adjacentFootnotesKeepTailInBlockCoordinates() {- // The inter-badge segment is empty; skipping it must not desynchronise lastEnd.+ // The inter-badge segment is empty; skipping it must not desynchronise the cursor. let source = "a[^1][^2] b" let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1", "2"])) #expect(sourceSpan(runs[0], in: source) == "a")- #expect(runs.last?.sourceStart == (source as NSString).range(of: "b").location)- #expect(sourceSpan(runs[runs.count - 1], in: source) == "b")+ #expect(runs.last?.sourceStart == (source as NSString).range(of: " b").location)+ #expect(sourceSpan(runs[runs.count - 1], in: source) == " b") } @Test("Astral text before a footnote keeps the tail run in UTF-16 block coordinates") func astralBeforeFootnoteKeepsUTF16Coordinates() {- // The rebase offset must be UTF-16, not Character count: 😀 is two UTF-16 units.+ // The cursor must advance in UTF-16, not Character count: 😀 is two UTF-16 units. let source = "a😀b[^1] tail" let runs = runs(for: .paragraph(markdown: source), footnotes: footnoteData(["1"]))- let expectedStart = (source as NSString).range(of: "tail").location+ let expectedStart = (source as NSString).range(of: " tail").location #expect(runs.last?.sourceStart == expectedStart)- #expect(sourceSpan(runs[runs.count - 1], in: source) == "tail")+ #expect(sourceSpan(runs[runs.count - 1], in: source) == " tail") } @MainActor@@ -275,7 +281,10 @@ struct DocumentSourceMapInvariantTests { in: block.textContent, utf16Range: .init(start: tail.sourceStart, length: tail.length) )- #expect(textRange?.selectedText == "after")+ // The tail run now covers the separator space too (see the T-1716 note above), so+ // the whole-run conversion quotes " after" — the point of the assertion is that it+ // resolves to the text AFTER the badge, not to text near the block start.+ #expect(textRange?.selectedText == " after") } @Test("UTF-16 coordinate space: astral chars count as surrogate pairs")
diff --git a/prismTests/WebRendering/WebSelectionNoteTests.swift b/prismTests/WebRendering/WebSelectionNoteTests.swiftindex 6f0846e..216f5af 100644--- a/prismTests/WebRendering/WebSelectionNoteTests.swift+++ b/prismTests/WebRendering/WebSelectionNoteTests.swift@@ -232,9 +232,12 @@ struct WebSelectionNoteTests { return FootnoteData(definitions: definitions, referenceOrder: identifiers) } - /// Selects the whole text of the run at `runIndex` inside `domID` and resolves it.+ /// Selects the text of the run at `runIndex` inside `domID` from `startOffset` to the+ /// end of the run, and resolves it. `startOffset` lets a test skip leading characters+ /// the run legitimately covers (e.g. the space separating a footnote badge from the+ /// following word) so the selection matches what a user would actually drag over. private func resolveWholeRun(- _ harness: WebDocumentLiveHarness, domID: String, runIndex: Int+ _ harness: WebDocumentLiveHarness, domID: String, runIndex: Int, startOffset: Int = 0 ) async throws -> [String: Any]? { let json = try await harness.evalString( """@@ -242,7 +245,7 @@ struct WebSelectionNoteTests { if (runs.length <= \(runIndex)) { return null; } var node = runs[\(runIndex)].firstChild; var range = document.createRange();- range.setStart(node, 0);+ range.setStart(node, \(startOffset)); range.setEnd(node, node.length); var r = window.__prismBridge.resolveSelectionRange(range); return r ? JSON.stringify(r) : null;@@ -255,15 +258,18 @@ struct WebSelectionNoteTests { @Test("A live selection after a resolved footnote badge resolves to the selected text") func liveSelectionAfterFootnoteResolvesSelectedText() async throws {- // T-1876: the source map's footnote-split segments must be rebased into full-block- // coordinates, otherwise the range resolved for text after the badge points near- // the block start and the note quotes the wrong words.+ // T-1876: runs after a footnote badge must be in full-block coordinates, otherwise+ // the range resolved for text after the badge points near the block start and the+ // note quotes the wrong words. Since T-1716 the post-badge run also covers the+ // separator space, so skip one character to select just the word. let source = "before[^1] after" let para = MarkdownBlock.paragraph(markdown: source) let harness = try await WebDocumentLiveHarness.make( blocks: [para], footnotes: footnoteData(["1"]), featureScripts: Self.notesScripts )- let resolved = try await resolveWholeRun(harness, domID: domID(para), runIndex: 1)+ let resolved = try await resolveWholeRun(+ harness, domID: domID(para), runIndex: 1, startOffset: 1+ ) #expect(resolved?["domID"] as? String == domID(para)) let start = (resolved?["start"] as? NSNumber)?.intValue ?? -1 let length = (resolved?["length"] as? NSNumber)?.intValue ?? -1@@ -291,8 +297,10 @@ struct WebSelectionNoteTests { let harness = try await WebDocumentLiveHarness.make( blocks: [para], footnotes: footnoteData(["a", "b"]), featureScripts: Self.notesScripts )- // Run 2 is "gamma", after both badges.- let resolved = try await resolveWholeRun(harness, domID: domID(para), runIndex: 2)+ // Run 2 is " gamma", after both badges; skip the separator space (T-1716).+ let resolved = try await resolveWholeRun(+ harness, domID: domID(para), runIndex: 2, startOffset: 1+ ) let start = (resolved?["start"] as? NSNumber)?.intValue ?? -1 let length = (resolved?["length"] as? NSNumber)?.intValue ?? -1 #expect(start == (source as NSString).range(of: "gamma").location)
diff --git a/specs/bugfixes/footnote-badge-inline-code/report.md b/specs/bugfixes/footnote-badge-inline-code/report.mdnew file mode 100644index 0000000..6464a31--- /dev/null+++ b/specs/bugfixes/footnote-badge-inline-code/report.md@@ -0,0 +1,267 @@+# Bugfix Report: Footnote Badges Replace References Inside Inline Code++**Date:** 2026-07-26+**Status:** Fixed+**Ticket:** T-1716 (also fixes T-1945)++## Description of the Issue++`FootnotePreprocessor.scanLineForReferences` deliberately skips backtick-delimited spans,+and `prismTests/TestData/footnotes-edge-cases.md:39` records the contract: "Inline code also+protects: `[^inline-code]` should not be a reference." The rendering side did not honour it.++`BlockHTMLEmitter.renderInline(_:)` scanned the raw block source with+`FootnoteData.referencePattern` and split it around every *resolvable* `[^id]` token BEFORE+handing the pieces to `InlineHTMLRenderer`. Because the scan ran on raw text with no notion+of markdown structure, a code span in one paragraph was split whenever the same identifier+was defined (i.e. referenced) anywhere else in the document.++**Reproduction steps:**+1. Open a document that defines `[^1]` (a real reference plus a `[^1]: …` definition).+2. In another paragraph, write `` Write `[^1]` to cite. ``+3. Observe the code span renders as `` `` (empty code) followed by a tappable footnote+ badge, instead of literal `[^1]` inside a code span.++**Impact:** Visible text differs from the source and cannot be selected or copied as+written; a documentation author cannot show readers the footnote syntax. Medium severity,+scoped to documents that both use a footnote and mention its syntax in code.++Three further faults shared the same cause and are fixed by the same change:++- **T-1945 (content loss):** `word[^1] more` — the trailing segment `" more"`+ re-parsed as an indented code block, and the Walker has no `visitCodeBlock`, so the+ default descend dropped `more` from the document entirely.+- **Emphasis broken by a badge:** `A *em [^1] end* tail` split into `A *em ` + badge ++ ` end* tail`; each fragment re-parsed with unbalanced emphasis delimiters, so the literal+ asterisks leaked into the rendered text.+- **Swallowed separator space:** each segment went through `Document(parsing:)`, which+ strips leading whitespace, so the space between a badge and the following word was+ dropped from the output (`before[^1] after` rendered `before`+badge+`after`).++**Second literal-display mechanism (found while reviewing this fix).** Inline code is only+one of CommonMark's two ways to display `[^id]` literally; the other is backslash escaping.+Moving substitution downstream of the parser fixed the code-span case but broke the escaped+one: swift-markdown resolves `\[\^1\]` while parsing, so it reaches `visitText` as a `Text`+node whose `.string` is a bare `[^1]`, the scan matched it, and a badge was emitted — the+very defect this report is about, reached through escaping instead of backticks. The pre-fix+emitter never had this problem because it scanned the *raw* source, where the interleaved+backslashes never form a contiguous `[^id]` match. The fix below therefore also filters+candidate tokens against their source spelling.++## Investigation Summary++- **Symptoms examined:** rendered HTML for paragraphs mixing code spans and footnote+ references; the emitter's segment loop; the preprocessor's inline-code skip.+- **Code inspected:** `BlockHTMLEmitter.renderInline` / `footnoteBadge` / `rebased`,+ `InlineHTMLRenderer.Walker`, `FootnotePreprocessor.scanLineForReferences`,+ `FootnoteData.referencePattern`, `prismTests/TestData/footnotes-edge-cases.md`.+- **Hypotheses tested:** the regex could be tightened to skip backticks (rejected — see+ Alternatives); the AST might split `[^id]` across sibling `Text` nodes, which would make+ an in-renderer fix unworkable. Ruled out by probing swift-markdown 0.7.3 directly: an+ undefined link label keeps `[^1]` as one contiguous `Text` node+ (`Text @1:1-1:9 "See[^1]."`), and a reference inside backticks arrives as `InlineCode`.++## Discovered Root Cause++Badge substitution ran on raw source text one stage too early — before markdown parsing —+so it had no way to know which `[^id]` occurrences the parser classifies as document text+and which it classifies as code. Splitting the source also forced N independent+`Document(parsing:)` calls per block, each of which could reinterpret its fragment+differently from the whole (indented code block, unbalanced emphasis, stripped leading+whitespace).++**Defect type:** Wrong pipeline stage / layering violation, with content-loss and+text-fidelity consequences.++**Why it occurred:** `InlineHTMLRenderer.render` already accepted a `footnotes:` parameter+whose doc comment claimed it rendered badge chrome, but the Walker never read it. The+substitution was implemented in the caller instead, and the unused parameter left the+intended design half-built.++**Contributing factors:** The re-parse-per-segment shape made every fragment-level+divergence (T-1945, emphasis, whitespace) invisible at the call site, and required a+`rebased(_:by:)` correction (T-1876) purely to undo the coordinate damage the split caused.++## Resolution for the Issue++Option (b) from the ticket: badge conversion moved into the inline renderer stage, which+understands code spans. There is now one source cursor over one parse per block.++**Changes made:**+- `prism/Services/WebRendering/InlineHTMLRenderer.swift` — `Walker.visitText` scans its own+ `Text` node for resolvable `[^id]` references, emitting visible segments through+ `appendVisible` and badges through the new `appendFootnoteBadge`, which closes the run and+ advances the UTF-16 cursor past the token so it maps to no run (inert chrome). Added+ `InlineHTMLRenderer.footnoteBadge(identifier:displayNumber:)` (moved from the emitter).+ `visitInlineCode` / `visitSymbolLink` call `appendVisible` directly and are therefore+ untouched by substitution — that is the fix for T-1716.+- `InlineHTMLRenderer.Walker.sourceSpellsLiveReference(_:after:)` — a candidate token is only+ badged when the block SOURCE spells it as a live reference. Because the parser has already+ consumed backslash escapes, the parsed text cannot distinguish `\[\^1\]` from `[^1]`, but+ the source still can: scanning forward from the token's source position, whichever spelling+ occurs first — literal or escaped (`matchAllowingEscapes`, which accepts `\` + ASCII+ punctuation in place of any token character, and treats `\\` as a literal backslash so+ `\\[^1]` stays live) — is the one the token came from. An escaped token takes the same+ `continue` path as an unresolvable identifier, so it stays inside the surrounding visible+ text segment. `footnoteScanEnd` keeps this scan monotonic: text containing an escaped+ reference does not locate verbatim in the source, which leaves the run cursor behind that+ reference, and without the extra anchor a later live reference in the same block would+ re-match the earlier escaped one.+- `InlineHTMLRenderer.Walker.visitText` + `sourceSpellsLiveReference` — **review round 4**, two+ defects that only appear once a single `Text` node holds more than two `[^id]`-shaped+ matches (no test did, which is why they were invisible):+ 1. *Quadratic scan.* The text handed to `sourceSpellsLiveReference` was sliced from the last+ BADGED token, so a run of tokens left literal made it re-span every earlier occurrence and+ be rebuilt — new `String`, new `Array(utf16)`, new `locate` scan — per token. `visitText`+ now tracks the last CLASSIFIED token separately (`classifiedEnd`, advanced on both+ outcomes) from the last emitted visible text (`lastEnd`, advanced only by a badge), so the+ scanned text spans one gap at a time. Measured on `\[\^1\] ` repeated: 800 references+ 0.606s → 0.0046s, 3,200 references 9.83s → 0.018s; growth 16.2x → 3.9x for 4x the input.+ 2. *Cursor-driven misclassification.* The scan started at `max(cursor, footnoteScanEnd)`, and+ `cursor` tracks mapped text, not classified tokens: when a segment holding an escaped+ reference found a later verbatim coincidence, `cursor` ended up AHEAD of the token being+ judged. In `\[\^1\] [^1] ` repeated, from the third pair onward a live reference rendered+ literally and an escaped one was badged — a swap that a badge count cannot see. The scan+ now anchors on `footnoteScanEnd` alone, which advances by exactly one occurrence per+ decision. New `sourcePosition(past:from:)` skips the text between two occurrences by+ consuming it in place with `matchAllowingEscapes` (escaped separators included, which is+ what keeps the escaped run linear) and falls back to the previous verbatim `locate`.+ 3. `matchAllowingEscapes` now tests `isEscapedBackslash` last. All the conditions are pure+ predicates, so the outcome is unchanged; the cheap unit comparisons in front of it stop+ its backward walk over the preceding backslash run from running at every position of a+ long backslash run.+- `prism/Services/WebRendering/BlockHTMLEmitter.swift` — `renderInline` is now a single+ `InlineHTMLRenderer.render` call. Deleted the segment loop, the `rebased(_:by:)` helper+ (unnecessary once there is one coordinate space) and the emitter's `footnoteBadge`.+ Net −79 lines in the emitter.+- `CLAUDE.md` — Footnote System step 4 now names `InlineHTMLRenderer` as the substitution+ site and states the inline-code contract.++**Approach rationale:** The parser is the only component that knows whether `[^id]` is text+or code, so substitution belongs downstream of it. Doing it in the Walker also removes the+splitting that caused T-1945, the emphasis breakage and the whitespace loss, and removes the+`rebased` coordinate correction rather than adding to it.++**Alternatives considered:**+- **Option (a): make the emitter's scan inline-code-aware.** Rejected. It patches the+ splitting approach rather than removing it, would need to re-implement backtick+ scanning (including fence-length matching) outside the parser, and would leave T-1945,+ the emphasis breakage and the swallowed space in place.+- **Deleting the unused `footnotes:` parameter** and keeping substitution in the emitter.+ Rejected for the same reasons; the parameter was the intended design, not dead weight.++**Deliberate behaviour change:** run spans after a badge now include the whitespace that+separates the badge from the following word (`" after"`, not `"after"` at +1). That+whitespace is real rendered text the old path discarded; the run invariant (a run's rendered+text equals its source span, unit for unit) still holds. Six `DocumentSourceMapInvariantTests`+assertions and two `WebSelectionNoteTests` assertions were updated for this, with the live+selection tests now selecting from offset 1 so they still exercise "user selects the word".++## Regression Test++**Test file:** `prismTests/WebRendering/BlockHTMLEmitterTests.swift`+**Suite:** `BlockHTMLEmitterFootnoteBadgeTests`++| Test | Verifies |+|------|----------|+| `referenceInsideInlineCodeIsNotBadged` | `` Write `[^1]` to cite. `` with `[^1]` defined emits no badge and keeps the literal text (T-1716) |+| `codeSpanProtectedButPlainReferenceBadged` | `Cite[^1] but write `[^1]` verbatim.` emits exactly one badge and keeps the code-span text |+| `referenceFollowedByIndentKeepsTrailingText` | `word[^1] more` keeps `more` (T-1945) |+| `emphasisSpanningReferenceIsPreserved` | `A *em [^1] end* tail` renders `<em>` with no literal asterisks |+| `escapedReferenceIsNotBadged` | `See \[\^1\] here.` outside a code span emits no badge and renders `See [^1] here.` |+| `partiallyEscapedReferenceIsNotBadged` | One escape is enough: `\[^1]`, `[\^1]` and `[^1\]` all stay literal |+| `escapedReferenceCoexistsWithLiveReference` | `Cite[^1] but write \[\^1\] verbatim.` — the resolvable id is badged once, the escaped occurrence stays literal |+| `liveReferenceAfterEscapedReferenceIsBadged` | `Write \[\^1\] then cite[^1] properly.` — order reversed; the live reference is still badged (covers the `footnoteScanEnd` anchor) |+| `escapedReferenceInsideCodeSpanStaysLiteral` | ``Write `\[\^1\]` to cite.`` keeps the backslashes verbatim and emits no badge |++The first four were verified failing before the substitution move and passing after. The four+escaped-form tests were verified failing on the fix's first iteration (each emitted a badge)+and passing after `sourceSpellsLiveReference` was added;+`escapedReferenceInsideCodeSpanStaysLiteral` passed already, since the AST routes code spans+away from `visitText`.++**Second test file (review round 4):** `prismTests/WebRendering/InlineFootnoteScanTests.swift`++Every test above puts at most two matches in one `Text` node. These put 200 in one node and+assert the rendered text **in order**, since a badge count cannot tell a correct rendering from+one that swapped a live reference for an escaped one:++| Test | Verifies | Pre-fix |+|------|----------|---------|+| `longEscapedRunIsAllLiteral` | 200 escaped references render `Start [^1] [^1] … end.`, no badge | passed |+| `longLiveRunIsAllBadged` | 200 live references render as 200 badges | passed |+| `alternatingRunClassifiesEachOccurrenceIndependently` | `\[\^1\] [^1] ` × 200 alternates literal/badge throughout | **failed** (swapped from the third pair on) |+| `liveReferenceAfterLongEscapedRunIsBadged` | one live reference after 200 escaped ones is the only badge, and it is the last item | passed |+| `longEscapedRunAfterLiveReferenceKeepsOneBadge` | reverse order — the badge stays on the first item | passed |+| `testEscapedReferenceScanDoesNotGrowQuadratically` | 4x the references must not cost ~16x | **failed** (16.22x) |++The growth guard asserts a ratio at 800 and 3,200 references rather than an absolute budget,+because one budget at one size cannot separate linear from quadratic — the precedent set by+T-1655 and reused in T-1877. The 8x ceiling leaves 2x headroom above linear and 2x below+quadratic, deliberately wide given this repo's timing-test flakiness (T-1541).++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism -destination 'platform=macOS' \+ -configuration Debug -derivedDataPath ./DerivedData -testPlan prism \+ -only-test-configuration "en (base)" -parallel-testing-worker-count 1 \+ -only-testing:prismTests/BlockHTMLEmitterFootnoteBadgeTests \+ -only-testing:prismTests/InlineFootnoteScanTests \+ -only-testing:prismTests/InlineFootnoteScanPerformanceTests+```++## Verification++- `make lint` — 0 violations in 497 files.+- Every suite in `prismTests/WebRendering` (53 suites, including the WebKit live harnesses)+ plus the footnote suites (`FootnotePreprocessorTests`, `FootnotePreprocessorPropertyTests`,+ `FootnoteStrippingTests`, `FootnoteSearchTests`, `MarkdownBlockParserFootnoteTests`,+ `FootnotePopoverContentTests`, `FootnotePresentationHostTests`) pass.+- Two failures observed are pre-existing: `WebDocumentBridgeLiveTests.bridgePostsReady` and+ `WebMediaBehaviourTests.imageActivatedPosts` fail identically on the unmodified baseline+ (verified by `git stash` + re-run).+- `make test-quick` was NOT run: it has a documented pre-existing crash cascade whose exit+ code is masked by the `xcbeautify` pipe.+- After the escaped-reference fix, re-ran `BlockHTMLEmitterFootnoteBadgeTests`,+ `BlockHTMLEmitterStructureTests`, `BlockHTMLEmitterTotalityTests`,+ `DocumentSourceMapInvariantTests`, `WebSelectionNoteTests`, `SamplesComplianceTests`,+ `WebDeliberateChangeTests` and the footnote suites: 0 failures. `make lint` still reports+ 0 violations.+- `SearchCoordinatorTests.recomputeResetsCursorToNilWhenNoMatches` failed at this branch's+ merge base and is unrelated to this fix — it is what T-1960 (#324) repairs, and it passes+ once the branch carries `origin/main`.++**Round-4 audit — three pre-existing quadratics remain in this file, none introduced here.**+All four quadratics found in this area share one shape: a loop whose non-match path does not+advance the position it scans from. Measured on the merge base vs. this branch (debug, macOS),+each is quadratic in both and each got roughly 2x faster from this change, so none is a+regression and none is the shape the round-3 review reported:++| Input | Loop that pays | Pre-fix | This branch |+|---|---|---|---|+| `\* [^1] ` × 3,200 (escaped punctuation between live references) | `appendVisible` → `locate`: the segment before each badge is not verbatim in the source, so the search runs to the end of the source per badge | 16.99s | 8.37s |+| `\\` × 16,000 then one reference | `isEscapedBackslash`'s backward walk, once per position of the run (the reorder above removes it from the token scan but not from consuming a gap that is itself backslashes) | 6.05s | 2.99s |+| `\[\^1\]A` × 1,600 (entity between escaped references) | `sourcePosition` → `locate`: an entity is neither verbatim nor a backslash escape, so neither step settles the gap | 4.18s | 2.58s |++Fixing these means changing `locate`, the shared source-mapping primitive every block type+depends on (and whose forward-search semantics T-1876's assertions rest on), so they are+deliberately out of scope for this bugfix and want their own ticket.++**Known limitation (unchanged by this fix):** text containing a backslash escape does not+locate verbatim in the block source, so its run is recorded with a zero length and anchored+at the cursor. Selection-anchored notes over escaped text are therefore not mappable. That is+pre-existing behaviour for escapes on both the old and new paths, not something the escape+filter introduces — the filter only stops such text being replaced by chrome.++## Relationship to other tickets++- **T-1945 — fixed by this change.** Covered by `referenceFollowedByIndentKeepsTrailingText`.+- **T-1941 — NOT fixed, still open.** Its item 1 (the footnote-split rebase) is now moot:+ the split and `rebased(_:by:)` are gone. Items 2 and 3 remain — `tableHTML` and+ `renderListMarkup` hand `renderInline` a *substring* (a cell, an item) and record the+ resulting runs into the enclosing block's map, so those offsets are still sub-span-local+ regardless of footnotes. That is a separate defect from badge substitution. The+ `renderInline` doc comment still points at T-1941 for it.+- **T-1669** (general fallback for unhandled block nodes in the inline re-parse) remains+ worthwhile: this change removes the footnote-split trigger for that class, but a block+ source that itself re-parses to an unhandled block node can still hit it.
diff --git a/CLAUDE.md b/CLAUDE.mdindex ed32b0a..7bbd683 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -62,7 +62,7 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 1. `FootnotePreprocessor` extracts `[^id]: content` definitions from raw markdown before `swift-markdown` parsing, producing cleaned source + `FootnoteData` sidecar 2. `MarkdownBlockParser.parseWithFootnotes()` integrates the preprocessor into the parsing pipeline; `parseFragment()` renders footnote content in popovers without the full pipeline 3. `DocumentSession` stores `FootnoteData` alongside parsed blocks; both are bundled in `ParseResult`-4. `BlockHTMLEmitter` replaces each resolvable `[^id]` reference in the emitted HTML with a styled pill-badge anchor (`prism://footnote/{id}`, the display number from `FootnoteData`) — footnote chrome, not document text+4. `InlineHTMLRenderer` replaces each resolvable `[^id]` reference with a styled pill-badge anchor (`prism://footnote/{id}`, the display number from `FootnoteData`) — footnote chrome, not document text. Substitution happens on the `Text` nodes swift-markdown produced, not by splitting the block source before parsing, so inline code protects a reference exactly as `FootnotePreprocessor.scanLineForReferences` does and surrounding inline markup is unaffected (T-1716). Backslash escaping is markdown's other way to display a reference literally, and the parser resolves it before the renderer sees the node, so each candidate is additionally checked against its spelling in the block source (`sourceSpellsLiveReference`) and an escaped `\[\^id\]` stays text 5. The badge anchor carries `data-prism-chrome` so it is not selectable; the badge's display number and id come from the `FootnoteData` sidecar threaded into the emitter 6. Badge taps produce `prism://footnote/{id}` links → `linkActivated` over the bridge → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` popover state (`PrismLinkRoute.footnotePrefix`) 7. `FootnotePopoverView` displays rendered footnote content (paragraphs, lists, blockquotes only) over a shared, non-persistent `FootnotePopoverWebPage` (emitter-rendered fragment). Since the WebKit cutover it is presented as a sheet on every platform via the shared `FootnotePresenter` modifier (`View.footnotePresentation(coordinator:session:)`), which **both** `CompactDocumentLayout` and `RegularDocumentLayout` must apply — the regular layout lacking it is T-1893. The web-rendered badge has no SwiftUI anchor view, so the anchored popover the original footnotes spec called for (Req 3.1/3.6/3.7) no longer applies; `FootnotePopoverView` self-sizes per platform
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 0d8d7a7..3eff66f 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A footnote reference written inside inline code stays literal text (T-1716). Writing `` `[^1]` `` to show readers the footnote syntax turned into a tappable footnote badge whenever `[^1]` was also used as a real reference somewhere in the document, so the code span displayed the wrong thing and could not be copied as written. Code spans now protect a reference exactly as the parser already promised, and so does escaping it with backslashes (`\[\^1\]`) — both of the ways markdown offers to show the syntax literally. The same change fixes three related faults in the rendered document: text following a footnote reference is no longer dropped when four or more spaces separate them (T-1945), emphasis or bold spanning a reference no longer breaks apart into literal asterisks, and the space between a badge and the next word is no longer swallowed. A paragraph mixing escaped and real references to the same footnote now renders every one of them correctly however many it holds — previously, from the third onward, a real reference could show as plain text while an escaped one became a badge — and a paragraph holding a long run of escaped references no longer takes seconds to appear: 3,200 of them went from ten seconds to two hundredths of a second. Documents with unusual markup can still slow other steps of the rendering pipeline; that is tracked separately. - Changing the theme while viewing raw source no longer brings back the previous version of the document (T-1759). If the file changed on disk — or a URL document was refreshed — at the same moment the colours changed, the recolour worked from a snapshot taken before the reload and, finishing last, put the old text back on screen, where it stayed until the next reload or raw/rendered toggle. Recolouring now only applies while the lines it started from are still the ones being shown. - The system **Increase Contrast** accessibility setting applies to the document again (T-1829). Since the WebKit rendering cutover, turning it on changed the app's own interface but left the document body untouched: search highlights and footnote badges stayed translucent and tertiary text stayed low-contrast. The rendered document now uses the same higher-contrast search, footnote, and tertiary colours as the rest of the app, on every theme, and the deliberately-faded "add note" button beside each block is shown at full strength. It follows the setting live — toggling it while a document is open updates immediately, without a reload and without losing your reading position, and the styling survives a WebKit process recovery. Footnote popovers keep the standard palette. - Jumping to a specific place in a document you have read before now lands where you asked instead of at your saved reading position (T-1775). This covers every way you can ask: following a link to a heading, picking a table-of-contents entry, opening a note, and stepping to a search match. The requested target was queued while the document's HTML was being prepared in the background, and the saved position — restored a moment later — replaced it, so the page settled where you last stopped reading. An explicit target now takes precedence for that load. Reading position is otherwise untouched: an ordinary reopen, returning from raw source, and a reload after the file changes on disk or a URL refresh all still restore where you left off, even with a search active.
Three xcodebuild test attempts against this worktree failed on infrastructure, not on the code: two were cut off mid-build and the third ran 657 s before "prism (43907) encountered an error (The test runner hung before establishing connection.)", with six concurrent xcodebuild processes on the machine. That matches the contention warning in the brief.
Rather than claim coverage I did not get, the renderer was compiled into a standalone executable: InlineHTMLRenderer.swift verbatim plus stubs for its six external symbols, linked against the project's own Markdown.o/cmark-gfm.o from DerivedData so the parser behaviour is identical. The same harness was built a second time from git show origin/main:…InlineHTMLRenderer.swift with a verbatim transcription of main's renderInline split loop, which is what makes the regression claims comparative rather than assertions about intent. The project's own unit and UI suites were NOT run by this review — the author reports them passing, and lint plus both platform builds are confirmed clean here.
Changing test expectations in the same commit as the behaviour is the pattern most likely to hide a regression, and an earlier round in this batch approved exactly that. So every one of the six updated DocumentSourceMapInvariantTests spans was recomputed from the renderer's actual output: before[^1] after → 0+6="before", 10+6=" after"; alpha[^a] beta[^b] gamma → "alpha", " beta", " gamma"; a[^missing] b[^1] c → "a[^missing] b", " c"; [^1] after → 4+6=" after"; a[^1][^2] b → "a", " b"; a😀b[^1] tail → 0+4="a😀b", 8+5=" tail". All match. These are corrections.
Reasonable people could differ. The trigger for findings 1-2 needs an earlier [^id]-shaped occurrence with the opposite spelling and a token whose precedingText is empty; every prose variant with text in between renders correctly. Finding 3 needs entity-spelled markup.
The argument for blocking anyway: finding 2 re-introduces the exact defect the ticket is titled after, and findings 1 and 3 both make output worse than origin/main for inputs main handled correctly. A bugfix branch that regresses two inputs the buggy code got right is worth one more round, particularly since all three collapse into one production change.
Confirmed: [label[^1]](url) emits an <a class="prism-footnote-badge"> inside the link's <a href>. Invalid HTML — the parser closes the outer anchor at the inner start tag, so any link text after the badge stops being part of the link. Explicitly not raised as a finding: on origin/main the raw-source split destroyed the link entirely into literal brackets, so this is a strict improvement, and run spans are unaffected. Worth a WebDocumentLiveHarness case eventually to pin what WebKit actually produces.
Re-confirmed by execution rather than taken on trust, since they were cheap once the harness existed: T-1716 itself (Write `[^1]` to cite. → literal, 0 badges), T-1945 (word[^1] more keeps more), emphasis spanning a badge (<em> survives, no literal asterisks), the preserved separator space, \\[^1] staying live, and the 200-pair alternating order test. All pass. rebased has no remaining references anywhere; T-1941's re-scoping comment is accurate; CLAUDE.md's Footnote System step 4 matches the code apart from the scanLineForReferences agreement claim in finding 4.