PR #412, head 189f9e46, reviewed as git diff origin/main...HEAD (merge base 2b10c6dc). Third round: verifies the round-2 fixes (<!--> one-line comment, ≤3-space indent guard, spaces/tabs-only blank line) and judges the branch as a whole. Strictly read-only — nothing was edited; the test run used a git archive export in an isolated job directory.
<!-->/<!---> close on their own line (FootnotePreprocessor.swift:536-543), ≤3-space guard shared by all four openers (:424-428), blank line = spaces/tabs only (:548-550) — each with a unit test and a swift-markdown parity fixture.scanners.re:182-187 and :204-221 confirm the type-1 any-of-four end tag, the type-2 whole-line -->, and the ASCII-space indent rule. The same scanner ([<] [/]? blocktagname) also shows </div> and <div/> open type-6 blocks — the code still says they never do (pre-existing, benign direction).FootnotePreprocessorTests, …HTMLBlockExtentTests, …BlockExtentParityTests, …PropertyTests, …PerformanceTests, MarkdownBlockParserFootnoteTests; 20 parity fixtures compare against HTMLBlock.range directly.<span>a <div>x</div> b) now opens a block; old code returned nil, cmark reads a paragraph. Harmful direction, rare shape, no test either way.report.md says 5 and 10 parity fixtures (there are 20), attributes the 16 new tests to FootnotePreprocessorTests.swift (they live in FootnotePreprocessorHTMLBlockExtentTests.swift), and its Verification list omits that file.prismTests/ScratchNestedListHTMLBlockTests.swift (mtime 03:05:01, contains Issue.record debug output) appeared in the worktree mid-review from a concurrent process. Not part of the PR; delete it rather than git add -A.Ready to push
Every item from the two prior review rounds is present and pinned by a test: detectHTMLCommentOpening tests --> over the whole opening line (<!-->/<!--->), one shared contentAfterBlockIndentation gates all four openers at three spaces with a tab deliberately left in place, and isBlankLine is spaces/tabs only. Each rule was checked against the vendored swift-cmark/src/scanners.re (_scan_html_block_start, _scan_html_block_end_1/2) and matches. Targeted run of the six footnote suites in the isolated export: 104/104 executed and passed (Tools/check-test-results.sh confirmed a non-zero count); swiftlint --strict and make verify-test-isolation clean. The full make test-quick was not run (one xcodebuild allowed), so this verdict covers the footnote pipeline, not the whole suite.
Nothing blocking. The one code finding worth acting on is narrow: the type-6 scan now short-circuits on the first recognised tag anywhere on a line that starts with <, so <span>a <div>x</div> b opens a block where the old balancing scan (and cmark) did not — a definition on the very next line renders as raw text. It is the same pre-existing design gap (cmark only looks at the line's first tag) surfacing on one more shape; matching cmark's start condition on the first tag alone would fix it and simplify the scanner. The bugfix report has three stale numbers/file names that should be corrected before merge. An untracked scratch test file appeared in the worktree during this review; it is not in the PR and must not be added.
5fe84df8 Fix T-1963: footnote preprocessor HTML-block extent diverges from swift-markdown 36e8d65f Review: give type-1 HTML blocks (pre/script/style/textarea) their own end condition 189f9e46 Review: hold HTML block openers to CommonMark's 3-space indent, comment end over whole line Prism lets you write footnotes in markdown: a reference like [^1] in the text and a definition line [^1]: Some note elsewhere. Before the real markdown parser runs, a small pre-pass finds those definition lines and lifts them out. But a definition line that sits inside a chunk of raw HTML in the document must be left alone, because to the parser it is just HTML text. So the pre-pass has to know where a chunk of HTML starts and, crucially, where it ends.
The pre-pass had its own home-made idea of where HTML ends, and it disagreed with the real parser (swift-markdown, which follows the CommonMark standard) in several ways. This change rewrites those rules to follow the standard: an ordinary HTML block (like <div>) ends at the first blank line, not at a closing tag; an HTML comment (<!-- ... -->) ends at its -->; and a <pre>, <script>, <style> or <textarea> block ignores blank lines and ends only at a line containing one of those closing tags. A tag that is indented four or more spaces is treated as a code sample, not HTML at all.
When the pre-pass got it wrong, a footnote either vanished silently (the reference showed as literal [^1] text and the definition showed as clutter) or a line that should have stayed inert became a footnote. Now the pre-pass and the parser agree, so footnotes near raw HTML behave the way the rest of the document does.
swift-markdown where its HTML blocks are and checks the pre-pass agrees, for 20 sample documents.One production file, prism/Services/FootnotePreprocessor.swift. The State enum splits the old inHTMLBlock(tag:) into three tagless states — inHTMLBlock (type-6, ends at isBlankLine), inRawTextHTMLBlock (type-1, ends at containsRawTextBlockEndTag), inHTMLComment (type-2, ends at -->). The .normal dispatch and the .inDefinition end-of-definition reprocessing, previously duplicated inline, are unified through classifyNormalLine → NormalLineOutcome → applyNormalLine. Four openers share contentAfterBlockIndentation, which strips at most three spaces and returns nil past that; a tab is left in place so every opener's leading-character check rejects it (cmark expands a tab to the next 4-column stop, i.e. indented code). detectHTMLBlockClosing and the open/close slot bookkeeping in detectHTMLBlockOpening are gone; it now returns Bool on the first recognised, non-self-closing opening tag.
Order in classifyNormalLine is load-bearing: code fence → comment → raw-text → type-6 → definition. The comment and raw-text checks must precede the type-6 scan because their end conditions differ and because a block-tag name inside a comment must not be read as markup. Both type-1 and type-2 openers can close on their own line (<pre>x</pre>, <!-->), which is why those two outcomes carry closesOnSameLine while type-6 does not. The parity suite (FootnotePreprocessorBlockExtentParityTests) treats the preprocessor as a black box: for every [^id]:-shaped line, "kept verbatim in cleanedSource" must equal "inside an HTMLBlock.range per swift-markdown".
</div> and <div/> as openers although cmark accepts both. All pre-existing; the whole-line scan combined with the new short-circuit is what produces the one new divergence noted in the findings.<details> is a special case upstream: CodeFenceHelper.protectDetailsBlankLines runs before this pass and replaces blank lines inside <details> with comment placeholders, so the blank-line rule and the details protection do not interact — which is why the moved fixture uses <section>.Checked line by line against swift-cmark/src/scanners.re: type-1 start [<] ('script'|'pre'|'textarea'|'style') (spacechar | [>]) (case-insensitive) matches detectRawTextBlockOpening's letter-run + boundary test; end_1 [<] [/] (any of four) [>] matches containsRawTextBlockEndTag; end_2 [^\n]* '-->' runs over the opening line, hence the whole-line contains("-->"). cmark checks start conditions only when !indented (indent < 4 after tab expansion) and end conditions from first_nonspace regardless of indent — the code mirrors both (openers gated, enders not). Two divergences survive at the boundary layer: type-1's boundary uses Character.isWhitespace (Unicode) where cmark's spacechar is ASCII, and type-6's matchBlockTag accepts / as a boundary without requiring the following > (cmark: [/]? [>]). Neither is reachable by sane input.
The per-line cost moved: contentAfterBlockIndentation computes line.count - trimmed.count (two whole-line grapheme walks) and is called up to four times per .normal line, i.e. ~8 whole-line walks versus ~3 before, offset by dropping one trimmingCharacters allocation. Linear, and the performance suite passes, but prefix(while:).count is O(indent) and behaviour-identical.
The scanTagRemainder machinery (HTML5 quote states, unquoted values, self-closing detection) existed to answer "what is still open at end of line". That question is gone. Its residual roles are (a) rejecting <div/> as an opener — which cmark does not reject — and (b) skipping a recognised name hidden in an unrecognised outer tag's attribute (<span title="<div>">). If detectHTMLBlockOpening instead implemented cmark's literal start condition on the first tag only ([<] [/]? blocktagname (spacechar | [/]? [>])), both residual roles, the whole-line scan, the attribute-quote tests and the new regression disappear together, at ~10 lines. That is the natural follow-up and would leave the tag-list subset and types 3/4/5/7 as the only remaining known divergences.
<span>a <div>x</div> b followed directly by a definition line — old balancing returned nil, new scan returns true at <div>, cmark parses a paragraph (type-7 needs the tag alone on the line). Harmful direction (definition kept verbatim, reference unbadged). Rare shape; untested in either direction.</div> or <div/> at line start followed directly by a definition: cmark keeps it in the HTMLBlock, the preprocessor extracts it. testSelfClosingBlockTagDoesNotOpenBlock has a blank line after the tag, so it does not actually pin the divergence.testHTMLTagBalancingDoesNotGrowQuadratically short-circuits after five characters and no longer measures the scan it is named for (the new code comment at :583-586 is honest about this; the test's own comments are not).[^id]:-shaped lines via substring containment and looks only at top-level HTMLBlock children; fixture 1's bare </section> already disagrees with swift-markdown silently. Fine today, one fixture away from a false pass.Fully implemented: ticket instances 1-3 (blank-line termination, comments, one-line blocks), review round 1 (type-1 raw-text blocks), review round 2 (indent limit, degenerate comment openers, ASCII blank line), retirement of detectHTMLBlockClosing, parser-backed parity coverage. Partially: parity with cmark's type-6 start condition (first-tag-only, </tag>, <tag/>, full tag list) — scoped out by the ticket but not enumerated in one place. Missing: nothing the ticket asked for.
prism/Services/FootnotePreprocessor.swift
Why it matters. This is the fix itself: type-6 ends at a blank line, type-1 at any of four end tags, type-2 at -->. The old single inHTMLBlock(tag:) with a </tag> substring search is what produced all three ticket instances.
What to look at. FootnotePreprocessor.swift:28-38 (State), :196-212 (state loop)
prism/Services/FootnotePreprocessor.swift
Why it matters. The .inDefinition branch used to re-implement the whole .normal dispatch inline; with three new outcomes that duplication would have been six-way. All six outcomes assign state, which is the invariant that makes the shared function safe to call from a non-.normal state.
What to look at. FootnotePreprocessor.swift:101-181, :230-231
prism/Services/FootnotePreprocessor.swift
Why it matters. Round-2 blocker: a <pre> indented four spaces is indented code to cmark, but the old whitespace trim opened a type-1 block that could swallow every definition in the document (no end tag anywhere → zero definitions → process() early-returns with no footnotes at all).
What to look at. FootnotePreprocessor.swift:416-428, used at :431, :496, :537, :588
prism/Services/FootnotePreprocessor.swift
Why it matters. The previous pre-push review's 'Needs fixes' item. <!--> and <!---> are complete one-line blocks in cmark (_scan_html_block_end_2 runs over the unchanged opening line); checking only past the opener left the machine in .inHTMLComment until a later --> or end of document.
What to look at. FootnotePreprocessor.swift:536-543; tests HTMLBlockExtentTests testDegenerateCommentOpenersCloseOnTheirOwnLine, parity fixtures 18-19
prism/Services/FootnotePreprocessor.swift
Why it matters. Retires the openSlots/slotsByTag balancing that made <div>Text</div> look closed (instance 3). But the scan still walks the whole line, so a line that starts with an unrecognised tag and contains a balanced block tag later now opens a block where the old code did not and cmark does not.
What to look at. FootnotePreprocessor.swift:587-621 (compare the removed balancing at the same location in the diff)
prismTests/FootnotePreprocessorBlockExtentParityTests.swift
Why it matters. The ticket's suggested coverage shape. 20 fixtures; for every [^id]:-shaped line, 'kept verbatim in cleanedSource' must equal 'inside a top-level HTMLBlock range'. Catches any further block-type divergence without hand-picked line numbers.
What to look at. FootnotePreprocessorBlockExtentParityTests.swift:46-292
The ticket's primary recommendation was to parse once and use HTMLBlock ranges. report.md:124-128 rejects it: definitions must be stripped before the parser sees the source, so inverting the pipeline is impractical. Mitigated by the parity test, which is the parser-backed check the recommendation was really after. Not recorded in specs/footnotes/decision_log.md; no bugfix has ever written there, so no precedent is broken.
<Textarea> is closed by </STYLE>. Source: CommonMark spec and _scan_html_block_end_1, cited in the rawTextTags doc comment and pinned by parity fixture 9.
cmark expands a tab to the next 4-column stop, which is always ≥ 4 for a leading tab, so a tab-indented opener is indented code. Leaving the tab in place lets every caller's hasPrefix("<") / fence-character check reject it without a column model. Commit 189f9e46 and the helper's doc comment.
CharacterSet.whitespaces includes U+00A0, which cmark treats as content. isBlankLine is allSatisfy { " " || "\t" }. Note the definition-continuation blank-line check at :215 still uses .whitespaces; that difference is unstated.
CodeFenceHelper.protectDetailsBlankLines runs before the preprocessor (MarkdownBlockParser.swift:59-62) and replaces blank lines inside <details>, so a details block containing a blank line is a shape production never hands this code. Stated in the test's comment.
The doc comment at :569-571 presents this as correct and 'unchanged from before'. cmark's scanner ([<] [/]? blocktagname (spacechar | [/]? [>])) accepts both </div> and <div/> as openers. No rationale is given for keeping the deviation; it is in the benign direction (a definition directly under such a line is extracted and works). Open question for the author: intentional scope cut, or unnoticed?
Inherited from the T-1877 balancing design; with the new short-circuit it is what opens a block on <span>a <div>x</div> b. No rationale in commits or comments. Open question: is the first-tag-only simplification wanted as a follow-up?
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | FootnotePreprocessor.swift:587-621 detectHTMLBlockOpening | Whole-line scan + first-tag short-circuit introduces one new harmful-direction divergence: a line starting with an unrecognised tag that contains a balanced block tag later (<span>a <div>x</div> b) now opens a type-6 block. Old balancing returned nil; cmark parses a paragraph (type-7 requires the tag alone on the line). A definition on the very next line is kept verbatim and its reference goes unbadged. Rare shape; no test in either direction. | Read-only review, not applied. Recommend implementing cmark's start condition on the first tag only ([<] [/]? blocktagname (spacechar | [/]? [>])), which also admits </div> and <div/> as openers and retires scanTagRemainder; at minimum add a parity fixture for this shape so the harness pins whichever behaviour is chosen. |
| minor | specs/bugfixes/footnote-html-block-extent/report.md | Three stale facts: ':115' says the parity test covers 'five fixtures' and ':198' says 'parameterised over 10 fixtures' (there are 20; the Affected Files table at :225 is right); ':109' and ':179' attribute the new regression tests to FootnotePreprocessorTests.swift, but all 16 live in FootnotePreprocessorHTMLBlockExtentTests.swift, which the Changes-made list never names; the Verification list (:229-236) omits that file from the run that produced '91/91 passed'. | Read-only review, not applied. Update the three counts/file names; the run command at :211-216 is already correct. |
| minor | FootnotePreprocessor.swift:424-428 contentAfterBlockIndentation | line.count - trimmed.count is two whole-line grapheme walks to measure at most three leading spaces, and classifyNormalLine calls the helper up to four times per line (~8 whole-line walks per ordinary line vs ~3 before; one trimmingCharacters allocation dropped). Linear, and the performance suite passes, but this pass is the entire cost of parsing a footnote-free document. | Read-only review, not applied. let indent = line.prefix(while: { $0 == " " }); guard indent.count <= 3 else { return nil }; return line[indent.endIndex...] — O(indent), behaviour-identical. Optionally compute it once in classifyNormalLine and dispatch on the first character. |
| minor | prismTests/FootnotePreprocessorTests.swift:368-530 T-1877 attribute fixtures | testBlockTagInDoubleQuotedAttributeDoesNotOpenBlock, testBlockTagInSingleQuotedAttributeDoesNotOpenBlock, testUnterminatedAttributeQuoteLeavesBlockOpen and the four literal-quote tests all lead with a recognised tag (<table …>, <div …>), so the scan now returns true at that tag before the attribute is examined. They pass whether or not scanTagRemainder's quote handling works. Quote handling is still load-bearing for <span title="<div>">x</span> (unrecognised outer tag) and that shape is now untested. | Read-only review, not applied. Re-base at least one fixture on a non-block outer tag, or fold into the first-tag-only refactor above which makes the machinery unnecessary. |
| minor | FootnotePreprocessor.swift:569-586, :623-653 doc comments | ':569-571' says a closing tag 'with no matching opening earlier on the line does not open one either — unchanged from before': closing tags are now unconditionally ignored (the 'matching opening' condition no longer exists), and cmark's scanner accepts </div> and <div/> as type-6 openers, so this is a known divergence, not correct behaviour. ':574' cites <table title="<div>"> as the attribute-value example, which no longer discriminates (table opens first). scanTagRemainder's doc justifies itself with 'cannot hide the closing tag that follows it' and 'ran past the real > and closing tag' — closing tags are no longer read at all. | Read-only review, not applied. Re-anchor the comments to what survives (self-closing rejection, progress guarantee, recognised name inside an unrecognised tag's attribute) and label the </div> and <div/> deviations as known. |
| minor | Remaining CommonMark divergences (pre-existing, all benign direction) | </div> at line start and <div/> do not open a block (cmark: both do); blockTags is 10 of cmark's ~62 type-6 names (no p, ul/ol/li, h1-h6, hr, figure, form, main …); types 3/4/5 (<?, <!X, <![CDATA[) and type-7 unimplemented. testSelfClosingBlockTagDoesNotOpenBlock has a blank line after <div/>, so it does not actually pin that divergence. None enumerated in one place; report.md mentions only the first. | Read-only review, not applied. Suggest a follow-up ticket and a single 'known divergences' comment on blockTags so the next session does not re-derive the list. |
| minor | Worktree hygiene | Untracked prismTests/ScratchNestedListHTMLBlockTests.swift (mtime 2026-09-06 03:05:01) appeared in the worktree during this review; the tree was clean at the start. It records Issue.record debug lines for an ordered-list + indented <div> shape and is not part of PR #412. Origin is a concurrent process, not this review (which wrote nothing in the tree and ran its build in an isolated export). | Not deleted (read-only). Remove it before any git add -A; it would fail as a test (Issue.record) if committed. |
| nit | prismTests/FootnotePreprocessorPerformanceTests.swift:180-206 | testHTMLTagBalancingDoesNotGrowQuadratically's fixture leads with <div>, so detectHTMLBlockOpening returns after ~5 characters; the test now measures line splitting / cleaned-source building / reference scanning only. The new source comment at :583-586 says so honestly; the test's own name and comments still describe balancing. | Read-only review, not applied. Rename, or lead the stress line with unrecognised tags so the scanner is exercised. |
| nit | FootnotePreprocessor.swift small shapes | RawTextOpening (:476) and CommentOpening (:518) are the same single-Bool struct declared twice; the type-2 end condition is a bare "-->" literal at :210 and :542 while type-1 has a named helper; finishCurrentDefinition() at :186 is unreachable-as-live in .normal (currentIdentifier is only non-nil in .inDefinition, whose exit already calls it); the ordering constraint comment→raw-text→type-6 is documented in the callees but not at classifyNormalLine where it is enforced; type-1 boundary uses Unicode isWhitespace where cmark's spacechar is ASCII. | Read-only review, not applied. Optional polish. |
| nit | FootnotePreprocessorBlockExtentParityTests.swift:254-291 | cleanedSource.contains(line) is substring containment (a duplicate or superstring line would give a false pass); htmlBlockLines walks only top-level children (an HTMLBlock inside a blockquote/list would yield expected=false silently); the harness compares only [^id]:-shaped lines, so fixture 1's bare </section> already disagrees with swift-markdown without failing. Correct for today's fixtures. | Read-only review, not applied. Split cleanedSource on newlines and use membership; recurse into block containers; say in the doc comment what is and is not compared. |
| nit | Docs | specs/footnotes/design.md:251-256 and specs/footnotes/implementation.md:40,63 still describe four parser states; there are six. CHANGELOG entry is accurate but does not say script/style/textarea were previously unrecognised entirely (a definition inside <script> is no longer extracted — a user-visible change). | Read-only review, not applied. One-line updates. |
Source: local run at 2026-09-06T03:05:57+10:00 · snapshot 189f9e4650597225eeeeec499a89983ec3df121a
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
Totals: 99 passed · 0 failed · 0 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
The test run changed these tracked files; they were restored afterwards.
prismTests/ScratchNestedListHTMLBlockTests.swift (untracked; appeared in the worktree at 03:05:01 from a concurrent process, not created by this run, which executed in an isolated git-archive export)Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot 189f9e4650597225eeeeec499a89983ec3df121a against base 2b10c6dc4944d8e50c99e6b60e962591a230b39f.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Services/FootnotePreprocessor.swift b/prism/Services/FootnotePreprocessor.swiftindex f8a8a17c..3dc984ca 100644--- a/prism/Services/FootnotePreprocessor.swift+++ b/prism/Services/FootnotePreprocessor.swift@@ -28,7 +28,12 @@ enum FootnotePreprocessor: Sendable { private enum State { case normal case inCodeFence(character: Character, count: Int)- case inHTMLBlock(tag: String)+ /// A CommonMark type-6 block (`<div>`, `<details>`, …): ends at the first blank line.+ case inHTMLBlock+ /// A CommonMark type-1 block (`<pre>`, `<script>`, `<style>`, `<textarea>`): ends+ /// only at a line containing one of the four end tags; blank lines do not end it.+ case inRawTextHTMLBlock+ case inHTMLComment case inDefinition(identifier: String) } @@ -93,6 +98,41 @@ enum FootnotePreprocessor: Sendable { let definitionLineIndices: Set<Int> } + /// What a line starting in (or returning to) `.normal` state does: opens a code+ /// fence, opens an HTML comment or block, starts a footnote definition, or is+ /// ordinary content. Shared by `.normal` itself and by `.inDefinition`'s+ /// end-of-definition reprocessing, which previously duplicated this same+ /// dispatch inline.+ private enum NormalLineOutcome {+ case codeFence(Character, Int)+ case htmlComment(closesOnSameLine: Bool)+ /// Type-1 block. Like a comment, it can close on its own opening line.+ case rawTextHTMLBlock(closesOnSameLine: Bool)+ /// Type-6 block. Never closes on its opening line: only a blank line ends it.+ case htmlBlock+ case definitionStart(identifier: String, firstLineContent: String)+ case ordinary+ }++ nonisolated private static func classifyNormalLine(_ line: String) -> NormalLineOutcome {+ if let (char, count) = detectCodeFenceOpening(line) {+ return .codeFence(char, count)+ }+ if let comment = detectHTMLCommentOpening(line) {+ return .htmlComment(closesOnSameLine: comment.closesOnSameLine)+ }+ if let rawText = detectRawTextBlockOpening(line) {+ return .rawTextHTMLBlock(closesOnSameLine: rawText.closesOnSameLine)+ }+ if detectHTMLBlockOpening(line) {+ return .htmlBlock+ }+ if let match = line.wholeMatch(of: definitionStartPattern) {+ return .definitionStart(identifier: String(match.1), firstLineContent: String(match.2))+ }+ return .ordinary+ }+ nonisolated private static func extractDefinitions(from lines: [String]) -> ExtractionResult { var state = State.normal var cleanedLines = [String?](repeating: nil, count: lines.count)@@ -115,44 +155,59 @@ enum FootnotePreprocessor: Sendable { currentContent = [] } + func applyNormalLine(_ line: String, at index: Int) {+ switch classifyNormalLine(line) {+ case .codeFence(let char, let count):+ state = .inCodeFence(character: char, count: count)+ cleanedLines[index] = line+ case .htmlComment(let closesOnSameLine):+ cleanedLines[index] = line+ state = closesOnSameLine ? .normal : .inHTMLComment+ case .rawTextHTMLBlock(let closesOnSameLine):+ cleanedLines[index] = line+ state = closesOnSameLine ? .normal : .inRawTextHTMLBlock+ case .htmlBlock:+ state = .inHTMLBlock+ cleanedLines[index] = line+ case .definitionStart(let identifier, let firstLineContent):+ currentIdentifier = identifier+ currentContent = [firstLineContent]+ state = .inDefinition(identifier: identifier)+ definitionLineIndices.insert(index)+ case .ordinary:+ state = .normal+ cleanedLines[index] = line+ }+ }+ for (index, line) in lines.enumerated() { switch state { case .normal:- if let (char, count) = detectCodeFenceOpening(line) {- finishCurrentDefinition()- state = .inCodeFence(character: char, count: count)- cleanedLines[index] = line- continue- }-- if let tag = detectHTMLBlockOpening(line) {- finishCurrentDefinition()- state = .inHTMLBlock(tag: tag)- cleanedLines[index] = line- continue- }+ finishCurrentDefinition()+ applyNormalLine(line, at: index) - if let match = line.wholeMatch(of: definitionStartPattern) {- finishCurrentDefinition()- currentIdentifier = String(match.1)- currentContent = [String(match.2)]- state = .inDefinition(identifier: String(match.1))- definitionLineIndices.insert(index)- continue+ case .inCodeFence(let fenceChar, let fenceCount):+ cleanedLines[index] = line+ let trimmed = line.trimmingCharacters(in: .whitespaces)+ if isFenceClosing(trimmed, character: fenceChar, minCount: fenceCount) {+ state = .normal } + case .inHTMLBlock: cleanedLines[index] = line+ if isBlankLine(line) {+ state = .normal+ } - case .inCodeFence(let fenceChar, let fenceCount):+ case .inRawTextHTMLBlock: cleanedLines[index] = line- let trimmed = line.trimmingCharacters(in: .whitespaces)- if isFenceClosing(trimmed, character: fenceChar, minCount: fenceCount) {+ if containsRawTextBlockEndTag(line) { state = .normal } - case .inHTMLBlock(let tag):+ case .inHTMLComment: cleanedLines[index] = line- if detectHTMLBlockClosing(line, tag: tag) {+ if line.contains("-->") { state = .normal } @@ -173,23 +228,7 @@ enum FootnotePreprocessor: Sendable { } finishCurrentDefinition()- state = .normal-- // Re-process this line in normal state- if let (char, count) = detectCodeFenceOpening(line) {- state = .inCodeFence(character: char, count: count)- cleanedLines[index] = line- } else if let tag = detectHTMLBlockOpening(line) {- state = .inHTMLBlock(tag: tag)- cleanedLines[index] = line- } else if let match = line.wholeMatch(of: definitionStartPattern) {- currentIdentifier = String(match.1)- currentContent = [String(match.2)]- state = .inDefinition(identifier: String(match.1))- definitionLineIndices.insert(index)- } else {- cleanedLines[index] = line- }+ applyNormalLine(line, at: index) } } @@ -374,10 +413,22 @@ enum FootnotePreprocessor: Sendable { // MARK: - Code Fence Detection - nonisolated private static func detectCodeFenceOpening(_ line: String) -> (Character, Int)? {+ /// CommonMark opens a code fence or an HTML block only "after up to three optional+ /// spaces of indentation"; four or more make the line an indented code block (or, after+ /// a paragraph, a lazy continuation line) — in either case something that opens no+ /// block, which `swift-markdown` follows. Returns the line past that indentation, or+ /// `nil` when the indentation alone disqualifies it. Only spaces are stripped: a tab+ /// advances to the next 4-column tab stop, so a tab-indented opener is indented code+ /// too, and leaving the tab in place makes every caller's leading-character check+ /// reject it (T-1963 review, round 2).+ nonisolated private static func contentAfterBlockIndentation(_ line: String) -> Substring? { let trimmed = line.drop(while: { $0 == " " })- let spacesCount = line.count - trimmed.count- guard spacesCount <= 3 else { return nil }+ guard line.count - trimmed.count <= 3 else { return nil }+ return trimmed+ }++ nonisolated private static func detectCodeFenceOpening(_ line: String) -> (Character, Int)? {+ guard let trimmed = contentAfterBlockIndentation(line) else { return nil } guard let first = trimmed.first, first == "`" || first == "~" else { return nil } let count = trimmed.prefix(while: { $0 == first }).count@@ -406,52 +457,137 @@ enum FootnotePreprocessor: Sendable { // MARK: - HTML Block Detection + /// CommonMark **type-1** tag names. A block opened by one of these runs to the first+ /// line containing ANY of the four end tags (`</pre>`, `</script>`, `</style>`,+ /// `</textarea>` — the spec says it need not match the opener, and `swift-cmark`'s+ /// `_scan_html_block_end_1` agrees); a blank line does not end it.+ nonisolated private static let rawTextTags = ["pre", "script", "style", "textarea"]++ /// The CommonMark **type-6** tag names recognised here (a subset of the spec's list).+ /// A block opened by one of these runs to the first blank line. `pre` is deliberately+ /// absent: it is type-1, and listing it here alongside the blank-line rule made a+ /// `<pre>` containing a blank line end at that blank line, so a definition-shaped line+ /// later inside it was extracted while `swift-markdown` kept the whole span as HTML+ /// (T-1963 review). nonisolated private static let blockTags = ["div", "details", "section", "article", "aside", "nav",- "header", "footer", "table", "pre", "blockquote"]+ "header", "footer", "table", "blockquote"]++ /// Whether a type-1 block opened by a line also closes on that same line.+ private struct RawTextOpening {+ let closesOnSameLine: Bool+ }++ /// Returns non-`nil` when the line opens a CommonMark **type-1** HTML block: after at+ /// most three spaces of indentation (`contentAfterBlockIndentation`) it begins with `<`+ /// followed by one of `rawTextTags` (case-insensitive) and then whitespace, `>`, or the+ /// end of the line. `<pre-wrap>` does not qualify (`-` is none of those), and neither+ /// does `<prefix>`. A `<pre>` indented four or more spaces is an indented code block,+ /// which `swift-markdown` ends at the first non-indented line — treating it as a type-1+ /// opener instead kept scanning for an end tag right past that line, swallowing a real+ /// definition there and, with no end tag anywhere later, every definition in the+ /// document (T-1963 review, round 2).+ ///+ /// Checked BEFORE `detectHTMLBlockOpening` because the two end conditions differ: a+ /// type-1 block is never interrupted by a blank line, only by the first line that+ /// contains an end tag — which may be the opening line itself, so `<pre>code</pre>`+ /// is a complete one-line block and the line directly under it is ordinary content,+ /// where the type-6 `<div>code</div>` still runs to the next blank line.+ nonisolated private static func detectRawTextBlockOpening(_ line: String) -> RawTextOpening? {+ guard let trimmed = contentAfterBlockIndentation(line), trimmed.hasPrefix("<") else { return nil }++ let afterBracket = trimmed.dropFirst()+ let name = afterBracket.prefix(while: { $0.isLetter })+ guard rawTextTags.contains(name.lowercased()) else { return nil }++ if let boundary = afterBracket.dropFirst(name.count).first,+ boundary != ">", !boundary.isWhitespace {+ return nil+ }+ return RawTextOpening(closesOnSameLine: containsRawTextBlockEndTag(String(trimmed)))+ }++ /// The type-1 end condition: the line contains any of the four end tags, in any case.+ nonisolated private static func containsRawTextBlockEndTag(_ line: String) -> Bool {+ let lowered = line.lowercased()+ return rawTextTags.contains { lowered.contains("</\($0)>") }+ } - /// Returns the block-level tag that this line leaves open, or `nil` when the line- /// opens no HTML block at all.+ /// Whether a comment span opened by a line also closes on that same line (`-->`+ /// present anywhere on it — `cmark` tests the end condition over the WHOLE opening+ /// line, so the `-->` overlapping the opener in `<!-->` and `<!--->` counts).+ private struct CommentOpening {+ let closesOnSameLine: Bool+ }++ /// Returns non-`nil` when the line opens a CommonMark **type-2** HTML comment block —+ /// one beginning with `<!--` after at most three spaces of indentation+ /// (`contentAfterBlockIndentation`; four or more make it indented code). ///- /// The line is scanned once, left to right, tracking opening and closing block tags so- /// a block opened and closed on the same line (`<div>Text</div>`) does not put the- /// parser into `.inHTMLBlock` state and swallow the footnote definitions that follow- /// (T-1877). Self-closing tags (`<div/>`) never open a block, and closing tags with- /// no matching opening on the line are ignored. When several tags remain unclosed the- /// first one (in line order) wins, since that is the block the following lines sit in.+ /// Checked BEFORE `detectHTMLBlockOpening` so a block tag name that only appears+ /// inside a comment (`<!-- comment with <div> -->`) is never read as real markup:+ /// `swift-markdown` parses a line like that as its own self-contained comment block,+ /// which — unlike a type-6 block — is not ended by a blank line at all, only by the+ /// first `-->` wherever it falls, including on the opening line itself. Treating a+ /// comment as an ordinary line once it opens no block would let a later footnote+ /// definition land inside HTML that was never real markup; treating it as an+ /// unclosed type-6 block would run it to the next blank line instead of to its+ /// actual `-->`. Either divergence swallows every following footnote definition,+ /// the same failure class T-1877 fixed for the tag scan (T-1963 instance 2).+ nonisolated private static func detectHTMLCommentOpening(_ line: String) -> CommentOpening? {+ guard let trimmed = contentAfterBlockIndentation(line), trimmed.hasPrefix("<!--") else { return nil }+ // Whole line, not `dropFirst(4)`: `<!-->` and `<!--->` are complete one-line blocks+ // (`_scan_html_block_end_2` runs over the opening line unchanged). Skipping the+ // opener left them in `.inHTMLComment` until a later `-->` or end of document,+ // swallowing every definition in between (T-1963 review, round 2).+ return CommentOpening(closesOnSameLine: trimmed.contains("-->"))+ }++ /// `cmark`'s blank line: nothing but spaces and tabs. `CharacterSet.whitespaces` also+ /// covers Unicode space separators such as U+00A0, which `cmark` treats as content —+ /// a line of those must NOT end a type-6 block (T-1963 review, round 2).+ nonisolated private static func isBlankLine(_ line: String) -> Bool {+ line.allSatisfy { $0 == " " || $0 == "\t" }+ }++ /// Returns whether this line opens a CommonMark **type-6** HTML block. Like the other+ /// openers it must sit within three spaces of indentation+ /// (`contentAfterBlockIndentation`); an indented-code `<div>` opens nothing. ///- /// Recognised tags are consumed whole by `scanTagRemainder`, so a block tag name that- /// only appears inside a quoted attribute value (`<table title="<div>">Content</table>`)- /// is not mistaken for real markup and left spuriously open.+ /// A type-6 block runs from its opening line to the first BLANK line+ /// (`extractDefinitions`'s `.inHTMLBlock` case enforces that via `isBlankLine`), regardless of whether+ /// any tag on the opening line is later closed on that very line — unlike a type-2+ /// comment or a type-1 raw-text block, a type-6 block is never ended by a closing tag at all. So+ /// `<div>Text</div>` opens a block exactly as a bare `<div>` does (T-1963 instance 3).+ /// This function only decides whether a block opens; it no longer needs to know+ /// which tag, or whether one remains unclosed at end of line, because closing is+ /// blank-line-only. It therefore scans left to right and returns `true` as soon as it+ /// finds the FIRST recognised, non-self-closing opening tag, ignoring anything after+ /// it on the line — earlier versions of this scan balanced every tag on the line to+ /// find what (if anything) remained open at end-of-line, which is exactly what made a+ /// balanced one-line block wrongly look closed. ///- /// Known gap, deliberately out of scope here: HTML comments get no special treatment, so- /// a block tag name that only appears inside one (`<!-- comment with <div> -->`) is still- /// read as real markup and leaves a block spuriously open — swallowing every following- /// footnote definition, the same user-visible failure as T-1877 itself. `swift-markdown`- /// disagrees: it parses that line as a CommonMark **type-2** comment block, which ends at- /// the `-->` rather than at a blank line, so the next line is an ordinary paragraph.- /// Closing this needs real comment-span recognition (`<!--` … `-->`, possibly across- /// lines) rather than a patch to the tag scan, so it is tracked in T-1963 alongside the- /// blank-line termination divergence — both are the same root cause: this preprocessor- /// keeps its own notion of HTML-block extent, which can disagree with the real parser.+ /// Self-closing tags (`<div/>`) never open a block, and a closing tag with no+ /// matching opening earlier on the line does not open one either — both unchanged+ /// from before. ///- /// Balancing costs O(tags), not O(tags²): each open reserves a slot in `openSlots` and- /// records that slot under its name, and each close pops its own name's most recent slot,- /// so a close with no matching open costs nothing rather than scanning the whole open- /// stack. Written this way deliberately — searching the stack per close is the shape- /// T-1655 had to undo in the raw-HTML image scan, and a line packed with unmatched- /// closing tags is reachable from any document, including a remote one opened by URL.- /// `testHTMLTagBalancingDoesNotGrowQuadratically` guards the growth rate.+ /// Recognised tags are consumed whole by `scanTagRemainder`, so a block tag name that+ /// only appears inside a quoted attribute value (`<table title="<div>">Content</table>`)+ /// is not mistaken for real markup. A tag name inside an HTML *comment* is excluded a+ /// level up — `classifyNormalLine` checks `detectHTMLCommentOpening` (and the type-1+ /// `detectRawTextBlockOpening`) first, so a comment-opening or raw-text-opening line+ /// never reaches this scan (T-1963 instance 2). ///- /// The whole scan is also cheaper than the check it replaced, which ran eleven- /// case-insensitive whole-line searches (one per block tag) before concluding anything:- /// this one walks each line once.- nonisolated private static func detectHTMLBlockOpening(_ line: String) -> String? {- let trimmed = line.trimmingCharacters(in: .whitespaces)- guard trimmed.hasPrefix("<") else { return nil }+ /// Short-circuiting on the first valid opening tag means this function no longer+ /// needs the open/close balance bookkeeping (`openSlots`/`slotsByTag`) it used to+ /// carry to answer "what, if anything, is still open at end of line" — that question+ /// no longer matters once closing is blank-line-only. `testHTMLTagBalancingDoesNotGrowQuadratically`+ /// still guards the surrounding per-character work (line splitting, cleaned-source+ /// building, reference scanning) against the same quadratic-growth risk (T-1655) the+ /// retired bookkeeping was built to avoid.+ nonisolated private static func detectHTMLBlockOpening(_ line: String) -> Bool {+ guard let content = contentAfterBlockIndentation(line), content.hasPrefix("<") else { return false }+ let trimmed = String(content) - // Open tags in line order; a slot is cleared when its closing tag is seen.- var openSlots: [String?] = []- var slotsByTag: [String: [Int]] = [:] var index = trimmed.startIndex while index < trimmed.endIndex {@@ -472,26 +608,16 @@ enum FootnotePreprocessor: Sendable { continue } - let tag = matchBlockTag(in: trimmed, at: nameStart)+ let isRecognizedTag = matchBlockTag(in: trimmed, at: nameStart) != nil let scan = scanTagRemainder(in: trimmed, from: nameStart) index = scan.end - guard let tag else { continue }-- if isClosing {- if let slot = slotsByTag[tag]?.popLast() {- openSlots[slot] = nil- }- } else if !scan.isSelfClosing {- slotsByTag[tag, default: []].append(openSlots.count)- openSlots.append(tag)+ if isRecognizedTag, !isClosing, !scan.isSelfClosing {+ return true } } - for slot in openSlots {- if let slot { return slot }- }- return nil+ return false } /// Consumes the HTML tag whose name starts at `index` (just past its `<` or `</`), and@@ -591,8 +717,4 @@ enum FootnotePreprocessor: Sendable { guard let boundary = remainder.dropFirst(name.count).first else { return name } return ">/ \t".contains(boundary) ? name : nil }-- nonisolated private static func detectHTMLBlockClosing(_ line: String, tag: String) -> Bool {- line.range(of: "</\(tag)>", options: .caseInsensitive) != nil- } }
diff --git a/prismTests/FootnotePreprocessorHTMLBlockExtentTests.swift b/prismTests/FootnotePreprocessorHTMLBlockExtentTests.swiftnew file mode 100644index 00000000..3036c2f2--- /dev/null+++ b/prismTests/FootnotePreprocessorHTMLBlockExtentTests.swift@@ -0,0 +1,370 @@+//+// FootnotePreprocessorHTMLBlockExtentTests.swift+// prismTests+//+// Created by Claude on 6/9/2026.+//++import Foundation+import Testing+@testable import prism++/// Hand-picked regression fixtures for where `FootnotePreprocessor` decides an HTML block+/// starts and ends (T-1963). Split out of `FootnotePreprocessorTests` for file length; the+/// black-box comparison against `swift-markdown`'s own `HTMLBlock` ranges lives in+/// `FootnotePreprocessorBlockExtentParityTests`.+struct FootnotePreprocessorHTMLBlockExtentTests {+ // MARK: - Block Extent Matches swift-markdown (T-1963)+ //+ // The three tests below are the fixed sides of the three divergences T-1963 catalogued+ // between this preprocessor's hand-rolled `.inHTMLBlock` model and `swift-markdown`'s+ // real CommonMark HTML-block rules. Each one failed before the fix (with the opposite+ // outcome from what it asserts) and is a positive demonstration of the corrected+ // behaviour, not just an absence-of-regression check.++ @Test("A blank line ends an HTML block even when its tag is still open (T-1963 instance 1)")+ func testBlankLineEndsHTMLBlockEvenWhenTagStillOpen() {+ // `<section>` is never closed, but CommonMark type-6 blocks end at the first blank+ // line regardless -- `swift-markdown` parses `[^inside]` as an ordinary paragraph+ // here, not as HTMLBlock content, so it must be extracted as a real definition.+ // Before the fix, `.inHTMLBlock` kept running past the blank line looking for+ // `</section>`, swallowing `[^inside]` -- it stayed literally in the cleaned source+ // and was never extracted, even though it is referenced and would otherwise survive+ // orphan filtering.+ let source = """+ Intro[^out] and a dangling ref[^inside].++ <div>x</div><section>++ [^inside]: Inside the still-open section.++ </section>++ [^out]: Outside.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "inside")?.content == "Inside the still-open section.")+ #expect(result.footnoteData.definition(for: "out")?.content == "Outside.")+ #expect(!result.cleanedSource.contains("[^inside]: Inside the still-open section."))+ }++ @Test("A block tag name inside an HTML comment does not open a block (T-1963 instance 2)")+ func testHTMLCommentDoesNotOpenBlock() {+ // `swift-markdown` parses this as a self-contained CommonMark type-2 comment block+ // that ends at `-->` on the same line, so `[^q]` on the next line is an ordinary+ // paragraph. Before the fix, the tag scan read `<div>` inside the comment text as+ // real markup and opened a `div` block that nothing on the line closed, so `[^q]`+ // was swallowed as HTML-block content and never extracted.+ let source = """+ Ref[^q] first.++ <!-- comment with <div> -->+ [^q]: Should be extracted.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "q")?.content == "Should be extracted.")+ #expect(result.cleanedSource.contains("<!-- comment with <div> -->"))+ }++ @Test("A multi-line HTML comment suppresses definitions until its closing -->")+ func testMultiLineHTMLCommentSuppressesUntilClosed() {+ // A type-2 comment's end condition is the first `-->`, wherever it falls -- including+ // several lines after the opener -- not the first blank line. `[^fake]` sits inside+ // the still-open comment and must stay suppressed; `[^q]` sits after the closing+ // `-->` and must be extracted.+ let source = """+ Ref[^q] first.++ <!-- comment+ [^fake]: Not a real definition.+ spanning multiple lines -->+ [^q]: Should be extracted.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "q")?.content == "Should be extracted.")+ #expect(result.footnoteData.definition(for: "fake") == nil)+ #expect(result.cleanedSource.contains("[^fake]: Not a real definition."))+ }++ @Test("A balanced one-line HTML block still suppresses an immediately following definition (T-1963 instance 3)")+ func testBalancedOneLineBlockStillSuppressesImmediatelyFollowingDefinition() {+ // CommonMark type-6 blocks are never ended by a matching closing tag -- only by a+ // blank line -- so `<div>Balanced</div>` opens a block that still covers the very+ // next (non-blank) line, exactly as `swift-markdown` reads it. Before the fix, a+ // tag balanced within the same line was treated as closing the block immediately,+ // so `[^inside]` on the next line was wrongly extracted instead of staying inert.+ let source = """+ Intro[^out] and a dangling ref[^inside].++ <div>Balanced</div>+ [^inside]: Should stay suppressed.++ [^out]: Outside.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "out")?.content == "Outside.")+ #expect(result.footnoteData.definition(for: "inside") == nil)+ #expect(result.cleanedSource.contains("[^inside]: Should stay suppressed."))+ }++ // MARK: - Type-1 HTML Blocks (T-1963 review)+ //+ // `<pre>`, `<script>`, `<style>` and `<textarea>` open CommonMark type-1 blocks, whose+ // end condition is a line containing one of the four end tags -- not a blank line. The+ // first cut of T-1963 listed `pre` with the type-6 tags, so the blank-line rule ended a+ // `<pre>` early and extracted a definition `swift-markdown` keeps as HTML.++ @Test("A blank line inside a pre block does not end it")+ func testBlankLineInsidePreBlockDoesNotEndIt() {+ let source = """+ Text[^inside] and[^after].++ <pre>+ line1++ [^inside]: Hidden in pre.++ </pre>++ [^after]: After pre.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "inside") == nil)+ #expect(result.cleanedSource.contains("[^inside]: Hidden in pre."))+ #expect(result.footnoteData.definition(for: "after")?.content == "After pre.")+ #expect(!result.cleanedSource.contains("[^after]: After pre."))+ }++ @Test("A script block ends at its end tag, and the very next line is ordinary content")+ func testScriptBlockEndsAtEndTag() {+ // `[^after]` follows `</script>` with no blank line between them. A type-6 block+ // would still cover it; a type-1 block ended on the `</script>` line, so it is a+ // definition.+ let source = """+ Text[^inside] and[^after].++ <script type="text/javascript">+ var x = 1;++ [^inside]: Hidden in script.+ </script>+ [^after]: After script.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "inside") == nil)+ #expect(result.cleanedSource.contains("[^inside]: Hidden in script."))+ #expect(result.footnoteData.definition(for: "after")?.content == "After script.")+ #expect(!result.cleanedSource.contains("[^after]: After script."))+ }++ @Test("A type-1 block closed on its opening line is a one-line block")+ func testRawTextBlockClosedOnOpeningLineIsOneLine() {+ // Contrast with `testBalancedOneLineBlockStillSuppressesImmediatelyFollowingDefinition`:+ // `<div>x</div>` runs to the next blank line, but `<pre>x</pre>` meets the type-1 end+ // condition on its own opening line, so the definition directly under it is real.+ let source = """+ Text[^n].++ <pre>code</pre>+ [^n]: Note.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "n")?.content == "Note.")+ #expect(!result.cleanedSource.contains("[^n]: Note."))+ #expect(result.cleanedSource.contains("<pre>code</pre>"))+ }++ @Test("Type-1 tags and end tags match case-insensitively")+ func testRawTextBlockTagsAreCaseInsensitive() {+ let source = """+ Text[^inside] and[^after].++ <PRE>++ [^inside]: Hidden.+ </Pre>+ [^after]: After.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "inside") == nil)+ #expect(result.cleanedSource.contains("[^inside]: Hidden."))+ #expect(result.footnoteData.definition(for: "after")?.content == "After.")+ }++ // MARK: - Indentation Limit on HTML Block Openers (T-1963 review, round 2)+ //+ // CommonMark opens an HTML block (any type) only after up to three spaces of+ // indentation; four or more make an indented code block, which `swift-markdown` ends+ // at the first non-indented line. The detectors trimmed ALL leading whitespace before+ // looking for the tag, so an indented-code `<pre>` opened a type-1 block that kept+ // scanning for an end tag past the real definition below it -- and, with no end tag+ // anywhere later, swallowed every definition in the document.++ @Test("A pre tag indented four spaces is indented code, not a type-1 block")+ func testFourSpaceIndentedPreDoesNotOpenRawTextBlock() {+ // The reviewer's shape: `</pre>` appears later, so before the fix the block ran+ // right over `[^x]` and the definition was silently dropped.+ let source = """+ Text[^x].++ <pre>+ line1+ [^x]: Should be a definition+ more indented text+ </pre>+ """+ let result = FootnotePreprocessor.process(source)++ // The two indented lines under `[^x]` are definition continuation (the footnote+ // rule, not the HTML-block one), so they join the content; what matters here is+ // that the definition exists at all and the `<pre>` line stayed ordinary source.+ #expect(result.footnoteData.definition(for: "x")?.content.hasPrefix("Should be a definition") == true)+ #expect(!result.cleanedSource.contains("[^x]: Should be a definition"))+ #expect(result.cleanedSource.contains(" <pre>"))+ }++ @Test("A pre tag indented four spaces with no end tag anywhere does not disable extraction")+ func testFourSpaceIndentedPreWithoutEndTagKeepsExtractingDefinitions() {+ // Worst case of the same bug: no `</pre>` ever arrives, so the type-1 state ran to+ // end of document, `extractDefinitions` found nothing, and `process()` returned the+ // source untouched with no footnotes at all.+ let source = """+ Text[^x] and[^y].++ <pre>+ [^x]: First.+ [^y]: Second.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "x")?.content == "First.")+ #expect(result.footnoteData.definition(for: "y")?.content == "Second.")+ }++ @Test("A comment opener indented four spaces is indented code, not a type-2 block")+ func testFourSpaceIndentedCommentDoesNotOpenCommentBlock() {+ let source = """+ Text[^x].++ <!-- comment+ [^x]: Should be a definition+ still no -->+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "x")?.content == "Should be a definition")+ #expect(!result.cleanedSource.contains("[^x]: Should be a definition"))+ }++ @Test("A div tag indented four spaces is indented code, not a type-6 block")+ func testFourSpaceIndentedDivDoesNotOpenHTMLBlock() {+ let source = """+ Text[^x].++ <div>+ [^x]: Should be a definition+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "x")?.content == "Should be a definition")+ #expect(!result.cleanedSource.contains("[^x]: Should be a definition"))+ }++ @Test("A tab-indented pre tag is indented code, not a type-1 block")+ func testTabIndentedPreDoesNotOpenRawTextBlock() {+ // A tab advances to the next 4-column tab stop, so it is indented code just as four+ // spaces are; the old whitespace trim stripped it and opened a block.+ let source = """+ Text[^x].++ \t<pre>+ [^x]: Should be a definition+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "x")?.content == "Should be a definition")+ }++ @Test("Openers indented one to three spaces still open their HTML blocks")+ func testUpToThreeSpaceIndentedOpenersStillOpenBlocks() {+ // The other side of the limit: three spaces before `<pre>`, two before `<!--` and+ // one before `<div>` are all within CommonMark's allowance, so each still suppresses+ // the definition-shaped line inside it and only the definitions outside are real.+ let source = """+ Text[^a] and[^b] and[^c] and[^after].++ <pre>+ [^a]: Hidden in pre.+ </pre>++ <!-- comment+ [^b]: Hidden in comment.+ -->++ <div>+ [^c]: Hidden in div.++ [^after]: After.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "a") == nil)+ #expect(result.footnoteData.definition(for: "b") == nil)+ #expect(result.footnoteData.definition(for: "c") == nil)+ #expect(result.cleanedSource.contains("[^a]: Hidden in pre."))+ #expect(result.cleanedSource.contains("[^b]: Hidden in comment."))+ #expect(result.cleanedSource.contains("[^c]: Hidden in div."))+ #expect(result.footnoteData.definition(for: "after")?.content == "After.")+ }++ // MARK: - Comment End Condition Covers the Opener (T-1963 review, round 2)++ @Test("<!--> and <!---> are one-line comment blocks, not openers that swallow what follows")+ func testDegenerateCommentOpenersCloseOnTheirOwnLine() {+ // cmark tests the type-2 end condition (`-->` anywhere on the line) over the WHOLE+ // opening line, so the `-->` overlapping the `<!--` counts and each of these is a+ // complete block. Checking only past the opener left them open until a later `-->`+ // or end of document, swallowing every definition in between.+ let source = """+ Text[^a] and[^b].++ <!-->+ [^a]: After the short one.++ <!--->+ [^b]: After the slightly longer one.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "a")?.content == "After the short one.")+ #expect(result.footnoteData.definition(for: "b")?.content == "After the slightly longer one.")+ }++ @Test("Only spaces and tabs make the blank line that ends a type-6 block")+ func testNonASCIIWhitespaceLineDoesNotEndHTMLBlock() {+ // cmark's blank line is spaces/tabs only; a line holding a no-break space is content,+ // so the block runs through it and `[^inside]` stays suppressed.+ let source = """+ Text[^inside] and[^after].++ <div>+ \u{00A0}+ [^inside]: Still inside.++ [^after]: After.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "inside") == nil)+ #expect(result.cleanedSource.contains("[^inside]: Still inside."))+ #expect(result.footnoteData.definition(for: "after")?.content == "After.")+ }+}
diff --git a/prismTests/FootnotePreprocessorBlockExtentParityTests.swift b/prismTests/FootnotePreprocessorBlockExtentParityTests.swiftnew file mode 100644index 00000000..bebf72e9--- /dev/null+++ b/prismTests/FootnotePreprocessorBlockExtentParityTests.swift@@ -0,0 +1,274 @@+//+// FootnotePreprocessorBlockExtentParityTests.swift+// prismTests+//+// Created by Claude on 6/9/2026.+//++import Foundation+import Markdown+import Testing+@testable import prism++/// Cross-checks `FootnotePreprocessor`'s HTML-block extent against `swift-markdown`'s own+/// parse, rather than against this test suite's own hand-picked expectations — the+/// coverage T-1963 suggested, since a hand-rolled scanner keeps re-deriving the same wrong+/// model that a direct comparison against the real parser would catch immediately.+///+/// For every `[^id]: ...`-shaped line in a fixture, the two must agree on whether that+/// line sits inside an `HTMLBlock`: `swift-markdown` says so via `HTMLBlock.range`+/// (`cmark`'s 1-based, end-line-INCLUSIVE source positions — see+/// `CommonMarkConverter.range(_:)`), and the preprocessor says so by whether it removed+/// the line from `cleanedSource` — removal means the line was recognised as extractable+/// definition content, independent of whether that identifier ends up referenced or+/// orphaned downstream. `FootnotePreprocessor`'s block-scanning internals are `private`+/// even under `@testable import`, so this observable, black-box signal is the only one+/// available from a test — which is also exactly what a rendering bug would surface as+/// (a definition line rendered raw, or a real footnote silently dropped).+struct FootnotePreprocessorBlockExtentParityTests {+ private static let definitionLinePattern = /^\[\^([a-zA-Z0-9_-]+)\]:/++ /// Fixtures spanning every T-1963 instance: blank-line termination past an unclosed+ /// tag (1), a block tag name hidden inside a comment (2), a comment spanning several+ /// lines, a balanced one-line block that still runs to the next blank line (3), the+ /// original (pre-T-1877) multi-line HTML-block case for a control, the type-1 shapes+ /// from the first review round, and from the second: the indentation limit (one to+ /// three spaces before an opener still open a block, four or a tab do not), the+ /// degenerate `<!-->`/`<!--->` one-line comments, and cmark's spaces-and-tabs-only+ /// blank line.+ static let fixtures: [String] = [+ // Instance 1: `<section>` never closes, but the blank line on line 4 ends the+ // block regardless, so `[^inside]` on line 5 is an ordinary paragraph.+ """+ Intro[^out] and a dangling ref[^inside].++ <div>x</div><section>++ [^inside]: Inside the still-open section.++ </section>++ [^out]: Outside.+ """,+ // Instance 2: the `<div>` inside the comment text is not real markup.+ """+ Ref[^q] first.++ <!-- comment with <div> -->+ [^q]: Should be extracted.+ """,+ // Instance 2, multi-line: the comment's end condition is its own `-->`, not a+ // blank line, so `[^fake]` (no blank line reaches it either way) stays suppressed.+ """+ Ref[^q] first.++ <!-- comment+ [^fake]: Not a real definition.+ spanning multiple lines -->+ [^q]: Should be extracted.+ """,+ // Instance 3: `<div>Balanced</div>` closes itself, but the block still runs to+ // the next blank line, so `[^inside]` directly underneath stays suppressed.+ """+ Intro[^out] and a dangling ref[^inside].++ <div>Balanced</div>+ [^inside]: Should stay suppressed.++ [^out]: Outside.+ """,+ // Control: a multi-line, never-closed `<div>` with no comment or same-line+ // balancing involved at all.+ """+ Some text[^ref].++ <div>+ [^html]: This is inside an HTML block.+ </div>++ [^ref]: Real definition.+ """,+ // Type-1 (T-1963 review): a `<pre>` is not ended by the blank lines on lines 5 and+ // 7, only by `</pre>` on line 8, so `[^inside]` on line 6 stays HTML. `[^after]` on+ // the line directly under `</pre>` is ordinary content -- a type-6 block would still+ // cover it.+ """+ Text[^inside] and[^after].++ <pre>+ line1++ [^inside]: Hidden in pre.++ </pre>+ [^after]: After pre.+ """,+ // Type-1: `<script>` with attributes, same blank-line immunity.+ """+ Text[^inside] and[^after].++ <script type="text/javascript">+ var x = 1;++ [^inside]: Hidden in script.+ </script>+ [^after]: After script.+ """,+ // Type-1 closed on its opening line: `<pre>code</pre>` is a one-line block, so the+ // definition directly under it is real (contrast instance 3 above).+ """+ Text[^n].++ <pre>code</pre>+ [^n]: Note.+ """,+ // Type-1 in mixed case, and closed by a DIFFERENT type-1 end tag: per CommonMark+ // the end tag need not match the opener, so `</STYLE>` ends the `<Textarea>` block.+ """+ Text[^inside] and[^after].++ <Textarea>++ [^inside]: Hidden in textarea.+ </STYLE>+ [^after]: After.+ """,+ // Not type-1: `<pre-wrap>` fails the tag-name boundary, so it opens no block at all+ // and `[^p]` right underneath is a definition.+ """+ Text[^p].++ <pre-wrap>Text+ [^p]: Paragraph, not HTML.+ """,+ // Indentation limit (T-1963 review, round 2): CommonMark allows up to three spaces+ // before any HTML block opener. Three before `<pre>` still opens a type-1 block.+ """+ Text[^inside] and[^after].++ <pre>+ [^inside]: Hidden in indented pre.+ </pre>+ [^after]: After.+ """,+ // Two spaces before `<!--` still open a type-2 comment block.+ """+ Text[^inside] and[^after].++ <!-- comment+ [^inside]: Hidden in indented comment.+ -->+ [^after]: After.+ """,+ // One space before `<div>` still opens a type-6 block.+ """+ Text[^inside] and[^after].++ <div>+ [^inside]: Hidden in indented div.++ [^after]: After.+ """,+ // Four spaces before `<pre>` make an indented code block instead, which ends at the+ // first non-indented line -- so `[^x]` is a paragraph and a real definition, even+ // though a `</pre>` turns up later.+ """+ Text[^x].++ <pre>+ line1+ [^x]: Should be a definition+ more indented text+ </pre>+ """,+ // Four spaces before `<!--`: indented code, so `[^x]` is a definition and the+ // `-->` further down is plain paragraph text.+ """+ Text[^x].++ <!-- comment+ [^x]: Should be a definition+ still no -->+ """,+ // Four spaces before `<div>`: indented code, so `[^x]` is a definition.+ """+ Text[^x].++ <div>+ [^x]: Should be a definition+ """,+ // A tab before `<pre>` reaches the 4-column tab stop, so it is indented code too.+ """+ Text[^x].++ \t<pre>+ [^x]: Should be a definition+ """,+ // `<!-->` is a one-line comment block: cmark tests `-->` over the whole opening line,+ // overlap with the `<!--` included, so `[^x]` right underneath is a definition.+ """+ Text[^x].++ <!-->+ [^x]: Should be a definition+ """,+ // `<!--->` likewise.+ """+ Text[^x].++ <!--->+ [^x]: Should be a definition+ """,+ // A line holding only a no-break space is NOT blank to cmark, so the `<div>` block+ // runs through it and `[^inside]` stays HTML.+ """+ Text[^inside] and[^after].++ <div>+ \u{00A0}+ [^inside]: Still inside.++ [^after]: After.+ """,+ ]++ /// 1-based source line numbers `swift-markdown` places inside a top-level `HTMLBlock`.+ private func htmlBlockLines(in source: String) -> Set<Int> {+ let document = Document(parsing: source)+ var lines = Set<Int>()+ for child in document.children {+ guard let htmlBlock = child as? HTMLBlock, let range = htmlBlock.range else { continue }+ guard range.lowerBound.line <= range.upperBound.line else { continue }+ for line in range.lowerBound.line...range.upperBound.line {+ lines.insert(line)+ }+ }+ return lines+ }++ @Test(+ "Preprocessor's HTML-block extent matches swift-markdown's own HTMLBlock ranges",+ arguments: fixtures+ )+ func testBlockExtentMatchesSwiftMarkdown(source: String) {+ let sourceLines = source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)+ let expectedInsideBlock = htmlBlockLines(in: source)+ let result = FootnotePreprocessor.process(source)++ for (zeroBasedIndex, line) in sourceLines.enumerated() {+ guard line.firstMatch(of: Self.definitionLinePattern) != nil else { continue }++ let oneBasedLine = zeroBasedIndex + 1+ let expectedSuppressed = expectedInsideBlock.contains(oneBasedLine)+ let actualSuppressed = result.cleanedSource.contains(line)++ #expect(+ actualSuppressed == expectedSuppressed,+ """+ Line \(oneBasedLine) ('\(line)') disagreement: swift-markdown says \+ inside-HTML-block=\(expectedSuppressed), preprocessor kept it verbatim=\(actualSuppressed)+ """+ )+ }+ }+}
diff --git a/prismTests/FootnotePreprocessorTests.swift b/prismTests/FootnotePreprocessorTests.swiftindex 99ba5062..7e9995d9 100644--- a/prismTests/FootnotePreprocessorTests.swift+++ b/prismTests/FootnotePreprocessorTests.swift@@ -146,14 +146,19 @@ struct FootnotePreprocessorTests { @Test("Definition inside details block is ignored") func testDefinitionInsideDetailsBlock() {+ // No blank line between `<summary>` and `[^inside]`, so the definition-shaped line+ // is genuinely inside the type-6 block. `[^inside]` is referenced from outside so a+ // wrongly extracted definition would survive orphan filtering, and `cleanedSource`+ // is asserted directly: an earlier shape of this fixture had a blank line there,+ // which (correctly, per CommonMark) ends the block, so `[^inside]` WAS extracted+ // and only vanished from `footnoteData` as an orphan -- the test passed without+ // discriminating anything (T-1963 review). let source = """- Some text[^out].+ Some text[^out] and[^inside]. <details> <summary>Click</summary>- [^inside]: Inside details.- </details> [^out]: Outside details.@@ -163,6 +168,34 @@ struct FootnotePreprocessorTests { #expect(result.footnoteData.definitions.count == 1) #expect(result.footnoteData.definition(for: "out") != nil) #expect(result.footnoteData.definition(for: "inside") == nil)+ #expect(result.cleanedSource.contains("[^inside]: Inside details."))+ }++ @Test("Definition after a blank line inside an unclosed section block is extracted")+ func testDefinitionAfterBlankLineInSectionBlockIsExtracted() {+ // The blank line after `<div>Intro</div>` ends the type-6 block regardless of the+ // still-open `<section>`, exactly as `swift-markdown` reads it, so `[^inside]` is an+ // ordinary definition. This is the shape `testDefinitionInsideDetailsBlock` used to+ // have, moved off `<details>`: `CodeFenceHelper.protectDetailsBlankLines` replaces+ // blank lines inside `<details>` before the preprocessor ever runs, so a `<details>`+ // with a blank line in it is a shape production never hands this code.+ let source = """+ Some text[^out] and[^inside].++ <section>+ <div>Intro</div>++ [^inside]: Inside section.++ </section>++ [^out]: Outside section.+ """+ let result = FootnotePreprocessor.process(source)++ #expect(result.footnoteData.definition(for: "inside")?.content == "Inside section.")+ #expect(result.footnoteData.definition(for: "out")?.content == "Outside section.")+ #expect(!result.cleanedSource.contains("[^inside]: Inside section.")) } // MARK: - Single-Line HTML Blocks (T-1877)@@ -230,10 +263,10 @@ struct FootnotePreprocessorTests { // with no blank line in between, so the raw-HTML block genuinely still covers it: a // CommonMark type-6 block runs from its opening line to the first blank line, and // `swift-markdown` parses these inputs with the `[^inside]` line inside the `HTMLBlock`.- // Separating them with a blank line would instead pin a divergence — the preprocessor's- // `.inHTMLBlock` continuation runs past blank lines while the real parser stops at them,- // so it would keep suppressing a definition `swift-markdown` reads as a plain paragraph.- // That divergence is pre-existing, out of scope for T-1877, and tracked in T-1963.+ // Separating them with a blank line would instead exercise the T-1963 blank-line+ // termination rule (`.inHTMLBlock` now ends at the first blank line, matching+ // `swift-markdown`, rather than continuing past it) — see+ // `testBlankLineEndsHTMLBlockEvenWhenTagStillOpen` below for that case directly. // Each fixture also references `[^inside]` from outside the block, so a definition that // was wrongly extracted would survive orphan filtering and fail the assertions. @@ -289,11 +322,12 @@ struct FootnotePreprocessorTests { @Test("A tag name that merely starts with a block tag name does not open a block") func testTagNameBoundaryPreventsPartialBlockTagMatch() {- // `matchBlockTag` requires a tag-name boundary after the name, so the custom element- // `<pre-wrap>` is not read as `<pre>`. Without that check it opens a `pre` block that- // nothing on the line closes, which swallows every following definition. CommonMark- // agrees there is no block here: `pre-wrap` is not a type-6 tag name, and the line- // has text after the tag so type-7 does not apply either — it is a paragraph.+ // Both tag matchers require a tag-name boundary after the name, so the custom element+ // `<pre-wrap>` is not read as `<pre>`. Without that check it opens a type-1 `pre`+ // block that nothing on the line closes, which swallows every following definition.+ // CommonMark agrees there is no block here: `pre-wrap` is neither a type-1 nor a+ // type-6 tag name, and the line has text after the tag so type-7 does not apply+ // either — it is a paragraph. let source = """ <pre-wrap>Text @@ -312,7 +346,7 @@ struct FootnotePreprocessorTests { // The blank line after the HTML line keeps preprocessor and parser in agreement: a // CommonMark type-6 block runs to the first blank line even when its tag closed on // the opening line, so `[^b]` is only a definition once that blank line has ended- // the block. Omitting it would pin a divergence (tracked in T-1963).+ // the block (T-1963 instance 3, fixed). let source = """ Text[^a] and more[^b]. @@ -413,15 +447,12 @@ struct FootnotePreprocessorTests { // definition after it, so a wrongly-opened block swallows the definition while the // reference survives orphan filtering and the assertion fails. //- // The blank line between the HTML line and the definition is what keeps these fixtures- // honest rather than divergence-pinning. A CommonMark type-6 block runs from its opening- // line to the first blank line *even when the tag closed on that opening line*, so a- // definition written directly underneath is still inside `swift-markdown`'s HTMLBlock,- // while this preprocessor — which ends the block at the end of the line — would extract- // it. With the blank line both agree the definition is an ordinary paragraph. The- // divergence itself is out of scope for T-1877 and recorded in T-1963; it does not- // weaken these fixtures, because `.inHTMLBlock` continues past blank lines, so a block- // wrongly left open still swallows the definition and still fails the assertions.+ // The blank line between the HTML line and the definition keeps these fixtures honest.+ // A CommonMark type-6 block runs from its opening line to the first blank line *even+ // when the tag closed on that opening line* (T-1963), so a definition written directly+ // underneath is still inside `swift-markdown`'s HTMLBlock and must stay suppressed here+ // too. With the blank line both agree the definition is an ordinary paragraph and gets+ // extracted, which is what each assertion below checks for. @Test("A literal quote after a closed attribute value does not extend the value") func testLiteralQuoteAfterAttributeValueDoesNotSwallowTag() {
diff --git a/specs/bugfixes/footnote-html-block-extent/report.md b/specs/bugfixes/footnote-html-block-extent/report.mdnew file mode 100644index 00000000..6e18a7e3--- /dev/null+++ b/specs/bugfixes/footnote-html-block-extent/report.md@@ -0,0 +1,265 @@+# Bugfix Report: Footnote Preprocessor's HTML-Block Extent Diverges from swift-markdown++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++`FootnotePreprocessor.extractDefinitions` decided where a raw-HTML block ended by+hand-rolling CommonMark's HTML-block rules instead of deriving them from the real+parser. This diverged from `swift-markdown` in three ways:++1. **Blank-line termination.** A CommonMark type-6 HTML block (the block tags this+ preprocessor recognises) always ends at the first blank line, whether or not its+ opening tag was ever closed. The preprocessor instead kept scanning for a literal+ `</tag>` substring, so it ran past blank lines the real parser had already used to+ end the block — swallowing footnote definitions `swift-markdown` reads as ordinary+ paragraphs.+2. **HTML comments.** `<!-- comment with <div> -->` had no special handling, so the+ `div` tag name inside the comment text was read as real markup and opened a block+ that nothing on the line closed — swallowing every following footnote definition.+ `swift-markdown` parses that line as its own self-contained CommonMark type-2+ comment block, ending at the `-->` rather than at a blank line.+3. **One-line blocks.** A block balanced on one line (`<div>Text</div>`) was treated+ as fully closed at the end of that line, so a definition directly underneath it was+ wrongly extracted. CommonMark type-6 blocks are never closed by a matching tag —+ only by a blank line — so `swift-markdown` still reads that definition as inside+ the HTMLBlock.++**Impact:** A footnote definition in one of these three shapes was either silently+dropped (rendered as visible raw text with an unbadged `[^id]` reference elsewhere) or+wrongly treated as a real footnote when it should have stayed inert — the same+user-visible symptom class as T-1877, reached through different input shapes.++## Investigation Summary++The three divergences (and a fourth, `detectHTMLBlockClosing`'s quote-blindness) were+already catalogued precisely in the T-1963 ticket description and its two review+comments, each verified against the vendored `swift-markdown` checkout during the+T-1877 PR #323 review. This fix implements the ticket's own recommendation rather than+re-deriving the diagnosis:++- Read `prism/Services/FootnotePreprocessor.swift`'s `.inHTMLBlock` state, its opening+ detector `detectHTMLBlockOpening`, and its closing detector `detectHTMLBlockClosing`.+- Read `FootnotePreprocessorTests.swift`'s existing fixtures for the same code — most+ of the T-1877-era tests already carried comments explaining that a blank line was+ deliberately inserted to *avoid* pinning the very divergences T-1963 describes,+ which confirmed the new behaviour would not conflict with them.+- Confirmed `swift-markdown`'s `HTMLBlock.range` reports 1-based, end-line-INCLUSIVE+ source positions (`CommonMarkConverter.range(_:)`: `endLine`/`endColumn` come+ straight from `cmark_node_get_end_line`/`_column`), which the new comparison test+ relies on — confirmed both by reading the vendored source and by running the new+ parity test itself.++## Discovered Root Cause++**Defect type:** Hand-rolled parser logic diverging from the real grammar it+approximates.++**Why it occurred:** The preprocessor has to decide HTML-block extent BEFORE+`swift-markdown` ever parses the (footnote-stripped) source, because footnote+definitions must be removed before parsing. Rather than deriving that decision from a+lightweight pre-parse, it reimplemented CommonMark's HTML-block rules directly in the+line-scanning state machine, and the reimplementation didn't fully match the type-6+"blank line only" termination rule or recognise type-2 comment spans at all.++**Contributing factors:** T-1877 fixed a same-line tag-balancing case by teaching the+scanner not to open a block when a tag closes on its own line — a plausible-looking+fix that (per the type-6 blank-line rule) was actually backwards: CommonMark doesn't+care whether a tag closes on its line at all, only whether a blank line follows.++## Resolution for the Issue++**Changes made:**+- `prism/Services/FootnotePreprocessor.swift`:+ - `State.inHTMLBlock` no longer carries a tag name; closing is blank-line-only, not+ tag-matched, so the associated value was dead weight.+ - Added `State.inHTMLComment` and `detectHTMLCommentOpening`, checked BEFORE+ `detectHTMLBlockOpening` in both the `.normal` dispatch and `.inDefinition`'s+ end-of-definition reprocessing, so a `<div>` tag name inside a comment is never+ read as markup. A comment closes at the first line containing `-->`, which may be+ the opening line itself or a later one.+ - Rewrote `detectHTMLBlockOpening` to return `Bool` rather than the tag left open at+ end-of-line: it now short-circuits `true` on the FIRST recognised, non-self-closing+ opening tag anywhere on the line, regardless of whether that same tag (or any+ other) closes later on the line. This is what makes a balanced one-line block+ still open a persisting block.+ - `.inHTMLBlock`'s only exit condition is now a blank line; `.inHTMLComment`'s is a+ line containing `-->`.+ - Retired `detectHTMLBlockClosing` entirely (no longer needed — closing is+ blank-line-only for type-6, and comment-only for type-2).+ - Review follow-up: `pre` is CommonMark **type-1**, not type-6, and the first cut+ left it in `blockTags` — so the new blank-line-only closing ended a `<pre>`+ containing a blank line early, and a definition-shaped line later inside it was+ extracted while `swift-markdown` kept the whole span as `HTMLBlock` (a regression+ against the retired `detectHTMLBlockClosing`, which for `pre` happened to be+ closer to right). Added `State.inRawTextHTMLBlock` with its own end condition —+ a line containing any of `</pre>`, `</script>`, `</style>`, `</textarea>`+ (case-insensitive; per the spec and `swift-cmark`'s `_scan_html_block_end_1` it+ need not match the opener), possibly the opening line itself — plus+ `detectRawTextBlockOpening`/`containsRawTextBlockEndTag`, and dropped `pre` from+ the type-6 list. `script`/`style`/`textarea` were previously unrecognised+ altogether.+ - Extracted the four-way "what does this line do in `.normal` state" dispatch+ (code fence / comment / block / definition / ordinary) into `classifyNormalLine`+ and `applyNormalLine`, shared between the `.normal` case and `.inDefinition`'s+ reprocessing branch (previously duplicated inline), which also brought+ `extractDefinitions` under SwiftLint's function-body-length limit after the fix+ added a fourth branch.+- `prismTests/FootnotePreprocessorTests.swift`: added four new regression tests (one+ per instance plus a multi-line comment case) and updated stale comments on five+ pre-existing fixtures that had been deliberately written to describe the divergence+ as future work rather than pin it.+- `prismTests/FootnotePreprocessorBlockExtentParityTests.swift` (new): the ticket's+ suggested coverage — compares the preprocessor's observable block boundaries against+ `swift-markdown`'s own `HTMLBlock.range` for five fixtures spanning all three+ instances, rather than against hand-picked expectations.++**Approach rationale:** The ticket's own recommendation — adopt the type-6 blank-line+rule for opening/closing, and recognise type-2 comment spans as a separate state — is+a minimal, targeted fix that resolves instances 1-3 with one change to+`detectHTMLBlockOpening`'s semantics plus one new state, and retires+`detectHTMLBlockClosing` as a side effect (per the ticket).++**Alternatives considered:**+- **Parse once with `swift-markdown` and derive extraction from its HTMLBlock ranges**+ — the ticket's stated ideal, but explicitly noted as impractical without inverting+ the pipeline (footnote definitions must be stripped BEFORE `swift-markdown` ever+ sees the source, so the real parser can't be consulted first without restructuring+ the whole preprocessing pass). Out of scope for this ticket.+- **Full open/close balance tracking retained, only reinterpreted** — keeping the+ original `openSlots`/`slotsByTag` bookkeeping and just changing what "opens a block"+ means (first-ever-opened rather than still-open-at-EOL) was considered, but since+ the new rule no longer needs to know whether anything closes on the line at all, the+ bookkeeping was pure dead weight; the short-circuiting rewrite is both simpler and+ faster.+- **Treating a lone closing tag (`</section>`) as also opening a block**, matching+ CommonMark precisely — real `swift-markdown` does treat this as a type-6 opener, but+ the original scanner never did (pre-existing, not one of the ticket's three+ instances), and changing it wasn't necessary to fix any of instances 1-3. Left+ unchanged to keep scope to block extent, per the ticket's explicit scope note.++### Review round 2: indentation limit on block openers++The local review found that all three HTML-block detectors trimmed *all* leading+whitespace before looking for the tag, while `detectCodeFenceOpening` already enforced+CommonMark's "up to three optional spaces of indentation". A `<pre>` indented four or+more spaces is an indented code block to `swift-markdown`, ended at the first+non-indented line; the preprocessor instead opened a type-1 block and kept scanning+for an end tag past the real definition on that line — and with no end tag anywhere+later, `extractDefinitions` found nothing and `process()` disabled footnotes for the+whole document. The fix is one shared helper, `contentAfterBlockIndentation`, used by+the fence detector and all three HTML detectors: it strips at most three spaces and+returns `nil` past that. Only spaces are stripped, so a tab-indented opener (a tab+reaches the 4-column tab stop) fails each caller's leading-character check and is+indented code too.++Two more cmark details from the same review pass, both regressions relative to `main`+the first cut introduced: the type-2 end condition is tested over the WHOLE opening+line, so `<!-->` and `<!--->` are complete one-line comment blocks (checking only past+the opener left them open until a later `-->` or EOF); and the blank line that ends a+type-6 block is spaces and tabs only (`isBlankLine`), where `CharacterSet.whitespaces`+also matched a line of no-break spaces that cmark reads as content.++### Consequences++**Positive:**+- All three catalogued divergences are fixed; `detectHTMLBlockClosing` (quote-blind,+ depth-less) is retired entirely rather than patched.+- `detectHTMLBlockOpening` is simpler and asymptotically faster (short-circuits on the+ first valid tag instead of scanning the whole line to find what's still open at EOL).++**Negative:**+- A lone closing tag (`</section>`) still does not open a block on its own, which is a+ known, narrower divergence from true CommonMark than before this fix — but it was+ already true before T-1963 and isn't one of the three catalogued instances.++## Regression Test++**Test file:** `prismTests/FootnotePreprocessorTests.swift`++**New tests:**+- `testBlankLineEndsHTMLBlockEvenWhenTagStillOpen` (instance 1)+- `testHTMLCommentDoesNotOpenBlock` (instance 2, single line)+- `testMultiLineHTMLCommentSuppressesUntilClosed` (instance 2, multi-line span)+- `testBalancedOneLineBlockStillSuppressesImmediatelyFollowingDefinition` (instance 3)+- Type-1 (review follow-up): `testBlankLineInsidePreBlockDoesNotEndIt`,+ `testScriptBlockEndsAtEndTag`, `testRawTextBlockClosedOnOpeningLineIsOneLine`,+ `testRawTextBlockTagsAreCaseInsensitive`+- `testDefinitionInsideDetailsBlock` reshaped (review follow-up): its blank line+ before `[^inside]` meant the definition was extracted and only orphan-filtered, so+ the test passed without discriminating. It now keeps `[^inside]` genuinely inside+ the block, references it from outside, and asserts `cleanedSource`; the old shape+ lives on as `testDefinitionAfterBlankLineInSectionBlockIsExtracted` (moved off `<details>`, since `protectDetailsBlankLines` removes blank lines inside `<details>` before the preprocessor runs), pinning that+ the blank line does end the block.++**Test file:** `prismTests/FootnotePreprocessorBlockExtentParityTests.swift` (new)++**Test name:** `testBlockExtentMatchesSwiftMarkdown` (parameterised over 10 fixtures:+the five original T-1963 shapes plus five type-1 shapes — `<pre>` and `<script>` with+blank lines inside, a `<pre>` closed on its opening line, a mixed-case `<Textarea>`+closed by `</STYLE>`, and `<pre-wrap>` as a boundary control)++**What it verifies:** For every `[^id]:`-shaped line in each fixture, whether the+preprocessor kept the line verbatim (suppressed, i.e. treated as HTML-block content)+matches whether `swift-markdown`'s own parse places that source line inside an+`HTMLBlock` node — the ticket's suggested coverage shape, which catches any further+block-type divergence without needing hand-picked line numbers.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=macOS' \+ -only-testing:prismTests/FootnotePreprocessorTests \+ -only-testing:prismTests/FootnotePreprocessorHTMLBlockExtentTests \+ -only-testing:prismTests/FootnotePreprocessorBlockExtentParityTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/FootnotePreprocessor.swift` | Blank-line type-6 termination, type-1 raw-text termination (`pre`/`script`/`style`/`textarea`), type-2 comment recognition, retired `detectHTMLBlockClosing`, shared normal-line dispatch, shared ≤3-space indentation guard (`contentAfterBlockIndentation`) for every block opener |+| `prismTests/FootnotePreprocessorTests.swift` | `testDefinitionInsideDetailsBlock` reshaped to assert `cleanedSource`; `testDefinitionAfterBlankLineInSectionBlockIsExtracted` added; updated stale comments on five pre-existing fixtures |+| `prismTests/FootnotePreprocessorHTMLBlockExtentTests.swift` | New (split out for SwiftLint file length) — the T-1963 regression tests: four per instance, four type-1, six for the indentation limit, one for `<!-->`/`<!--->`, one for the spaces-and-tabs-only blank line |+| `prismTests/FootnotePreprocessorBlockExtentParityTests.swift` | New — swift-markdown range comparison test (ticket's suggested coverage), 20 fixtures including five type-1 shapes, seven indentation shapes (1–3 spaces open a block; 4 spaces or a tab do not), `<!-->`/`<!--->`, and a no-break-space line |++## Verification++**Automated:**+- [x] Regression tests pass — targeted run of `FootnotePreprocessorTests`,+ `FootnotePreprocessorBlockExtentParityTests`, `FootnotePreprocessorPropertyTests`,+ `FootnotePreprocessorPerformanceTests`, `MarkdownBlockParserFootnoteTests` on+ macOS: 91/91 passed, confirmed via `Tools/check-test-results.sh` (not a+ zero-tests-executed false green). `make test-quick` / full suite intentionally+ NOT used as the primary signal — this machine is running several other+ worktrees' test suites concurrently (known contention issue, see+ `docs/agent-notes` / project memory), which makes full-suite runs unreliable+ here.+- [x] `make lint` (SwiftLint, `--strict`) passes.+- [x] `make build-macos` succeeds.++**Manual verification:**+- Traced every existing `FootnotePreprocessorTests.swift` HTML-block fixture by hand+ against the new blank-line/comment rules before running the suite, to predict which+ (if any) would need updated expectations. None needed behavioural changes — several+ already carried comments anticipating this exact fix.++## Prevention++**Recommendations to avoid similar bugs:**+- Prefer deriving block/line-extent decisions from the real parser's own output where+ the pipeline allows it; a hand-rolled re-implementation of a grammar rule reliably+ drifts from the real one over time (this is the second ticket, after T-1877, to find+ a divergence in the same function).+- `FootnotePreprocessorBlockExtentParityTests` is the reusable shape for this: compare+ observable behaviour against `swift-markdown`'s AST directly, rather than encoding+ expected line numbers by hand, so a wider fuzz/property sweep could be added later+ with the same technique.++## Related++- T-1877 (PR #323): introduced the same-line tag-balancing fix that (per this ticket)+ had the type-6 blank-line rule backwards for one-line blocks.+- T-1968 (escaped references) and T-2277 (CRLF): open tickets touching the same file+ in later batches — out of scope here per the ticket's own scope note.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..8948182b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A footnote definition sitting near raw HTML in the source could be silently dropped or wrongly suppressed, depending on the exact shape of that HTML, because the footnote preprocessor decided where an HTML block ended with its own hand-rolled rules instead of the ones `swift-markdown` actually uses (T-1963). Three shapes diverged: a block whose opening tag was never closed kept swallowing content past a blank line, where the real parser always stops; a block tag name that only appeared inside an HTML comment (`<!-- comment with <div> -->`) was read as real markup and opened a block nothing on the line could close; and a block balanced on one line (`<div>Text</div>`) was treated as already closed, when CommonMark runs it to the next blank line regardless. All three now match `swift-markdown`: an HTML block ends only at the first blank line (or, for one built entirely from an HTML comment, at its own closing `-->`, which may be on the opening line or several lines later), never at a matching closing tag. The one exception is the CommonMark raw-text block — `<pre>`, `<script>`, `<style>` and `<textarea>` — which is not interrupted by a blank line at all and ends only at a line containing one of those four end tags (any of them, in any case, possibly the opening line itself); the first cut of this fix listed `pre` with the blank-line tags, so a `<pre>` containing a blank line ended early and a definition-shaped line later inside it was wrongly extracted. Every opener is also now held to CommonMark's three-space indentation limit: a `<pre>`, `<!--` or `<div>` indented four or more spaces (or a tab) is an indented code block, which opens no HTML block at all — previously the detectors trimmed all leading whitespace, so an indented-code `<pre>` opened a raw-text block that swallowed the real definitions below it. Two more cmark details now match as well: `<!-->` and `<!--->` are complete one-line comment blocks rather than openers that run to a later `-->`, and only a line of spaces and tabs counts as the blank line that ends a block. - 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.
<!-->: detectHTMLCommentOpening returns closesOnSameLine: trimmed.contains("-->") over the whole line (:542); test testDegenerateCommentOpenersCloseOnTheirOwnLine + parity fixtures 18-19. Indent: contentAfterBlockIndentation at :431/:496/:537/:588; six unit tests + seven parity fixtures (1/2/3 spaces open, 4 spaces and tab do not, including 3 spaces + tab which fails the < check). Blank line: isBlankLine spaces/tabs; NBSP test + fixture 20.
cmark checks end conditions from first_nonspace regardless of indent, so </pre> still ends a type-1 block. containsRawTextBlockEndTag has no indent gate, so it matches. Same for --> and the blank line.
Old detectHTMLBlockOpening on <span>a <div>x</div> b: span unrecognised, div slot opened then cleared by </div>, loop over openSlots finds nothing, returns nil. New: span skipped, div recognised and not self-closing, returns true. cmark: line starts with <span>, not a type-6 name; type-7 needs the tag followed only by whitespace; so it is a paragraph and the next line a continuation. Worth a fixture before deciding which way to pin it.
None of the new suites touch WebKit; make verify-test-isolation passed in the export. FootnotePreprocessorBlockExtentParityTests imports Markdown only.
Only the six footnote suites were run (104 tests). make test-quick / make test were not; the change is confined to one production file whose only caller is MarkdownBlockParser.parseWithFootnotes, and MarkdownBlockParserFootnoteTests was included, so the residual risk is low but not zero.