prism branch T-2005/bugfix-multiline-code-span-details commits 3 (since merge-base) files 6 touched lines +1798 / -240

Pre-push review: T-2005 multiline code-span details tag matching (PR #409)

Final gate review of commit 5888c101 (round 3), which addresses all four "Needs fixes" items from the previous review of d5f85eb2: the unconditional quadratic fence index, a dead unmatchedRunLengths memo, a missing CHANGELOG entry, and the CommonMark type-6 design call.

At a glance

  • Fence index: now a single forward pass per marker (FenceScanner/PatternCursor), gated behind a UTF-8 byte-scan early return when the document has no <details candidate at all — verified against the code and against the G5/G5b growth tests.
  • Dead memo: unmatchedRunLengths is gone; confirmed via grep, no remnants in source or tests.
  • CHANGELOG: two [Unreleased]/Fixed entries present, covering both the parsing correctness fix and the preprocessing performance fix.
  • CommonMark type-6 design call: implemented as isParagraphBreak/startsDetailsHTMLBlock, matching isAtLineStart's indentation allowance; the now-provably-unreachable inline-code check in protectDetailsBlankLines's candidate gate was removed rather than left dead, and the design rationale (spec reading over the ticket's suggested reading) is documented in the bugfix report's Known Residual section.
  • Independent test run: targeted suite re-executed in an isolated git archive export (never touching the actual worktree) — 50/50 passed, ~9s total including the 4.5s fuzz test.
  • Manual code trace: verified PatternCursor's cache-invalidation-by-position logic, FenceScanner's shared forward cursors, codeSpanContentRanges's fence-skipping, and appendCodeSpans's reverse next-same-length pass by hand — all sound, no new defects found.

Verdict

Ready to push

All four previously-flagged items are fixed and independently verified: fence discovery is a single forward pass (FenceScanner/PatternCursor) gated by a UTF-8 byte-scan early return (containsDetailsTagCandidate) so documents with no <details> pay nothing; the dead unmatchedRunLengths memo is gone, replaced by a linear per-window run list with a reverse next-same-length pass; CHANGELOG entries are present for both the parsing fix and the preprocessing cost; and the CommonMark type-6 rule is implemented in isParagraphBreak/startsDetailsHTMLBlock with the now-unreachable inline-code check removed from protectDetailsBlankLines's candidate gate. Manual trace of PatternCursor caching, FenceScanner's persistent cursors, and the reverse-pass run matching in appendCodeSpans found no correctness defects. The targeted suite (DetailsBlankLinePlaceholderTests, MultilineCodeSpanDetailsTests, CodeSpanParagraphBreakTests, DetailsTagScanGrowthTests) was re-run independently in an isolated git archive export: 50/50 passed, including the 20,000-round differential fuzz and the G5/G5b growth-ratio guards for the round-3 regression. The only overlap with origin/main's seven new commits is an append-only line in CHANGELOG.md — a trivial merge, not a code conflict.

Tests

Pass rate: 100% (50 of 50)

New tests: 30

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

A collapsible <details> section in a markdown document can contain an inline code span (backtick-quoted text) that itself mentions the literal text <details> or </details> — for example, documentation explaining how the tag works. If that quoted mention happened to wrap onto a second line, the parser used to get confused: it only checked the single line a candidate tag sat on for surrounding backticks, so it miscounted the quoted tag as a real one. That either made the whole section fail to close (swallowing everything below it) or close too early (dropping real content). This PR fixes that by tracking backtick state across the whole paragraph instead of resetting it at every line break, matching what the CommonMark markdown spec actually says inline code spans are allowed to do.

Why it matters

Nobody writes buggy markdown on purpose — this is exactly the kind of content a documentation author would naturally write (a sentence about the <details> tag, inside a details block, wrapped by an editor). Before this fix, writing about the feature could break the feature.

Key concepts

  • Inline code span: backtick-quoted text, like `this`.
  • CommonMark: the markdown specification this codebase follows; it says a code span's backtick delimiters can be separated by a line break.
  • Growth-ratio test: a performance test that checks a function takes roughly 4x as long on 4x the input, rather than checking against an absolute time budget (which becomes stale as hardware changes).

Architecture

The fix replaces a per-line, stateless backtick scan (isInsideInlineCode) with a document-wide classifier, CodeFenceHelper.CodeRegionIndex, computed once per parse and queried by binary search. The index holds two sorted, non-overlapping range lists: fenced code blocks (allCodeFenceRanges) and inline code span content ranges (codeSpanContentRanges). Both call sites that used to duplicate ad-hoc classification — findMatchingDetailsClose and protectDetailsBlankLines's candidate gate — now share this one index.

Patterns worth reusing

  • Forward-only cursors instead of re-searching from a moving start. PatternCursor caches the last hit for one literal pattern and only re-searches once the caller's position has moved past it; a pattern with no further occurrence is searched exactly once more, not once per loop iteration. FenceScanner shares one pair of these cursors across the whole document, turning a per-fence O(remaining document) rescan into one O(document) pass overall.
  • Gate expensive preprocessing on a cheap negative check. containsDetailsTagCandidate is a raw UTF-8 byte scan (not String.range(of:options:.caseInsensitive), which the PR measured at 219ms on 2MB) that lets protectDetailsBlankLines return immediately for the common case: a document with no <details> tag at all.
  • Resolve a whole window at once instead of scanning per opener. appendCodeSpans collects every backtick run in a paragraph window in one pass, then a single reverse pass links each run to the next run of the same length, turning span matching into a linear walk instead of a per-opener rescan.

Trade-offs

The span scan has no full CommonMark block structure — it only recognizes fences, blank lines, blockquote-marker-only lines, and line-start <details> tags as paragraph breaks. A non-empty list item or an ATX heading is not recognized as an interrupting block, so a span could theoretically bridge across one of those in a real document. This is documented as a Known Residual rather than silently accepted.

Deep dive

CodeRegionIndex answers point-containment queries ("is this position inside a fence/span?") via binary search over sorted ranges built in one forward pass (init cost O(n), query cost O(log n)). The one-shot conveniences (isInsideCodeFence(in:at:), isInsideInlineCode(in:at:)) rebuild the whole index per call and are explicitly documented as being for tests and single questions only — a loop must build the index once and hold it, which is exactly the discipline that was missing in the first push (a 5-15x regression from rebuilding the span index once per candidate tag).

The round-3 fix addresses a subtler quadratic that round 1 left standing: allCodeFenceRanges previously called findNextCodeFenceStart per fence, and that function itself searched BOTH fence markers (backtick and tilde) from its start position to the end of the document on every call — so a document with 500 backtick fences and zero tildes searched for ~~~ to EOF 500 times. FenceScanner fixes this by giving each marker its own PatternCursor that persists across the whole scan, so a marker with no further occurrence is searched exactly once more rather than once per fence. This is the same technique applied to the tag search in findMatchingDetailsClose.

The unmatchedRunLengths memo removal is a correctness-of-reasoning fix as much as a dead-code removal: the memo could never fire because a scan that fails to close a run of length k has, by construction, already examined every run in the window without finding a match — there is no later opener of length k left to short-circuit against. Its removal in favor of collect-then-reverse-pass-match is asymptotically better (O(n) vs O(n^1.5) worst case on windows with distinct increasing run lengths) and removes a subtly-wrong invariant from the codebase rather than papering over it.

Edge cases verified

  • PatternCursor.next(in:from:)'s cache invalidation is purely position-based (lastHit.lowerBound >= position), not reason-based — so a cached hit that becomes stale because the caller skipped past it (e.g., because it fell inside a fence) is correctly invalidated without any explicit discard() call; the explicit discard() calls in FenceScanner for a rejected mid-line candidate are technically redundant (the position-based check would invalidate them anyway) but harmless.
  • startsDetailsHTMLBlock's indentation counting (each space/tab counts as 1, allow ≤3) exactly mirrors isAtLineStart's counting, so the documented claim "the indentation allowance matches isAtLineStart" holds by inspection, including their shared (pre-existing, unchanged) inaccuracy around tab-stop columns per strict CommonMark.
  • Manually verified the KMP-style restart logic in containsDetailsTagCandidate is correct: since < appears only once in the pattern <details, a naive single-character restart (rather than a full KMP failure function) is provably equivalent to the general algorithm for this specific pattern.

Important changes — detailed

CodeFenceHelper: CodeRegionIndex replaces line-scoped inline-code check

prism/Services/CodeFenceHelper.swift

Why it matters. This is the actual bug fix. The old isInsideInlineCode reset all delimiter state at every newline, contradicting CommonMark §6.1 (a code span's line ending is content, not a terminator). Both findMatchingDetailsClose and protectDetailsBlankLines now query one shared, document-wide index instead of duplicating (or omitting) the check.

What to look at. CodeFenceHelper.swift: CodeRegionIndex struct, codeSpanContentRanges, appendCodeSpans

Takeaway. When a text-scanning classifier stands in for a spec'd construct (code spans, fences, HTML blocks), check the spec's actual scoping rules before assuming a scan resets per line — CommonMark deliberately lets several inline constructs span line endings.
Rationale. A single shared classifier answers the containment query correctly for both call sites, which is what the ticket specifically asked for (share one multiline-aware classifier rather than patching each site independently).

FenceScanner/PatternCursor: one forward pass per marker

prism/Services/CodeFenceHelper.swift

Why it matters. Fixes the quadratic that the previous review flagged: findNextCodeFenceStart searched both fence markers from the current position to end-of-document on every call, so allCodeFenceRanges paid that cost once per fence. Measured 25s on a 2MB/500-fence document with main paying nothing (no details tags present).

What to look at. CodeFenceHelper.swift: FenceScanner, PatternCursor structs

Takeaway. A forward-only cursor that caches its last hit and only re-searches once the caller's position moves past it turns a per-item rescan into one pass shared across all items — applicable to any loop that repeatedly asks "where's the next X" from an advancing cursor.
Rationale. Documented directly in the type's doc comment: without persistent cursors, a document with 500 backtick fences and no tildes searched for ~~~ to end-of-document 500 times.

containsDetailsTagCandidate: early return for documents with no candidate tag

prism/Services/CodeFenceHelper.swift

Why it matters. protectDetailsBlankLines runs on every parse. Before this, it built a full CodeRegionIndex (fences + spans) unconditionally, even for the common case of a document with no <details> at all — paying the fence-scan cost for nothing.

What to look at. CodeFenceHelper.swift: containsDetailsTagCandidate, detailsTagPrefixBytes

Takeaway. A raw UTF-8 byte scan with a hand-rolled single-character restart can beat Foundation's String.range(of:options:.caseInsensitive) by two orders of magnitude (219ms → ~1ms on 2MB) for a fixed short needle — worth it only on a genuinely hot path like a per-parse guard.
Rationale. Measured: range(of:options:.caseInsensitive) alone cost 219ms on a 2MB document, paid on every parse of every document, nearly all of which contain no details tag.

appendCodeSpans: window-at-once matching replaces the dead unmatchedRunLengths memo

prism/Services/CodeFenceHelper.swift

Why it matters. The previous review flagged unmatchedRunLengths as dead code that could never fire. Rather than fix the memo, this removes it and restructures matching as collect-all-runs-then-reverse-pass, which is provably linear instead of the previous O(n^1.5) worst case.

What to look at. CodeFenceHelper.swift: BacktickRun, appendCodeSpans, nextOfSameLength

Takeaway. When a "cache" or "memo" can be proven to never fire (verify why, don't just delete on suspicion), removing it and restructuring the algorithm around the actual invariant is better than leaving a comment explaining why dead code is dead.
Rationale. A scan that fails to close a run of length k has, by construction, examined every run through to the window's end without finding one of length k — so no later opener of that length exists to short-circuit against. This is proven in the doc comment, not merely asserted.

isParagraphBreak/startsDetailsHTMLBlock: CommonMark type-6 rule

prism/Services/CodeFenceHelper.swift

Why it matters. Resolves the design disagreement flagged in the previous review. A comment on the ticket (from Codex) suggested the opposite reading from what rounds 1-2 shipped; round 3 reverses to match the CommonMark spec, and demonstrates the change is safe by making the now-superseded check in protectDetailsBlankLines's candidate gate literally unreachable and removing it.

What to look at. CodeFenceHelper.swift: isParagraphBreak, startsDetailsHTMLBlock

Takeaway. When two readings of a spec are both defensible from intuition alone, look up the actual spec section (here, CommonMark §4.6's list of block types that interrupt a paragraph) rather than deciding by which failure mode feels less bad.
Rationale. CommonMark §4.6 lists HTML block type 6 (which <details>/<summary> tags open) as one of the block types whose start condition MAY interrupt a paragraph. A line-start <details tag therefore always ends the enclosing paragraph, so it can never be code-span content — removing the alternate check rather than adding a new one, and the 20,000-round differential fuzz (retired loop WITH the gate vs. production WITHOUT it) is the test that holds that removal down.

DetailsTagScanGrowthTests.swift: G5/G5b growth guards and hand-derived goldens

prismTests/DetailsTagScanGrowthTests.swift

Why it matters. New test file added specifically to pin the round-3 regression (G5/G5b) so a return to per-fence marker rescanning fails the build rather than the reading experience, plus goldens that assert an actual expected value rather than only agreement between two implementations.

What to look at. DetailsTagScanGrowthTests.swift: entire file (488 lines, new)

Takeaway. A differential oracle (comparing a rewritten loop against the retired one, verbatim) proves the restructure changed nothing but performance — but it cannot prove either implementation is correct. Pairing it with hand-derived goldens (with a reasoned expected value, like "the Nth </details> closes it") closes that gap.
Rationale. Documented in the file's own header comment: round 1 fixed the first regression on a shape it measured, and round 2 left a second, unmeasured quadratic standing on a different shape — the growth guards now cover both.

Key decisions

Delegate token classification to the Markdown parser — rejected.

Considered and rejected because protectDetailsBlankLines/findMatchingDetailsClose run as a raw-text preprocessing pass BEFORE swift-markdown parses the document; introducing a parser dependency here would be a much larger structural change for a scoped bug fix.

Track only "is there an unmatched backtick run" per paragraph, without full ranges — rejected.

Rejected because both call sites need a point-containment query, and precomputing all span ranges once per document keeps that query simple and shareable rather than re-deriving state at each candidate position.

List markers are deliberately not stripped in isParagraphBreak.

An empty list item cannot interrupt a paragraph per CommonMark §5.2 (foo\n* is one paragraph), so a marker-only line is continuation unless a list is already open — which a scan with no block structure cannot know. Treating it as a break would cut a real span short, which is the exact defect class T-2005 fixes.

The candidate-gate inline-code check was removed, not left as dead code.

Once the type-6 paragraph-break rule exists, a line-start <details tag can never be inline-code content, making protectDetailsBlankLines's previous inline-code check at that gate unreachable. The team chose to delete it and let the 20,000-round differential fuzz (which runs a retired loop WITH the check against production WITHOUT it) prove the removal is safe, rather than keep an unreachable branch with a comment.

Tests

Source: local run at 2026-09-06T09:20:00+10:00 · snapshot 5888c101

Baseline: none

Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

Totals: 50 passed · 0 failed · 0 skipped · 0 errored · 0 flaky

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 2b10c6dc4944d8e50c99e6b60e962591a230b39f.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..73479cad 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- A collapsible section whose body quotes `<details>` or `</details>` in backticks now stays in one piece when the quote wraps across a line (T-2005). Documentation about HTML tags is the natural place to write that, and the tag inside the quote was counted as a real one: the section either never closed — swallowing everything below it — or closed at the quote, dropping the rest of its own content. Inline code may contain a line ending per CommonMark, so the check now follows a quote across lines instead of looking only at the line the tag sits on. Two things bound how far it follows. A blank line ends it, including a line holding only blockquote markers, which is a blank line inside the quote. And a `<details>` or `</details>` written at the start of a line ends it too, because that opens an HTML block, which interrupts whatever paragraph precedes it — so a stray backtick in a sentence can never reach down and turn the real section below it into quoted text. The same tag written mid-line is still ordinary quoted text, which is the case the ticket was raised for.+- Preparing a document for display no longer slows down as it gains code blocks (T-2005). Every parse indexed the document's fenced code blocks by searching for each of the two fence markers from scratch at every fence, which is quadratic: a 2 MB document of 500 code blocks took 25 seconds, and a 219 KB one took a second — paid even when the document contains no collapsible section for any of it to matter to. The index is one pass now, and a document with no `<details>` in it at all skips the work entirely: the same 2 MB document takes 10 ms, and the 219 KB one 1 ms. Growth-ratio guards cover both shapes, so a return to per-fence rescanning fails the build rather than the reading experience. - The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down. - Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged. - `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.
docs/agent-notes/markdown-parser.md Modified +3 / -1
diff --git a/docs/agent-notes/markdown-parser.md b/docs/agent-notes/markdown-parser.mdindex 2583395a..e0fae134 100644--- a/docs/agent-notes/markdown-parser.md+++ b/docs/agent-notes/markdown-parser.md@@ -19,9 +19,11 @@ Both operate on raw string content before AST parsing. `findNextCodeFenceStart` and `findCodeFenceEnd` recognize both backtick (`` ` ``) and tilde (`~`) fences per CommonMark. The closing fence must use the same character type as the opening and have at least as many characters. These functions are used by:  - `isInsideCodeFence` - checks if a position is inside any code fence (used by `protectDetailsBlankLines` to skip `<details>` tags in code)-- `isInsideInlineCode` - checks if a position is inside a backtick-delimited inline code span on the same line (supports multi-backtick delimiters per CommonMark)+- `isInsideInlineCode` - checks if a position is inside a backtick-delimited inline code span (multi-backtick delimiters per CommonMark). A span MAY cross line endings (§6.1), so the scan is not line-scoped: it runs to the end of the enclosing paragraph. The window ends at a blank line, at a line holding only blockquote markers (that is a blank line inside the quote), at a fenced code block, or at a line-start `<details`/`</details` — HTML block type 6 interrupts a paragraph, so a tag there is a block opener and never span content, however many backticks the lines above left open (T-2005) - `findMatchingDetailsClose` - skips code fence content and inline code spans when counting `<details>` nesting depth +Both entry points answer every "is this code?" question from one `CodeFenceHelper.CodeRegionIndex` per pass; the one-shot `isInsideCodeFence(in:at:)` / `isInsideInlineCode(in:at:)` helpers build an index each and are for single questions only. `protectDetailsBlankLines` returns before building anything when the document holds no `<details` candidate, and that candidate check is a UTF-8 byte scan because `String.range(of:options:.caseInsensitive)` costs 219 ms on a 2 MB document. Growth guards for all of it: `prismTests/DetailsTagScanGrowthTests.swift`.+ ## Key Design Decisions  - The fence upgrade preprocessing uses heuristics to determine the "intended" closing fence. It stops scanning at paragraph boundaries (blank line followed by non-fence content) to avoid merging separate code blocks.
prism/Services/CodeFenceHelper.swift Modified +~550 / -~220
diff --git a/prism/Services/CodeFenceHelper.swift b/prism/Services/CodeFenceHelper.swiftindex a5d9ce97..65f631ac 100644--- a/prism/Services/CodeFenceHelper.swift+++ b/prism/Services/CodeFenceHelper.swift@@ -12,6 +12,24 @@ import Foundation /// Both `MarkdownBlockParser` and `DetailsBlockParser` need to find code fences /// and protect blank lines inside `<details>` elements. This type provides the /// shared implementations to avoid duplication.+///+/// Every "is this position code?" question — inside a fenced block, inside an inline+/// code span — is answered from a `CodeRegionIndex` built ONCE per pass over a+/// document. The one-shot `isInsideCodeFence(in:at:)` / `isInsideInlineCode(in:at:)`+/// conveniences build that index themselves, so each costs a full scan of the+/// document: they are for tests and single questions, never for a loop. Rebuilding+/// the index per candidate tag was the 5-15x regression found in review of T-2005,+/// and rescanning fences from the start per candidate was the quadratic before it.+///+/// The index itself is a single forward pass, which is not free to keep true. Every+/// scan in this file carries a cursor forward instead of re-searching from a moving+/// start: `PatternCursor` for the tag and fence markers (a marker with no further+/// occurrence is searched exactly once more, not once per fence), and the span scan+/// walks each paragraph window once. Searching both fence markers from scratch per+/// fence cost 1.0 s on 400 fences and 25 s on 500 fences in 2 MB — and+/// `protectDetailsBlankLines` paid it on documents holding no `<details>` at all,+/// which is why the pass now leaves before building anything when the document has+/// no candidate tag (T-2005 review round 3). enum CodeFenceHelper: Sendable {     /// Placeholder used to protect blank lines inside `<details>` blocks from GFM parsing.     ///@@ -28,6 +46,65 @@ enum CodeFenceHelper: Sendable {         html.replacingOccurrences(of: detailsBlankLinePlaceholder, with: "")     } +    // MARK: - Code Region Index++    /// The fenced code blocks and inline code spans of one document, computed once so+    /// that a pass over the document can ask "is this position code?" in O(log n) per+    /// query instead of rescanning the document per question.+    ///+    /// Both range lists are in source order and non-overlapping: fences are found by+    /// one forward scan, and the span scan skips fence content entirely (backticks in+    /// a fenced block can neither open nor close a span).+    nonisolated struct CodeRegionIndex: Sendable {+        /// Every fenced code block, opening fence line through closing fence line. An+        /// unclosed fence extends to the end of the content.+        let fencedBlocks: [Range<String.Index>]++        /// The content range (delimiters excluded) of every inline code span.+        let inlineCodeSpans: [Range<String.Index>]++        init(_ content: String) {+            fencedBlocks = CodeFenceHelper.allCodeFenceRanges(in: content)+            inlineCodeSpans = CodeFenceHelper.codeSpanContentRanges(in: content, skipping: fencedBlocks)+        }++        /// The fenced code block containing `position`, if any.+        func fencedBlock(containing position: String.Index) -> Range<String.Index>? {+            Self.range(containing: position, in: fencedBlocks)+        }++        /// Whether `position` falls inside a fenced code block.+        func isInsideCodeFence(at position: String.Index) -> Bool {+            fencedBlock(containing: position) != nil+        }++        /// Whether `position` falls inside the content of an inline code span.+        func isInsideInlineCode(at position: String.Index) -> Bool {+            Self.range(containing: position, in: inlineCodeSpans) != nil+        }++        /// Binary search over sorted, non-overlapping ranges: the last range starting at+        /// or before `position` is the only one that can contain it.+        private static func range(+            containing position: String.Index,+            in ranges: [Range<String.Index>]+        ) -> Range<String.Index>? {+            var low = 0+            var high = ranges.count+            while low < high {+                let mid = (low + high) / 2+                if ranges[mid].lowerBound <= position {+                    low = mid + 1+                } else {+                    high = mid+                }+            }+            guard low > 0 else { return nil }+            let candidate = ranges[low - 1]+            return candidate.upperBound > position ? candidate : nil+        }+    }+     // MARK: - Details Blank Line Protection      /// Protects blank lines inside `<details>` blocks from terminating the HTML block.@@ -36,34 +113,55 @@ enum CodeFenceHelper: Sendable {     /// This function replaces blank lines inside `<details>...</details>` spans with a     /// placeholder that keeps the block intact.     ///+    /// The pass builds one `CodeRegionIndex` and scans the ORIGINAL content, copying it+    /// into the output block by block. Blocks are non-overlapping and every replacement+    /// stays inside its block, so the search position after a block in the original is+    /// exactly where the old mutate-in-place loop resumed in its rewritten copy — and no+    /// index into the original is ever invalidated.+    ///+    /// A document with no `<details` in it at all leaves before the index is built. That+    /// is the common case — this runs on every parse — and the index is the only part of+    /// the pass that costs anything on a document full of code fences (T-2005 review+    /// round 3: 25 s on a 2 MB document of 500 fences and no details tags).+    ///     /// - Parameters:     ///   - content: The markdown content.     ///   - validateContext: When true, checks that `<details` is at line start and not inside-    ///     a code fence before processing. Set to true when processing full markdown documents,-    ///     false when processing content already known to be inside a details element.+    ///     a code fence before processing. Set to true when processing full markdown+    ///     documents, false when processing content already known to be inside a details+    ///     element.     /// - Returns: Content with blank lines inside details blocks protected.     nonisolated static func protectDetailsBlankLines(         _ content: String,         validateContext: Bool     ) -> String {-        var result = content-        var searchStart = result.startIndex+        guard containsDetailsTagCandidate(content) else { return content }++        let regions = CodeRegionIndex(content)+        var output = ""+        output.reserveCapacity(content.utf8.count)+        var copiedUpTo = content.startIndex+        var searchStart = content.startIndex -        while let detailsStart = result.range(+        while let detailsStart = content.range(             of: "<details",             options: .caseInsensitive,-            range: searchStart..<result.endIndex+            range: searchStart..<content.endIndex         ) {+            // Only process <details that could be a real HTML block:+            // 1. Must be at start of line (GFM HTML blocks require this)+            // 2. Must not be inside a code fence+            //+            // There is deliberately no inline-code-span check here. A line-start+            // "<details" opens an HTML block of CommonMark type 6, and a type-6 block+            // INTERRUPTS a paragraph — so it is never the content of a code span, whatever+            // backticks the lines above it left open. `isParagraphBreak` encodes that, which+            // ends the span scan's window at such a line and makes a check here unreachable+            // rather than merely redundant.             if validateContext {-                // Only process <details that could be a real HTML block:-                // 1. Must be at start of line (GFM HTML blocks require this)-                // 2. Must not be inside a code fence-                if !isAtLineStart(in: result, at: detailsStart.lowerBound) {-                    searchStart = detailsStart.upperBound-                    continue-                }--                if isInsideCodeFence(in: result, at: detailsStart.lowerBound) {+                let position = detailsStart.lowerBound+                if !isAtLineStart(in: content, at: position)+                    || regions.isInsideCodeFence(at: position) {                     searchStart = detailsStart.upperBound                     continue                 }@@ -71,56 +169,70 @@ enum CodeFenceHelper: Sendable {              // Find the matching </details> accounting for nesting             guard let detailsEnd = findMatchingDetailsClose(-                in: result,-                from: detailsStart.upperBound+                in: content,+                from: detailsStart.upperBound,+                regions: regions             ) else {                 // No matching close tag, move past this opening                 searchStart = detailsStart.upperBound                 continue             } -            // Extract the details block content-            let blockRange = detailsStart.lowerBound..<detailsEnd-            var blockContent = String(result[blockRange])-             // Replace blank lines (empty lines or lines with only whitespace)             // with the placeholder. This includes blank lines inside code fences,             // which is necessary to keep the entire details block as one HTMLBlock.             // Placeholders in code fence content are restored later during block             // conversion (see convertCodeBlock and DetailsBlockParser.convertMarkup).-            blockContent = blockContent.replacingOccurrences(+            let blockContent = String(content[detailsStart.lowerBound..<detailsEnd]).replacingOccurrences(                 of: "\n\\s*\n",                 with: "\n\(detailsBlankLinePlaceholder)\n",                 options: .regularExpression             ) -            // Calculate the starting offset BEFORE modifying the string.-            // After replaceSubrange, all indices into `result` become invalidated,-            // so we need to remember the character offset and reconstruct.-            let startOffset = result.distance(from: result.startIndex, to: blockRange.lowerBound)--            // Replace the block in the result-            result.replaceSubrange(blockRange, with: blockContent)--            // Move search past this block: start offset + new content length-            let newSearchOffset = startOffset + blockContent.count-            searchStart = result.index(-                result.startIndex,-                offsetBy: newSearchOffset,-                limitedBy: result.endIndex-            ) ?? result.endIndex+            output += content[copiedUpTo..<detailsStart.lowerBound]+            output += blockContent+            copiedUpTo = detailsEnd+            searchStart = detailsEnd         } -        return result+        output += content[copiedUpTo...]+        return output+    }++    /// The bytes of the opening-tag prefix, lowercased, for `containsDetailsTagCandidate`.+    nonisolated private static let detailsTagPrefixBytes = Array("<details".utf8)++    /// Whether `content` holds anything that could be a `<details` tag.+    ///+    /// A byte scan over the UTF-8 view rather than `range(of:options:.caseInsensitive)`,+    /// which costs 219 ms on a 2 MB document — measured, and paid on every parse of every+    /// document, nearly all of which contain no details tag at all. ASCII case folding is+    /// a superset of what the Foundation search that follows can match here: a match has to+    /// begin with `<` followed by a character that case-folds to `d`, and the only such+    /// characters are `d` and `D`.+    nonisolated private static func containsDetailsTagCandidate(_ content: String) -> Bool {+        var matched = 0+        for byte in content.utf8 {+            let lowered = (byte >= UInt8(ascii: "A") && byte <= UInt8(ascii: "Z")) ? byte + 32 : byte+            if lowered == detailsTagPrefixBytes[matched] {+                matched += 1+                if matched == detailsTagPrefixBytes.count { return true }+            } else {+                // `<` occurs only at the needle's start, so a mismatch can only ever+                // restart the match at that first byte.+                matched = lowered == detailsTagPrefixBytes[0] ? 1 : 0+            }+        }+        return false     }      // MARK: - Details Tag Matching      /// Finds the closing `</details>` tag that matches an opening tag, accounting for nesting.     ///-    /// Uses range-based searching for efficiency instead of character-by-character iteration.-    /// Skips content inside markdown code fences and inline code spans to avoid counting-    /// example `<details>` text as real tags.+    /// Builds a `CodeRegionIndex` for the whole content, so each call costs a scan of the+    /// document; a loop over many openings must build the index once and call+    /// `findMatchingDetailsClose(in:from:regions:)`.     ///     /// - Parameters:     ///   - content: The string to search in.@@ -130,93 +242,65 @@ enum CodeFenceHelper: Sendable {         in content: String,         from start: String.Index     ) -> String.Index? {-        guard start < content.endIndex else { return nil }+        findMatchingDetailsClose(in: content, from: start, regions: CodeRegionIndex(content))+    } -        let openPattern = "<details"-        let closePattern = "</details>"+    /// Finds the closing `</details>` tag that matches an opening tag, accounting for nesting.+    ///+    /// Tags inside fenced code blocks and inline code spans are not counted, so example+    /// `<details>` text does not change the nesting depth; a tag inside a fence skips the+    /// whole fence. Each tag pattern is searched once per occurrence, not once per loop+    /// iteration: a pattern with no further occurrence is searched exactly once more.+    ///+    /// - Parameters:+    ///   - content: The string to search in.+    ///   - start: The index to start searching from (after the opening `<details`).+    ///   - regions: The code regions of `content`, built once by the caller.+    /// - Returns: The index just after the matching `</details>` tag, or nil if not found.+    nonisolated static func findMatchingDetailsClose(+        in content: String,+        from start: String.Index,+        regions: CodeRegionIndex+    ) -> String.Index? {+        guard start < content.endIndex else { return nil }          var depth = 1         var searchStart = start+        var opens = PatternCursor(pattern: "<details", options: .caseInsensitive)+        var closes = PatternCursor(pattern: "</details>", options: .caseInsensitive) -        while searchStart < content.endIndex && depth > 0 {-            let searchRange = searchStart..<content.endIndex--            // Check if there's a code fence before the next details tag-            // Code fences start with ``` at the beginning of a line-            if let fenceStart = findNextCodeFenceStart(in: content, range: searchRange) {-                // Find next open or close tag-                let nextOpen = content.range(of: openPattern, options: .caseInsensitive, range: searchRange)-                let nextClose = content.range(of: closePattern, options: .caseInsensitive, range: searchRange)--                // Get the earliest tag position-                let earliestTag: String.Index? = [nextOpen?.lowerBound, nextClose?.lowerBound]-                    .compactMap { $0 }-                    .min()--                // If fence comes before any tag, skip the entire fence block-                if earliestTag == nil || fenceStart < earliestTag! {-                    // Find the closing fence-                    if let fenceEnd = findCodeFenceEnd(in: content, from: fenceStart) {-                        searchStart = fenceEnd-                        continue-                    } else {-                        // Unclosed fence - skip to end-                        return nil-                    }-                }-            }--            // Find next open or close tag, whichever comes first-            let nextOpen = content.range(of: openPattern, options: .caseInsensitive, range: searchRange)-            let nextClose = content.range(of: closePattern, options: .caseInsensitive, range: searchRange)--            switch (nextOpen, nextClose) {+        while searchStart < content.endIndex {+            let tag: Range<String.Index>+            let isOpen: Bool+            switch (opens.next(in: content, from: searchStart), closes.next(in: content, from: searchStart)) {             case (nil, nil):-                // No more tags found                 return nil-+            case (let openRange?, nil):+                (tag, isOpen) = (openRange, true)             case (nil, let closeRange?):-                // Only close tag found — skip if inside inline code-                if isInsideInlineCode(in: content, at: closeRange.lowerBound) {-                    searchStart = closeRange.upperBound-                    continue-                }-                depth -= 1-                if depth == 0 {-                    return closeRange.upperBound-                }-                searchStart = closeRange.upperBound+                (tag, isOpen) = (closeRange, false)+            case (let openRange?, let closeRange?):+                isOpen = openRange.lowerBound < closeRange.lowerBound+                tag = isOpen ? openRange : closeRange+            }+            searchStart = tag.upperBound -            case (let openRange?, nil):-                // Only open tag found — skip if inside inline code-                if isInsideInlineCode(in: content, at: openRange.lowerBound) {-                    searchStart = openRange.upperBound-                    continue-                }-                depth += 1-                searchStart = openRange.upperBound+            // A tag inside a fence skips the entire fence. An unclosed fence extends to+            // the end of the content, so the loop ends and no match is reported.+            if let fence = regions.fencedBlock(containing: tag.lowerBound) {+                searchStart = fence.upperBound+                continue+            }+            if regions.isInsideInlineCode(at: tag.lowerBound) {+                continue+            } -            case (let openRange?, let closeRange?):-                // Both found - process whichever comes first-                if openRange.lowerBound < closeRange.lowerBound {-                    // Skip open tag if inside inline code-                    if isInsideInlineCode(in: content, at: openRange.lowerBound) {-                        searchStart = openRange.upperBound-                        continue-                    }-                    depth += 1-                    searchStart = openRange.upperBound-                } else {-                    // Skip close tag if inside inline code-                    if isInsideInlineCode(in: content, at: closeRange.lowerBound) {-                        searchStart = closeRange.upperBound-                        continue-                    }-                    depth -= 1-                    if depth == 0 {-                        return closeRange.upperBound-                    }-                    searchStart = closeRange.upperBound+            if isOpen {+                depth += 1+            } else {+                depth -= 1+                if depth == 0 {+                    return tag.upperBound                 }             }         }@@ -224,12 +308,47 @@ enum CodeFenceHelper: Sendable {         return nil     } +    /// A forward-only cursor over the occurrences of one literal pattern.+    ///+    /// Remembers the last hit and re-searches only once the caller has moved past it, and+    /// remembers a miss for good. Without this, a loop that searches TWO patterns on every+    /// iteration rescans to the end of the content for the absent one each time —+    /// quadratic in the number of hits. Both such loops in this file use it: the+    /// `<details>`/`</details>` tag search and the ```` ``` ````/`~~~` fence-marker search.+    nonisolated private struct PatternCursor {+        let pattern: String+        let options: String.CompareOptions+        private var lastHit: Range<String.Index>?+        private var exhausted = false++        init(pattern: String, options: String.CompareOptions = []) {+            self.pattern = pattern+            self.options = options+        }++        /// The first occurrence starting at or after `position`, if any.+        mutating func next(in content: String, from position: String.Index) -> Range<String.Index>? {+            if exhausted { return nil }+            if let lastHit, lastHit.lowerBound >= position { return lastHit }+            lastHit = content.range(of: pattern, options: options, range: position..<content.endIndex)+            exhausted = lastHit == nil+            return lastHit+        }++        /// Forgets the current hit, so the next `next(in:from:)` searches again from the+        /// position it is given. For a caller that has REJECTED the current hit and needs+        /// the following occurrence of this pattern rather than the same one back.+        mutating func discard() {+            lastHit = nil+        }+    }+     // MARK: - Code Fence Detection      /// Finds the start of the next code fence in the given range.     ///-    /// A code fence starts with ``` or ~~~ at the beginning of a line (or at content start).-    /// Both backtick and tilde fences are valid per CommonMark.+    /// One-shot convenience: builds a `FenceScanner` for this single question. A loop over+    /// many fences must keep ONE scanner instead — see `allCodeFenceRanges`.     ///     /// - Parameters:     ///   - content: The string to search in.@@ -239,40 +358,71 @@ enum CodeFenceHelper: Sendable {         in content: String,         range: Range<String.Index>     ) -> String.Index? {-        var searchStart = range.lowerBound+        var scanner = FenceScanner()+        return scanner.nextFenceStart(in: content, from: range.lowerBound, limit: range.upperBound)+    } -        while searchStart < range.upperBound {-            let searchRange = searchStart..<range.upperBound+    /// A forward-only scanner over the line-start fence openers of one document.+    ///+    /// The two markers keep their own cursors ACROSS calls, which is what makes indexing a+    /// document's fences one forward pass rather than one pass per fence. A document with+    /// 500 backtick fences and no tildes used to search for `~~~` from the current fence to+    /// the end of the document 500 times over (25 s on 2 MB); it now searches for it once.+    nonisolated private struct FenceScanner {+        private var backticks = PatternCursor(pattern: "```")+        private var tildes = PatternCursor(pattern: "~~~")++        /// The start of the next fence at or after `position` and fully before `limit`.+        ///+        /// A fence starts with ``` or ~~~ at the beginning of a line (or at content start);+        /// both are valid per CommonMark. A mid-line candidate is rejected without+        /// re-searching the OTHER marker, whose next occurrence has not moved.+        mutating func nextFenceStart(+            in content: String,+            from position: String.Index,+            limit: String.Index+        ) -> String.Index? {+            var searchStart = position++            while searchStart < limit {+                let backtickHit = backticks.next(in: content, from: searchStart)+                let tildeHit = tildes.next(in: content, from: searchStart)++                let candidate: Range<String.Index>+                let isBacktick: Bool+                switch (backtickHit, tildeHit) {+                case (nil, nil):+                    return nil+                case (let backtickRange?, nil):+                    (candidate, isBacktick) = (backtickRange, true)+                case (nil, let tildeRange?):+                    (candidate, isBacktick) = (tildeRange, false)+                case (let backtickRange?, let tildeRange?):+                    isBacktick = backtickRange.lowerBound <= tildeRange.lowerBound+                    candidate = isBacktick ? backtickRange : tildeRange+                } -            // Look for both ``` and ~~~ and take whichever comes first-            let backticks = content.range(of: "```", range: searchRange)-            let tildes = content.range(of: "~~~", range: searchRange)+                // A hit that runs past the caller's limit ends the search: it is the+                // earliest hit, so the other marker's is later still.+                guard candidate.upperBound <= limit else { return nil } -            let candidate: Range<String.Index>-            switch (backticks, tildes) {-            case (nil, nil):-                return nil-            case (let backtickRange?, nil):-                candidate = backtickRange-            case (nil, let tildeRange?):-                candidate = tildeRange-            case (let backtickRange?, let tildeRange?):-                candidate = backtickRange.lowerBound <= tildeRange.lowerBound ? backtickRange : tildeRange-            }--            // Check if it's at the start of content or preceded by a newline-            let isAtStart = candidate.lowerBound == content.startIndex ||-                content[content.index(before: candidate.lowerBound)] == "\n"+                let isAtStart = candidate.lowerBound == content.startIndex ||+                    content[content.index(before: candidate.lowerBound)] == "\n"+                if isAtStart {+                    return candidate.lowerBound+                } -            if isAtStart {-                return candidate.lowerBound+                // Not at line start: take the next occurrence of THIS marker only.+                if isBacktick {+                    backticks.discard()+                } else {+                    tildes.discard()+                }+                searchStart = candidate.upperBound             } -            // Not at line start, continue searching after this position-            searchStart = candidate.upperBound+            return nil         }--        return nil     }      /// Finds the end of a code fence block (after the closing fence line).@@ -349,12 +499,44 @@ enum CodeFenceHelper: Sendable {         return nil     } +    /// Finds every fenced code block range in `content`, in source order.+    ///+    /// An unclosed fence is treated as extending to the end of the content, so a position+    /// after an unclosed opening fence counts as inside it.+    ///+    /// One `FenceScanner` serves the whole document, so both markers are searched forward+    /// once in total rather than once per fence, and `findCodeFenceEnd` only ever walks+    /// forward over a fence's own body: the index is one pass over the content.+    ///+    /// - Parameter content: The string to search in.+    /// - Returns: The ranges of all fenced code blocks found.+    nonisolated private static func allCodeFenceRanges(in content: String) -> [Range<String.Index>] {+        var ranges: [Range<String.Index>] = []+        var scanner = FenceScanner()+        var searchStart = content.startIndex++        while let fenceStart = scanner.nextFenceStart(+            in: content,+            from: searchStart,+            limit: content.endIndex+        ) {+            guard let fenceEnd = findCodeFenceEnd(in: content, from: fenceStart) else {+                ranges.append(fenceStart..<content.endIndex)+                break+            }+            ranges.append(fenceStart..<fenceEnd)+            searchStart = fenceEnd+        }++        return ranges+    }+     // MARK: - Code Fence Context Checks      /// Checks if a position in the content is inside a code fence.     ///-    /// Iterates through all code fences from the start of content to determine-    /// if the given position falls within any fence block.+    /// One-shot convenience: builds a `CodeRegionIndex` for the whole content, so each+    /// call costs a scan of the document. A loop must build the index once instead.     ///     /// - Parameters:     ///   - content: The string to search in.@@ -364,32 +546,22 @@ enum CodeFenceHelper: Sendable {         in content: String,         at position: String.Index     ) -> Bool {-        var searchStart = content.startIndex--        while let fenceStart = findNextCodeFenceStart(in: content, range: searchStart..<position) {-            // Found a fence starting before the position-            if let fenceEnd = findCodeFenceEnd(in: content, from: fenceStart) {-                if fenceEnd > position {-                    // The fence ends after the position, so position is inside the fence-                    return true-                }-                // Fence ends before position, continue searching for more fences-                searchStart = fenceEnd-            } else {-                // Unclosed fence - position is inside it-                return true-            }-        }--        return false+        CodeRegionIndex(content).isInsideCodeFence(at: position)     } -    /// Checks if a position falls inside an inline code span on the same line.+    /// Checks if a position falls inside an inline code span.+    ///+    /// Per CommonMark §6.1, inline code spans are delimited by equal-length backtick+    /// strings (e.g., `` ` `` or ``` `` ```) and MAY contain line endings — a line ending+    /// inside a code span is normalized to a space, it does not close the span. A code+    /// span's delimiter state therefore has to be tracked across the WHOLE containing+    /// inline block, not reset at every newline: it only ends at a boundary the block+    /// structure itself imposes — a paragraph break (a blank line, or a line that starts a+    /// block able to interrupt a paragraph; see `isParagraphBreak`) or a fenced code block+    /// (a block-level construct whose content is never inline text).     ///-    /// Per CommonMark, inline code spans are delimited by equal-length backtick strings-    /// (e.g., `` ` `` or ``` `` ```). This function extracts the line containing the position,-    /// scans it for backtick-delimited spans, and returns true if the position is inside one.-    /// Inline code spans cannot cross line boundaries, so checking the single line is sufficient.+    /// One-shot convenience: builds a `CodeRegionIndex` for the whole content, so each+    /// call costs a scan of the document. A loop must build the index once instead.     ///     /// - Parameters:     ///   - content: The string to search in.@@ -399,86 +571,211 @@ enum CodeFenceHelper: Sendable {         in content: String,         at position: String.Index     ) -> Bool {-        // Find line boundaries around the position-        let lineStart: String.Index-        if let newlineBefore = content[content.startIndex..<position].lastIndex(of: "\n") {-            lineStart = content.index(after: newlineBefore)-        } else {-            lineStart = content.startIndex-        }+        CodeRegionIndex(content).isInsideInlineCode(at: position)+    } -        let lineEnd: String.Index-        if let newlineAfter = content[position..<content.endIndex].firstIndex(of: "\n") {-            lineEnd = newlineAfter-        } else {-            lineEnd = content.endIndex+    /// Computes the content ranges (delimiters excluded) of every inline code span in+    /// `content`, scanning backtick-run delimiter state across each containing inline+    /// block rather than resetting at each newline.+    ///+    /// Two boundaries bound that scan, matching CommonMark's block structure: a fenced+    /// code block is skipped entirely (its content is never inline text, so backticks in+    /// there can neither open nor close a span), and a paragraph break — see+    /// `isParagraphBreak` — ends the window, dropping any pending unmatched backtick run+    /// (it ends the enclosing paragraph, so a span cannot cross it). Within those bounds, a+    /// line ending is just ordinary content the span may contain.+    ///+    /// The scan is linear in the content: each paragraph window is located once and then+    /// resolved in `appendCodeSpans`, and the outer loop jumps straight to the window's+    /// end, so every character is visited a bounded number of times.+    ///+    /// - Parameters:+    ///   - content: The string to scan.+    ///   - fences: Every fenced code block in `content`, in source order.+    /// - Returns: The content ranges of every inline code span found, in source order.+    nonisolated private static func codeSpanContentRanges(+        in content: String,+        skipping fences: [Range<String.Index>]+    ) -> [Range<String.Index>] {+        var fenceIterator = fences.makeIterator()+        var nextFence = fenceIterator.next()++        var ranges: [Range<String.Index>] = []+        var idx = content.startIndex++        while idx < content.endIndex {+            if let fence = nextFence, fence.lowerBound <= idx {+                if idx < fence.upperBound {+                    idx = fence.upperBound+                }+                nextFence = fenceIterator.next()+                continue+            }++            guard content[idx] == "`" else {+                idx = content.index(after: idx)+                continue+            }++            // Delimiters may not pair across the next fence (fences are skipped, not inline+            // text) or the next paragraph break, so the window between here and the first of+            // those is resolved as a unit. The window always ends strictly after `idx`: it+            // starts on a backtick, so the first line of the scan can be neither blank nor+            // an HTML block opener.+            let windowEnd = paragraphScanEnd(+                in: content,+                from: idx,+                limit: nextFence?.lowerBound ?? content.endIndex+            )+            appendCodeSpans(in: content, window: idx..<windowEnd, to: &ranges)+            idx = windowEnd         } -        let line = content[lineStart..<lineEnd]-        guard line.contains("`") else { return false }+        return ranges+    } -        // Calculate the position's offset within the line-        let positionOffset = line.distance(from: line.startIndex, to: position)+    /// A maximal run of backticks: a candidate code-span delimiter.+    nonisolated private struct BacktickRun {+        let start: String.Index+        /// The index just past the run — where a span it opens begins.+        let end: String.Index+        let length: Int+    } -        // Scan the line for backtick-delimited inline code spans-        var idx = line.startIndex-        while idx < line.endIndex {-            guard line[idx] == "`" else {-                idx = line.index(after: idx)+    /// Appends the content ranges of every inline code span inside one paragraph window.+    ///+    /// Per CommonMark §6.1 a span is closed by the next backtick run of EXACTLY the+    /// opening run's length; a run of any other length is ordinary content. So the window's+    /// runs are collected in one pass and each run is pointed at the next run of its own+    /// length by one reverse pass, after which matching is a walk over the run list.+    ///+    /// Scanning forward from each opening run instead is what the previous version did, and+    /// it re-walks the window for every run that has no closer — quadratic in the number of+    /// distinct run lengths, so O(n^1.5) on a window of runs of increasing length. Its guard+    /// against that (remembering lengths already proven unmatched) could never fire: a scan+    /// that fails for length k has, by construction, examined every run through to the+    /// window's end without finding one of length k, so no later opener of length k exists+    /// in that window to be short-circuited. It is gone rather than corrected.+    nonisolated private static func appendCodeSpans(+        in content: String,+        window: Range<String.Index>,+        to ranges: inout [Range<String.Index>]+    ) {+        var runs: [BacktickRun] = []+        var idx = window.lowerBound+        while idx < window.upperBound {+            guard content[idx] == "`" else {+                idx = content.index(after: idx)                 continue             }--            // Count opening backticks-            let openStart = idx-            var backtickCount = 0-            while idx < line.endIndex && line[idx] == "`" {-                backtickCount += 1-                idx = line.index(after: idx)+            let start = idx+            var length = 0+            while idx < window.upperBound, content[idx] == "`" {+                length += 1+                idx = content.index(after: idx)             }+            runs.append(BacktickRun(start: start, end: idx, length: length))+        } -            // Search for matching closing backtick string of the same length-            var searchIdx = idx-            while searchIdx < line.endIndex {-                guard line[searchIdx] == "`" else {-                    searchIdx = line.index(after: searchIdx)-                    continue-                }+        // nextOfSameLength[i] is the first run after i with the same length, or nil.+        var nextOfSameLength = [Int?](repeating: nil, count: runs.count)+        var lastSeenOfLength: [Int: Int] = [:]+        for position in runs.indices.reversed() {+            nextOfSameLength[position] = lastSeenOfLength[runs[position].length]+            lastSeenOfLength[runs[position].length] = position+        } -                // Count closing backticks, remembering where the run starts —-                // `searchIdx` is left pointing PAST them.-                let closeStart = searchIdx-                var closeCount = 0-                while searchIdx < line.endIndex && line[searchIdx] == "`" {-                    closeCount += 1-                    searchIdx = line.index(after: searchIdx)-                }+        var opener = 0+        while opener < runs.count {+            guard let closer = nextOfSameLength[opener] else {+                // Unmatched: the run is literal content, and the next run may still open.+                opener += 1+                continue+            }+            ranges.append(runs[opener].end..<runs[closer].start)+            opener = closer + 1+        }+    } -                if closeCount == backtickCount {-                    // Found matching close. "Inside" means inside the CONTENT:-                    // both delimiter runs are excluded. Measuring the end from-                    // `searchIdx` (past the closing run) counted the closing-                    // backtick as inside while the opening one was excluded —-                    // an asymmetry, since the two delimiters are the same kind-                    // of thing. The start bound likewise advances by-                    // `backtickCount` so a multi-backtick opener such as ``x``-                    // is fully excluded rather than just its first character.-                    let contentStartOffset =-                        line.distance(from: line.startIndex, to: openStart) + backtickCount-                    let contentEndOffset = line.distance(from: line.startIndex, to: closeStart)-                    if positionOffset >= contentStartOffset && positionOffset < contentEndOffset {-                        return true-                    }-                    // Move idx past the closing backticks and continue scanning-                    idx = searchIdx-                    break-                }-                // closeCount != backtickCount, keep searching for closing delimiter+    /// Finds the end of the paragraph-scoped scan window starting at `start`.+    ///+    /// A code span's delimiter search may run up to (but not across) the next paragraph+    /// break, since that ends the paragraph that is the span's containing inline block.+    /// `start` points at an opening backtick, so the first (partial) line it examines+    /// begins with a backtick and can satisfy neither break condition — in effect only+    /// lines strictly after `start` can be found to be a break, and those are whole lines.+    ///+    /// - Parameters:+    ///   - content: The string to search in.+    ///   - start: The index to begin scanning from.+    ///   - limit: An upper bound the scan may not pass (e.g. the next fenced code block).+    /// - Returns: The index of the paragraph break, or `limit` if none is found first.+    nonisolated private static func paragraphScanEnd(+        in content: String,+        from start: String.Index,+        limit: String.Index+    ) -> String.Index {+        var lineStart = start++        while lineStart < limit {+            guard let newlineIdx = content[lineStart..<limit].firstIndex(of: "\n") else {+                return limit+            }+            if isParagraphBreak(content[lineStart..<newlineIdx]) {+                return lineStart             }-            // If no matching close was found, idx already moved past the opening-            // backticks during counting, so the outer loop continues scanning.+            lineStart = content.index(after: newlineIdx)         } -        return false+        return limit+    }++    /// Whether a line ends the paragraph an inline code span could be scanning across.+    ///+    /// A blank line does. So does a line that is blank once its blockquote markers are+    /// stripped — `>`, `> >`, `  >` — because inside a blockquote that IS a blank line+    /// (it ends the enclosing paragraph, CommonMark §5.1), and outside one it starts a+    /// blockquote, which interrupts a paragraph; either way no span crosses it. Nothing+    /// but whitespace and `>` may remain, so stripping reduces to a character test.+    ///+    /// A line whose first non-space content is `<details` or `</details` does too, however+    /// much prose precedes it: that opens an HTML block of CommonMark type 6, and a type-6+    /// block is one of the few that INTERRUPT a paragraph (§4.6, "start condition ... may+    /// interrupt a paragraph"). A tag in that position is therefore never code-span content,+    /// so the span scan must stop before it rather than let an unclosed backtick above bind+    /// to one below. Without this rule, `` Type a ` to start code. `` on the line above a+    /// real `<details>` block made the whole block code-span content — the block stopped+    /// being recognised, blank lines inside it went unprotected, and it fell apart at the+    /// first one. The indentation allowance matches `isAtLineStart`, so a tag this rule+    /// admits is exactly a tag `protectDetailsBlankLines` would accept as a block opener.+    ///+    /// List markers are deliberately NOT stripped. An empty list item cannot interrupt a+    /// paragraph (CommonMark §5.2: `foo\n*` is the paragraph "foo *"), so a marker-only+    /// line is paragraph continuation unless a list is already open — which this scan,+    /// having no block structure, cannot know. Treating it as a break would cut a real+    /// span short, the very defect class T-2005 fixes.+    nonisolated private static func isParagraphBreak(_ line: Substring) -> Bool {+        if line.allSatisfy({ $0 == " " || $0 == "\t" || $0 == "\r" || $0 == ">" }) {+            return true+        }+        return startsDetailsHTMLBlock(line)+    }++    /// Whether `line` opens a `<details>`/`</details>` HTML block (CommonMark type 6):+    /// up to three spaces of indentation, then the tag.+    nonisolated private static func startsDetailsHTMLBlock(_ line: Substring) -> Bool {+        var idx = line.startIndex+        var indent = 0+        while idx < line.endIndex, line[idx] == " " || line[idx] == "\t" {+            indent += 1+            if indent > 3 { return false }+            idx = line.index(after: idx)+        }+        guard idx < line.endIndex, line[idx] == "<" else { return false }++        let rest = line[idx...]+        return rest.prefix(8).caseInsensitiveCompare("<details") == .orderedSame+            || rest.prefix(9).caseInsensitiveCompare("</details") == .orderedSame     }      /// Checks if a position is at the start of a line (after newline or at content start).
prismTests/DetailsBlankLinePlaceholderTests.swift Modified +392 / -3
diff --git a/prismTests/DetailsBlankLinePlaceholderTests.swift b/prismTests/DetailsBlankLinePlaceholderTests.swiftindex e853f73f..0f27d2db 100644--- a/prismTests/DetailsBlankLinePlaceholderTests.swift+++ b/prismTests/DetailsBlankLinePlaceholderTests.swift@@ -596,9 +596,399 @@ struct DetailsBlankLinePlaceholderTests {      @Test("isInsideInlineCode scopes to the line containing the position")     func testIsInsideInlineCodeLineScoped() {-        // The <details> on line 2 is NOT inside inline code, even though line 1 has backticks+        // The <details> on line 2 is NOT inside inline code, even though line 1 has backticks.+        // The code span on line 1 is fully OPENED AND CLOSED there, so it never reaches+        // line 2 — this is unrelated to whether spans may cross line endings.         let content = "Line with `code` here.\n<details> on this line."         let secondLineStart = content.index(after: content.firstIndex(of: "\n")!)         #expect(!CodeFenceHelper.isInsideInlineCode(in: content, at: secondLineStart))     }++}++// MARK: - Multiline Inline Code Spans (T-2005)++/// CommonMark §6.1 explicitly permits line endings inside an inline code span (they are+/// normalized to a space). `isInsideInlineCode` used to reset its scan at every newline, so+/// a details tag written as the literal content of a code span that happens to cross a line+/// ending was miscounted as a real tag by `findMatchingDetailsClose`, and could be accepted+/// as a real opening by `protectDetailsBlankLines`. Both call sites share one+/// multiline-aware classifier now (`CodeFenceHelper.codeSpanContentRanges`).+struct MultilineCodeSpanDetailsTests {++    @Test("isInsideInlineCode returns true across a line ending for a single-backtick span")+    func testIsInsideInlineCodeMultilineSingleBacktick() {+        let content = "Here is `<details>\ncontinued` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode returns true across a line ending for a multi-backtick span")+    func testIsInsideInlineCodeMultilineMultiBacktick() {+        let content = "Here is ``<details>\ncontinued`` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode does not let an unmatched backtick cross a blank line")+    func testIsInsideInlineCodeDoesNotCrossBlankLine() {+        // A blank line ends the enclosing paragraph, so a backtick left unmatched+        // before it can never be closed by a backtick after it.+        let content = "Unclosed `span\n\n<details> after the blank line."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(!CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode does not let an unmatched backtick cross a fenced code block")+    func testIsInsideInlineCodeDoesNotCrossFence() {+        let content = """+        Unclosed `span before a fence.+        ```+        fenced content+        ```+        <details> after the fence.+        """+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(!CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("findMatchingDetailsClose ignores a multiline single-backtick span containing an opening tag")+    func testFindMatchingDetailsCloseIgnoresMultilineSingleBacktickOpenTag() {+        // The exact T-2005 repro: the code span `<details>\ncontinued` spans two+        // lines. Before the fix, the tag's own line ("continued` in code.") has no+        // backtick, so it was miscounted as a real nested opener, leaving depth+        // nonzero and the real </details> below unmatched.+        let content = """+        <details>+        <summary>Example</summary>++        Here is `<details>+        continued` in code.++        </details>+        """+        let start = content.index(content.startIndex, offsetBy: "<details>".count)+        let result = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start)+        #expect(result != nil, "Should find the real closing tag despite the multiline code span")+    }++    @Test("findMatchingDetailsClose ignores a multiline single-backtick span containing a closing tag")+    func testFindMatchingDetailsCloseIgnoresMultilineSingleBacktickCloseTag() {+        // Mirror case: the code span `</details>\ncontinued` must not be counted as+        // a real close, or the outer details block would end early.+        let content = """+        <details>+        <summary>Example</summary>++        Here is `</details>+        continued` in code.++        Real content after the span.++        </details>+        """+        let start = content.index(content.startIndex, offsetBy: "<details>".count)+        guard let closeIndex = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start) else {+            Issue.record("Expected to find the real closing tag")+            return+        }+        let finalCloseRange = content.range(of: "</details>", options: [.caseInsensitive, .backwards])!+        #expect(closeIndex == finalCloseRange.upperBound,+                "Should match the real closing tag, not the one inside the multiline code span")+    }++    @Test("findMatchingDetailsClose ignores a multiline multi-backtick span containing an opening tag")+    func testFindMatchingDetailsCloseIgnoresMultilineMultiBacktickOpenTag() {+        let content = """+        <details>+        <summary>Example</summary>++        Here is ``<details>+        continued`` in code.++        </details>+        """+        let start = content.index(content.startIndex, offsetBy: "<details>".count)+        let result = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start)+        #expect(result != nil, "Should find the real closing tag despite the multiline multi-backtick span")+    }++    @Test("findMatchingDetailsClose ignores a multiline multi-backtick span containing a closing tag")+    func testFindMatchingDetailsCloseIgnoresMultilineMultiBacktickCloseTag() {+        let content = """+        <details>+        <summary>Example</summary>++        Here is ``</details>+        continued`` in code.++        Real content after the span.++        </details>+        """+        let start = content.index(content.startIndex, offsetBy: "<details>".count)+        guard let closeIndex = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start) else {+            Issue.record("Expected to find the real closing tag")+            return+        }+        let finalCloseRange = content.range(of: "</details>", options: [.caseInsensitive, .backwards])!+        #expect(closeIndex == finalCloseRange.upperBound,+                "Should match the real closing tag, not the one inside the multiline multi-backtick span")+    }++    @Test("protectDetailsBlankLines treats a line-start details tag as a block opener, not span content")+    func testProtectDetailsBlankLinesLineStartTagOpensBlockDespiteOpenBacktick() {+        // A line-start "<details>" opens an HTML block of CommonMark type 6, and type 6 is+        // one of the block types that INTERRUPT a paragraph (§4.6). So it is a real opener+        // however many backticks the line above it left open: the apparent span from line 1+        // to line 3 does not exist, because the paragraph ends at line 2.+        let content = """+        `start+        <details>+        end` of span.++        Real content after the span.++        </details>+        """+        // Both blank lines inside the block become placeholders; the paragraph above the+        // opener is copied through untouched.+        let expected = """+        `start+        <details>+        end` of span.+        \(CodeFenceHelper.detailsBlankLinePlaceholder)+        Real content after the span.+        \(CodeFenceHelper.detailsBlankLinePlaceholder)+        </details>+        """+        #expect(CodeFenceHelper.protectDetailsBlankLines(content, validateContext: true) == expected)+    }++    @Test("A multi-backtick run above a line-start details tag does not make it span content either")+    func testProtectDetailsBlankLinesLineStartTagOpensBlockDespiteOpenMultiBacktick() {+        let content = """+        ``start+        <details>+        end`` of span.++        Real content after the span.++        </details>+        """+        let expected = """+        ``start+        <details>+        end`` of span.+        \(CodeFenceHelper.detailsBlankLinePlaceholder)+        Real content after the span.+        \(CodeFenceHelper.detailsBlankLinePlaceholder)+        </details>+        """+        #expect(CodeFenceHelper.protectDetailsBlankLines(content, validateContext: true) == expected)+    }++    @Test("A details block below a line holding a stray backtick still parses as a details block")+    func testDetailsBlockBelowStrayBacktickLineParses() {+        // The shape the type-6 rule exists for, and the one the other reading broke: a+        // sentence mentioning a backtick, then an ordinary details block whose summary+        // contains a code span. Reading the line-start "<details>" as the content of a span+        // opened on line 1 and closed in the summary left the block unprotected, so its+        // first blank line ended the HTML block and the section fell apart.+        let content = """+        Type a ` to start code.+        <details>+        <summary>Use `x` for a code span</summary>++        Body of the section.++        </details>+        """+        let blocks = MarkdownBlockParser.parse(content)++        let detailsChildren = blocks.compactMap { block -> [MarkdownBlock]? in+            if case .details(_, let children, _, _) = block { return children }+            return nil+        }+        #expect(detailsChildren.count == 1, "Expected exactly one details block, got \(blocks)")+        let bodyIsInside = detailsChildren.first?.contains { child in+            if case .paragraph(let markdown) = child { return markdown.contains("Body of the section") }+            return false+        }+        #expect(bodyIsInside == true, "The body paragraph belongs inside the details block")+    }++    @Test("Details with multiline code span containing an opening tag parses as one block")+    func testDetailsMultilineCodeSpanWithOpeningTagParsesAsOneBlock() {+        // End-to-end version of the T-2005 repro via MarkdownBlockParser.+        let content = """+        <details>+        <summary>Example</summary>++        Here is `<details>+        continued` in code.++        </details>+        """+        let blocks = MarkdownBlockParser.parse(content)++        #expect(blocks.count == 1, "Expected single details block but got \(blocks.count): \(blocks)")+        guard case .details(_, let children, _, _) = blocks.first else {+            Issue.record("Expected details block, got \(String(describing: blocks.first))")+            return+        }++        let paragraphs = children.filter { child in+            if case .paragraph = child { return true }+            return false+        }+        let hasContinuedText = paragraphs.contains { child in+            if case .paragraph(let markdown) = child {+                return markdown.contains("continued")+            }+            return false+        }+        #expect(hasContinuedText, "Paragraph containing the multiline code span should be inside the details block")+    }++    @Test("Details with multiline code span containing a closing tag does not close early")+    func testDetailsMultilineCodeSpanWithClosingTagDoesNotCloseEarly() {+        let content = """+        <details>+        <summary>Example</summary>++        Here is `</details>+        continued` in code.++        Real content after the span.++        </details>+        """+        let blocks = MarkdownBlockParser.parse(content)++        #expect(blocks.count == 1, "Expected single details block but got \(blocks.count): \(blocks)")+        guard case .details(_, let children, _, _) = blocks.first else {+            Issue.record("Expected details block, got \(String(describing: blocks.first))")+            return+        }++        let paragraphs = children.filter { child in+            if case .paragraph = child { return true }+            return false+        }+        let hasRealContent = paragraphs.contains { child in+            if case .paragraph(let markdown) = child {+                return markdown.contains("Real content")+            }+            return false+        }+        #expect(hasRealContent, "Paragraph after the multiline code span should still be inside the details block")+    }+}+++// MARK: - Paragraph breaks with container prefixes (T-2005 review)++/// A code span's delimiter scan stops at the end of its paragraph. A bare blank line is+/// the obvious paragraph end; the review of the T-2005 fix found that a line holding+/// only blockquote markers is one too, and the classifier bridged a span across it.+struct CodeSpanParagraphBreakTests {+    @Test("isInsideInlineCode does not let an unmatched backtick cross a blockquote's blank line")+    func testIsInsideInlineCodeDoesNotCrossBlockquoteBlankLine() {+        // Review finding on the T-2005 fix: a line holding only `>` is a blank line+        // INSIDE the blockquote per CommonMark, which ends the enclosing paragraph+        // exactly as a bare blank line does. The classifier used to read the `>` as+        // content and let the unmatched backtick bridge across it.+        let content = "> `span opens\n>\n> <details> closes here` more text"+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(!CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode treats nested and indented blockquote markers as a blank line too")+    func testIsInsideInlineCodeBlockquoteMarkerVariants() {+        for blankLine in [">", "> >", "  >", ">\t", "> > "] {+            let content = "> > `span opens\n\(blankLine)\n> > <details> closes here`"+            let tagIndex = content.range(of: "<details>")!.lowerBound+            #expect(!CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex),+                    "\(blankLine.debugDescription) should end the paragraph")+        }+    }++    @Test("isInsideInlineCode follows a span across non-blank blockquote lines")+    func testIsInsideInlineCodeMultilineInsideBlockquote() {+        // The blockquote's own prefix on a CONTENT line is not a break: the span+        // continues across the line ending exactly as it does outside a blockquote.+        let content = "> Here is `<details>\n> continued` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode follows a span across a list item's continuation line")+    func testIsInsideInlineCodeMultilineInsideListItem() {+        let content = "- Here is `<details>\n  continued` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode does not treat a marker-only list line as a paragraph break")+    func testIsInsideInlineCodeMarkerOnlyListLineIsNotABreak() {+        // Deliberate: an empty list item cannot interrupt a paragraph (CommonMark §5.2,+        // `foo\n*` is one paragraph), so unlike a `>`-only line this is continuation+        // text and the span still closes on the next line.+        let content = "Here is `<details>\n*\ncontinued` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode never treats a line-start details tag as span content")+    func testIsInsideInlineCodeLineStartDetailsTagIsNeverSpanContent() {+        // CommonMark §4.6: a `<details` HTML block (type 6) may interrupt a paragraph, so a+        // line-start tag ends the paragraph a code span would have to be scanning across. It+        // is a block opener, never span content, whatever the surrounding backticks suggest.+        let shapes = [+            "Type a ` to start code.\n<details>\n<summary>Use `x`</summary>",+            "Type a ` to start code.\n   <details>\n<summary>Use `x`</summary>",+            "`start\n</details>\nend` of span.",+            "`start\n<DETAILS open>\nend` of span."+        ]+        for content in shapes {+            let tagIndex = content.range(of: "<", options: [], range: content.range(of: "\n")!.upperBound..<content.endIndex)!+            #expect(!CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex.lowerBound),+                    Comment(rawValue: "line-start tag classified as span content in \(content.debugDescription)"))+        }+    }++    @Test("isInsideInlineCode still treats a mid-line details tag as span content")+    func testIsInsideInlineCodeMidLineDetailsTagIsStillSpanContent() {+        // The counterpart the type-6 rule must not break: mid-line, the tag starts no block,+        // so the ticket's original repro is still span content across the line ending.+        let content = "Here is `<details>\ncontinued` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("isInsideInlineCode ignores a details tag indented past the HTML block limit")+    func testIsInsideInlineCodeDeeplyIndentedDetailsTagIsSpanContent() {+        // Four spaces is past the three an HTML block may carry, so this line opens no+        // block and the span runs across it — matching `isAtLineStart`, which is what+        // decides whether such a tag can be a block opener at all.+        let content = "Here is `x\n    <details>\ncontinued` in code."+        let tagIndex = content.range(of: "<details>")!.lowerBound+        #expect(CodeFenceHelper.isInsideInlineCode(in: content, at: tagIndex))+    }++    @Test("findMatchingDetailsClose counts a tag after a blockquote's blank line as real")+    func testFindMatchingDetailsCloseAfterBlockquoteBlankLine() {+        // The review's shape at the call site that matters: the backtick before the+        // `>` line is unmatched, so the `</details>` after it is the real close.+        let content = "<details>\n> `span opens\n>\n> </details> closes here` more text\n</details>"+        let start = content.index(content.startIndex, offsetBy: "<details>".count)+        guard let closeIndex = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start) else {+            Issue.record("Expected to find a closing tag")+            return+        }+        let firstCloseRange = content.range(of: "</details>")!+        #expect(closeIndex == firstCloseRange.upperBound,+                "The `>`-only line ends the paragraph, so the first </details> is not span content")+    } }
prismTests/DetailsTagScanGrowthTests.swift Added +488 / -0
diff --git a/prismTests/DetailsTagScanGrowthTests.swift b/prismTests/DetailsTagScanGrowthTests.swiftnew file mode 100644index 00000000..966d3396--- /dev/null+++ b/prismTests/DetailsTagScanGrowthTests.swift@@ -0,0 +1,488 @@+//+//  DetailsTagScanGrowthTests.swift+//  prismTests+//+//  Growth + equivalence guards for the `<details>` preprocessing scans in+//  `CodeFenceHelper` (T-2005 review). The multiline code-span fix first shipped with+//  `isInsideInlineCode` rebuilding every code-span range in the document on every call —+//  once per candidate tag, from inside `findMatchingDetailsClose`'s loop and+//  `protectDetailsBlankLines`'s candidate check — measured at 5-15x slower than `main` on+//  a document of prose mentions of `<details>`. `main` was itself quadratic on that input+//  (`findMatchingDetailsClose` searched for the next fence from scratch on every iteration,+//  and `protectDetailsBlankLines` rescanned fences from the start for every candidate).+//+//  Both are now answered from one `CodeRegionIndex` per pass. Round 2 then paid for that+//  index on documents that never needed one: it was built at the top of every+//  `protectDetailsBlankLines` call, before anything checked whether the document held a+//  `<details` at all, and it was built by searching BOTH fence markers from the current+//  fence to the end of the document once per fence — 1.0 s on 400 fences in 219 KB, 25 s+//  on 500 fences in 2 MB, where `main` had paid nothing at all. G5 is that shape.+//+//  Same two kinds of assertion as `RawHTMLImageScanGrowthTests` (T-1951/T-2147):+//+//  - GROWTH: measured at N and 4N with a ratio ceiling, never an absolute budget at one size+//    (see `GrowthRatioGuard`).+//  - EQUIVALENCE: the restructure of the two entry points is pure performance work, so the+//    pre-restructure loops are kept here verbatim as differential oracles. They call the+//    production one-shot helpers, so the CLASSIFICATION (fences, spans, line starts) is the+//    same on both sides and only the loop shape is under test. The goldens carry hand-derived+//    expected values as well, since a differential oracle alone can only say that two loops+//    agree — not that either is right.+//+//  The suite is `.serialized`: the growth tests are timing measurements, and running them+//  concurrently puts the base and the 4x measurement under different contention.+//++import Foundation+import Testing+@testable import prism++@Suite("Details tag scans — growth and equivalence (T-2005)", .serialized)+struct DetailsTagScanGrowthTests {++    // MARK: - Shapes++    /// The review's measurement input: a details block whose body is documentation about+    /// details tags, so every paragraph carries both tags as inline code. Every tag is a+    /// candidate the close-matcher has to classify and skip.+    private static func proseMentions(paragraphs: Int) -> String {+        "<details>\n<summary>Guide</summary>\n\n"+            + String(repeating: mentionParagraph + "\n\n", count: paragraphs)+            + "</details>\n"+    }++    /// Many real, sequential details blocks, each with blank lines to protect. Every+    /// block is a validated candidate, so this is the shape that rescanned fences from+    /// the document start once per candidate.+    private static func sequentialBlocks(count: Int) -> String {+        let block = "<details>\n<summary>Item</summary>\n\nBody text.\n\nMore body.\n\n</details>\n\n"+        return String(repeating: block, count: count)+    }++    /// A document of fenced code examples holding no `<details` at all — most documents,+    /// and the shape round 2 made expensive: the fence index was built at the top of every+    /// pass whether or not the document had a candidate tag, by searching both fence+    /// markers from the current fence to the end of the document once per fence.+    private static func fencedExamples(count: Int) -> String {+        let block = "Example:\n\n```swift\nlet value = compute(input)\nprint(value)\n```\n\n"+        return String(repeating: block, count: count)+    }++    /// The same fences with a real details block around them, so the index IS built and+    /// what the ratio measures is the index's own growth rather than the early return.+    private static func fencedExamplesInsideDetails(count: Int) -> String {+        "<details>\n<summary>Examples</summary>\n\n" + fencedExamples(count: count) + "</details>\n"+    }++    // MARK: - Growth++    @Test("G1: findMatchingDetailsClose over prose mentions costs time proportional to the document")+    func closeMatcherScalesLinearly() {+        // The review's table: 0.69 s at 8.8 KB, 4.09 s at 17.5 KB, 10.84 s at 35 KB.+        GrowthRatioGuard.expectLinearGrowth(shape: "code-span mentions of details tags", baseCount: 250) { count in+            let content = Self.proseMentions(paragraphs: count)+            let start = content.index(content.startIndex, offsetBy: "<details".count)+            _ = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start)+        }+    }++    @Test("G2: protectDetailsBlankLines over prose mentions scales linearly")+    func protectionEntryPointScalesLinearlyOnMentions() {+        // Through the entry point a parse actually reaches, so the fix is pinned where+        // the stall lived rather than only where it was written.+        GrowthRatioGuard.expectLinearGrowth(shape: "protectDetailsBlankLines of code-span mentions",+                                            baseCount: 250) { count in+            _ = CodeFenceHelper.protectDetailsBlankLines(Self.proseMentions(paragraphs: count),+                                                         validateContext: true)+        }+    }++    @Test("G3: protectDetailsBlankLines over many real blocks scales linearly")+    func protectionEntryPointScalesLinearlyOnBlocks() {+        // The per-candidate fence rescan that predates T-2005. Not slow enough to have+        // been noticed on its own; pinned so the index cannot quietly stop being shared.+        GrowthRatioGuard.expectLinearGrowth(shape: "sequential details blocks", baseCount: 250) { count in+            _ = CodeFenceHelper.protectDetailsBlankLines(Self.sequentialBlocks(count: count),+                                                         validateContext: true)+        }+    }++    @Test("G4: one paragraph of many code spans indexes linearly")+    func spanIndexScalesLinearlyWithinOneParagraph() {+        // The classifier's own trap: the paragraph window was located once per opening+        // backtick, so a paragraph with no blank line cost every span a walk to its end.+        // Runs of four lengths are mixed in, so the matcher has to keep several delimiter+        // lengths in flight rather than pairing every run with its neighbour.+        GrowthRatioGuard.expectLinearGrowth(shape: "code spans in one paragraph", baseCount: 1_000) { count in+            let content = String(repeating: "`a` ``b`` ``` text ```` more\n", count: count)+            _ = CodeFenceHelper.CodeRegionIndex(content)+        }+    }++    @Test("G5: a document of code fences and no details tag stays cheap")+    func protectionScalesLinearlyOnFencesWithoutDetails() {+        // Measured on round 2's code: 1.0 s at 400 fences (219 KB), 25 s at 500 fences+        // (2 MB) — for a document with nothing to protect. The pass leaves before building+        // an index now, so what this measures is the candidate scan.+        GrowthRatioGuard.expectLinearGrowth(shape: "code fences with no details tag", baseCount: 250) { count in+            _ = CodeFenceHelper.protectDetailsBlankLines(Self.fencedExamples(count: count),+                                                         validateContext: true)+        }+    }++    @Test("G5b: the same code fences inside a details block index linearly")+    func protectionScalesLinearlyOnFencesInsideDetails() {+        // The half an early return cannot cover: here the index IS built, so this is the+        // one that fails if fence discovery goes back to re-searching a marker per fence.+        GrowthRatioGuard.expectLinearGrowth(shape: "code fences inside a details block", baseCount: 250) { count in+            _ = CodeFenceHelper.protectDetailsBlankLines(Self.fencedExamplesInsideDetails(count: count),+                                                         validateContext: true)+        }+    }++    // MARK: - Equivalence — differential oracles++    /// The pre-restructure close matcher, verbatim from the T-2005 fix as first pushed+    /// (PR #409): fences re-found from the cursor on every iteration, both tag patterns+    /// re-searched on every iteration, and the one-shot span check per tag.+    private static func retiredFindMatchingDetailsClose(+        in content: String,+        from start: String.Index+    ) -> String.Index? {+        guard start < content.endIndex else { return nil }++        let openPattern = "<details"+        let closePattern = "</details>"++        var depth = 1+        var searchStart = start++        while searchStart < content.endIndex && depth > 0 {+            let searchRange = searchStart..<content.endIndex++            if let fenceStart = CodeFenceHelper.findNextCodeFenceStart(in: content, range: searchRange) {+                let nextOpen = content.range(of: openPattern, options: .caseInsensitive, range: searchRange)+                let nextClose = content.range(of: closePattern, options: .caseInsensitive, range: searchRange)++                let earliestTag: String.Index? = [nextOpen?.lowerBound, nextClose?.lowerBound]+                    .compactMap { $0 }+                    .min()++                if earliestTag == nil || fenceStart < earliestTag! {+                    if let fenceEnd = CodeFenceHelper.findCodeFenceEnd(in: content, from: fenceStart) {+                        searchStart = fenceEnd+                        continue+                    } else {+                        return nil+                    }+                }+            }++            let nextOpen = content.range(of: openPattern, options: .caseInsensitive, range: searchRange)+            let nextClose = content.range(of: closePattern, options: .caseInsensitive, range: searchRange)++            switch (nextOpen, nextClose) {+            case (nil, nil):+                return nil++            case (nil, let closeRange?):+                if CodeFenceHelper.isInsideInlineCode(in: content, at: closeRange.lowerBound) {+                    searchStart = closeRange.upperBound+                    continue+                }+                depth -= 1+                if depth == 0 {+                    return closeRange.upperBound+                }+                searchStart = closeRange.upperBound++            case (let openRange?, nil):+                if CodeFenceHelper.isInsideInlineCode(in: content, at: openRange.lowerBound) {+                    searchStart = openRange.upperBound+                    continue+                }+                depth += 1+                searchStart = openRange.upperBound++            case (let openRange?, let closeRange?):+                if openRange.lowerBound < closeRange.lowerBound {+                    if CodeFenceHelper.isInsideInlineCode(in: content, at: openRange.lowerBound) {+                        searchStart = openRange.upperBound+                        continue+                    }+                    depth += 1+                    searchStart = openRange.upperBound+                } else {+                    if CodeFenceHelper.isInsideInlineCode(in: content, at: closeRange.lowerBound) {+                        searchStart = closeRange.upperBound+                        continue+                    }+                    depth -= 1+                    if depth == 0 {+                        return closeRange.upperBound+                    }+                    searchStart = closeRange.upperBound+                }+            }+        }++        return nil+    }++    /// The pre-restructure protection pass, verbatim from the same commit: mutates the+    /// result in place and resumes its search in the rewritten copy, re-validating each+    /// candidate against that copy with the one-shot helpers.+    private static func retiredProtectDetailsBlankLines(_ content: String) -> String {+        var result = content+        var searchStart = result.startIndex++        while let detailsStart = result.range(+            of: "<details",+            options: .caseInsensitive,+            range: searchStart..<result.endIndex+        ) {+            if !CodeFenceHelper.isAtLineStart(in: result, at: detailsStart.lowerBound) {+                searchStart = detailsStart.upperBound+                continue+            }+            if CodeFenceHelper.isInsideCodeFence(in: result, at: detailsStart.lowerBound) {+                searchStart = detailsStart.upperBound+                continue+            }+            if CodeFenceHelper.isInsideInlineCode(in: result, at: detailsStart.lowerBound) {+                searchStart = detailsStart.upperBound+                continue+            }++            guard let detailsEnd = retiredFindMatchingDetailsClose(in: result, from: detailsStart.upperBound) else {+                searchStart = detailsStart.upperBound+                continue+            }++            let blockRange = detailsStart.lowerBound..<detailsEnd+            var blockContent = String(result[blockRange])+            blockContent = blockContent.replacingOccurrences(+                of: "\n\\s*\n",+                with: "\n\(CodeFenceHelper.detailsBlankLinePlaceholder)\n",+                options: .regularExpression+            )++            let startOffset = result.distance(from: result.startIndex, to: blockRange.lowerBound)+            result.replaceSubrange(blockRange, with: blockContent)+            let newSearchOffset = startOffset + blockContent.count+            searchStart = result.index(+                result.startIndex,+                offsetBy: newSearchOffset,+                limitedBy: result.endIndex+            ) ?? result.endIndex+        }++        return result+    }++    // MARK: - Equivalence — goldens++    /// One named shape with the answers derived by hand from the rules — the protected+    /// output, and WHICH `</details>` closes the opening tag — so the goldens say what the+    /// pass should do and not merely that two implementations of it agree.+    private struct Golden {+        /// The input.+        let content: String+        /// The expected `protectDetailsBlankLines(_:validateContext: true)` output.+        let protected: String+        /// The 1-based occurrence of `</details>` (counting those in code, which are+        /// skipped, and case-insensitively) that closes the opening tag, or nil when the+        /// opening tag has no match.+        let closedBy: Int?+    }++    private static let placeholder = CodeFenceHelper.detailsBlankLinePlaceholder+    private static let mentionParagraph =+        "Wrap a section in `<details>` and end it with `</details>`; nest them freely."++    private static let goldens: [Golden] = [+        // Every blank line inside the block becomes a placeholder; the trailing newline+        // after the closing tag is outside the block and is copied through.+        Golden(+            content: proseMentions(paragraphs: 3),+            protected: "<details>\n<summary>Guide</summary>\n\(placeholder)\n"+                + String(repeating: mentionParagraph + "\n\(placeholder)\n", count: 3)+                + "</details>\n",+            closedBy: 4  // three mentions in code spans come first+        ),+        // The "\n\n" BETWEEN blocks sits outside both, so it survives unprotected.+        Golden(+            content: sequentialBlocks(count: 3),+            protected: String(+                repeating: "<details>\n<summary>Item</summary>\n\(placeholder)\nBody text.\n"+                    + "\(placeholder)\nMore body.\n\(placeholder)\n</details>\n\n",+                count: 3+            ),+            closedBy: 1+        ),+        // Both tags on line 2 are code spans; nothing to protect.+        Golden(+            content: "<details>\nUse `<details>` and `</details>` for sections.\n</details>",+            protected: "<details>\nUse `<details>` and `</details>` for sections.\n</details>",+            closedBy: 2+        ),+        // The ticket's repro: a mid-line opener inside a span crossing a line ending.+        Golden(+            content: "<details>\n<summary>Example</summary>\n\nHere is `<details>\ncontinued` in code.\n\n</details>",+            protected: "<details>\n<summary>Example</summary>\n\(placeholder)\nHere is `<details>\n"+                + "continued` in code.\n\(placeholder)\n</details>",+            closedBy: 1+        ),+        // Its mirror: the span holds a CLOSING tag, which must not end the block early.+        Golden(+            content: "<details>\n<summary>Example</summary>\n\nHere is `</details>\ncontinued` in code."+                + "\n\nAfter.\n\n</details>",+            protected: "<details>\n<summary>Example</summary>\n\(placeholder)\nHere is `</details>\n"+                + "continued` in code.\n\(placeholder)\nAfter.\n\(placeholder)\n</details>",+            closedBy: 2+        ),+        // A closing tag inside a fenced block is skipped along with the rest of the fence.+        Golden(+            content: "<details>\n```\n</details>\n```\n\n</details>",+            protected: "<details>\n```\n</details>\n```\n\(placeholder)\n</details>",+            closedBy: 2+        ),+        // …and so is an opening tag, so the nesting depth never rises.+        Golden(+            content: "<details>\n~~~\n<details>\n~~~\nbody\n\n</details>",+            protected: "<details>\n~~~\n<details>\n~~~\nbody\n\(placeholder)\n</details>",+            closedBy: 1+        ),+        // An unclosed fence runs to the end of the content, swallowing the closing tag:+        // no match, so nothing is protected.+        Golden(+            content: "<details>\n```\nunclosed fence\n</details>",+            protected: "<details>\n```\nunclosed fence\n</details>",+            closedBy: nil+        ),+        // Real nesting: the outer block is closed by the SECOND closing tag.+        Golden(+            content: "<details>\n<details>\ninner\n\n</details>\n\nouter\n\n</details>",+            protected: "<details>\n<details>\ninner\n\(placeholder)\n</details>\n\(placeholder)\nouter\n"+                + "\(placeholder)\n</details>",+            closedBy: 2+        ),+        Golden(content: "<details>\nno close", protected: "<details>\nno close", closedBy: nil),+        // Mid-line, so not a block opener: `protectDetailsBlankLines` skips it. The direct+        // close-matcher call starts after it and still finds the closing tag.+        Golden(+            content: "text <details> mid-line\n\n</details>",+            protected: "text <details> mid-line\n\n</details>",+            closedBy: 1+        ),+        // Three spaces of indentation are still a block opener; the indent is outside the+        // block and is copied through.+        Golden(+            content: "   <details>\n\nindented opener\n\n</details>",+            protected: "   <details>\n\(placeholder)\nindented opener\n\(placeholder)\n</details>",+            closedBy: 1+        ),+        // The `>`-only line ends the paragraph, so the backtick above it never opens a+        // span and the tag on line 3 is a real (mid-line, hence non-opening) tag: depth+        // goes to 2 and the single closing tag cannot bring it back to 0.+        Golden(+            content: "> `span opens\n>\n> <details> closes here` more text\n\n</details>",+            protected: "> `span opens\n>\n> <details> closes here` more text\n\n</details>",+            closedBy: nil+        ),+        // CommonMark type 6: the line-start tag interrupts the paragraph the backtick+        // opened, so it is a block opener and the "span" around it does not exist.+        Golden(+            content: "`start\n<details>\nend` of span.\n\nReal content.\n\n</details>",+            protected: "`start\n<details>\nend` of span.\n\(placeholder)\nReal content.\n"+                + "\(placeholder)\n</details>",+            closedBy: 1+        ),+        Golden(+            content: "<DETAILS>\n\nupper case\n\n</DETAILS>",+            protected: "<DETAILS>\n\(placeholder)\nupper case\n\(placeholder)\n</DETAILS>",+            closedBy: 1+        ),+        Golden(content: "", protected: "", closedBy: nil)+    ]++    /// Resolves a golden's `closedBy` to the index just past that occurrence.+    private static func expectedClose(_ golden: Golden) -> String.Index? {+        guard let occurrence = golden.closedBy else { return nil }+        var position = golden.content.startIndex+        for _ in 0..<occurrence {+            guard let hit = golden.content.range(+                of: "</details>",+                options: .caseInsensitive,+                range: position..<golden.content.endIndex+            ) else {+                Issue.record(Comment(rawValue: "golden names occurrence \(occurrence) of </details>,"+                    + " which \(golden.content.debugDescription) does not have"))+                return nil+            }+            position = hit.upperBound+        }+        return position+    }++    @Test("Both entry points match hand-derived goldens, and their retired loops agree")+    func goldenShapes() {+        for golden in Self.goldens {+            let content = golden.content+            let protected = CodeFenceHelper.protectDetailsBlankLines(content, validateContext: true)+            #expect(protected == golden.protected,+                    Comment(rawValue: "protection of \(content.debugDescription):"+                        + " expected \(golden.protected.debugDescription), got \(protected.debugDescription)"))+            #expect(protected == Self.retiredProtectDetailsBlankLines(content),+                    Comment(rawValue: "protection diverged from the retired loop on \(content.debugDescription)"))++            let start = content.index(content.startIndex, offsetBy: min(content.count, "<details".count))+            let close = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start)+            #expect(close == Self.expectedClose(golden),+                    Comment(rawValue: "close matching of \(content.debugDescription) picked the wrong tag"))+            #expect(close == Self.retiredFindMatchingDetailsClose(in: content, from: start),+                    Comment(rawValue: "close matching diverged from the retired loop"+                        + " on \(content.debugDescription)"))+        }+    }++    // MARK: - Equivalence — differential fuzz++    @Test("The rewritten loops reproduce the retired loops on random fragments")+    func fuzzRandomFragments() {+        // The units that decide the outcome at high density: both tags in both cases and+        // truncated, backtick runs of every length that matters, fence openers of both+        // kinds, line endings, blank lines and blockquote markers. Every fragment starts+        // with a line-start opener so the direct close-matcher call begins where a real+        // one does — after an opening tag that is outside any fence. "\r" is left out:+        // "\r\n" is one Character, and the retired loop's character-count arithmetic and+        // the regex's UTF-16 view disagree about it in ways that predate this change.+        let alphabet = ["<details>", "</details>", "<details", "</details", "<DETAILS>", "</Details>",+                        "`", "`", "``", "```", "````", "~~~", "\n", "\n", "\n", "\n\n", " ", "  ",+                        "> ", ">", "- ", "a", "text ", "```\n", "~~~\n", "```swift\n", "<summary>x</summary>"]+        var rng = SeededRandomNumberGenerator(seed: 0x2005_1A)+        for _ in 0..<20_000 {+            let units = Int.random(in: 0...40, using: &rng)+            var content = "<details>\n"+            for _ in 0..<units { content += alphabet.randomElement(using: &rng) ?? "a" }+            let start = content.index(content.startIndex, offsetBy: "<details".count)++            let expectedClose = Self.retiredFindMatchingDetailsClose(in: content, from: start)+            let actualClose = CodeFenceHelper.findMatchingDetailsClose(in: content, from: start)+            guard expectedClose == actualClose else {+                Issue.record(Comment(rawValue: "close matching diverged on \(content.debugDescription):"+                    + " expected \(String(describing: expectedClose.map { content.distance(from: content.startIndex, to: $0) })),"+                    + " got \(String(describing: actualClose.map { content.distance(from: content.startIndex, to: $0) }))"))+                return+            }++            let expectedProtected = Self.retiredProtectDetailsBlankLines(content)+            let actualProtected = CodeFenceHelper.protectDetailsBlankLines(content, validateContext: true)+            guard expectedProtected == actualProtected else {+                Issue.record(Comment(rawValue: "protection diverged on \(content.debugDescription):"+                    + " expected \(expectedProtected.debugDescription), got \(actualProtected.debugDescription)"))+                return+            }+        }+    }+}
specs/bugfixes/multiline-code-span-details-tag-matching/report.md Added +379 / -0
diff --git a/specs/bugfixes/multiline-code-span-details-tag-matching/report.md b/specs/bugfixes/multiline-code-span-details-tag-matching/report.mdnew file mode 100644index 00000000..6a1f5bf3--- /dev/null+++ b/specs/bugfixes/multiline-code-span-details-tag-matching/report.md@@ -0,0 +1,379 @@+# Bugfix Report: Multiline Code Spans Break Details Tag Matching++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++`CodeFenceHelper.isInsideInlineCode(in:at:)` only scanned the single physical+line containing a candidate `<details`/`</details>` tag for backtick+delimiters. Per CommonMark §6.1, inline code spans MAY contain line endings+(a line ending inside a span is normalized to a space, not a span+terminator). A `<details>` or `</details>` token written as literal content+of a code span that happens to cross a line boundary was therefore+miscounted as a real tag.++**Reproduction steps:**+1. Parse the following markdown:+   ```+   <details>+   <summary>Example</summary>++   Here is `<details>+   continued` in code.++   </details>+   ```+2. `MarkdownBlockParser.parse` walks `CodeFenceHelper.findMatchingDetailsClose`+   to find the real closing tag for the outer `<details>`.+3. The `<details>` literal inside the multiline span (opened on "Here is+   \`<details>", closed on "continued\` in code.") sits on a line with no+   backtick, so the old per-line check reported it as NOT inside inline+   code — it was counted as a real nested opener, pushing depth to 2. The+   genuine `</details>` at the end only brought depth back to 1, so no match+   was found and the outer details block failed to parse as one block.++**Impact:** Any details block whose body contains an inline code span+demonstrating `<details>`/`</details>` syntax across a line wrap (a+plausible thing to write in documentation) parsed incorrectly — either the+outer block failed to close (this repro) or, in the mirror shape, could+close early, dropping trailing content. A related path in+`protectDetailsBlankLines`'s candidate-opening validation could also accept+a line-start `<details` that was really code-span content and corrupt the+span with the `<!-- prism-details-blank -->` placeholder.++## Investigation Summary++- **Symptoms examined:** Traced the exact repro from the ticket through+  `MarkdownBlockParser.parse` -> `protectDetailsBlankLines` ->+  `findMatchingDetailsClose` -> `isInsideInlineCode`.+- **Code inspected:** `prism/Services/CodeFenceHelper.swift` in full,+  focusing on the three call sites of `isInsideInlineCode` inside+  `findMatchingDetailsClose`, and the candidate-opening validation block in+  `protectDetailsBlankLines`.+- **Hypotheses tested:** Confirmed by manual trace that (a) the per-line+  scan in `isInsideInlineCode` cannot see a backtick delimiter on an+  adjacent line, and (b) `protectDetailsBlankLines`'s `validateContext`+  branch checked line-start and code-fence context but never checked+  inline-code-span context at all, so a line-start `<details` inside a+  multiline span was accepted as a real candidate opening whenever a real+  `</details>` existed later in the document.++## Discovered Root Cause++**Defect type:** Incorrect scope assumption in a text-scanning classifier+(the code comment asserted "inline code spans cannot cross line+boundaries," which contradicts CommonMark §6.1) plus a missing check at a+second call site that needed the same classifier.++**Why it occurred:** `isInsideInlineCode` was written to answer "is this+line-local backtick span open" and reset all delimiter state at each+newline. That was wrong per spec from the start, but latent — plausible+real-world content rarely contains a details-tag literal that line-wraps+inside a code span.++**Contributing factors:** The classifier was inlined into+`findMatchingDetailsClose`'s specific query shape (a position to test) and+was never factored into something both call sites could share, so when+`protectDetailsBlankLines` needed the same test it was easy to omit+entirely rather than duplicate.++## Resolution for the Issue++**Changes made:**+- `prism/Services/CodeFenceHelper.swift` — replaced the per-line scan+  inside `isInsideInlineCode` with a delegate to a new+  `codeSpanContentRanges(in:)`, which scans backtick-run delimiter state+  across the whole containing inline block. Its scan is bounded by two+  block-structure boundaries: a fenced code block (skipped entirely via a+  new `allCodeFenceRanges(in:)` helper, since fence content is never inline+  text) and a blank line (via a new `paragraphScanEnd(in:from:limit:)`+  helper, since a blank line ends the enclosing paragraph and a code span+  cannot cross it). Corrected the doc comment that claimed code spans+  cannot cross line boundaries.+- `prism/Services/CodeFenceHelper.swift` — added the missing+  `isInsideInlineCode` check to `protectDetailsBlankLines`'s+  `validateContext` branch, so a line-start `<details` that is really+  multiline code-span content is skipped there too, using the same shared+  classifier as `findMatchingDetailsClose`.+- `prism/Services/CodeFenceHelper.swift` (review round 1) — the classifier+  is now built ONCE per pass as `CodeRegionIndex` (fenced blocks + code-span+  ranges, both sorted and non-overlapping, queried by binary search).+  `protectDetailsBlankLines` builds one index on the original content and+  copies the document into its output block by block instead of mutating in+  place, and `findMatchingDetailsClose(in:from:regions:)` takes the index;+  the old two-argument signature stays as a one-shot convenience. The+  first push rebuilt every code-span range on EVERY `isInsideInlineCode`+  call — once per candidate tag — and measured 5-15x slower than `main`+  (0.69 s at 8.8 KB, 10.84 s at 35 KB). `main` was itself quadratic on the+  same input: `findMatchingDetailsClose` re-searched the next fence and+  both tag patterns from the cursor on every iteration, and+  `protectDetailsBlankLines` rescanned fences from the document start per+  candidate. Both go with the index; the tag search keeps a forward-only+  `TagCursor` per pattern so an absent pattern is searched once, not once+  per iteration, and `findNextCodeFenceStart` no longer re-searches the+  other marker when a mid-line candidate is rejected.+- `prism/Services/CodeFenceHelper.swift` (review round 1) — the span scan+  itself is linear: the paragraph window is located once per paragraph, not+  once per opening backtick.+- `prism/Services/CodeFenceHelper.swift` (review round 1) — a line holding+  only blockquote markers (`>`, `> >`, `  >`) is a paragraph break for the+  span scan (`isParagraphBreak`). Inside a blockquote it IS a blank line,+  which ends the paragraph; outside one it starts a blockquote, which+  interrupts the paragraph — so no span crosses it either way. The review+  showed `> \`span opens\n>\n> <details> closes here\`` classified as+  inside a span. List markers are deliberately not stripped: an empty list+  item cannot interrupt a paragraph (CommonMark `foo\n*` is one+  paragraph), so a marker-only line is continuation unless a list is+  already open, which a scan with no block structure cannot know.+- `prism/Services/CodeFenceHelper.swift` (review round 2 fallout, fixed in+  round 3) — round 1 removed one quadratic and left another standing, on a+  shape it did not measure. `findNextCodeFenceStart` searched for BOTH fence+  markers from its start position to the end of the document, and+  `allCodeFenceRanges` called it once per fence, so a document whose fences+  are all backticks searched for `~~~` to the end of the document once per+  fence. Worse, `protectDetailsBlankLines` built that index at the top of+  every call, before anything asked whether the document contained a+  `<details` at all — so a document of code examples and no collapsible+  sections paid the whole cost for nothing, where `main` had paid none of+  it. Measured on round 2's code with a standalone `swiftc -O` harness:+  1.94 s at 400 fences (206 KB) and 35.1 s at 500 fences (2 MB), both with+  no `<details>` anywhere. Round 3: the pass returns immediately when the+  document holds no candidate tag (a UTF-8 byte scan, because+  `range(of:options:.caseInsensitive)` alone costs 219 ms on 2 MB and this+  runs on every parse); the fence markers keep persistent cursors in one+  `FenceScanner` shared across the whole index build, so each marker is+  searched forward once in total rather than once per fence; and the+  forward-only cursor type is now shared by the tag search and the fence+  search (`PatternCursor`). Same harness after: 1.0 ms and 9.9 ms.+- `prism/Services/CodeFenceHelper.swift` (round 3) — the `unmatchedRunLengths`+  memo is deleted rather than corrected. It could never fire: a scan that+  fails to close a run of length k has, by construction, examined every run+  through to the window's end without finding one of length k, so no later+  opener of length k exists in that window to short-circuit. Its comments+  claimed cmark's trick and the code implemented nothing, leaving the real+  worst case (a window of runs of distinct increasing lengths) at O(n^1.5).+  `appendCodeSpans` now resolves a whole window at once: one pass collects+  the window's backtick runs, one reverse pass points each run at the next+  run of its own length, and matching is a walk over that list — linear in+  the window, with no memo to be wrong about.+- `prism/Services/CodeFenceHelper.swift` (round 3) — a line whose first+  non-space content is `<details`/`</details` (indent ≤ 3, matching+  `isAtLineStart`) is a paragraph break for the span scan. Per CommonMark+  §4.6 that opens an HTML block of type 6, and type 6 is one of the block+  types that may INTERRUPT a paragraph, so such a tag can never be code-span+  content. This makes the inline-code check in `protectDetailsBlankLines`'s+  candidate gate unreachable, and it has been removed rather than left as+  dead code; the mid-line case the ticket was raised for is untouched. See+  Known Residual for why the spec reading wins over the opposite suggestion+  recorded on the ticket.++**Approach rationale:** A single shared classifier (`codeSpanContentRanges`)+answers the "is this position inside inline code" query correctly for both+call sites, satisfying the ticket's ask to share one multiline-aware+classifier rather than duplicating or patching the two sites independently.++**Alternatives considered:**+- **Delegate token classification to the Markdown parser** (mentioned as an+  option in the ticket) — rejected because `protectDetailsBlankLines` and+  `findMatchingDetailsClose` run as a raw-text preprocessing pass *before*+  `swift-markdown` parses the document; introducing a parser dependency+  here would be a much larger structural change for a scoped bug fix.+- **Track only "is there an unmatched backtick run" per paragraph without+  computing full ranges** — rejected because both call sites need a+  point-containment query, and pre-computing all span ranges once per+  document keeps that query simple and shareable rather than re-deriving+  state at each candidate position.++## Regression Test++**Test file:** `prismTests/DetailsBlankLinePlaceholderTests.swift`, suite+`MultilineCodeSpanDetailsTests` (split out of+`DetailsBlankLinePlaceholderTests` in round 3, which had outgrown SwiftLint's+type-body limit).++**Test names:**+- `testIsInsideInlineCodeMultilineSingleBacktick`+- `testIsInsideInlineCodeMultilineMultiBacktick`+- `testIsInsideInlineCodeDoesNotCrossBlankLine`+- `testIsInsideInlineCodeDoesNotCrossFence`+- `testFindMatchingDetailsCloseIgnoresMultilineSingleBacktickOpenTag`+- `testFindMatchingDetailsCloseIgnoresMultilineSingleBacktickCloseTag`+- `testFindMatchingDetailsCloseIgnoresMultilineMultiBacktickOpenTag`+- `testFindMatchingDetailsCloseIgnoresMultilineMultiBacktickCloseTag`+- `testProtectDetailsBlankLinesLineStartTagOpensBlockDespiteOpenBacktick`+  (round 3, replacing+  `testProtectDetailsBlankLinesIgnoresMultilineSingleBacktickCandidate`)+- `testProtectDetailsBlankLinesLineStartTagOpensBlockDespiteOpenMultiBacktick`+  (round 3, replacing the multi-backtick candidate test)+- `testDetailsBlockBelowStrayBacktickLineParses` (round 3)+- `testDetailsMultilineCodeSpanWithOpeningTagParsesAsOneBlock`+- `testDetailsMultilineCodeSpanWithClosingTagDoesNotCloseEarly`++The two replaced tests pinned the non-spec side: they asserted that a+line-start `<details>` under a line with an open backtick is code-span+content and must NOT be protected. The replacements assert the exact+protected output for the same two inputs, and the third is the shape that+reading broke — a sentence mentioning a backtick above an ordinary details+block whose summary contains a code span, which must still parse as a+details block.++**Review round 1 additions:**+- `prismTests/DetailsBlankLinePlaceholderTests.swift`, suite+  `CodeSpanParagraphBreakTests`: the review's blockquote repro, nested and+  indented marker variants, a span that DOES continue across non-blank+  `> ` lines and across a list item's continuation line, the deliberate+  marker-only-list-line non-break, and the close matcher on the blockquote+  shape.+- `prismTests/DetailsTagScanGrowthTests.swift` (the T-2147/T-1951+  convention): growth-ratio guards G1-G4 for `findMatchingDetailsClose`,+  `protectDetailsBlankLines` on prose mentions and on many real blocks, and+  `CodeRegionIndex` on one paragraph of many spans; plus goldens and a+  20,000-round differential fuzz of both entry points against the first+  push's loops, kept verbatim as oracles, so the restructure moved nothing+  but time.++**Review round 3 additions:**+- `prismTests/DetailsTagScanGrowthTests.swift`: G5 (a document of code+  fences and no `<details>` — the shape round 2 made quadratic and an early+  return now skips) and G5b (the same fences inside a real details block, so+  the index IS built and its own growth is what the ratio measures).+- `prismTests/DetailsTagScanGrowthTests.swift`: `goldenShapes` now carries a+  hand-derived expected output and an expected closing tag (named as "the+  Nth `</details>`") per case, so it states what the pass should do instead+  of only that two implementations of it agree. The differential comparison+  against the retired loops is kept alongside.+- `prismTests/DetailsBlankLinePlaceholderTests.swift`, suite+  `CodeSpanParagraphBreakTests`: four line-start tag shapes that must never+  be span content (including an indented one and an upper-case one), the+  mid-line counterpart that still must be, and a tag indented four spaces —+  past the HTML block limit, so it opens no block and the span runs across+  it, matching what `isAtLineStart` will accept.++**What it verifies:** Covers both single- and multi-backtick multiline code+spans containing both opening and closing `<details>` tag text, exercised+at both call sites (`findMatchingDetailsClose` directly, and+`protectDetailsBlankLines` directly on its exact output), plus end-to-end+tests through `MarkdownBlockParser.parse` reproducing the ticket's repro+shape, its mirror, and the stray-backtick-above-a-real-block shape. Further+tests pin the classifier's boundaries: a blank line, a blockquote's blank+line, a fenced code block, and a line-start details tag all stop the scan,+while a mid-line tag, a deeply indented one, and a marker-only list line do+not.++**Run command:**+```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -testPlan prism \+  -parallel-testing-worker-count 1 -only-test-configuration "en (base)" \+  -only-testing:prismTests/DetailsBlankLinePlaceholderTests \+  -only-testing:prismTests/MultilineCodeSpanDetailsTests \+  -only-testing:prismTests/CodeSpanParagraphBreakTests \+  -only-testing:prismTests/DetailsTagScanGrowthTests \+  -only-testing:prismTests/DetailsBlockParserTests \+  -only-testing:prismTests/MarkdownBlockParserTests test+```++Confirmed red/green: the 10 originally new tests (plus one that must still+pass, `testIsInsideInlineCodeLineScoped`, unaffected) were re-run against the+pre-fix `CodeFenceHelper.swift` (via `git stash`) and failed as expected.+The run above passes 134/134 (round 3), confirmed via+`Tools/check-test-results.sh`; a second run over the adjacent details and+fence suites (`DetailsTokenizerTests`, `TOCDetailsHeadingsTests`,+`NestedBackticksTests`, `DetailsPerformanceTests`,+`DetailsSearchIntegrationTests`) passes 79/79.++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/CodeFenceHelper.swift` | New multiline-aware span classifier behind a once-per-pass `CodeRegionIndex`; `protectDetailsBlankLines` and `findMatchingDetailsClose` consult it (linear tag search); blockquote-marker-only lines and line-start details tags are paragraph breaks; one forward pass per marker for the fence index (`FenceScanner`/`PatternCursor`); early return when the document holds no candidate tag; whole-window code-span matching in `appendCodeSpans`; corrected the inaccurate doc comment. |+| `prismTests/DetailsBlankLinePlaceholderTests.swift` | Regression coverage for multiline single- and multi-backtick code spans at both call sites, boundary tests for blank lines and fenced code blocks, and the CommonMark type-6 rule (line-start tags open blocks; mid-line tags are still span content). |+| `prismTests/DetailsTagScanGrowthTests.swift` | Growth-ratio guards G1-G5b and differential equivalence (hand-derived goldens + 20,000-round fuzz) for the index restructure. |+| `CHANGELOG.md` | `[Unreleased] / Fixed` entries for the parsing fix and the preprocessing cost. |+| `docs/agent-notes/markdown-parser.md` | Corrected the "on the same line" description of `isInsideInlineCode` and recorded the window rules and the cost model. |++## Verification++**Automated:**+- [x] Regression tests pass (targeted `xcodebuild test`, confirmed via+      `Tools/check-test-results.sh`)+- [x] Targeted test suite passes: `DetailsBlankLinePlaceholderTests`,+      `MultilineCodeSpanDetailsTests`, `CodeSpanParagraphBreakTests`,+      `DetailsTagScanGrowthTests`, `DetailsBlockParserTests`,+      `MarkdownBlockParserTests` (134/134); adjacent details/fence suites+      (79/79)+- [x] `make lint` passes+- [x] `make build-macos` passes+- [ ] Full `make test-quick` — not run; per the parallel bug-fixing session+      running on this machine, full-suite runs are unreliable under+      contention (WebKit test-host aborts under load, T-1541/T-2096/T-2219).+      Validated instead via `make lint`, `make build-macos`, and targeted+      `-only-testing:` suites confirmed through+      `Tools/check-test-results.sh`.++**Manual verification:** Traced the exact ticket repro (and its+opening/closing-tag mirror, and a `protectDetailsBlankLines`-specific+corruption case) by hand through the pre- and post-fix code paths; all+match the new tests' expectations. Timing was measured outside the test+harness as well, by compiling `CodeFenceHelper.swift` standalone with+`swiftc -O` at both revisions and running `protectDetailsBlankLines` over+generated documents:++| shape | round 2 | round 3 |+|---|---|---|+| 400 fences, 206 KB, no details | 1939 ms | 1.0 ms |+| 500 fences, 1967 KB, no details | 35133 ms | 9.9 ms |+| 500 fences, 1967 KB, inside a details block | 31450 ms | 628 ms |+| prose mentions x2000, 154 KB | 109 ms | 71 ms |++## Known Residual++The span scan has no full block structure. It knows about fences, blank+lines, blockquote-marker-only lines and `<details>` HTML block openers;+other lines that CommonMark would parse as a block interrupting the+paragraph — a non-empty list item (`- text`) or an ATX heading — are still+treated as paragraph continuation, so a backtick left unmatched before one+can still pair with one after it. Closing those needs the block parser's+view, which this preprocessing pass runs before (the "delegate+classification to the parser" alternative rejected above), and belongs to+its own ticket if the shape turns up in real documents.++**Decision: the spec wins over the ticket's suggestion.** A comment recorded+on T-2005 (from Codex) suggested the opposite reading — that a line-start+`<details>` under an unclosed backtick should be treated as code-span+content — and rounds 1 and 2 shipped that reading, pinned by two tests.+Round 3 reverses it, for three reasons. First, CommonMark is explicit:+`<details` at line start (indent ≤ 3) begins an HTML block of type 6, and+type 6 is listed among the block types whose start condition MAY interrupt a+paragraph (§4.6), so the paragraph — and any span scanning across it —+ends at that line. Second, the other reading is the more damaging one in+practice: a single stray backtick anywhere above a real collapsible section+silently turned the whole section into "code-span content", so its blank+lines went unprotected and it fell apart at the first one, whereas the+reading now adopted can at worst mis-handle a `<details>` deliberately+quoted at the start of a line inside a span — a shape with no reason to+exist, since the author can indent it or keep it mid-line. Third, it removes+a check rather than adding one: with this rule the inline-code test in+`protectDetailsBlankLines`'s candidate gate is unreachable, which is exactly+the kind of claim the 20,000-round differential fuzz (retired loop WITH the+gate vs. production without it) can hold down. The ticket's own repro is+mid-line (`` Here is `<details> ``, continuing on the next line) and is+unaffected either way.++## Prevention++**Recommendations to avoid similar bugs:**+- When writing a text-scanning classifier that stands in for a CommonMark+  concept (code spans, code fences, HTML blocks, etc.), check the spec+  section for the construct's actual scoping rules before assuming+  "resets per line" — CommonMark deliberately allows several inline+  constructs to span line endings.+- When two call sites need the same classification, share the classifier+  function rather than inlining a check at only one site; a review can+  legitimately ask "why does the other caller not perform this check" only+  if the check is a named, reusable thing.++## Related++- Transit ticket T-2005

Things to double-check

CHANGELOG.md merge overlap with origin/main

Origin/main gained 7 commits since this branch's merge-base, and CHANGELOG.md is the only file both sides touched — both append entries under the same [Unreleased] / Fixed heading. This is a routine, low-risk append-only merge (not a logic conflict), but worth resolving deliberately at merge time rather than trusting an auto-merge to place both entries sensibly.

Residual scope gap is documented, not fixed here

The span scan still treats a non-empty list item or an ATX heading as paragraph continuation rather than an interrupting block, so a code span could theoretically bridge across one of those in a real document. This is explicitly called out in the bugfix report's "Known Residual" section as needing the block parser's own view, which this preprocessing pass runs before — correctly scoped out rather than silently ignored.

make lint / make build-macos claims not independently re-run

The bugfix report claims make lint and make build-macos both pass. This review re-ran only the targeted test suite (per the task's single-xcodebuild-run constraint) in an isolated git archive export and did not independently re-verify the lint/build claims.