diff --git a/src/webview/cm/fenced-code/fenced-code-collapse.ts b/src/webview/cm/fenced-code/fenced-code-collapse.ts index d19f9e32..4105ad63 100644 --- a/src/webview/cm/fenced-code/fenced-code-collapse.ts +++ b/src/webview/cm/fenced-code/fenced-code-collapse.ts @@ -28,6 +28,7 @@ import { type LineCollapseSpec, } from "../collapse/line-collapse-field.js"; import { leadingFrontmatterEnd } from "../frontmatter/detect.js"; +import { linkReferenceRisk } from "../link-reference-risk.js"; import { fencedCollapseTarget } from "./fenced-code-collapse-state.js"; import { FencedCollapseToggleWidget } from "./fenced-code-collapse-widget.js"; @@ -63,9 +64,11 @@ export function buildFencedCollapse( * under-triggering is unsound). The hot path stays bounded only for edits whose * changed lines carry none of those shapes (plain code body or plain prose — which the * fenced-heavy perf case is). - * BLANK-LINE boundaries are the one non-locality STRUCTURAL cannot see (a blank line - * carries no shape): a type-6/7 HTML block (and a paragraph / loose list) is TERMINATED - * by a blank line, so MOVING the blank line that ends an HTML block extends/contracts it + * Boundary changes also escape STRUCTURAL: indentation and link-reference formation + * can flip a far indented fence between ListItem and Document without re-pairing it + * (covered by topLevelBoundaryRisk). Blank-line boundaries carry no shape: a blank line + * ends a type-6/7 HTML block (and a paragraph / loose list), so MOVING that blank + * extends/contracts it * over a following top-level fence WITHOUT touching any tag/marker line (Codex cycle-2/3, * both parser-verified). `topLevelBoundaryRisk` covers this: any TOP-LEVEL edit that moves a * blank-line boundary — a newline inserted/deleted, OR a changed line's blankness flipped @@ -76,8 +79,8 @@ export function buildFencedCollapse( * "Top-level" (not inside a reused block's [blockFrom, blockTo]) keeps IN-BODY newlines * bounded — an in-body edit is contained (its own block rebuilds via touchesRange, and * un-closing its fence is caught by STRUCTURAL's fence alt), so writing code stays fast; - * and a pure non-newline insertion never moves a blank boundary, so plain typing stays - * bounded. G2 + the background-parse self-heal remain as defense-in-depth. */ + * ordinary typing with no boundary risk stays bounded. G2 + the background-parse + * self-heal remain as defense-in-depth. */ const STRUCTURAL = /(?:^|\n)[ \t]{0,3}(?:`{3,}|~{3,})|(?:^|\n)[ \t]*(?:[-*+]|\d{1,9}[.)]|>)|(?:^|\n)[ \t]{0,3}<[/!?A-Za-z]|<\/(?:script|pre|style|textarea)>|-->|\?>|\]\]>/i; function touchesStructural(tr: Transaction): boolean { @@ -104,7 +107,7 @@ function touchesStructural(tr: Transaction): boolean { const BLANK_LINE = /^[ \t]*$/; /** A block boundary MOVED by a TOP-LEVEL edit in a way STRUCTURAL's per-line-SHAPE check - * cannot see. Two shapeless HTML-block terminators drive this: + * cannot see. Two shapeless HTML-block terminators are part of this: * - a BLANK line ends a type-6/7 block (and a paragraph / loose list): moving the blank * that ends an HTML block extends/contracts it over a following top-level fence WITHOUT * touching a tag/marker line (Codex cycle-2 + cycle-3, parser-verified). @@ -115,10 +118,13 @@ const BLANK_LINE = /^[ \t]*$/; * — is narrow: it fires only when the user actually types or deletes a `>`. * Fires when the edit changes the LINE COUNT (a newline inserted/deleted), OR flips a * changed line's blankness in EITHER direction (delete-to-blank, or type-into-the-blank), - * OR adds/removes a `>`. All three are conservative supersets (e.g. a newline splitting - * non-blank prose, or a `>` typed in plain prose, reshapes nothing — a safe over-trigger). - * Any within-line edit that keeps the line's blankness, adds/removes no newline, and - * touches no `>` cannot move a boundary, so plain typing stays on the bounded hot path. + * OR adds/removes a `>`, changes leading `[ \t]*`, or touches a possible link-reference + * run. Indent changes and reference formation/dissolution can change whether a far + * indented fence continues a loose list. All are conservative supersets (e.g. a newline + * splitting non-blank prose, or a `>` typed in plain prose, reshapes nothing — a safe + * over-trigger). + * Link-reference presence also over-triggers on line-start links/code and malformed + * container prefixes in the same nonblank run (link-reference-risk.ts explains multi-line coverage). * Fires ONLY when the edit is NOT fully inside a reused block's [blockFrom, blockTo] — an * in-body edit is contained (its own block rebuilds via touchesRange, and un-closing its * fence is caught by STRUCTURAL's fence alt), so in-body edits stay bounded. @@ -138,19 +144,30 @@ function topLevelBoundaryRisk( if (risk) { return; } + const insideBlock = prevBlocks.some((b) => fromA >= b.blockFrom && toA <= b.blockTo); + if (insideBlock) { + return; + } const insertedText = tr.state.doc.sliceString(fromB, toB); const deletedText = tr.startState.doc.sliceString(fromA, toA); const newlineDelta = insertedText.includes("\n") || deletedText.includes("\n"); const gtDelta = insertedText.includes(">") || deletedText.includes(">"); - const oldBlank = BLANK_LINE.test(tr.startState.doc.lineAt(fromA).text); - const newBlank = BLANK_LINE.test(tr.state.doc.lineAt(fromB).text); - if (!newlineDelta && !gtDelta && oldBlank === newBlank) { - return; // boundary-inert: no line count change, no blankness flip, no `>` delta - } - const insideBlock = prevBlocks.some((b) => fromA >= b.blockFrom && toA <= b.blockTo); - if (!insideBlock) { - risk = true; + const oldText = tr.startState.doc.lineAt(fromA).text; + const newText = tr.state.doc.lineAt(fromB).text; + const oldBlank = BLANK_LINE.test(oldText); + const newBlank = BLANK_LINE.test(newText); + const indentDelta = /^[ \t]*/.exec(oldText)?.[0] !== /^[ \t]*/.exec(newText)?.[0]; + if ( + !newlineDelta && + !gtDelta && + oldBlank === newBlank && + !indentDelta && + !linkReferenceRisk(tr.startState, fromA) && + !linkReferenceRisk(tr.state, fromB) + ) { + return; // boundary-inert: no line/blank/indent/terminator/reference change } + risk = true; }); return risk; } diff --git a/src/webview/cm/link-reference-risk.ts b/src/webview/cm/link-reference-risk.ts new file mode 100644 index 00000000..60b823b0 --- /dev/null +++ b/src/webview/cm/link-reference-risk.ts @@ -0,0 +1,49 @@ +import type { EditorState } from "@codemirror/state"; + +const BLANK_LINE = /^[ \t]*$/; +const LINK_REFERENCE_START = /^[ \t>*+\d.)-]*\[/; + +/** Lezer's LinkReference leaf starts with `[`, but its length-limited nonempty label, + * destination and optional quoted/parenthesized title can reach later lines. The colon + * must immediately follow `]`; whitespace may put the destination or title on a + * following line. The destination/title must have a whitespace-only suffix to EOL. URLs themselves cannot span lines (angle URLs reject + * newlines; bare URLs stop at whitespace), while labels and titles can. + * + * A changed continuation line need not contain `[`. Look back through the nonblank + * run on BOTH sides of the edit instead: a leaf cannot cross an ASCII blank line. + * Before a leaf starts, Lezer only skips whitespace and list/blockquote prefixes. + * Accept ANY sequence of those marker characters before `[`, rather than mirroring + * their nesting, spacing and digit limits. This covers container-relative starts + * and over-triggers on malformed prefixes, ordinary line-start links/code, and later + * prose in the same run. Inline images (`![…]`) remain on the bounded hot path. + * Newline/blank/indent changes are handled by the callers' other arms; this check + * also covers shapeless continuation edits. A reverse cursor avoids per-line tree lookups. */ +export function linkReferenceRisk(state: EditorState, pos: number): boolean { + const line = state.doc.lineAt(pos); + if (BLANK_LINE.test(line.text)) { + return false; + } + if (LINK_REFERENCE_START.test(line.text)) { + return true; + } + const cursor = state.doc.iterRange(line.from, 0); + let lineHasText = true; + while (!cursor.next().done) { + if (cursor.lineBreak) { + // Empty lines have no text item; the first break follows the inspected line. + if (!lineHasText) { + return false; + } + lineHasText = false; + } else { + lineHasText = true; + if (BLANK_LINE.test(cursor.value)) { + return false; + } + if (LINK_REFERENCE_START.test(cursor.value)) { + return true; + } + } + } + return false; +} diff --git a/src/webview/cm/structural-guard.ts b/src/webview/cm/structural-guard.ts index c6e4080a..1146babc 100644 --- a/src/webview/cm/structural-guard.ts +++ b/src/webview/cm/structural-guard.ts @@ -29,6 +29,7 @@ import { syntaxTreeAvailable } from "@codemirror/language"; import type { EditorState, Transaction } from "@codemirror/state"; import type { Interval } from "./bounded-recompute.js"; +import { linkReferenceRisk } from "./link-reference-risk.js"; /** SHAPE over-approximation for a structural reparse — the fenced field's proven * `STRUCTURAL` regex (`fenced-code-collapse.ts`, the #63 precedent: fence delimiters, @@ -48,6 +49,8 @@ import type { Interval } from "./bounded-recompute.js"; * finding, cm-structural-guard-exhaustive.test.ts). ⚠️ `STRUCTURAL` has NO `m` flag, so a * line end MUST be spelled `(?:\n|$)` — writing `$` anchors to end-of-STRING and silently * matches almost nothing. + * Link references need more than a changed-line SHAPE alt: labels/destinations/titles + * may reach later lines. The LINK-REFERENCE arm below scans their nonblank prefix. * Every marker alternation's indent bound is `[ \t]*`, NOT CommonMark's top-level `{0,3}`: * a line's legal marker indent is relative to its CONTAINER's content indent, and inside a * nested list ` # h` IS a real heading — a line-in-isolation regex cannot see the @@ -213,7 +216,7 @@ export function tableRowShapeChanged(oldText: string, newText: string): boolean * a FULL rebuild so a structural edit never leaves a stranded/missing chevron. * * Mirrors the fenced field's guard (`touchesStructural` dual old/new line-expanded slice - * scan + `topLevelBoundaryRisk`'s newline/`>`/blank arms), FOLDED into one pass and with + * scan + `topLevelBoundaryRisk`'s newline/`>`/blank/indent/reference arms), with * NO `insideBlock` gate: unlike the fenced field, this guard does not scope a trigger * against a consumer's own reused per-block record, so ANY top-level structural trigger * ⇒ full rebuild regardless of whether a given consumer holds reusable records at all. @@ -227,6 +230,14 @@ export function tableRowShapeChanged(oldText: string, newText: string): boolean * `` declaration (a same-line, non-newline, non-shape edit that SHAPE and * the other arms all miss); keying on the `>` being ADDED/REMOVED (not merely present) * keeps it narrow (mirrors the fenced field's cycle-5 type-4 rationale). + * - LINK-REFERENCE — a possible container-relative `[` leaf start occurs at/before + * the changed line in its nonblank run on either side. Forming/dissolving a + * definition ends/extends a leaf, changing lazy list continuation and far block + * containment. Unlike a line-start SHAPE + * alt, this covers edits in multi-line labels, destinations and titles (see + * link-reference-risk.ts); NEWLINE-DELTA/BLANK-FLIP/INDENT cover boundary moves. + * Ordinary line-start links, malformed prefixes and code over-trigger, costing + * a full walk. * - TABLE-DELIM — the changed line's TABLE SHAPE flips (`tableRowShapeChanged`). A HYBRID: * PRESENCE on a line that is delimiter-shaped on either side (raw or `[ \t]`-stripped), * because two of the parser's inputs there — `nextLine`'s `line.next` gate and @@ -311,10 +322,14 @@ export function touchesStructuralReparse(tr: Transaction): boolean { } // Past this point the change is confined to ONE line on each side — neither the // inserted nor the deleted text contains a `\n` (NEWLINE-DELTA returned above) — so - // `fromA..toA` and `fromB..toB` each sit inside a single line. Every arm below is a - // per-line OLD-vs-NEW comparison and depends on that. + // `fromA..toA` and `fromB..toB` each sit inside a single line. The comparisons below + // depend on that; LINK-REFERENCE also reads the preceding nonblank lines. const oldLine = tr.startState.doc.lineAt(fromA); const newLine = tr.state.doc.lineAt(fromB); + if (linkReferenceRisk(tr.startState, fromA) || linkReferenceRisk(tr.state, fromB)) { + hit = true; + return; + } // TABLE-DELIM — the per-line facts lezer forms a `Table` from (see the mirrors above). // Completing or breaking a delimiter row, or changing a header's cell count, makes a // `Table` appear or vanish and can CLOSE an enclosing list — re-shaping block structure diff --git a/test/webview/cm-link-reference-risk.test.ts b/test/webview/cm-link-reference-risk.test.ts new file mode 100644 index 00000000..f1c056a0 --- /dev/null +++ b/test/webview/cm-link-reference-risk.test.ts @@ -0,0 +1,23 @@ +import { EditorState } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import { linkReferenceRisk } from "../../src/webview/cm/link-reference-risk.js"; + +describe("linkReferenceRisk", () => { + it("scans a long nonblank run without per-line random access", () => { + const state = EditorState.create({ doc: Array(2000).fill("ordinary text").join("\n") }); + const lineSpy = vi.spyOn(state.doc, "line"); + + expect(linkReferenceRisk(state, state.doc.length)).toBe(false); + expect(lineSpy.mock.calls.length).toBeLessThanOrEqual(2); + }); + + it("finds a reference start above the current line", () => { + const state = EditorState.create({ doc: "[a]:\ncontinuation\nmore text\nlast line" }); + expect(linkReferenceRisk(state, state.doc.length)).toBe(true); + }); + + it("stops at a blank line", () => { + const state = EditorState.create({ doc: "[a]:\ncontinuation\n\nmore text\nlast line" }); + expect(linkReferenceRisk(state, state.doc.length)).toBe(false); + }); +}); diff --git a/test/webview/cm-structural-guard-exhaustive.test.ts b/test/webview/cm-structural-guard-exhaustive.test.ts index f8c0c882..41e2b67d 100644 --- a/test/webview/cm-structural-guard-exhaustive.test.ts +++ b/test/webview/cm-structural-guard-exhaustive.test.ts @@ -113,7 +113,9 @@ describe("touchesStructuralReparse — bounded-exhaustive differential oracle", } }); } - console.log(`exhaustive oracle: checked=${checked} arms-silent=${silent}`); + console.log( + `exhaustive oracle: checked=${checked} arms-silent=${silent} residuals=${residual.length}` + ); expect(residual.slice(0, 5)).toEqual([]); // Non-vacuity: if the guard fired on EVERYTHING the comparison would be empty and a // broken bounded path would still be green. diff --git a/test/webview/decorations/cm-decoration-callout-marker-conceal.test.ts b/test/webview/decorations/cm-decoration-callout-marker-conceal.test.ts index 4ad070ad..62fb2f48 100644 --- a/test/webview/decorations/cm-decoration-callout-marker-conceal.test.ts +++ b/test/webview/decorations/cm-decoration-callout-marker-conceal.test.ts @@ -408,6 +408,20 @@ describe("calloutMarkerConcealField — bounded recompute ≡ full recompute", ( checkEquivalence(`intro\n\n${CALLOUT}`, [{ changes: { from: 0, insert: "```\n" } }]); }); + // A definition above a lazy continuation controls the distant callout's list context. + // This checks consumer equivalence; the shared oracle pins the ancestry change itself. + it("link-reference formation and dissolution above a distant callout stay equivalent", () => { + const doc = `- item\n\n [a]: /url\nlazy\n\n > [!NOTE]\n${" > q\n".repeat(12)}`; + for (const [before, after] of [ + [":", ";"], + [";", ":"], + ]) { + checkEquivalence(doc.replace("[a]:", `[a]${before}`), [ + { changes: { from: 13, to: 14, insert: after } }, + ]); + } + }); + it("multi-range (multi-cursor) transaction touching two callouts", () => { const doc = `${CALLOUT}\n\nmid\n\n> [!TIP]\n> t`; const tipMarker = doc.indexOf("[!TIP]"); diff --git a/test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts b/test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts index e084a5a5..18007ee4 100644 --- a/test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts +++ b/test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts @@ -14,6 +14,7 @@ import { fencedCodeCollapseFieldFullRecompute, } from "../../../src/webview/cm/fenced-code/fenced-code-collapse.js"; import { setFencedCollapseEffect } from "../../../src/webview/cm/fenced-code/fenced-code-collapse-state.js"; +import { settledState } from "../helpers/settled-state.js"; import { settledMount, settledView } from "../helpers/settled-view.js"; import { withUnstarvedFrontier } from "../helpers/unstarved-frontier.js"; @@ -121,6 +122,11 @@ function run(initial: string, edits: Edit[]): void { describe("fencedCodeCollapseField bounded ≡ full", () => { const F = fence(15); // closed, 15 body lines → collapsible const masked = `---\ndescription: |\n \`\`\`\n${Array.from({ length: 14 }, (_, i) => ` metadata ${i}`).join("\n")}\n \`\`\`\n---\n`; + it("never collapses the fence-shaped run inside leading frontmatter", () => { + const state = settledState(EditorState.create({ doc: masked, extensions: exts() })); + expect(state.field(fencedCodeCollapseField).blocks).toEqual([]); + expect(state.field(fencedCodeCollapseFieldFullRecompute).blocks).toEqual([]); + }); it("tracks frontmatter boundary removal, restoration, and movement", () => { const closing = masked.lastIndexOf("---\n"); run(masked, [ @@ -194,6 +200,23 @@ describe("fencedCodeCollapseField bounded ≡ full", () => { initial: `- item\n\n ${fence(12).split("\n").join("\n ")}\n\ntail`, edits: [{ changes: { from: 0, to: 2 } }], // delete "- " }, + // Pins topLevelBoundaryRisk's indent arm: a far paragraph's indent controls whether + // the indented fence continues the list, without changing a marker or blank boundary. + { + name: "GF indent: dedenting and reindenting a paragraph promotes and nests a far fence", + initial: `- item\n\n para\n\n ${F.split("\n").join("\n ")}`, + edits: [{ changes: { from: 8, to: 10 } }, { changes: { from: 8, insert: " " } }], + }, + // Link-reference formation ends the leaf before `lazy`, closing the list and + // promoting the distant indented fence without an indent/newline/blank delta. + { + name: "GF link reference: dissolving and forming a definition changes fence eligibility", + initial: `- item\n\n [a]: /url\nlazy\n\n ${F.split("\n").join("\n ")}`, + edits: [ + { changes: { from: 13, to: 14, insert: ";" } }, + { changes: { from: 13, to: 14, insert: ":" } }, + ], + }, // Pins that the fence arm of STRUCTURAL is load-bearing: deleting block A's closing // ``` leaves it unclosed, so the parser re-pairs with block B's opener — block B (a // DISTANT node outside the edit span) vanishes. Without the fence arm, the bounded @@ -381,6 +404,11 @@ describe("fencedCodeCollapseField bounded ≡ full", () => { }, ], }, + { + name: "fenced hot path: indenting a body line stays equivalent", + initial: F, + edits: [{ changes: { from: F.indexOf("code line 12"), insert: " " } }], + }, // The SHARED structural guard (../structural-guard.ts) gained an ATX, an underscore and a // Setext alternation and relaxed its indent bound, and now fires on all of these. THIS // field deliberately keeps its own narrower STRUCTURAL so that editing inside a fence — @@ -396,6 +424,8 @@ describe("fencedCodeCollapseField bounded ≡ full", () => { ...( [ ["a hash comment", "# comment"], + ["an indented body line", " code"], + ["a link-reference-shaped body line", "[a]: /url"], ["an equals line", "==="], ["an underscore rule", "___"], ["a four-space-indented hash", " # deep"], diff --git a/test/webview/helpers/table-shape-corpus.ts b/test/webview/helpers/table-shape-corpus.ts index 95b38071..d2ba8029 100644 --- a/test/webview/helpers/table-shape-corpus.ts +++ b/test/webview/helpers/table-shape-corpus.ts @@ -24,6 +24,16 @@ export const SHAPE_CORPUS: readonly string[] = [ // tables as a lazy continuation of a list item (the class the arm was added for) "- item one\n| a | b |\n|---|---|\n| c | d |\n\n- item two\n", "- x\n head | only\n ---|---\n body | row\n- y\n", + // Link-reference formation/dissolution ends a lazy list continuation far above the quotes. + // Both spellings matter: ALPHABET has `:` but not `;`. + `- item\n\n [a]: /url\nlazy\n\n${" > q\n".repeat(12)}`, + `- item\n\n [a]; /url\nlazy\n\n${" > q\n".repeat(12)}`, + // Continuation edits can form/dissolve multi-line labels, destinations and titles. + `- item\n\n [a\nb]: /url\nlazy\n\n${" > q\n".repeat(12)}`, + `- item\n\n [a]:\n /url\nlazy\n\n${" > q\n".repeat(12)}`, + `- item\n\n [a]: /url\n "title\nend"\nlazy\n\n${" > q\n".repeat(12)}`, + // Frontmatter is raw Markdown to Lezer: its fence-shaped run has real block identity. + "---\ndescription: |\n ```\n metadata\n ```\n---\n", // container shapes — indent, blockquote, ordered, nested " | a | b |\n |---|---|\n | c | d |\n", " | a | b |\n |---|---|\n",