Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions src/webview/cm/fenced-code/fenced-code-collapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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).
Expand All @@ -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.
Expand All @@ -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;
}
Expand Down
49 changes: 49 additions & 0 deletions src/webview/cm/link-reference-risk.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 18 additions & 3 deletions src/webview/cm/structural-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -227,6 +230,14 @@ export function tableRowShapeChanged(oldText: string, newText: string): boolean
* `<!DOCTYPE …>` 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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions test/webview/cm-link-reference-risk.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 3 additions & 1 deletion test/webview/cm-structural-guard-exhaustive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
Expand Down
30 changes: 30 additions & 0 deletions test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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, [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand All @@ -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"],
Expand Down
10 changes: 10 additions & 0 deletions test/webview/helpers/table-shape-corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading