From d99f2db8fb5e3c65046f41641918a5423b5041a5 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:20:13 +0900 Subject: [PATCH 01/14] test(cm): pin structural block hook failure and table widget identity (red) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert-check evidence for ARCH-07 PR3. Committed red on purpose against the pre-contract table and image block fields; the rows go green at the end of the Step 7 migration. `pnpm vitest run test/webview/cm-structural-blocks.test.ts -t "failure table|failure image|widget identity table"` on this commit (mount preconditions pass; each row fails on its behaviour assertion): - failure table: AssertionError: expected [Function] to not throw an error but 'Error: parseTable armed to throw' was thrown - failure image: AssertionError: expected [Function] to not throw an error but 'Error: commonMarkAltText armed to throw' was thrown - widget identity table: AssertionError: expected TableBlockWidget{ …(5) } to be TableBlockWidget{ …(5) } // Object.is equality --- test/webview/cm-structural-blocks.test.ts | 274 ++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 test/webview/cm-structural-blocks.test.ts diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts new file mode 100644 index 00000000..f74fa979 --- /dev/null +++ b/test/webview/cm-structural-blocks.test.ts @@ -0,0 +1,274 @@ +// @vitest-environment happy-dom +// +// The structural block contract (src/webview/cm/structural-blocks.ts) as seen by its two +// production consumers, table and image block widgets. Rows that are revert-check +// evidence are written against names that exist both before and after the contract +// landed (`tableBlockField`, `imageBlockField`, decorations read through the +// `EditorView.decorations` facet and filtered by widget class), so the same row can be +// run on either revision. +// +// Precondition asserts are separate `expect`s from the behaviour assert, so a harness +// failure is never read as "behaviour absent". +import { foldable } from "@codemirror/language"; +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView, type WidgetType } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { settledState } from "./helpers/settled-state.js"; +import { settledMount } from "./helpers/settled-view.js"; + +// One switch for both throwing seams. A mock only throws while `armed` is true, so the +// fields' `create` (which already calls both seams) never throws at mount. +const seam = vi.hoisted(() => ({ armed: false, armedCalls: 0 })); + +// The specifier the table block field imports `parseTable` from. +vi.mock("../../src/markdown/table/index.js", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + parseTable: (source: string, from: number, to: number) => { + if (seam.armed) { + seam.armedCalls++; + if (source.includes("BOOM")) { + throw new Error("parseTable armed to throw"); + } + } + return real.parseTable(source, from, to); + }, + }; +}); + +// The specifier the image block field imports `commonMarkAltText` from. +vi.mock("../../src/webview/cm/inline/inline-ir.js", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commonMarkAltText: (raw: string) => { + if (seam.armed) { + seam.armedCalls++; + if (raw.includes("BOOM")) { + throw new Error("commonMarkAltText armed to throw"); + } + } + return real.commonMarkAltText(raw); + }, + }; +}); + +afterEach(() => { + seam.armed = false; + seam.armedCalls = 0; + vi.restoreAllMocks(); +}); + +interface Slot { + from: number; + to: number; + widget: WidgetType; +} + +/** Every block widget of class `cls` the state's decoration facet carries, in order. */ +function widgetSlots(state: EditorState, cls: abstract new (...args: never[]) => WidgetType) { + const out: Slot[] = []; + for (const source of state.facet(EditorView.decorations)) { + if (typeof source === "function") { + continue; + } + const iter = source.iter(); + while (iter.value !== null) { + const widget = iter.value.spec.widget as WidgetType | undefined; + if (widget instanceof cls) { + out.push({ from: iter.from, to: iter.to, widget }); + } + iter.next(); + } + } + return out.sort((a, b) => a.from - b.from); +} + +const HOOK_LOG = "[quoll] structural block hook threw"; +function hookLogCount(spy: { mock: { calls: unknown[][] } }): number { + return spy.mock.calls.filter((c) => typeof c[0] === "string" && c[0].startsWith(HOOK_LOG)).length; +} + +function mountView(state: EditorState): EditorView { + const parent = document.createElement("div"); + document.body.appendChild(parent); + return settledMount({ state, parent }, 10_000); +} + +// A marker-line list table (its ListItem fold is the fold gate's subject), a second table +// that also fails once armed, and a healthy sibling. +const TABLE_DOC = [ + "Intro paragraph.", + "", + "- | BOOM | b |", + " | - | - |", + " | c | d |", + "", + "Middle paragraph.", + "", + "| BOOM2 | y |", + "| - | - |", + "| 1 | 2 |", + "", + "| ok | z |", + "| - | - |", + "| 3 | 4 |", + "", + "Tail paragraph.", + "", +].join("\n"); + +const IMAGE_DOC = [ + "Intro paragraph.", + "", + "![BOOM alt](https://example.com/a.png)", + "", + "Middle paragraph.", + "", + "![ok alt](https://example.com/b.png)", + "", + "![BOOM2 alt](https://example.com/c.png)", + "", + "Tail paragraph.", + "", +].join("\n"); + +describe("structural block hook failure leaves the block raw", () => { + it("failure table: a throwing parseTable keeps the edit, the sibling widget and the list fold", async () => { + vi.resetModules(); + const { tableBlockField, TableBlockWidget } = await import( + "../../src/webview/cm/table/index.js" + ); + const { quollMarkdownLanguage } = await import("../../src/webview/cm/markdown.js"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const extensions: Extension[] = [quollMarkdownLanguage(), tableBlockField]; + const view = mountView(EditorState.create({ doc: TABLE_DOC, extensions })); + try { + const doc0 = view.state.doc; + const targetLine = doc0.line(3); // "- | BOOM | b |" + const siblingFrom = TABLE_DOC.indexOf("| ok |"); + const second = TABLE_DOC.indexOf("| BOOM2 |"); + // Precondition: the field built all three tables, and the widget over the marker + // line suppresses that ListItem's fold. + expect(widgetSlots(view.state, TableBlockWidget).map((s) => s.from)).toEqual([ + targetLine.from, + second, + siblingFrom, + ]); + expect(foldable(view.state, targetLine.from, targetLine.to)).toBeNull(); + + seam.armed = true; + const insideTarget = TABLE_DOC.indexOf("BOOM") + 4; + expect(() => view.dispatch({ changes: { from: insideTarget, insert: "x" } })).not.toThrow(); + expect(seam.armedCalls).toBeGreaterThan(0); // the armed seam really ran + const tail = view.state.doc.toString().indexOf("Tail paragraph."); + expect(() => view.dispatch({ changes: { from: tail, insert: "y" } })).not.toThrow(); + + const text = view.state.doc.toString(); + expect(text).toContain("- | BOOMx | b |"); + expect(text).toContain("yTail paragraph."); + const slots = widgetSlots(view.state, TableBlockWidget).map((s) => s.from); + expect(slots).toContain(text.indexOf("| ok |")); + expect(slots).not.toContain(view.state.doc.line(3).from); + // No widget over the marker line → its ListItem fold is offered again. + const line3 = view.state.doc.line(3); + expect(foldable(view.state, line3.from, line3.to)).not.toBeNull(); + expect(hookLogCount(errorSpy)).toBe(1); + + // A second table failing on the same arm logs nothing more. + const insideSecond = text.indexOf("BOOM2") + 5; + expect(() => view.dispatch({ changes: { from: insideSecond, insert: "z" } })).not.toThrow(); + expect(view.state.doc.toString()).toContain("| BOOM2z | y |"); + expect(hookLogCount(errorSpy)).toBe(1); + } finally { + view.destroy(); + } + }); + + it("failure image: a throwing alt helper keeps the edit and the sibling widget", async () => { + vi.resetModules(); + const { imageBlockField, ImageBlockWidget } = await import( + "../../src/webview/cm/image/index.js" + ); + const { quollMarkdownLanguage } = await import("../../src/webview/cm/markdown.js"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const extensions: Extension[] = [quollMarkdownLanguage(), imageBlockField]; + const view = mountView(EditorState.create({ doc: IMAGE_DOC, extensions })); + try { + const target = IMAGE_DOC.indexOf("![BOOM alt]"); + const sibling = IMAGE_DOC.indexOf("![ok alt]"); + const second = IMAGE_DOC.indexOf("![BOOM2 alt]"); + // Precondition: all three standalone images have a widget. + expect(widgetSlots(view.state, ImageBlockWidget).map((s) => s.from)).toEqual([ + target, + sibling, + second, + ]); + + seam.armed = true; + expect(() => view.dispatch({ changes: { from: target + 6, insert: "x" } })).not.toThrow(); + expect(seam.armedCalls).toBeGreaterThan(0); + const tail = view.state.doc.toString().indexOf("Tail paragraph."); + expect(() => view.dispatch({ changes: { from: tail, insert: "y" } })).not.toThrow(); + + const text = view.state.doc.toString(); + expect(text).toContain("![BOOMx alt]"); + expect(text).toContain("yTail paragraph."); + const slots = widgetSlots(view.state, ImageBlockWidget).map((s) => s.from); + expect(slots).toContain(text.indexOf("![ok alt]")); + expect(slots).not.toContain(text.indexOf("![BOOMx alt]")); + expect(hookLogCount(errorSpy)).toBe(1); + + const insideSecond = text.indexOf("BOOM2") + 5; + expect(() => view.dispatch({ changes: { from: insideSecond, insert: "z" } })).not.toThrow(); + expect(view.state.doc.toString()).toContain("![BOOM2z alt]"); + expect(hookLogCount(errorSpy)).toBe(1); + } finally { + view.destroy(); + } + }); +}); + +describe("structural block widget identity", () => { + it("widget identity table: an edit after the last table, one line clear of it, keeps its widget object", async () => { + const { tableBlockField, TableBlockWidget } = await import( + "../../src/webview/cm/table/index.js" + ); + const { quollMarkdownLanguage } = await import("../../src/webview/cm/markdown.js"); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + // The caret sits at offset 0, on "Top." — clear of every table line. + const doc = [ + "Top.", + "", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "Between.", + "", + "| c | d |", + "| - | - |", + "| 3 | 4 |", + "", + "Edit here.", + "", + ].join("\n"); + const st0 = settledState( + EditorState.create({ doc, extensions: [quollMarkdownLanguage(), tableBlockField] }) + ); + const before = widgetSlots(st0, TableBlockWidget); + // Precondition: both tables have a widget. + expect(before.map((s) => s.from)).toEqual([doc.indexOf("| a |"), doc.indexOf("| c |")]); + + const at = doc.indexOf("Edit here.") + 4; + const st1 = st0.update({ changes: { from: at, insert: "x" } }).state; + expect(st1.doc.toString()).toContain("Editx here."); + const after = widgetSlots(st1, TableBlockWidget); + expect(after.map((s) => s.from)).toEqual(before.map((s) => s.from)); + expect(after[1].widget).toBe(before[1].widget); + }); +}); From 064767ed83458eaa5a038b6c870524651c57c2d7 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:21:01 +0900 Subject: [PATCH 02/14] test(cm): reproduce the snapshot/context frontier gap for block widgets (red) Row "frontier advance": a doc whose init snapshot is partial, the parse context advanced to the end by ensureSyntaxTree without a republish, then a single prose keystroke far above a table and an image. Every precondition passes on this commit, including the two that put HEAD on its bounded path (touchesStructuralReparse(tr) === false, syntaxTreeAvailable(tr.state, doc.length) === true), and the behaviour assertion fails: AssertionError: expected [] to deeply equal [ 20070 ] (the image widget beyond the old snapshot is never published) Verdict: the gap is real, so the frontier-advance arm stays in scope. The table half passes here only because this row mounts tableBlockField without tableSkeletonField, and that fallback full-walks on every change; the production skeleton field takes the bounded path like the image one. --- test/webview/cm-structural-blocks.test.ts | 65 ++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index f74fa979..319e42b0 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -9,10 +9,11 @@ // // Precondition asserts are separate `expect`s from the behaviour assert, so a harness // failure is never read as "behaviour absent". -import { foldable } from "@codemirror/language"; +import { ensureSyntaxTree, foldable, syntaxTree, syntaxTreeAvailable } from "@codemirror/language"; import { EditorState, type Extension } from "@codemirror/state"; import { EditorView, type WidgetType } from "@codemirror/view"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { touchesStructuralReparse } from "../../src/webview/cm/structural-guard.js"; import { settledState } from "./helpers/settled-state.js"; import { settledMount } from "./helpers/settled-view.js"; @@ -272,3 +273,65 @@ describe("structural block widget identity", () => { expect(after[1].widget).toBe(before[1].widget); }); }); + +/** Prose long enough that `EditorState.create` publishes a PARTIAL snapshot (its init + * parse stops near a 3000-char viewport), followed by `tail`. No fixed offset is relied + * on: the partial snapshot is asserted where it matters. */ +function beyondInitFrontier(tail: string[]): string { + const prose: string[] = []; + for (let i = 0; prose.join("\n").length < 20_000; i++) { + prose.push(`Paragraph ${i} of plain prose that the initial parse does not reach.`, ""); + } + return [...prose, ...tail].join("\n"); +} + +describe("structural blocks beyond the initial parse frontier", () => { + it("frontier advance: a distant prose edit after an ensureSyntaxTree context advance publishes the blocks", async () => { + const { tableBlockField, TableBlockWidget } = await import( + "../../src/webview/cm/table/index.js" + ); + const { imageBlockField, ImageBlockWidget } = await import( + "../../src/webview/cm/image/index.js" + ); + const { quollMarkdownLanguage } = await import("../../src/webview/cm/markdown.js"); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + const doc = beyondInitFrontier([ + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "![alt](https://example.com/a.png)", + "", + "End.", + "", + ]); + const state = EditorState.create({ + doc, + extensions: [quollMarkdownLanguage(), tableBlockField, imageBlockField], + }); + // Precondition: the init snapshot is partial and stops before both blocks. + expect(syntaxTree(state).length).toBeLessThan(doc.indexOf("| a |")); + expect(widgetSlots(state, TableBlockWidget)).toEqual([]); + expect(widgetSlots(state, ImageBlockWidget)).toEqual([]); + + // Advance the parse CONTEXT to the end without republishing the snapshot. + expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); + expect(syntaxTree(state).length).toBeLessThan(state.doc.length); + + // A single letter inside the first plain paragraph line, far above both blocks. + const tr = state.update({ changes: { from: 4, insert: "x" } }); + // Preconditions: the post-edit snapshot is complete, and HEAD's admission test sends + // this transaction down the bounded path. + expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); + expect(touchesStructuralReparse(tr)).toBe(false); + expect(syntaxTreeAvailable(tr.state, tr.state.doc.length)).toBe(true); + + const text = tr.state.doc.toString(); + expect(widgetSlots(tr.state, TableBlockWidget).map((s) => s.from)).toEqual([ + text.indexOf("| a |"), + ]); + expect(widgetSlots(tr.state, ImageBlockWidget).map((s) => s.from)).toEqual([ + text.indexOf("![alt]"), + ]); + }); +}); From b3505f596a119c43b9f2e52d69d1150ee3598861 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:27:44 +0900 Subject: [PATCH 03/14] feat(cm): add the structural block contract module (types, walk, containment, ownership) structuralBlockWidgets(spec) builds one StateField whose value is { records, decorations }. This commit lands Steps 4a-4c of the ARCH-07 PR3 plan: the full walk, recordAt, hook containment (build in the walk, widget in makeRecord, one latched console.error per arm that never reads err), and range ownership (frontmatter filter, from>=to / containment drop reported as errKind "invalid-range", mapped fmEnd comparison with the 0 sentinel). Toy rows: "toy full", "toy build throws", "toy widget throws", "toy ownership". Revert-checked: replacing either catch with a rethrow turns "toy build throws" / "toy widget throws" red ("Error: toy build" / "Error: toy widget"). --- src/webview/cm/structural-blocks.ts | 313 ++++++++++++++++++++++ test/webview/cm-structural-blocks.test.ts | 245 ++++++++++++++++- 2 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 src/webview/cm/structural-blocks.ts diff --git a/src/webview/cm/structural-blocks.ts b/src/webview/cm/structural-blocks.ts new file mode 100644 index 00000000..bf626cb2 --- /dev/null +++ b/src/webview/cm/structural-blocks.ts @@ -0,0 +1,313 @@ +// The one contract behind Quoll's structural block widgets (table, image). A consumer +// supplies only a spec — which Lezer node to look at, how to build its block, and the +// widget for that block — and this module owns the rest: range ownership, the +// changed-range-bounded update, parse determinacy, hook failure, and reveal. +// +// Block widgets MUST come from a StateField — CodeMirror throws on a ViewPlugin +// `block: true` Decoration.replace (see CLAUDE.md block-widget invariant). Widgets are +// display-only: decorations only, never a document change → byte-identical round-trip. +// +// The field value is `{ records, decorations }`. `records` is the canonical, +// selection-independent state: one record per block that HAS a widget, sorted by +// `block.from`, each carrying its own decoration (precedent: collapse/line-collapse-field.ts). +// `decorations` is the selection-filtered view of it. Updates derive exclusively from the +// previous field value; the field closure holds only the diagnostic latch. EditorState is +// persistent — two transactions branching from one state never share mutable update data. +// +// 1. Ownership. A block whose node starts before `leadingFrontmatterEnd(state)` is not +// emitted (frontmatter owns [0, fmEnd]). A built block with `from >= to`, or one that +// does not contain its node, is dropped (the `Decoration.replace({ block: true })` +// precondition and the containment precondition live at this one call site). fmEnd +// "moved" → full rebuild; see `frontmatterMoved`. +// 2. Change range / tree identity / determinacy. On a doc change, +// `requiresFullBoundedRebuild` (../structural-guard.ts — the single home of every +// full-rebuild reason) → full; otherwise every prev record whose block is untouched and +// outside the span (changed ranges ±1 line) is reused — the SAME record when unmoved, +// else rebased through `makeRecord` — and only the span is re-walked, each matched node +// processed at most once per rebuild (`visited`). Reused records and walked nodes are +// disjoint by construction: the reuse guard drops every record whose mapped range meets +// the span, every walked node meets the span, and a block contains its node. No doc +// change but a new tree (the parser reached further) → full. Else prev. +// 3. Failure → raw. `spec.build` is called only in the walk and `spec.widget` only in +// `makeRecord`, each inside one try/catch. A throw → no record: the raw source stays +// editable, sibling blocks, the transaction and edit-sync proceed. A dropped block is +// retried only when a changed span touches it or a full rebuild happens. The report is +// latched once per `${name}:${arm}` and reads NO property of `err` (a throwing getter +// inside the catch would escape `StateField.update` — the failure this arm contains; +// same rule as widget-base.ts `reportOnce`). DOM lifecycle failures stay owned by +// widget-base.ts. +// 4. Reveal. `decorations` = the record decorations whose block does not line-overlap any +// selection range. Recomputed only when the records or the selection line spans change; +// when neither did, the field returns `prev` itself. + +import { syntaxTree } from "@codemirror/language"; +import { type EditorState, StateField, type Transaction } from "@codemirror/state"; +import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { + extractRanges, + type Interval, + intersects, + lineExpandWithNeighbours, + lineRangeOverlapsSelection, + mergeIntervals, + selectionLineSpansEqual, +} from "./bounded-recompute.js"; +import { quollBlockReplaceZones } from "./decorations/orchestrator.js"; +import { leadingFrontmatterEnd } from "./frontmatter/detect.js"; +import { requiresFullBoundedRebuild } from "./structural-guard.js"; +import type { QuollWidget } from "./widget-base.js"; + +/** A line-aligned document range one consumer owns as a block widget. Any other + * position a consumer stores is an offset from `from`, so the contract can + * rebase a reused block by a uniform shift ({ ...block, from, to }). */ +export interface StructuralBlock { + readonly from: number; + readonly to: number; +} + +export interface StructuralBlockSpec { + /** For the failure log. */ + readonly name: string; + /** Lezer node name that may yield a block; descendants of a match are not visited. */ + readonly node: string; + /** The block for one matched node, or null when ineligible. It MUST contain the node + * (`from <= node.from && node.to <= to`): liveness is judged on the block range, so a block + * narrower than its node would reuse a record an in-node edit invalidated. May throw: that block stays raw. */ + build(state: EditorState, node: SyntaxNodeRef): B | null; + /** Widget for a block object. May throw: that block stays raw. */ + widget(block: B): QuollWidget; +} + +export interface StructuralBlockRecord { + readonly block: B; + readonly deco: Decoration; +} + +export interface StructuralBlockState { + /** Exactly the blocks that HAVE a widget (build and widget both succeeded), sorted by `block.from`. */ + readonly records: readonly StructuralBlockRecord[]; + readonly decorations: DecorationSet; +} + +/** The record whose block starts exactly at `blockFrom`, or undefined. A binary search: + * `records` is sorted by `block.from` (Contract 2). */ +export function recordAt( + records: readonly StructuralBlockRecord[], + blockFrom: number +): StructuralBlockRecord | undefined { + let lo = 0; + let hi = records.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const from = records[mid].block.from; + if (from === blockFrom) { + return records[mid]; + } + if (from < blockFrom) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return undefined; +} + +/** Did the leading frontmatter's end move? `0` is the no-frontmatter sentinel, not a + * position, so it is never mapped — mapping it would send every offset-0 insertion in a + * frontmatter-less doc to a full rebuild. A rigid shift inside the frontmatter body maps + * onto the new end and stays bounded (blocks rebase uniformly); a closer that appears, + * vanishes or moves edits a `---` line, which the structural guard sends to full anyway. */ +function frontmatterMoved(tr: Transaction, oldEnd: number, newEnd: number): boolean { + return oldEnd === 0 || newEnd === 0 ? oldEnd !== newEnd : tr.changes.mapPos(oldEnd, 1) !== newEnd; +} + +/** Records in `from` order with any block that overlaps an earlier one dropped. Reused + * and walked records are disjoint by construction (header, Contract 2), so the drop is a + * backstop for a spec whose blocks overlap each other: overlapping block replaces would + * otherwise reach `Decoration.set`. */ +function sortedDisjoint( + records: StructuralBlockRecord[] +): StructuralBlockRecord[] { + records.sort((a, b) => a.block.from - b.block.from); + const out: StructuralBlockRecord[] = []; + for (const r of records) { + const last = out[out.length - 1]; + if (last === undefined || r.block.from > last.block.to) { + out.push(r); + } + } + return out; +} + +function assemble( + records: readonly StructuralBlockRecord[], + state: EditorState +): DecorationSet { + const ranges = []; + for (const r of records) { + if (!lineRangeOverlapsSelection(state.selection, r.block.from, r.block.to)) { + ranges.push(r.deco.range(r.block.from, r.block.to)); + } + } + // Records are sorted and disjoint, so the ranges are too: no sort requested. + return Decoration.set(ranges); +} + +export function structuralBlockWidgets( + spec: StructuralBlockSpec +): StateField> { + const reported = new Set(); + /** `errKind` is `typeof err` (or "invalid-range") — computing it reads nothing off `err`. */ + const report = (arm: "build" | "widget", errKind: string): void => { + const key = `${spec.name}:${arm}`; + if (reported.has(key)) { + return; + } + reported.add(key); + console.error("[quoll] structural block hook threw; leaving it as raw source", { + name: spec.name, + arm, + errKind, + }); + }; + + /** The one call site of `spec.widget`; also mints the block decoration. */ + const makeRecord = (block: B): StructuralBlockRecord | null => { + let widget: QuollWidget; + try { + widget = spec.widget(block); + } catch (err) { + report("widget", typeof err); + return null; + } + return { block, deco: Decoration.replace({ widget, block: true }) }; + }; + + /** Walk [from, to] and append a record per eligible matched node not yet in `visited`. + * The one call site of `spec.build`. */ + const walk = ( + state: EditorState, + from: number, + to: number, + fmEnd: number, + visited: Set, + out: StructuralBlockRecord[] + ): void => { + syntaxTree(state).iterate({ + from, + to, + enter: (node) => { + if (node.name !== spec.node) { + return undefined; + } + if (visited.has(node.from) || node.from < fmEnd) { + return false; + } + visited.add(node.from); + let block: B | null; + try { + block = spec.build(state, node); + } catch (err) { + report("build", typeof err); + return false; + } + if (block === null) { + return false; + } + if (block.from >= block.to || block.from > node.from || node.to > block.to) { + report("build", "invalid-range"); + return false; + } + const record = makeRecord(block); + if (record !== null) { + out.push(record); + } + return false; + }, + }); + }; + + const full = (state: EditorState): StructuralBlockRecord[] => { + const out: StructuralBlockRecord[] = []; + walk(state, 0, state.doc.length, leadingFrontmatterEnd(state), new Set(), out); + return sortedDisjoint(out); + }; + + const bounded = ( + prev: readonly StructuralBlockRecord[], + tr: Transaction, + fmEnd: number + ): readonly StructuralBlockRecord[] => { + const raw: Interval[] = []; + tr.changes.iterChangedRanges((_fa, _ta, fromB, toB) => { + raw.push(lineExpandWithNeighbours(tr.state, fromB, toB)); + }); + const span = mergeIntervals(raw); + let changed = false; + const next: StructuralBlockRecord[] = []; + for (const r of prev) { + const { from, to } = r.block; + const nf = tr.changes.mapPos(from, 1); + const nt = tr.changes.mapPos(to, -1); + if (tr.changes.touchesRange(from, to) !== false || intersects(span, nf, nt)) { + changed = true; + } else if (nf === from && nt === to) { + next.push(r); + } else { + changed = true; + const rebased = makeRecord({ ...r.block, from: nf, to: nt }); + if (rebased !== null) { + next.push(rebased); + } + } + } + const reused = next.length; + const visited = new Set(); + for (const iv of span) { + walk(tr.state, iv.from, iv.to, fmEnd, visited, next); + } + if (!changed && next.length === reused) { + return prev; + } + return sortedDisjoint(next); + }; + + const nextRecords = ( + prev: readonly StructuralBlockRecord[], + tr: Transaction + ): readonly StructuralBlockRecord[] => { + if (tr.docChanged) { + const newEnd = leadingFrontmatterEnd(tr.state); + if ( + frontmatterMoved(tr, leadingFrontmatterEnd(tr.startState), newEnd) || + requiresFullBoundedRebuild(tr) + ) { + return full(tr.state); + } + return bounded(prev, tr, newEnd); + } + if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) { + return full(tr.state); // the parser reached further: a block may have appeared + } + return prev; + }; + + return StateField.define>({ + create: (state) => { + const records = full(state); + return { records, decorations: assemble(records, state) }; + }, + update: (prev, tr) => { + const records = nextRecords(prev.records, tr); + if (records === prev.records && selectionLineSpansEqual(tr.startState, tr.state)) { + return prev; + } + return { records, decorations: assemble(records, tr.state) }; + }, + provide: (f) => [ + EditorView.decorations.from(f, (s) => s.decorations), + quollBlockReplaceZones.from(f, (s) => extractRanges(s.decorations)), + ], + }); +} diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 319e42b0..5171d206 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -10,10 +10,22 @@ // Precondition asserts are separate `expect`s from the behaviour assert, so a harness // failure is never read as "behaviour absent". import { ensureSyntaxTree, foldable, syntaxTree, syntaxTreeAvailable } from "@codemirror/language"; -import { EditorState, type Extension } from "@codemirror/state"; +import { EditorState, type Extension, type StateField } from "@codemirror/state"; import { EditorView, type WidgetType } from "@codemirror/view"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { touchesStructuralReparse } from "../../src/webview/cm/structural-guard.js"; +import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js"; +import { + recordAt, + type StructuralBlock, + type StructuralBlockSpec, + type StructuralBlockState, + structuralBlockWidgets, +} from "../../src/webview/cm/structural-blocks.js"; +import { + requiresFullBoundedRebuild, + touchesStructuralReparse, +} from "../../src/webview/cm/structural-guard.js"; +import { QuollWidget } from "../../src/webview/cm/widget-base.js"; import { settledState } from "./helpers/settled-state.js"; import { settledMount } from "./helpers/settled-view.js"; @@ -335,3 +347,232 @@ describe("structural blocks beyond the initial parse frontier", () => { ]); }); }); + +// --------------------------------------------------------------------------------------- +// Toy spec: the contract arms exercised directly through `structuralBlockWidgets`. +// A toy block is a fenced code block snapped to whole lines. + +interface ToyBlock extends StructuralBlock { + readonly text: string; +} + +class ToyWidget extends QuollWidget { + readonly widgetName = "ToyBlockWidget"; + constructor( + readonly at: number, + readonly text: string + ) { + super(); + } + protected render(): HTMLElement { + const el = document.createElement("div"); + el.textContent = this.text; + return el; + } + protected sameAs(other: QuollWidget): boolean { + return other instanceof ToyWidget && other.at === this.at && other.text === this.text; + } +} + +interface ToyOptions { + /** Runs after the default block is computed; may throw, transform or veto it. */ + build?: (block: ToyBlock) => ToyBlock | null; + /** Runs before the widget is made; may throw. */ + widget?: (block: ToyBlock) => void; +} + +interface Toy { + field: StateField>; + /** `block.from` of every hook call, in call order. */ + calls: { build: number[]; widget: number[] }; +} + +function toy(opts: ToyOptions = {}): Toy { + const calls = { build: [] as number[], widget: [] as number[] }; + const spec: StructuralBlockSpec = { + name: "toy", + node: "FencedCode", + build(state, node) { + const from = state.doc.lineAt(node.from).from; + const to = state.doc.lineAt(node.to).to; + calls.build.push(from); + const block: ToyBlock = { from, to, text: state.sliceDoc(from, to) }; + return opts.build ? opts.build(block) : block; + }, + widget(block) { + calls.widget.push(block.from); + opts.widget?.(block); + return new ToyWidget(block.from, block.text); + }, + }; + return { field: structuralBlockWidgets(spec), calls }; +} + +function toyState(doc: string, t: Toy, extra: Extension[] = []): EditorState { + const state = settledState( + EditorState.create({ doc, extensions: [quollMarkdownLanguage(), t.field, ...extra] }) + ); + t.calls.build.length = 0; + t.calls.widget.length = 0; + return state; +} + +function ranges(state: EditorState, t: Toy): Array<[number, number]> { + return state.field(t.field).records.map((r) => [r.block.from, r.block.to]); +} + +/** The FencedCode node set of a fully-parsed state, line-snapped, in order. */ +function fencedRanges(state: EditorState): Array<[number, number]> { + const out: Array<[number, number]> = []; + syntaxTree(state).iterate({ + enter: (node) => { + if (node.name !== "FencedCode") { + return undefined; + } + out.push([state.doc.lineAt(node.from).from, state.doc.lineAt(node.to).to]); + return false; + }, + }); + return out; +} + +function fence(body: string[]): string[] { + return ["```", ...body, "```"]; +} + +const TOY_DOC = [ + "Intro paragraph.", + "", + ...fence(["alpha"]), + "", + "Middle paragraph.", + "", + ...fence(["beta BOOM"]), + "", + ...fence(["gamma"]), + "", + "Tail paragraph.", + "", + "Last line.", + "", +].join("\n"); + +describe("structural block contract (toy spec)", () => { + it("toy full: records are the spec's node set in order, and recordAt finds them", () => { + const t = toy(); + const state = toyState(TOY_DOC, t); + const expected = fencedRanges(state); + expect(expected).toHaveLength(3); // precondition + expect(ranges(state, t)).toEqual(expected); + const records = state.field(t.field).records; + for (const r of records) { + expect(recordAt(records, r.block.from)).toBe(r); + } + expect(recordAt(records, 0)).toBeUndefined(); + expect(recordAt(records, records[0].block.from + 1)).toBeUndefined(); + expect(recordAt(records, records[2].block.to + 1)).toBeUndefined(); + expect(recordAt([], 0)).toBeUndefined(); + expect(widgetSlots(state, ToyWidget).map((s) => s.from)).toEqual(expected.map((e) => e[0])); + }); + + it("toy build throws: that block stays raw, its siblings render, one log per arm", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const t = toy({ + build: (b) => { + if (b.text.includes("BOOM")) { + throw new Error("toy build"); + } + return b; + }, + }); + const state = toyState(TOY_DOC, t); + const all = fencedRanges(state); + const boom = all[1]; + expect(ranges(state, t)).toEqual([all[0], all[2]]); + expect(widgetSlots(state, ToyWidget).map((s) => s.from)).toEqual([all[0][0], all[2][0]]); + // A second failing build (full rebuild after a structural edit) logs nothing more. + const next = state.update({ changes: { from: 0, insert: "\n" } }).state; + expect(ranges(next, t).map((r) => r[0])).not.toContain(boom[0] + 1); + expect(hookLogCount(spy)).toBe(1); + expect(spy.mock.calls.find((c) => String(c[0]).startsWith(HOOK_LOG))?.[1]).toEqual({ + name: "toy", + arm: "build", + errKind: "object", + }); + }); + + it("toy widget throws: that block stays raw, its siblings render, one log per arm", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const t = toy({ + widget: (b) => { + if (b.text.includes("BOOM")) { + throw new Error("toy widget"); + } + }, + }); + const state = toyState(TOY_DOC, t); + const all = fencedRanges(state); + expect(ranges(state, t)).toEqual([all[0], all[2]]); + expect(widgetSlots(state, ToyWidget).map((s) => s.from)).toEqual([all[0][0], all[2][0]]); + state.update({ changes: { from: 0, insert: "\n" } }); + expect(hookLogCount(spy)).toBe(1); + expect(spy.mock.calls.find((c) => String(c[0]).startsWith(HOOK_LOG))?.[1]).toEqual({ + name: "toy", + arm: "widget", + errKind: "object", + }); + }); + + it("toy ownership: frontmatter owns its blocks, invalid ranges are dropped, a body shift stays bounded", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const t = toy({ + build: (b) => { + if (b.text.includes("NARROW")) { + return { ...b, from: b.from + 4 }; // does not contain its node + } + if (b.text.includes("EMPTY")) { + return { ...b, to: b.from }; // from >= to + } + return b; + }, + }); + const doc = [ + "---", + "title: x", + "note: y", + ...fence(["inside"]), + "---", + "", + ...fence(["NARROW"]), + "", + ...fence(["EMPTY"]), + "", + ...fence(["after"]), + "", + ].join("\n"); + const state = toyState(doc, t); + const after = doc.indexOf("```\nafter"); + expect(ranges(state, t).map((r) => r[0])).toEqual([after]); + expect(hookLogCount(spy)).toBe(1); + expect(spy.mock.calls.find((c) => String(c[0]).startsWith(HOOK_LOG))?.[1]).toEqual({ + name: "toy", + arm: "build", + errKind: "invalid-range", + }); + + // A rigid shift inside the frontmatter body: bounded, the block is rebased, not rebuilt. + const shiftTr = state.update({ changes: { from: doc.indexOf("x\n"), insert: "z" } }); + expect(requiresFullBoundedRebuild(shiftTr)).toBe(false); // precondition + expect(ranges(shiftTr.state, t).map((r) => r[0])).toEqual([after + 1]); + expect(t.calls.build).not.toContain(after + 1); + + // Deleting the closer dissolves the frontmatter: the inner block appears. + const closer = doc.indexOf("---\n\n"); + const open = state.update({ changes: { from: closer, to: closer + 4 } }).state; + const inner = open.doc.toString().indexOf("```\ninside"); + expect(ranges(open, t).map((r) => r[0])).toContain(inner); + // Re-adding it takes the block away again. + const closed = open.update({ changes: { from: closer, insert: "---\n" } }).state; + expect(ranges(closed, t).map((r) => r[0])).toEqual([after]); + }); +}); From bb9ddb67bf39de44d2f73ee33ec0dd9fa4dc245e Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:30:06 +0900 Subject: [PATCH 04/14] test(cm): pin the structural block contract's bounded update arms Steps 4d-4i of the ARCH-07 PR3 plan. The bounded update, rebase through makeRecord, the visited dedupe and the tree-identity arm landed with the module in the previous commit; this commit adds their toy rows: "toy bounded update", "toy rebase", "toy widget throws on rebase", "toy dedupe", "toy tree identity", "toy branching", "toy seeded oracle". Revert-checked: dropping the visited check turns "toy dedupe" red, and returning prev on a republished tree turns "toy tree identity" red. --- test/webview/cm-structural-blocks.test.ts | 165 ++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 5171d206..d01df90c 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -576,3 +576,168 @@ describe("structural block contract (toy spec)", () => { expect(ranges(closed, t).map((r) => r[0])).toEqual([after]); }); }); + +describe("structural block contract updates (toy spec)", () => { + function fromOf(state: EditorState, needle: string): number { + const i = state.doc.toString().indexOf(needle); + expect(i).toBeGreaterThanOrEqual(0); // precondition: the fixture carries the needle + return state.doc.lineAt(i).from; + } + + it("toy bounded update: an edit clear of a block reuses its record; an edit inside rebuilds only it", () => { + const t = toy(); + const state = toyState(TOY_DOC, t); + const before = state.field(t.field).records; + expect(before).toHaveLength(3); // precondition + + const clearTr = state.update({ + changes: { from: state.doc.toString().indexOf("Tail paragraph.") + 4, insert: "x" }, + }); + expect(requiresFullBoundedRebuild(clearTr)).toBe(false); // precondition: bounded path + const clear = clearTr.state.field(t.field).records; + clear.forEach((r, i) => { + expect(r).toBe(before[i]); + }); + expect(t.calls.build).toEqual([]); + + // Inside the LAST block, so no other block moves (a moved block is rebased: "toy rebase"). + const gamma = before[2].block.from; + const insideTr = state.update({ + changes: { from: state.doc.toString().indexOf("gamma") + 2, insert: "x" }, + }); + expect(requiresFullBoundedRebuild(insideTr)).toBe(false); // precondition: bounded path + const inside = insideTr.state.field(t.field).records; + expect(t.calls.build).toEqual([gamma]); + expect(inside[2]).not.toBe(before[2]); + expect(inside[2].block.text).toContain("gaxmma"); + expect(inside[0]).toBe(before[0]); + expect(inside[1]).toBe(before[1]); + }); + + it("toy rebase: an insertion before a block mints a shifted record without rebuilding it", () => { + const t = toy(); + const state = toyState(TOY_DOC, t); + const before = state.field(t.field).records; + const tr = state.update({ changes: { from: 4, insert: "x" } }); + expect(requiresFullBoundedRebuild(tr)).toBe(false); // precondition: bounded path + const after = tr.state.field(t.field).records; + expect(after.map((r) => [r.block.from, r.block.to])).toEqual( + before.map((r) => [r.block.from + 1, r.block.to + 1]) + ); + after.forEach((r, i) => { + expect(r).not.toBe(before[i]); + }); + expect(t.calls.build).toEqual([]); + expect(t.calls.widget).toEqual(before.map((r) => r.block.from + 1)); + expect(widgetSlots(tr.state, ToyWidget).map((s) => (s.widget as ToyWidget).at)).toEqual( + before.map((r) => r.block.from + 1) + ); + }); + + it("toy widget throws on rebase: the dispatch succeeds and the rebased block is absent", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const gamma = TOY_DOC.indexOf("```\ngamma"); + const t = toy({ + widget: (b) => { + if (b.from === gamma + 1) { + throw new Error("toy widget on rebase"); + } + }, + }); + const state = toyState(TOY_DOC, t); + expect(ranges(state, t).map((r) => r[0])).toContain(gamma); // precondition: first build ok + const tr = state.update({ changes: { from: 4, insert: "x" } }); + expect(requiresFullBoundedRebuild(tr)).toBe(false); // precondition: bounded path + expect(t.calls.build).toEqual([]); + expect(t.calls.widget).toContain(gamma + 1); + const after = ranges(tr.state, t).map((r) => r[0]); + expect(after).toHaveLength(2); + expect(after).not.toContain(gamma + 1); + expect(hookLogCount(spy)).toBe(1); + }); + + it("toy dedupe: two distant edits inside one long block call each hook once for it", () => { + const t = toy(); + const body = Array.from({ length: 12 }, (_, i) => `code line ${i}`); + const doc = ["Intro.", "", ...fence(body), "", "Tail.", ""].join("\n"); + const state = toyState(doc, t); + const blockFrom = fromOf(state, "```"); + const text = state.doc.toString(); + const tr = state.update({ + changes: [ + { from: text.indexOf("code line 1") + 2, insert: "x" }, + { from: text.indexOf("code line 9") + 2, insert: "y" }, + ], + }); + expect(requiresFullBoundedRebuild(tr)).toBe(false); // precondition: bounded path + expect(t.calls.build).toEqual([blockFrom]); + expect(t.calls.widget).toEqual([blockFrom]); + expect(ranges(tr.state, t)).toEqual(fencedRanges(tr.state)); + }); + + it("toy tree identity: a republished tree with no doc change publishes a block past the old frontier", () => { + const t = toy(); + const doc = beyondInitFrontier([...fence(["late"]), "", "End.", ""]); + const state = EditorState.create({ doc, extensions: [quollMarkdownLanguage(), t.field] }); + const late = doc.indexOf("```\nlate"); + expect(syntaxTree(state).length).toBeLessThan(late); // precondition: partial snapshot + expect(ranges(state, t)).toEqual([]); + expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); + const next = state.update({}).state; + expect(syntaxTree(next)).not.toBe(syntaxTree(state)); // precondition: tree republished + expect(ranges(next, t).map((r) => r[0])).toEqual([late]); + }); + + it("toy branching: transactions and states sharing the field keep independent records", () => { + const t = toy(); + const state = toyState(TOY_DOC, t); + const base = state.field(t.field).records; + const text = state.doc.toString(); + // Two branches: one edits inside the last block, the other the prose after it. + const a = state.update({ changes: { from: text.indexOf("gamma") + 1, insert: "x" } }).state; + const b = state.update({ changes: { from: text.indexOf("Tail") + 1, insert: "y" } }).state; + expect(state.field(t.field).records).toBe(base); + const ra = a.field(t.field).records; + const rb = b.field(t.field).records; + expect(ra[2]).not.toBe(base[2]); + expect(ra[0]).toBe(base[0]); + rb.forEach((r, i) => { + expect(r).toBe(base[i]); + }); + expect(ra[2].block.text).toContain("gxamma"); + expect(rb[2].block.text).not.toContain("gxamma"); + expect(base[2].block.text).not.toContain("gxamma"); + + // A second state built with the same field instance is untouched by updates to the first. + const other = toyState(["Other.", "", ...fence(["delta"]), ""].join("\n"), t); + const otherRecords = other.field(t.field).records; + state.update({ changes: { from: 0, insert: "\n\n" } }); + expect(other.field(t.field).records).toBe(otherRecords); + expect(otherRecords.map((r) => r.block.text)).toEqual(["```\ndelta\n```"]); + }); + + it("toy seeded oracle: after each of 20 seeded edits the records equal a fresh full build", () => { + let seed = 0x5eed; + const rand = (n: number): number => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed % n; + }; + const inserts = ["x", " ", "\n", "`", "```\n", "\n\n", "ab"]; + const t = toy(); + let state = toyState(TOY_DOC, t); + for (let i = 0; i < 20; i++) { + const len = state.doc.length; + const from = rand(len + 1); + const spec = + rand(3) === 0 && from < len + ? { from, to: Math.min(len, from + 1 + rand(4)) } + : { from, insert: inserts[rand(inserts.length)] }; + state = state.update({ changes: spec }).state; + const got = ranges(state, t); + const sorted = [...got].sort((x, y) => x[0] - y[0]); + expect(got).toEqual(sorted); + const oracle = toyState(state.doc.toString(), toy()); + expect(got).toEqual(fencedRanges(oracle)); + } + }); +}); From 1032d688a0cec2581db0081b3a78ef9006a23a52 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:30:38 +0900 Subject: [PATCH 05/14] test(cm): pin structural block reveal keeps the widget object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4j of the ARCH-07 PR3 plan: row "reveal" (toy spec) — a caret on a block line hides its decoration while the record stays; leaving restores the same widget object; a same-line-span caret move returns the field value. --- test/webview/cm-structural-blocks.test.ts | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index d01df90c..3f89d176 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -741,3 +741,33 @@ describe("structural block contract updates (toy spec)", () => { } }); }); + +describe("structural block reveal (toy spec)", () => { + it("reveal: a caret on a block line hides its widget, leaving shows the SAME widget object", () => { + const t = toy(); + const state = toyState(TOY_DOC, t); + const before = widgetSlots(state, ToyWidget); + expect(before).toHaveLength(3); // precondition: caret at 0 reveals nothing + const alpha = before[0]; + + const entered = state.update({ selection: { anchor: alpha.from + 1 } }).state; + expect(widgetSlots(entered, ToyWidget).map((s) => s.from)).toEqual([ + before[1].from, + before[2].from, + ]); + // The record is kept while its decoration is hidden. + expect(entered.field(t.field).records).toBe(state.field(t.field).records); + + const left = entered.update({ selection: { anchor: 0 } }).state; + const after = widgetSlots(left, ToyWidget); + expect(after.map((s) => s.from)).toEqual(before.map((s) => s.from)); + after.forEach((s, i) => { + expect(s.widget).toBe(before[i].widget); + }); + expect(t.calls.widget).toEqual([]); + + // A caret move that keeps the same line spans returns the field value itself. + const same = left.update({ selection: { anchor: 1 } }).state; + expect(same.field(t.field)).toBe(left.field(t.field)); + }); +}); From a9a571dfc084acec73c1c4f7c89ef14533dc6202 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:32:24 +0900 Subject: [PATCH 06/14] test(cm): pin the upstream leaf-completeness of partial Markdown parses Rows "frontier pin table" and "frontier pin image" run the production parser with stopAt(p) for every p strictly inside the complete target, fresh and incremental (TreeFragment.addTree + applyChanges, for a distant prose edit and an edit inside the target). The partial tree must hold the target with its complete range or not at all. This is the evidence that the structural block contract needs no "settled" rule; it goes red if a future @lezer/markdown cuts a leaf block at stoppedAt. --- test/webview/cm-structural-blocks.test.ts | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 3f89d176..69217ff8 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -12,6 +12,7 @@ import { ensureSyntaxTree, foldable, syntaxTree, syntaxTreeAvailable } from "@codemirror/language"; import { EditorState, type Extension, type StateField } from "@codemirror/state"; import { EditorView, type WidgetType } from "@codemirror/view"; +import { type Tree, TreeFragment } from "@lezer/common"; import { afterEach, describe, expect, it, vi } from "vitest"; import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js"; import { @@ -771,3 +772,105 @@ describe("structural block reveal (toy spec)", () => { expect(same.field(t.field)).toBe(left.field(t.field)); }); }); + +// --------------------------------------------------------------------------------------- +// Frontier pin: the upstream leaf-completeness behaviour the contract relies on (no +// "settled" rule exists because of it). A partial @lezer/markdown tree holds a leaf block +// whole or not at all; a block the parser has not reached is simply absent. If a future +// parser cut a leaf block at `stoppedAt`, these rows go red. + +const productionParser = quollMarkdownLanguage().language.parser; + +function parseTo(doc: string, stop: number | null, fragments: readonly TreeFragment[] = []): Tree { + const parse = productionParser.startParse(doc, fragments); + if (stop !== null) { + parse.stopAt(stop); + } + for (;;) { + const tree = parse.advance(); + if (tree !== null) { + return tree; + } + } +} + +function nodeRanges(tree: Tree, name: string): Array<[number, number]> { + const out: Array<[number, number]> = []; + tree.iterate({ + enter: (node) => { + if (node.name === name) { + out.push([node.from, node.to]); + } + }, + }); + return out; +} + +/** For every stop strictly inside the complete target, the partial tree either lacks the + * target or holds it with its complete range. Returns the number of stops checked. */ +function expectLeafWholeOrAbsent( + doc: string, + name: string, + fragments: () => readonly TreeFragment[] +): number { + const complete = nodeRanges(parseTo(doc, null), name); + expect(complete).toHaveLength(1); // non-vacuity: the complete target exists + const [from, to] = complete[0]; + let checked = 0; + for (let p = from + 1; p < to; p++) { + const partial = nodeRanges(parseTo(doc, p, fragments()), name).filter( + ([f, t]) => f < to && t > from + ); + if (partial.length > 0) { + expect(partial).toEqual([[from, to]]); + } + checked++; + } + expect(checked).toBeGreaterThan(0); // non-vacuity: at least one stop inside the target + return checked; +} + +function expectIncremental(prev: string, edit: { from: number; insert: string }, name: string) { + const next = prev.slice(0, edit.from) + edit.insert + prev.slice(edit.from); + const prevTree = parseTo(prev, null); + const fragments = () => + TreeFragment.applyChanges(TreeFragment.addTree(prevTree), [ + { fromA: edit.from, toA: edit.from, fromB: edit.from, toB: edit.from + edit.insert.length }, + ]); + expect(fragments().length).toBeGreaterThan(0); // non-vacuity: reusable fragments supplied + expectLeafWholeOrAbsent(next, name, fragments); +} + +const PIN_PROSE = Array.from( + { length: 6 }, + (_, i) => `Paragraph ${i} of plain prose before and after the target block.` +); + +describe("frontier pin: a partial parse holds a leaf block whole or not at all", () => { + it("frontier pin table: fresh and incremental parses never cut a Table", () => { + const doc = [ + ...PIN_PROSE.slice(0, 3).flatMap((l) => [l, ""]), + "| head a | head b | head c |", + "| --- | :-: | --: |", + ...Array.from({ length: 8 }, (_, i) => `| r${i}a | r${i}b | r${i}c |`), + "", + ...PIN_PROSE.slice(3).flatMap((l) => [l, ""]), + ].join("\n"); + expectLeafWholeOrAbsent(doc, "Table", () => []); + expectIncremental(doc, { from: 4, insert: "x" }, "Table"); + expectIncremental(doc, { from: doc.indexOf("r3b") + 1, insert: "x" }, "Table"); + }); + + it("frontier pin image: fresh and incremental parses never cut an Image", () => { + const alt = Array.from({ length: 12 }, (_, i) => `word${i}`).join(" "); + const doc = [ + ...PIN_PROSE.slice(0, 3).flatMap((l) => [l, ""]), + `![${alt}](https://example.com/picture.png "a title")`, + "", + ...PIN_PROSE.slice(3).flatMap((l) => [l, ""]), + ].join("\n"); + expectLeafWholeOrAbsent(doc, "Image", () => []); + expectIncremental(doc, { from: 4, insert: "x" }, "Image"); + expectIncremental(doc, { from: doc.indexOf("word5") + 2, insert: "x" }, "Image"); + }); +}); From a3b6d2a21e91587a0f6b509d85d32f03c6e5ccb5 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:35:32 +0900 Subject: [PATCH 07/14] fix(cm): full-rebuild bounded fields whose records cover a partial snapshot frontierIncomplete(tr) is the frontier half of requiresFullBoundedRebuild. Besides G2 (post-edit context incomplete) it is true when the records were built over a partial SNAPSHOT: after ensureSyntaxTree the parse context is complete while syntaxTree(startState) is still partial, so the first docChanged transaction took the bounded path and never published blocks past the old snapshot end (Step 2 reproduced this for table and image). The docblock is the canonical explanation of the snapshot/context split. fenced-code-collapse docChangePlan calls frontierIncomplete in place of its own syntaxTreeAvailable term and keeps its narrower structural check. The frontier-gate guard counts frontierIncomplete calls by name (ALLOW: structural-guard 2, fenced-code 1) with a non-vacuity fixture. Rows frontier advance fenced/fold/callout pin the other consumers. Revert checks: restoring the old fenced term reds only the fenced row; restoring the old guard term reds the base, fold and callout rows. --- .../cm/fenced-code/fenced-code-collapse.ts | 13 ++-- src/webview/cm/structural-guard.ts | 43 ++++++++++- ...ontier-gate-needs-structural-guard.test.ts | 47 +++++++----- test/webview/cm-structural-blocks.test.ts | 74 +++++++++++++++++++ 4 files changed, 151 insertions(+), 26 deletions(-) diff --git a/src/webview/cm/fenced-code/fenced-code-collapse.ts b/src/webview/cm/fenced-code/fenced-code-collapse.ts index 4105ad63..91045988 100644 --- a/src/webview/cm/fenced-code/fenced-code-collapse.ts +++ b/src/webview/cm/fenced-code/fenced-code-collapse.ts @@ -17,7 +17,6 @@ // zone is non-atomic, and reachability is the auto-expand's job — not the generic // blockZoneArrowKeymap's. -import { syntaxTreeAvailable } from "@codemirror/language"; import type { EditorState, Transaction } from "@codemirror/state"; import type { DecorationSet } from "@codemirror/view"; import { type Interval, lineExpandWithNeighbours, mergeIntervals } from "../bounded-recompute.js"; @@ -29,6 +28,7 @@ import { } from "../collapse/line-collapse-field.js"; import { leadingFrontmatterEnd } from "../frontmatter/detect.js"; import { linkReferenceRisk } from "../link-reference-risk.js"; +import { frontierIncomplete } from "../structural-guard.js"; import { fencedCollapseTarget } from "./fenced-code-collapse-state.js"; import { FencedCollapseToggleWidget } from "./fenced-code-collapse-widget.js"; @@ -195,11 +195,12 @@ function computeExtendedSpan(tr: Transaction): Interval[] { } // The fenced policy, unchanged: GF structural edit or a top-level boundary move → full; -// an incomplete parse → full (G2 frontier); otherwise changed lines ±1 ∪ selection lines. +// an incomplete parse frontier → full; otherwise changed lines ±1 ∪ selection lines. // -// The frontier gate (an incomplete parse can reveal nodes outside the span) is spelled out -// here, and the structural-reparse fallback is deliberately NOT part of this condition, the -// way it is in the fields that import ../structural-guard.js: `touchesStructural` / +// The frontier gate (an incomplete parse can reveal nodes outside the span, and records +// built over a partial snapshot miss blocks beyond it) is the shared `frontierIncomplete`, +// but the structural-reparse half of `requiresFullBoundedRebuild` is deliberately NOT part +// of this condition, the way it is in the other bounded fields: `touchesStructural` / // `topLevelBoundaryRisk` above already return "full" before the gate is reached. That guard // is narrower than the shared predicate on purpose — this field's hot path is editing INSIDE // a code fence, where a `#` comment or a `___` line must stay bounded; ../structural-guard.ts's @@ -221,7 +222,7 @@ const fencedCollapseSpec: LineCollapseSpec = { leadingFrontmatterEnd(tr.startState) !== leadingFrontmatterEnd(tr.state) || touchesStructural(tr) || topLevelBoundaryRisk(tr, prevBlocks) || - !syntaxTreeAvailable(tr.state, tr.state.doc.length) + frontierIncomplete(tr) ? "full" : computeExtendedSpan(tr), }; diff --git a/src/webview/cm/structural-guard.ts b/src/webview/cm/structural-guard.ts index 1146babc..2c52c4d2 100644 --- a/src/webview/cm/structural-guard.ts +++ b/src/webview/cm/structural-guard.ts @@ -26,7 +26,7 @@ // so if a future CM ever cloned the transaction per field, each field would just // recompute the same verdict — result unchanged, only the sharing lost. -import { syntaxTreeAvailable } from "@codemirror/language"; +import { syntaxTree, 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"; @@ -359,7 +359,8 @@ export function touchesStructuralReparse(tr: Transaction): boolean { * transaction before it may reuse records: take the FULL rebuild when either the edit * could re-shape a block boundary outside the recomputed window (`touchesStructuralReparse` * — see above), or the post-edit parse frontier is incomplete so the tree cannot be - * trusted outside it (G2). + * trusted outside it, or the records were built over a partial snapshot + * (`frontierIncomplete` — see below). * * The two terms answer DIFFERENT questions and neither implies the other: a structural * reparse happens with a COMPLETE frontier, and a starved frontier happens on edits with @@ -370,7 +371,43 @@ export function touchesStructuralReparse(tr: Transaction): boolean { * Callers may OR their own extra risks IN FRONT of this (fold/index.ts adds a facet flip); * what they must not do is take either term away. */ export function requiresFullBoundedRebuild(tr: Transaction): boolean { - return touchesStructuralReparse(tr) || !syntaxTreeAvailable(tr.state, tr.state.doc.length); + return touchesStructuralReparse(tr) || frontierIncomplete(tr); +} + +/** The frontier half of `requiresFullBoundedRebuild`: true when the records a bounded field + * holds cannot be trusted outside the recomputed window because some parse frontier is + * incomplete. + * + * Two frontiers are in play, and they diverge. The parse CONTEXT (`syntaxTreeAvailable`) + * is what an `ensureSyntaxTree` caller advances; the SNAPSHOT (`syntaxTree(state)`, the + * tree stored in the state) is what every field walked to build its records, and it is + * only replaced by the next transaction. So: + * - `!syntaxTreeAvailable(tr.state, …)` — the post-edit parse has not reached the end + * (G2): the tree outside the window is not final. + * - `syntaxTree(tr.startState).length < tr.startState.doc.length` — the records were + * built over a PARTIAL snapshot. Spelled on the snapshot, never as + * `syntaxTreeAvailable(tr.startState, …)`: after `ensureSyntaxTree` the context is + * already complete while the snapshot is still partial, so the context spelling stays + * false on exactly the transaction this term exists for — the first docChanged + * transaction after the context advance, whose new tree holds blocks the records never + * saw beyond the old snapshot's end. A bounded update there would keep the stale + * records and never add those blocks. + * A selection-only transaction after `ensureSyntaxTree` self-heals without this term: + * the tree identity changes with no doc change, which every bounded field already treats + * as "full". The gap is specifically a docChanged first transaction. + * + * Cost: one extra full rebuild on the first docChanged transaction after the snapshot + * completes (edits made while the frontier is incomplete are already full through G2). + * Rows "frontier advance*" in test/webview/cm-structural-blocks.test.ts pin it. + * + * Exported for fenced-code/fenced-code-collapse.ts only, which pairs it with its own + * narrower structural check. Any other bounded field calls `requiresFullBoundedRebuild`; + * test/build/frontier-gate-needs-structural-guard.test.ts counts calls to this name. */ +export function frontierIncomplete(tr: Transaction): boolean { + return ( + !syntaxTreeAvailable(tr.state, tr.state.doc.length) || + syntaxTree(tr.startState).length < tr.startState.doc.length + ); } /** Expand [from,to] to the enclosing blank-line-delimited block: line-align, then diff --git a/test/build/frontier-gate-needs-structural-guard.test.ts b/test/build/frontier-gate-needs-structural-guard.test.ts index a9ecad3d..43141ec1 100644 --- a/test/build/frontier-gate-needs-structural-guard.test.ts +++ b/test/build/frontier-gate-needs-structural-guard.test.ts @@ -1,7 +1,10 @@ // Choke point: `syntaxTreeAvailable(.state, …)` — the transaction-reducer spelling // of the parse-frontier check a changed-range-bounded StateField consults before reusing -// its records — may appear in exactly ONE place: the definition of -// `requiresFullBoundedRebuild` in structural-guard.ts. +// its records — may appear in exactly ONE place: the definition of `frontierIncomplete` +// in structural-guard.ts, the frontier half that `requiresFullBoundedRebuild` composes. +// A call to `frontierIncomplete` itself is counted the same way, by NAME: it is the +// frontier term alone, so a field that imported it as its whole admission test would be +// exactly the hand-spelled frontier-only gate this guard exists to stop. // // WHY: two bounded fields (imageBlockField, tableSkeletonField) shipped with only this // frontier half of the admission test and no structural-reparse half, because the OR was @@ -28,10 +31,10 @@ // already reached for the same answer for the same class of bug in // no-bare-unstarved-gate.test.ts and no-file-level-ts-nocheck.test.ts. // -// SCOPE — this walks `src/webview/cm/**/*.ts` and counts exactly one CALL SHAPE: a +// SCOPE — this walks `src/webview/cm/**/*.ts` and counts two CALL SHAPES: a // `CallExpression` whose callee is the bare identifier `syntaxTreeAvailable` and whose // FIRST argument is a property-access expression named `state` (the `tr.state` / -// `view.state` reducer spelling). A first argument that is a plain identifier — +// `view.state` reducer spelling), and any bare call to `frontierIncomplete`. A first argument that is a plain identifier — // `syntaxTreeAvailable(state, …)` inside `fold/index.ts`'s `reconcileReseedFolds`, a // helper QUERY rather than a bounded-field reducer — is deliberately NOT counted; that is // what lets `fold/index.ts` hold a plain call while still being required to route its @@ -42,14 +45,16 @@ // exactly the discipline no-bare-unstarved-gate.test.ts already argues for. // // ALLOWLIST: -// - structural-guard.ts (1) — the definition of `requiresFullBoundedRebuild` itself. +// - structural-guard.ts (2) — the `syntaxTreeAvailable(tr.state, …)` call inside +// `frontierIncomplete`, and the `frontierIncomplete(tr)` call inside +// `requiresFullBoundedRebuild`. // - fenced-code/fenced-code-collapse.ts (1) — documented, deliberate exception. Its -// frontier check is the `!syntaxTreeAvailable(…)` term of its `docChangePlan` -// (the test-oracle `mode === "full"` switch now lives in the shared collapse reducer, -// which holds no such call); it is a DIFFERENT disjunction from the structural guard's, -// and its actual structural check (`touchesStructural` + `topLevelBoundaryRisk`) is the -// first term of that same plan. It is not migrated to `requiresFullBoundedRebuild` — -// see the comment at its own call site and structural-guard.ts's header. +// frontier term is the `frontierIncomplete(tr)` call in its `docChangePlan` (the +// test-oracle `mode === "full"` switch lives in the shared collapse reducer, which holds +// no such call); its structural check (`touchesStructural` + `topLevelBoundaryRisk`) is +// narrower than the shared guard's and stays local, as the first terms of that same plan. +// It is not migrated to `requiresFullBoundedRebuild` — see the comment at its own call +// site and structural-guard.ts's header. // - every other file — 0. // // Two further assertions close the gap a call-count alone cannot: (a) `structural-guard.ts` @@ -62,8 +67,8 @@ // the helper itself, only of `syntaxTreeAvailable`. // // KNOWN GAPS — the match is SYNTACTIC, so each of these slips past it: -// - an import alias, or a re-exported wrapper around `syntaxTreeAvailable`, defeats the -// bare-identifier match. +// - an import alias, or a re-exported wrapper around `syntaxTreeAvailable` under any name +// other than `frontierIncomplete`, defeats the bare-identifier match. // - a first argument reached through a local variable (`const st = tr.state; // syntaxTreeAvailable(st, …)`) is not the property-access shape and is not counted — // it would neither be flagged as a violation NOR correctly recognised as the reducer @@ -85,7 +90,7 @@ const CM_ROOT = fileURLToPath(new URL("../../src/webview/cm", import.meta.url)); * here — as a field on this Map or as a comment beside each entry — so there is one place * to read them and none to leave stale. */ const ALLOW = new Map([ - ["structural-guard.ts", 1], + ["structural-guard.ts", 2], ["fenced-code/fenced-code-collapse.ts", 1], ]); @@ -110,9 +115,15 @@ function matchingNodeLines( } function isReducerShapedCall(node: ts.Node): boolean { + if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) { + return false; + } + // Counted by NAME: a field reaches the frontier term only through + // requiresFullBoundedRebuild or the allowlisted fenced policy. + if (node.expression.text === "frontierIncomplete") { + return true; + } return ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && node.expression.text === "syntaxTreeAvailable" && node.arguments.length >= 1 && ts.isPropertyAccessExpression(node.arguments[0]) && @@ -121,7 +132,7 @@ function isReducerShapedCall(node: ts.Node): boolean { } /** 1-based line numbers of every reducer-shaped `syntaxTreeAvailable(.state, …)` - * call in `text`. Not exported: this file's own tests below are its only consumer. */ + * or `frontierIncomplete(…)` call in `text`. Not exported: this file's own tests below are its only consumer. */ function findReducerShapedGates(text: string, fileName: string): number[] { return matchingNodeLines(text, fileName, isReducerShapedCall); } @@ -223,6 +234,8 @@ describe("the scanner itself is not vacuous", () => { "x.ts" ) ).toEqual([2]); + // The exported frontier term, counted by name. + expect(findReducerShapedGates("frontierIncomplete(tr);", "x.ts")).toEqual([1]); }); it("reports EVERY gate in a file that carries more than one", () => { diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 69217ff8..5fcde354 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -14,6 +14,9 @@ import { EditorState, type Extension, type StateField } from "@codemirror/state" import { EditorView, type WidgetType } from "@codemirror/view"; import { type Tree, TreeFragment } from "@lezer/common"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { calloutMarkerConcealField } from "../../src/webview/cm/decorations/callout-marker-conceal.js"; +import { fencedCodeCollapseField } from "../../src/webview/cm/fenced-code/fenced-code-collapse.js"; +import { headingFoldGutterLineClass } from "../../src/webview/cm/fold/index.js"; import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js"; import { recordAt, @@ -349,6 +352,77 @@ describe("structural blocks beyond the initial parse frontier", () => { }); }); +/** The other bounded consumers of the frontier term share the same gap: each target record + * is absent over the partial init snapshot, and after the ensureSyntaxTree context advance + * plus a distant prose keystroke it must be present and equal to a fully-parsed oracle. */ +function expectFrontierAdvancePublishes( + extensions: Extension[], + tail: string[], + needle: string, + read: (state: EditorState) => unknown[] +): void { + const doc = beyondInitFrontier(tail); + const state = EditorState.create({ doc, extensions: [quollMarkdownLanguage(), ...extensions] }); + expect(syntaxTree(state).length).toBeLessThan(doc.indexOf(needle)); // partial snapshot + expect(read(state)).toEqual([]); + expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); + expect(syntaxTree(state).length).toBeLessThan(state.doc.length); // snapshot/context split + const tr = state.update({ changes: { from: 4, insert: "x" } }); + expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); + expect(touchesStructuralReparse(tr)).toBe(false); + expect(syntaxTreeAvailable(tr.state, tr.state.doc.length)).toBe(true); + const oracle = read( + settledState( + EditorState.create({ + doc: tr.state.doc, + extensions: [quollMarkdownLanguage(), ...extensions], + }) + ) + ); + expect(oracle.length).toBeGreaterThan(0); // non-vacuity: the target exists when fully parsed + expect(read(tr.state)).toEqual(oracle); +} + +describe("bounded consumers beyond the initial parse frontier", () => { + it("frontier advance fenced: the collapse record of a long fence is published", () => { + const body = Array.from({ length: 30 }, (_, i) => `code line ${i}`); + expectFrontierAdvancePublishes( + [fencedCodeCollapseField], + ["```txt", ...body, "```", "", "End.", ""], + "```txt", + (st) => + st.field(fencedCodeCollapseField).blocks.map((b) => [b.blockFrom, b.blockTo, b.hiddenCount]) + ); + }); + + it("frontier advance fold: the heading gutter marker is published", () => { + expectFrontierAdvancePublishes( + [headingFoldGutterLineClass], + ["## Late heading", "", "Body under it.", "", "End.", ""], + "## Late heading", + (st) => { + const out: Array<[number, string]> = []; + st.field(headingFoldGutterLineClass).between(0, st.doc.length, (from, _to, marker) => { + out.push([from, marker.elementClass]); + }); + return out; + } + ); + }); + + it("frontier advance callout: the callout marker conceal record is published", () => { + expectFrontierAdvancePublishes( + [calloutMarkerConcealField], + ["> [!NOTE]", "> callout body", "", "End.", ""], + "> [!NOTE]", + (st) => + st + .field(calloutMarkerConcealField) + .records.map((r) => [r.markerFrom, r.markerTo, r.blockTo]) + ); + }); +}); + // --------------------------------------------------------------------------------------- // Toy spec: the contract arms exercised directly through `structuralBlockWidgets`. // A toy block is a fenced code block snapped to whole lines. From 21d8b7d741ed8ba0e15edee6f9f8f2ad6c436be4 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:47:25 +0900 Subject: [PATCH 08/14] refactor(cm): move table and image block widgets onto the structural block contract The table and image block fields are now specs of structuralBlockWidgets (structural-blocks.ts): each says only which Lezer node it looks at, how to build its block and which widget shows it. Range ownership, the bounded update, the full-rebuild reasons, failure -> raw source and reveal live in the contract. The field value is now { records, decorations }. - table: tableBlockField replaces tableSkeletonField + the old table field; table-skeleton.ts, the resolveModels fallback and its warn latch are gone. table-ranges.ts stays for the lint rule only. - image: image-field.ts keeps its eligibility rules, render gate and alt extraction; computeBounded / computeExtendedSpan / G3 are gone. - markdown.ts tableEmitsBlockWidget reads the records through recordAt. - Tests read .decorations / .records; cm-table-skeleton.test.ts ports to cm-table-blocks.test.ts; cm-table-fallback-warn.test.ts is deleted; the fold blockquote harness registers tableBlockField explicitly, plus a row without it. - New production rows: ownership table/image, frontmatter body insert/delete, no frontmatter offset-0 insert, viewport outside table/image, reveal table; "structural build failure keeps sync" in cm-widget-containment. Revert-checks run before committing: (i) frontmatterMoved -> unmapped `oldEnd !== newEnd`: x frontmatter body insert: one character inside the body rebases the blocks AssertionError: expected [ ...(2) ] to deeply equal [] x frontmatter body delete: one character out of the body rebases the blocks AssertionError: expected [ ...(2) ] to deeply equal [] no frontmatter offset-0 insert: green (ii) frontmatterMoved -> `tr.changes.mapPos(oldEnd, 1) !== newEnd` (no sentinel): x no frontmatter offset-0 insert: a keystroke at offset 0 rebases the blocks AssertionError: expected [ ...(2) ] to deeply equal [] (iii) build catch in structural-blocks.ts -> rethrow: x structural build failure keeps sync: a throwing parseTable never stops edit-sync AssertionError: expected [Function] to not throw an error but 'Error: parseTable armed to throw' was thrown All restored; structural-blocks.ts unchanged by this commit. --- src/webview/cm/bounded-recompute.ts | 17 +- src/webview/cm/image/image-field.ts | 368 ++++-------------- src/webview/cm/markdown.ts | 50 +-- src/webview/cm/structural-guard.ts | 31 +- src/webview/cm/table/index.ts | 3 +- src/webview/cm/table/table-field.ts | 204 +++------- src/webview/cm/table/table-ranges.ts | 6 +- src/webview/cm/table/table-skeleton.ts | 196 ---------- src/webview/cm/table/table-widget.ts | 18 +- src/webview/editor.ts | 5 +- ...ontier-gate-needs-structural-guard.test.ts | 3 +- .../helpers/table-drag-harness.ts | 13 +- test/webview/cm-block-widget-bounded.test.ts | 61 +-- .../cm-block-widget-byte-identity.test.ts | 40 +- test/webview/cm-raw-html-inert.test.ts | 4 +- test/webview/cm-structural-blocks.test.ts | 203 +++++++++- test/webview/cm-structural-guard-fuzz.test.ts | 12 +- test/webview/cm-url-security-matrix.test.ts | Bin 11241 -> 11253 bytes test/webview/cm-widget-containment.test.ts | 55 ++- .../cm-block-zone-arrow-keymap.test.ts | 10 +- .../cm-fenced-collapse-bounded.test.ts | 2 +- test/webview/fold/cm-fold-blockquote.test.ts | 43 +- test/webview/image/cm-image-field.test.ts | 64 +-- ...eleton.test.ts => cm-table-blocks.test.ts} | 171 ++++---- .../table/cm-table-fallback-warn.test.ts | 63 --- test/webview/table/cm-table-field.test.ts | 66 ++-- 26 files changed, 661 insertions(+), 1047 deletions(-) delete mode 100644 src/webview/cm/table/table-skeleton.ts rename test/webview/table/{cm-table-skeleton.test.ts => cm-table-blocks.test.ts} (64%) delete mode 100644 test/webview/table/cm-table-fallback-warn.test.ts diff --git a/src/webview/cm/bounded-recompute.ts b/src/webview/cm/bounded-recompute.ts index 97a06fc7..60c179f2 100644 --- a/src/webview/cm/bounded-recompute.ts +++ b/src/webview/cm/bounded-recompute.ts @@ -1,15 +1,8 @@ -// Small pure leaf helpers shared by the block-widget StateFields that maintain -// their DecorationSet / model list with a changed-range-bounded recompute -// (image/image-field.ts, table/table-field.ts, table/table-skeleton.ts — see -// PERF.md). These were byte-identical copies in each file; they live here as ONE -// implementation so the copies cannot drift. -// -// SCOPE: only the small, self-contained leaf helpers are shared. A full -// `defineBlockWidgetField` FACTORY over `buildAll` was deliberately REJECTED as -// leaky — the table field's global ordinal makes bounding it genuinely different -// from the image field's widget-reuse — and is gated on a future third consumer -// (the list block widget). See the recorded decision in -// .claude/docs/TODO-archive.md. Do NOT grow this module into that factory. +// Small pure leaf helpers shared by the changed-range-bounded StateFields. The +// structural block widgets (table, image) use them through the contract module +// structural-blocks.ts, which owns the bounded update itself; this module stays the +// home of the leaf helpers only, so the field logic lives in exactly one place +// (structural-blocks.ts) and the helpers cannot drift between consumers. // // The three fold-gutter fields (fold/index.ts), the callout marker-conceal field // (decorations/callout-marker-conceal.ts), and fenced-code collapse diff --git a/src/webview/cm/image/image-field.ts b/src/webview/cm/image/image-field.ts index 07ce7e08..881e2d87 100644 --- a/src/webview/cm/image/image-field.ts +++ b/src/webview/cm/image/image-field.ts @@ -1,309 +1,111 @@ -// StateField that renders every STANDALONE Markdown image (an inline `Image` -// node that is the sole content of its parent Paragraph and line) as a non- -// editable block widget, and publishes the widget's range to the -// quollBlockReplaceZones facet so C4a's inline-reveal orchestrator drops marks -// inside the widget and blockZoneArrowKeymap navigates across it. +// The image spec of the structural block contract (../structural-blocks.ts): every +// STANDALONE Markdown image (an inline `Image` node that is the sole content of its parent +// Paragraph and line) renders as a non-editable block widget. Range ownership, the +// changed-range-bounded update, the full-rebuild reasons, failure → raw source and +// line-level reveal all live in the contract module; this file says only which images are +// eligible, what their block carries and which widget shows it. // -// Block widgets MUST be sourced from a StateField, not a ViewPlugin — -// CodeMirror throws if a ViewPlugin emits a `block: true` Decoration.replace. -// -// Eligibility (explicit; CommonMark images are INLINE — they live inside a -// Paragraph): -// 1. inline form only: a `URL` child means `![alt](url)`. Reference images -// `![a][ref]` and empty-destination `![a]()` have no URL child → skipped -// (left as raw source). -// 2. parent is a Paragraph whose entire trimmed content equals the image — -// excludes inline images amid prose and soft-break siblings sharing the -// paragraph (promoting one line of a multi-line paragraph would split it). -// 3. the image's line(s) trimmed equal the image — excludes blockquote -// `> ![…]` and list `- ![…]`, whose paragraph text equals the image but -// whose LINE carries a prefix marker that a whole-line block-replace would -// wrongly swallow. +// Eligibility (explicit; CommonMark images are INLINE — they live inside a Paragraph): +// 1. inline form only: a `URL` child means `![alt](url)`. Reference images `![a][ref]` +// and empty-destination `![a]()` have no URL child → skipped (left as raw source). +// 2. parent is a Paragraph whose entire trimmed content equals the image — excludes +// inline images amid prose and soft-break siblings sharing the paragraph (promoting one +// line of a multi-line paragraph would split it). +// 3. the image's line(s) trimmed equal the image — excludes blockquote `> ![…]` and list +// `- ![…]`, whose paragraph text equals the image but whose LINE carries a prefix +// marker that a whole-line block-replace would wrongly swallow. // // Render-gate (fail-closed): the Lezer `URL` child is decoded + allowlist-gated via the -// SHARED `renderSafeMarkdownDestination` (the one render-side decode→gate choke -// point, also used by the neutral inline tokenizer cm/inline/inline-ir.ts that -// the table-cell renderer drives, so the two render gates cannot drift). -// Passing a raw slice straight to the allowlist -// would fail open on angle-bracket / backslash / char-ref forms. Any throw in -// decode/gate is caught and treated as blocked (safeUrl = null). -// The verdict (AllowlistedUrl -// | null) is handed to the widget, which never re-gates. +// SHARED `renderSafeMarkdownDestination` (the one render-side decode→gate choke point, also +// used by the neutral inline tokenizer cm/inline/inline-ir.ts that the table-cell renderer +// drives, so the two render gates cannot drift). Passing a raw slice straight to the +// allowlist would fail open on angle-bracket / backslash / char-ref forms. Any throw in +// decode/gate is caught and treated as blocked (safeUrl = null): that is this spec's own +// fallback — a blocked-placeholder widget — not the contract's absent-record fallback. The +// verdict (AllowlistedUrl | null) is handed to the widget, which never re-gates. // -// Alt is the CommonMark-normalized image label: the raw chars between `![` and -// the label-closing `]` are run through `commonMarkAltText` (backslash/entity -// decode + emphasis flatten) so `` and the blocked placeholder's -// `aria-label` carry the rendered text, not the raw markup. +// Alt is the CommonMark-normalized image label: the raw chars between `![` and the +// label-closing `]` are run through `commonMarkAltText` (backslash/entity decode + emphasis +// flatten) so `` and the blocked placeholder's `aria-label` carry the rendered +// text, not the raw markup. // -// Recompute + reveal strategy: the widget DecorationSet is rebuilt -// changed-range-bounded, NOT by a full-document syntax-tree walk per keystroke. -// On a docChanged (or span-changing selection-only) transaction we reuse the -// previously-built widgets OUTSIDE an `extendedSpan` and re-walk the tree only -// INSIDE it (`buildRange`). The measured cost of the old full `iterate` was -// whole-tree materialisation over the changed-into-fresh post-edit tree (~5 ms -// per keystroke at 1 MB), NOT node count — so a bounded `{from,to}` iterate -// (≈0 ms added) is the fix; a "skip-Paragraph / cheaper full walk" was measured -// and REJECTED (the cost is materialisation, not descent). Soundness rests on -// `extendedSpan` covering every line whose widget eligibility can change: -// G1 — each changed range is expanded by ±1 line, because a blank-line toggle -// ADJACENT to an image re-groups its parent Paragraph and flips its -// standalone eligibility WITHOUT touching the image's own bytes. -// G2 — if the post-edit parser frontier is incomplete -// (`!syntaxTreeAvailable`), a docChanged transaction can reveal nodes -// outside the changed range, so fall back to a full recompute (the later -// background-parse publication self-heals via the tree-identity branch). -// G3 — a change to `leadingFrontmatterEnd` flips the `from < fmEnd` gate for -// images near the top → full recompute. -// The docChanged arm's admission test is the shared `requiresFullBoundedRebuild` -// (../structural-guard.js), which ORs G2 above with a structural-reparse check: a block -// boundary can re-shape OUTSIDE the changed range with the frontier staying COMPLETE, -// which G2 alone cannot see. Rationale + the check itself live in that file, which also -// owns the roster of bounded fields that keep a NARROWER structural predicate of their own -// instead; no copy of that roster is kept here, because a copy goes stale the next time one -// of them moves. ⚠️ PERF: that structural term's SHAPE arm is still PRESENCE-based over -// the whole changed line, so typing in the body of a list item, a blockquote line, or an -// ATX heading — and every Enter — still takes the full walk this field's bounding exists -// to avoid. Its TABLE-DELIM arm is no longer a plain presence test (a per-line SHAPE-DELTA -// off the delimiter row, `tableRowShapeChanged`), so an edit on a `|`-bearing line elsewhere -// in the document — a keystroke inside another table's cell that leaves that row's shape -// unchanged — no longer full-rebuilds this field. THIS field's own full-walk cost was -// measured at 0.71–0.75 ms/keystroke (19 KB / 150 block images and 154 KB / 1200 block -// images) — essentially flat in document size, which the table field's is NOT, so neither -// field's number may be inferred from the other's. Accepted on that measurement, not free: -// see PERF.md. -// A reused widget whose document position SHIFTED is reconstructed with the new -// docFrom (cheap: same alt/safeUrl/slice, NO re-parse). The small pure leaf -// helpers (mergeIntervals / lineExpandWithNeighbours / intersects / -// selectionLineSpansEqual / extractRanges) are shared with the table fields via -// ../bounded-recompute.js. A full `defineBlockWidgetField` FACTORY over -// `buildAll` was deliberately REJECTED as leaky (the table field's global -// ordinal makes bounding it genuinely different) — see the recorded decision in -// .claude/docs/TODO-archive.md, gated on a future third consumer. +// The block stores no absolute position besides `from`/`to`, so a reused block rebases by a +// uniform shift with no re-parse. Performance numbers: .claude/docs/PERF.md. -import { syntaxTree } from "@codemirror/language"; -import { type EditorState, StateField, type Transaction } from "@codemirror/state"; -import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; +import type { EditorState } from "@codemirror/state"; +import type { SyntaxNodeRef } from "@lezer/common"; import { renderSafeMarkdownDestination } from "../../../markdown/render-safe-markdown-destination.js"; import type { AllowlistedUrl } from "../../../markdown/url-allowlist.js"; -import { - extractRanges, - type Interval, - intersects, - lineExpandWithNeighbours, - lineRangeOverlapsSelection, - mergeIntervals, - selectionLineSpansEqual, -} from "../bounded-recompute.js"; -import { quollBlockReplaceZones } from "../decorations/orchestrator.js"; -import { leadingFrontmatterEnd } from "../frontmatter/detect.js"; import { commonMarkAltText } from "../inline/inline-ir.js"; -import { requiresFullBoundedRebuild } from "../structural-guard.js"; +import { type StructuralBlock, structuralBlockWidgets } from "../structural-blocks.js"; import { ImageBlockWidget } from "./image-widget.js"; import { quollResourceBaseUri, resolveAgainstBase } from "./resource-base.js"; -interface BuiltWidget { - from: number; - to: number; - widget: ImageBlockWidget; - deco: Decoration; +export interface ImageBlock extends StructuralBlock { + readonly alt: string; + readonly safeUrl: AllowlistedUrl | null; + /** Trimmed image source: the widget's eq() key. */ + readonly imgText: string; } -// Trim only ASCII *structural* whitespace: space, tab, LF, CR — the set the -// Lezer markdown parser treats as insignificant at a line/paragraph boundary -// (indentation + line endings). It deliberately EXCLUDES U+000B (vertical -// tab), U+000C (form feed), NBSP, and every other Unicode space, which the -// parser keeps as significant paragraph content (Codex probe: a leading VT -// stays inside the Paragraph node) — so a line whose only prefix is one of -// them must NOT be promoted to a standalone image, and a whole-line block- -// replace must not swallow it. `String.prototype.trim()` strips all of them -// and would mis-promote such lines (Codex re-review N1 + N4). +// Trim only ASCII *structural* whitespace: space, tab, LF, CR — the set the Lezer markdown +// parser treats as insignificant at a line/paragraph boundary (indentation + line endings). +// It deliberately EXCLUDES U+000B (vertical tab), U+000C (form feed), NBSP, and every other +// Unicode space, which the parser keeps as significant paragraph content (Codex probe: a +// leading VT stays inside the Paragraph node) — so a line whose only prefix is one of them +// must NOT be promoted to a standalone image, and a whole-line block-replace must not +// swallow it. `String.prototype.trim()` strips all of them and would mis-promote such lines +// (Codex re-review N1 + N4). function trimAsciiWs(s: string): string { return s.replace(/^[ \t\n\r]+/, "").replace(/[ \t\n\r]+$/, ""); } -function makeWidget( - alt: string, - safeUrl: AllowlistedUrl | null, - imgText: string, - from: number, - to: number -): BuiltWidget { - const widget = new ImageBlockWidget(alt, safeUrl, imgText, from); - return { from, to, widget, deco: Decoration.replace({ widget, block: true }) }; -} - -/** Build every standalone-image widget whose node OVERLAPS [rangeFrom, rangeTo]. - * Called with [0, doc.length] for a full recompute and with each bounded - * interval otherwise. Pure reader of the lazy syntaxTree. */ -function buildRange(state: EditorState, rangeFrom: number, rangeTo: number): BuiltWidget[] { - const tree = syntaxTree(state); - const out: BuiltWidget[] = []; - // The frontmatter block (frontmatterBlockField) owns the outermost block over - // [0, fmEnd]; never emit a competing block replace inside it. - const fmEnd = leadingFrontmatterEnd(state); - const base = state.facet(quollResourceBaseUri); - tree.iterate({ - from: rangeFrom, - to: rangeTo, - enter: (node) => { - if (node.name !== "Image") { - return; - } - if (node.from < fmEnd) { - return; - } - // (1) inline form only. - const urlNode = node.node.getChild("URL"); - if (!urlNode) { - return; - } - // (2) parent Paragraph contains ONLY the image. - const parent = node.node.parent; - if (!parent || parent.name !== "Paragraph") { - return; - } - const imgText = trimAsciiWs(state.sliceDoc(node.from, node.to)); - if (trimAsciiWs(state.sliceDoc(parent.from, parent.to)) !== imgText) { - return; - } - // (3) the image's line(s) carry no prefix marker beyond the image. - const startLine = state.doc.lineAt(node.from); - const endLine = state.doc.lineAt(Math.min(node.to, state.doc.length)); - if (trimAsciiWs(state.sliceDoc(startLine.from, endLine.to)) !== imgText) { - return; - } - const from = startLine.from; - const to = endLine.to; - if (from >= to) { - return; - } - // Render-gate, fail-closed. Shared decode→gate choke point with the - // table-cell renderer (cm/table/cell-render.ts) so the two render gates - // cannot drift. Any decode/gate throw also blocks rendering. - let safeUrl: AllowlistedUrl | null; - try { - const safe = renderSafeMarkdownDestination(state.sliceDoc(urlNode.from, urlNode.to)); - safeUrl = safe.kind === "safe" ? resolveAgainstBase(safe.url, base) : null; - } catch { - safeUrl = null; - } - // Alt = raw label source between `![` and the label-closing `]`. The `]` - // is the last `]` before the `(` that opens the destination — robust to - // inner paren whitespace `![a]( url )` and to a title `![a](url "t")`. - const gap = state.sliceDoc(node.from + 2, urlNode.from); - const parenIdx = gap.lastIndexOf("("); - const bracketIdx = parenIdx >= 0 ? gap.lastIndexOf("]", parenIdx) : gap.lastIndexOf("]"); - const altRaw = bracketIdx >= 0 ? gap.slice(0, bracketIdx) : ""; - const alt = commonMarkAltText(altRaw); - out.push(makeWidget(alt, safeUrl, imgText, from, to)); - }, - }); - return out; -} - -function lineExpand(state: EditorState, from: number, to: number): Interval { - const len = state.doc.length; - const lo = state.doc.lineAt(Math.max(0, Math.min(from, len))); - const hi = state.doc.lineAt(Math.max(0, Math.min(to, len))); - return { from: lo.from, to: hi.to }; -} - -function computeExtendedSpan(tr: Transaction): Interval[] { - const state = tr.state; - const raw: Interval[] = []; - if (tr.docChanged) { - tr.changes.iterChangedRanges((_fa, _ta, fromB, toB) => { - raw.push(lineExpandWithNeighbours(state, fromB, toB)); // G1 - }); +function buildImageBlock(state: EditorState, node: SyntaxNodeRef): ImageBlock | null { + // (1) inline form only. + const urlNode = node.node.getChild("URL"); + if (!urlNode) { + return null; } - for (const r of tr.startState.selection.ranges) { - const a = tr.changes.mapPos(r.from, 1); - const b = tr.changes.mapPos(r.to, -1); - raw.push(lineExpand(state, Math.min(a, b), Math.max(a, b))); + // (2) parent Paragraph contains ONLY the image. + const parent = node.node.parent; + if (!parent || parent.name !== "Paragraph") { + return null; } - for (const r of tr.state.selection.ranges) { - raw.push(lineExpand(state, r.from, r.to)); + const imgText = trimAsciiWs(state.sliceDoc(node.from, node.to)); + if (trimAsciiWs(state.sliceDoc(parent.from, parent.to)) !== imgText) { + return null; } - return mergeIntervals(raw); -} - -function toSet(all: BuiltWidget[], state: EditorState): DecorationSet { - const visible = all.filter((w) => !lineRangeOverlapsSelection(state.selection, w.from, w.to)); - if (visible.length === 0) { - return Decoration.none; - } - return Decoration.set(visible.map((w) => w.deco.range(w.from, w.to))); -} - -function computeFreshFull(state: EditorState): DecorationSet { - const all = buildRange(state, 0, state.doc.length); - all.sort((a, b) => a.from - b.from); - return toSet(all, state); -} - -function computeBounded( - prev: DecorationSet, - tr: Transaction, - intervals: Interval[] -): DecorationSet { - const state = tr.state; - const byFrom = new Map(); - const iter = prev.iter(); - while (iter.value !== null) { - const widget = iter.value.spec.widget as ImageBlockWidget; - const touched = tr.changes.touchesRange(iter.from, iter.to) !== false; - const newFrom = tr.changes.mapPos(iter.from, 1); - const newTo = tr.changes.mapPos(iter.to, -1); - if (!touched && !intersects(intervals, newFrom, newTo)) { - if (newFrom === iter.from && newTo === iter.to) { - byFrom.set(iter.from, { - from: iter.from, - to: iter.to, - widget, - deco: iter.value as Decoration, - }); - } else { - // Position shift only: alt/safeUrl/slice unchanged, new docFrom. No re-parse. - const r = makeWidget(widget.alt, widget.safeUrl, widget.slice, newFrom, newTo); - byFrom.set(r.from, r); - } - } - iter.next(); + // (3) the image's line(s) carry no prefix marker beyond the image. + const startLine = state.doc.lineAt(node.from); + const endLine = state.doc.lineAt(Math.min(node.to, state.doc.length)); + if (trimAsciiWs(state.sliceDoc(startLine.from, endLine.to)) !== imgText) { + return null; } - for (const ivl of intervals) { - for (const w of buildRange(state, ivl.from, ivl.to)) { - byFrom.set(w.from, w); // fresh wins (a node spanning two intervals de-dupes here) - } + // Render-gate, fail-closed. Shared decode→gate choke point with the table-cell renderer + // (cm/table/cell-render.ts) so the two render gates cannot drift. Any decode/gate throw + // also blocks rendering. + let safeUrl: AllowlistedUrl | null; + try { + const safe = renderSafeMarkdownDestination(state.sliceDoc(urlNode.from, urlNode.to)); + safeUrl = + safe.kind === "safe" ? resolveAgainstBase(safe.url, state.facet(quollResourceBaseUri)) : null; + } catch { + safeUrl = null; } - const all = [...byFrom.values()].sort((a, b) => a.from - b.from); - return toSet(all, state); + // Alt = raw label source between `![` and the label-closing `]`. The `]` is the last `]` + // before the `(` that opens the destination — robust to inner paren whitespace + // `![a]( url )` and to a title `![a](url "t")`. + const gap = state.sliceDoc(node.from + 2, urlNode.from); + const parenIdx = gap.lastIndexOf("("); + const bracketIdx = parenIdx >= 0 ? gap.lastIndexOf("]", parenIdx) : gap.lastIndexOf("]"); + const altRaw = bracketIdx >= 0 ? gap.slice(0, bracketIdx) : ""; + const alt = commonMarkAltText(altRaw); + return { from: startLine.from, to: endLine.to, alt, safeUrl, imgText }; } -export const imageBlockField = StateField.define({ - create: (state) => computeFreshFull(state), - update: (prev, tr) => { - if (leadingFrontmatterEnd(tr.startState) !== leadingFrontmatterEnd(tr.state)) { - return computeFreshFull(tr.state); // G3 - } - if (tr.docChanged) { - // An `Image` node can vanish or appear here with no edit to its own bytes, driven by - // a structural reparse outside the changed span with a COMPLETE frontier — see - // requiresFullBoundedRebuild in ../structural-guard.ts. - if (requiresFullBoundedRebuild(tr)) { - return computeFreshFull(tr.state); - } - return computeBounded(prev, tr, computeExtendedSpan(tr)); - } - if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) { - return computeFreshFull(tr.state); // background-parse publication - } - if (selectionLineSpansEqual(tr.startState, tr.state)) { - return prev; - } - return computeBounded(prev, tr, computeExtendedSpan(tr)); - }, - provide: (f) => [ - EditorView.decorations.from(f), - quollBlockReplaceZones.from(f, (set) => extractRanges(set)), - ], +export const imageBlockField = structuralBlockWidgets({ + name: "image", + node: "Image", + build: buildImageBlock, + widget: (b) => new ImageBlockWidget(b.alt, b.safeUrl, b.imgText, b.from), }); diff --git a/src/webview/cm/markdown.ts b/src/webview/cm/markdown.ts index 4742b7d8..35bbd2a7 100644 --- a/src/webview/cm/markdown.ts +++ b/src/webview/cm/markdown.ts @@ -50,9 +50,9 @@ import { type EditorState, Prec } from "@codemirror/state"; import { keymap } from "@codemirror/view"; import type { MarkdownExtension, MarkdownParser } from "@lezer/markdown"; import { highlightMarkExtension } from "../../markdown/highlight-mark.js"; -import { parseTable } from "../../markdown/table/index.js"; import { codeHighlightExtension } from "./fenced-code/fenced-code-highlight-languages.js"; -import { leadingFrontmatterEnd } from "./frontmatter/detect.js"; +import { recordAt } from "./structural-blocks.js"; +import { tableBlockField } from "./table/table-field.js"; // SyntaxNode without a direct @lezer/common import (a direct dep as of PR #66; // derived rather than imported to avoid widening the direct-dep import surface). @@ -71,29 +71,19 @@ function firstContentChild(node: SyntaxNode): SyntaxNode | null { return first?.type.name === "ListMark" ? first.nextSibling : first; } -// True when a `Table` node would render as a block widget — mirroring EVERY -// non-emission gate tableBlockField applies (table-field.ts `buildAll`), so fold- -// suppression and widget-emission stay in lockstep: -// 1. Leading frontmatter — the frontmatter block owns [0, fmEnd] and buildAll -// emits no competing table widget inside it (`m.from < fmEnd`). A marker-line -// table inside a frontmatter fence parses, but renders as raw source with no -// widget (Codex Conf-74). -// 2. parseTable rejects the per-node slice — a malformed slice (cell-count -// mismatch), which renders as raw source (Codex Conf-84). A blockquote-nested -// table is rejected the same way, but never gets here: the blockquote gate in -// `nonFoldableBlocks` answers for a quoted ListItem before listItemFold runs. -// (buildAll's third gate, a degenerate zero-width block range, is unreachable for a -// ListItem's first-content Table, which always spans at least the marker line.) -function tableEmitsBlockWidget(state: EditorState, from: number, to: number): boolean { - if (from < leadingFrontmatterEnd(state)) { - return false; - } - // Deliberately byte-identical to table-skeleton.ts's `buildModel` — this gate - // and that capture path must agree on the slice they hand `parseTable`. That - // includes the `replace`, which matches nothing today; `buildModel` carries - // the why (the CM interior is LF-only by construction). - const slice = state.sliceDoc(from, to).replace(/\r\n?/g, "\n"); - return parseTable(slice, 0, slice.length) !== null; +// True when the `Table` node starting at `from` renders as a block widget: the table +// field's record list (../structural-blocks.ts, sorted by `block.from`) holds a record +// whose node starts there. Reading the field rather than re-deriving its gates keeps +// fold-suppression and widget emission in lockstep by construction — a table inside +// leading frontmatter, one `parseTable` rejects, or one whose build threw has no record, +// so its list fold stays; a state without the field has no widget at all. `recordAt` is +// a binary search, so a list of N table-bearing items stays O(N log N) per fold query. +function tableEmitsBlockWidget(state: EditorState, from: number): boolean { + const r = recordAt( + state.field(tableBlockField, false)?.records ?? [], + state.doc.lineAt(from).from + ); + return r !== undefined && r.block.from + r.block.nodeOffset === from; } // True when `node` sits anywhere inside a Blockquote (a callout is a Blockquote @@ -119,12 +109,12 @@ function insideBlockquote(node: SyntaxNode): boolean { // of the item's first line to the item end) EXCEPT when the item's first content // child is a GFM Table that starts on the marker line AND actually emits a block // widget. In that tight shape (`- | a | b |\n | - | - |\n…`) the table's block -// widget is line-snapped to the marker-line start (table-skeleton.ts snaps -// blockFrom = lineAt(Table.from).from), so it SWALLOWS the `-`/`1.` marker and the +// widget is line-snapped to the marker-line start (the table spec in +// table/table-field.ts snaps block.from = lineAt(Table.from).from), so it SWALLOWS the `-`/`1.` marker and the // ListItem chevron would visually land on the table — a meaningless affordance. // Returning null suppresses that lone chevron. The emit guard is load-bearing: a -// marker-line table that emits NO widget (inside leading frontmatter, or a slice -// parseTable rejects) renders as raw source, so its list fold must be kept. An +// marker-line table that emits NO widget (inside leading frontmatter, a slice +// parseTable rejects, or a build that threw) renders as raw source, so its list fold must be kept. An // item inside a blockquote never folds at all, whatever its body — that is the // gate in `nonFoldableBlocks`, not a check here. Every other genuine list fold (a // table on a continuation line, a plain multi-line body) keeps the default range. @@ -137,7 +127,7 @@ function listItemFold(node: SyntaxNode, state: EditorState): FoldRange | null { if ( content?.type.name === "Table" && state.doc.lineAt(content.from).from === state.doc.lineAt(node.from).from && - tableEmitsBlockWidget(state, content.from, content.to) + tableEmitsBlockWidget(state, content.from) ) { return null; } diff --git a/src/webview/cm/structural-guard.ts b/src/webview/cm/structural-guard.ts index 2c52c4d2..87948d78 100644 --- a/src/webview/cm/structural-guard.ts +++ b/src/webview/cm/structural-guard.ts @@ -11,11 +11,8 @@ // consumer list, and no count is kept here because a count goes stale the next time // a field is added. // -// Fenced-code collapse (fenced-code/fenced-code-collapse.ts) does NOT import this: -// it keeps a NARROWER STRUCTURAL (no ATX/underscore alts) plus its own -// `topLevelBoundaryRisk` + `insideBlock` record scoping, because its hot path is -// editing INSIDE a code fence — where a `#` comment or `___` line must stay on -// the bounded path. See the STRUCTURAL doc-comment below + docs/LEARNING.md. +// Fenced-code collapse composes the shared frontier predicate with its own structural +// predicate; see `fencedCollapseSpec.docChangePlan`. // // The dual old/new line-slice scan is memoised per Transaction (a WeakMap keyed // on `tr`): in @codemirror/state 6.6.x CodeMirror hands the SAME Transaction to @@ -265,26 +262,8 @@ export function tableRowShapeChanged(oldText: string, newText: string): boolean * ⚠️ Do NOT "simplify" a DELTA clause with an over-approximating predicate the way a * PRESENCE test may be: pinning both sides of a comparison to `true` erases the flip it * exists to detect, turning a cheap false positive into a missed rebuild. - * What that costs depends on the consumer's PRE-guard baseline: - * - For the fold / callout consumers this guard was written for, the baseline was an - * always-full rebuild, so presence-based firing is a strict improvement. - * - For `image/image-field.ts` and `table/table-skeleton.ts`, which admitted on the - * frontier term alone before they adopted this pair, the baseline was always-BOUNDED, so - * it is a REGRESSION on the keystroke classes that now fire: typing in a list-item body, - * a blockquote line or an ATX heading, and every Enter (an in-cell keystroke is NOT in - * this set — see the TABLE-DELIM note above). EACH field's own - * full-walk cost was measured on its own fixtures and accepted for this PR — separately, - * because the two cost curves differ in SHAPE: the table field's grows with document - * size (its full walk re-runs `parseTable` per node), the image field's is essentially - * flat — for a reason that was NOT isolated. It is specifically NOT reuse: the image - * field's full walk re-derives alt/safeUrl/slice fresh per node (`buildRange` takes no - * `prev`); the reuse lives only on `computeBounded`'s position-shift branch. Sharing - * this guard and this trigger set does NOT imply sharing a cost, so neither number may - * be inferred from the other. Numbers: PERF.md; scoping follow-up: TODO.md. - * A delta-based / per-consumer refinement is deferred to that follow-up - * (`fenced-code-collapse.ts`'s `insideBlock` + `topLevelBoundaryRisk` is the in-repo - * precedent). Exported so the negative-assertion tests can call it - * directly (pinning that plain prose typing stays on the bounded hot path). + * Performance numbers and consumer-specific costs: .claude/docs/PERF.md. + * Exported so the negative-assertion tests can call it directly (pinning that plain prose typing stays on the bounded hot path). * Memoised per Transaction so every consumer shares one dual-slice scan per * dispatch (one scan per dispatch under CM 6.6.x). */ const structuralMemo = new WeakMap(); @@ -335,7 +314,7 @@ export function touchesStructuralReparse(tr: Transaction): boolean { // `Table` appear or vanish and can CLOSE an enclosing list — re-shaping block structure // OUTSIDE the changed run, which SHAPE and the other arms all miss. Keying on the DELTA // of pipe presence and cell count, rather than on the mere PRESENCE of a `|` on the - // line, is what keeps typing inside a table cell — `tableSkeletonField`'s own primary + // line, is what keeps typing inside a table cell — the table spec's (`tableBlockField`) own primary // scenario — on the bounded path; the delimiter ROW itself stays on presence. if (tableRowShapeChanged(oldLine.text, newLine.text)) { hit = true; diff --git a/src/webview/cm/table/index.ts b/src/webview/cm/table/index.ts index 910a3ca3..41999e39 100644 --- a/src/webview/cm/table/index.ts +++ b/src/webview/cm/table/index.ts @@ -1,4 +1,3 @@ export { type CaretResolver, quollTableCaretResolver } from "./cell-point.js"; -export { tableBlockField } from "./table-field.js"; -export { tableSkeletonField } from "./table-skeleton.js"; +export { type TableBlock, tableBlockField } from "./table-field.js"; export { TableBlockWidget } from "./table-widget.js"; diff --git a/src/webview/cm/table/table-field.ts b/src/webview/cm/table/table-field.ts index d2054f76..96b55abb 100644 --- a/src/webview/cm/table/table-field.ts +++ b/src/webview/cm/table/table-field.ts @@ -1,167 +1,57 @@ -// StateField that renders every GFM Table node as a non-editable block -// widget and publishes the widget's range to the quollBlockReplaceZones -// facet so C4a's inline-reveal orchestrator drops marks inside the widget. +// The table spec of the structural block contract (../structural-blocks.ts): every GFM +// `Table` node that `parseTable` accepts renders as a non-editable block widget. Range +// ownership, the changed-range-bounded update, the full-rebuild reasons, failure → raw +// source and line-level reveal all live in the contract module; this file says only what a +// table block is and which widget shows it. // -// Block widgets MUST be sourced from a StateField, not a ViewPlugin — -// CodeMirror throws if a ViewPlugin emits a `block: true` Decoration.replace -// (the constraint that motivated C4a Task 2 carving out this facet). +// A block is the node's whole-line range (`block: true` widgets must cover complete lines; +// Lezer's Table range usually IS line-aligned, but doc-final-no-newline edges may not be), +// plus everything the widget needs, stored as offsets from `from` so a reused block rebases +// by a uniform shift: `nodeOffset` (the Lezer node start, the base for per-cell offsets), +// the per-node LF source slice (the widget's eq() key) and the parse. A `Table` node +// `parseTable` rejects — a blockquote-nested table whose continuation lines carry `>` +// markers, or a cell-count mismatch — yields no block and stays raw source. // -// Recompute strategy: -// 1. tr.docChanged OR tree-identity changed → walk the tree, parse each -// table, build a fresh DecorationSet keyed on per-node source slices. -// 2. tr.selection only (no doc/tree change): -// - if every selection range still resolves to the same line span -// as before, the per-table line-overlap verdict is unchanged → -// return `prev` byte-for-byte (no tree walk, no parse). -// - otherwise, recompute from scratch (one tree walk; the doc is -// unchanged so per-node sliceDoc remains O(table-bytes)). -// Cursor moves within the same line — the dominant case — take the -// identity-return branch. (Codex Conf 91 + error-handler Conf 95.) -// 3. Anything else → return the previous DecorationSet unchanged. -// -// Reveal contract is LINE-LEVEL (Codex Conf 89): if any selection range -// overlaps the LINE RANGE the table occupies (closed-interval overlap so -// caret AT line.from / line.to also reveals), the widget is dropped. This -// mirrors C5's checkbox pattern and matches CM6's default click-on-block -// caret placement (the caret lands at the widget boundary, which is on a -// table line → reveal fires → source becomes editable). Half-open overlap -// would leave a click-at-boundary in the "widget visible" state and the -// user would click without getting source. -// -// Per-table sourcing: each table is parsed from `state.sliceDoc(node.from, -// node.to)` — NOT `state.doc.toString()`. The doc-wide toString materialises -// the whole rope into a single string and is O(doc-bytes) per recompute; -// per-node sliceString is O(table-bytes). Cell `from`/`to` in the parsed -// model are slice-relative (base 0 per node); the widget converts each to an -// absolute document offset (`nodeFrom + from`) stamped as `data-cell-from`, -// which drives click-to-reveal caret placement. - -import { syntaxTree } from "@codemirror/language"; -import { type EditorState, StateField, type Transaction } from "@codemirror/state"; -import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; - -import { - extractRanges, - lineRangeOverlapsSelection, - selectionLineSpansEqual, -} from "../bounded-recompute.js"; -import { quollBlockReplaceZones } from "../decorations/orchestrator.js"; -import { leadingFrontmatterEnd } from "../frontmatter/detect.js"; -import { type TableModel, tableModels, tableSkeletonField } from "./table-skeleton.js"; +// Per-table sourcing: each table is parsed from `state.sliceDoc(node.from, node.to)`, never +// `state.doc.toString()` — per-node slicing is O(table-bytes), the doc-wide string is +// O(doc-bytes) per rebuild. Cell `from`/`to` in the parsed model are slice-relative; the +// widget adds `nodeFrom` to stamp absolute `data-cell-from` offsets for click-to-reveal. + +import type { EditorState } from "@codemirror/state"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { parseTable, type Table } from "../../../markdown/table/index.js"; +import { type StructuralBlock, structuralBlockWidgets } from "../structural-blocks.js"; import { TableBlockWidget } from "./table-widget.js"; -interface BuiltWidget { - from: number; - to: number; - deco: Decoration; -} - -// One-shot latch for the missing-skeleton-field dev warning (see resolveModels). -// Module-scoped so the warn fires at most once per session, not per keystroke. -let warnedMissingSkeletonField = false; - -// Read the bounded-maintained models (no per-keystroke full walk OR per-table -// re-parse — PERF.md). Fallback to the unbounded full walk+parse when the field -// is absent (tests / any state that doesn't register it); `state.field(…, false)` -// returns `undefined` only when the field is ABSENT, never for an empty `[]` set, -// so a tableless doc is NOT mistaken for absence. -// -// The fallback is a SUPPORTED path — the unit suite mounts `tableBlockField` -// without the skeleton field — so this warns rather than throws. But the fallback -// silently breaks PERF.md's bounding invariant (O(doc) full reparse per change), -// and a future harness/embed that forgot to register the field would only surface -// it via profiling. So emit a one-shot dev-visible warning. Warn-once (module -// latch), because `buildAll` runs on every doc/tree change — an unguarded warn -// would flood the console. Not gated on QUOLL_PERF: production always registers -// the field, so the latch stays false there (zero noise) while a broken embed -// still gets its one signal even in a production build. -function resolveModels(state: EditorState): readonly TableModel[] { - const models = state.field(tableSkeletonField, false); - if (models !== undefined) { - return models; - } - if (!warnedMissingSkeletonField) { - warnedMissingSkeletonField = true; - console.warn( - "[quoll] tableSkeletonField not registered — tableBlockField is falling back to an unbounded full reparse per change (PERF.md bounding invariant not held). Register tableSkeletonField alongside tableBlockField." - ); - } - return tableModels(state); -} - -function buildAll(state: EditorState): BuiltWidget[] { - const models = resolveModels(state); - const out: BuiltWidget[] = []; - // The frontmatter block (frontmatterBlockField) owns the outermost block over - // [0, fmEnd]; never emit a competing block replace inside it. - const fmEnd = leadingFrontmatterEnd(state); - models.forEach((m) => { - if (m.from < fmEnd) { - return; - } - if (m.table === null) { - return; // non-emitting Table node - } - const from = m.blockFrom; - const to = m.blockTo; - // Precondition: `Decoration.replace({block: true})` requires from < to. The - // only reachable rejection is a degenerate zero-width range (Table at doc edge). - if (from >= to) { - return; - } - out.push({ - from, - to, - deco: Decoration.replace({ - // `from` (block line-start) is docFrom; `m.from` (Lezer node start) is - // nodeFrom — the base for per-cell offsets. `m.slice` is the LF- - // normalised parse slice (eq key). All three are LF-internal. - widget: new TableBlockWidget(m.table, m.slice, from, m.from), - block: true, - }), - }); - }); - out.sort((a, b) => a.from - b.from); - return out; +export interface TableBlock extends StructuralBlock { + /** Lezer `Table` node start minus `from` — the widget's `nodeFrom` is `from + nodeOffset`. */ + readonly nodeOffset: number; + /** Per-node source slice: the widget's eq() key and the input `table` was parsed from. + * LF-only, because the CM document itself is: `cm/seed.ts`'s `splitToCmText` owns that + * invariant at the one seam where host bytes become CM text, so no cell `raw` string + * carries a `\r` the DOM textNode would render as stray whitespace. */ + readonly slice: string; + readonly table: Table; } -function computeFresh(state: EditorState): DecorationSet { - const visible = buildAll(state).filter( - (b) => !lineRangeOverlapsSelection(state.selection, b.from, b.to) - ); - if (visible.length === 0) { - return Decoration.none; - } - return Decoration.set(visible.map((b) => b.deco.range(b.from, b.to))); -} - -function updateForTransaction(prev: DecorationSet, tr: Transaction): DecorationSet { - if (tr.docChanged || syntaxTree(tr.startState) !== syntaxTree(tr.state)) { - return computeFresh(tr.state); - } - if (tr.selection) { - // Selection-only fast path: if every selection range still sits on - // the same line-span as before, the per-table overlap verdict is - // unchanged and we can reuse `prev` byte-for-byte. Cursor moves - // within the same line — the overwhelmingly common case — short- - // circuit here and skip both the tree walk and the per-table parse - // (Codex Conf 91). - if (selectionLineSpansEqual(tr.startState, tr.state)) { - return prev; - } - return computeFresh(tr.state); +function buildTableBlock(state: EditorState, node: SyntaxNodeRef): TableBlock | null { + // The `\r\n?` replace matches nothing today (see `slice` above); it is a belt-and-braces + // normaliser, not a sign that the source doc carries CRLF. + const slice = state.sliceDoc(node.from, node.to).replace(/\r\n?/g, "\n"); + const table = parseTable(slice, 0, slice.length); + if (table === null) { + return null; } - return prev; + const from = state.doc.lineAt(node.from).from; + const to = state.doc.lineAt(Math.min(node.to, state.doc.length)).to; + return { from, to, nodeOffset: node.from - from, slice, table }; } -export const tableBlockField = StateField.define({ - create: (state) => computeFresh(state), - update: (value, tr) => updateForTransaction(value, tr), - provide: (f) => [ - // Widget DecorationSet → editor renders them. - EditorView.decorations.from(f), - // Same ranges → C4a's facet so the inline orchestrator drops marks - // inside the widget. - quollBlockReplaceZones.from(f, (set) => extractRanges(set)), - ], +export const tableBlockField = structuralBlockWidgets({ + name: "table", + node: "Table", + build: buildTableBlock, + // `from` (block line-start) is docFrom; `from + nodeOffset` (Lezer node start) is + // nodeFrom, the base for per-cell offsets. All LF-internal. + widget: (b) => new TableBlockWidget(b.table, b.slice, b.from, b.from + b.nodeOffset), }); diff --git a/src/webview/cm/table/table-ranges.ts b/src/webview/cm/table/table-ranges.ts index ce852a12..32743085 100644 --- a/src/webview/cm/table/table-ranges.ts +++ b/src/webview/cm/table/table-ranges.ts @@ -1,6 +1,6 @@ -// The ONE Lezer `Table`-node walk used by table-skeleton.ts's `tableSkeletonField` -// to collect document-ordered Table ranges. Table detection, descendant-skip, and -// range semantics live in exactly one place so they can never drift. +// A Lezer `Table`-node walk collecting document-ordered Table ranges, for the +// lint rule lint/rules/table-column-count.ts. The table block widgets do not use it: +// their walk is the structural block contract's (../structural-blocks.ts). // Pure: a lazy reader of the passed tree, no field/state dependency, so nothing in // the table dir forms an import cycle around it. Typed via // `ReturnType` to avoid a direct `@lezer/common` import diff --git a/src/webview/cm/table/table-skeleton.ts b/src/webview/cm/table/table-skeleton.ts deleted file mode 100644 index 3ce67462..00000000 --- a/src/webview/cm/table/table-skeleton.ts +++ /dev/null @@ -1,196 +0,0 @@ -// A bounded-maintained, document-ordered list of EVERY Lezer `Table` node as a -// renderable table MODEL — `{from,to}` (the node range, produced by a full -// `syntaxTree(state).iterate()` via `collectTableRanges`) PLUS the line-snapped -// block range, the per-node LF source slice (the CM interior is LF-only — see -// `buildModel`), and the cached `parseTable` result. -// `tableBlockField`'s `buildAll` reads THIS field instead of re-walking the tree -// AND re-parsing every table per keystroke (~5 ms/MB whole-tree materialisation -// + O(tables) `parseTable` — see PERF.md). The list includes NON-emitting `Table` -// nodes (blockquote-nested tables whose continuation lines carry `>` markers, -// and cell-count-mismatch / malformed slices, that `parseTable` rejects → -// `table: null`) so that `tableBlockField` can derive a stable, document-ordered -// index over all Table nodes via the array's index + length. -// -// Bounded update mirrors imageBlockField's proven shape (PERF.md): reuse models -// OUTSIDE an `extendedSpan` (their node range + block range mapped through -// `tr.changes`, slice + parse reused verbatim), re-walk + re-parse the tree only -// INSIDE it. The parse + line-snap ride on the SAME liveness decision the ranges -// already use, so they inherit its soundness: Guards G1 (±1 line — a blank-line -// toggle adjacent to a table merges/splits it or (un)absorbs a trailing -// paragraph WITHOUT touching the table's own bytes; lezer also overshoots a -// table's `to` into a following non-blank paragraph — -// [[quoll-lezer-table-to-overshoots-trailing-line]]); and, on the docChanged arm, -// the shared admission test `requiresFullBoundedRebuild` (../structural-guard.js), -// which ORs G2 (an incomplete post-edit frontier → full walk; the later -// background-parse publication, a `!docChanged` tree-identity change, full-walks -// again to self-heal) with G-STRUCT (a structural reparse re-shapes boundaries -// outside the span with a complete frontier → full walk). ⚠️ PERF: G-STRUCT's -// TABLE-DELIM arm is a per-line SHAPE-DELTA (`tableRowShapeChanged`), not a -// presence test over the whole changed line, so typing INSIDE a cell whose row -// shape is unchanged stays on the bounded path this field exists to protect. -// The full walk is taken when the edited line — ANY row, not only a header — -// flips one of the facts lezer forms a `Table` from: it is delimiter-SHAPED on -// either side (read RAW or `[ \t]`-stripped, a PRESENCE retreat), its -// unescaped-pipe presence flips (a `\|` escape in prose or in a cell), or its -// `parseRow` cell count changes. G-STRUCT's OTHER arm (SHAPE) is still -// presence-based, so a list-item body, a blockquote line, an ATX heading, or any -// Enter still takes the full walk regardless of this field. Measured and accepted, -// not free: see PERF.md. Soundness (bounded ≡ fullWalk, ranges AND parses) is -// pinned by cm-table-skeleton.test.ts. - -import { syntaxTree } from "@codemirror/language"; -import { type EditorState, StateField, type Transaction } from "@codemirror/state"; -import { parseTable, type Table } from "../../../markdown/table/index.js"; -import { - type Interval, - intersects, - lineExpandWithNeighbours, - mergeIntervals, -} from "../bounded-recompute.js"; -import { requiresFullBoundedRebuild } from "../structural-guard.js"; -import { collectTableRanges } from "./table-ranges.js"; - -export interface TableModel { - /** Lezer `Table` node range — the bounded-reuse identity, re-walk dedup key, - * and document-order position within the array. */ - readonly from: number; - readonly to: number; - /** Whole-line-snapped block range — the block widget's `docFrom`..end. Block - * widgets must cover complete lines; precomputed here so `buildAll` does no - * per-keystroke `lineAt` over every table. */ - readonly blockFrom: number; - readonly blockTo: number; - /** Per-node source slice. The widget's eq() key; the input the cached `table` - * was parsed from. `readonly` because an eq() key that changed after - * construction would make two models compare equal while rendering - * differently — the field must be rebuilt, never patched. - * LF-only, because the CM document itself is (see `buildModel`) — so no cell - * `raw` string carries an embedded `\r` the DOM textNode would render as - * stray whitespace. That invariant is NOT enforced here: `cm/seed.ts`'s - * `splitToCmText` owns it upstream, at the one seam where host bytes become - * CM text. */ - readonly slice: string; - /** Cached parse, or `null` for a `Table` node `parseTable` rejects — a - * blockquote-nested table (continuation lines bear `>` markers, not - * whitespace) or a malformed slice (cell-count mismatch). List- and - * space/tab-indented tables now parse, so they are NOT in this set. Still - * occupies an ordinal slot in the document-order array for stable index - * accounting. */ - readonly table: Table | null; -} - -/** Build one model from a `Table` node range: per-node slice + parse + - * whole-line snap. Pure reader of the passed state's lazy doc. */ -function buildModel(state: EditorState, nodeFrom: number, nodeTo: number): TableModel { - const len = state.doc.length; - // Per-node slice — O(table-bytes). Never materialise the full doc. - // The `\r\n?` replace matches nothing today: the CM document is LF-only by - // construction (cm/seed.ts `splitToCmText` splits on /\r\n?|\n/) and - // `sliceDoc` renders that interior verbatim, because `EditorState. - // lineSeparator` is deliberately never provided. It is kept only as a - // belt-and-braces normaliser — NOT because the source doc carries CRLF. - const slice = state.sliceDoc(nodeFrom, nodeTo).replace(/\r\n?/g, "\n"); - const table = parseTable(slice, 0, slice.length); - // Snap to whole-line boundaries — `block: true` widgets must cover complete - // lines. Defensive: Lezer's Table node range usually IS line-aligned, but - // doc-final-no-newline and partial-tree edges may not be. - const startLine = state.doc.lineAt(nodeFrom); - const endLine = state.doc.lineAt(Math.min(nodeTo, len)); - return { - from: nodeFrom, - to: nodeTo, - blockFrom: startLine.from, - blockTo: endLine.to, - slice, - table, - }; -} - -/** Every `Table` node as a model, in document order — the unbounded reference - * used by the field's `create`/self-heal/G2 paths AND by `buildAll`'s fallback - * when the field is absent (tests). Lazy reader of `syntaxTree(state)`. */ -export function tableModels(state: EditorState): TableModel[] { - return collectTableRanges(syntaxTree(state)).map((r) => buildModel(state, r.from, r.to)); -} - -function extendedSpan(tr: Transaction): Interval[] { - const raw: Interval[] = []; - tr.changes.iterChangedRanges((_fa, _ta, fromB, toB) => { - raw.push(lineExpandWithNeighbours(tr.state, fromB, toB)); // G1 - }); - return mergeIntervals(raw); -} - -function boundedUpdate( - prev: readonly TableModel[], - tr: Transaction, - span: Interval[] -): TableModel[] { - const byFrom = new Map(); - // Reuse models OUTSIDE the span: map the node range AND the block range; reuse - // slice + parse verbatim. Liveness has a PRIMARY and an AUXILIARY guard: - // - PRIMARY: `!intersects(span, nf, nt)` — the new-range span guard. The - // `extendedSpan` (changed range ±1 line, G1) is centred on the change in - // NEW-doc coords, so any table whose bytes changed OR whose structure could - // flip (a blank-line toggle merging/splitting it, lezer trailing overshoot) - // has its mapped range intersect the span → re-walked + re-parsed. This is - // what makes parse-reuse sound: a reused parse's input slice is its node - // bytes, and G1/G2 already guarantee those bytes (and the node range) are - // unchanged for non-intersecting tables — node-range change ⇒ slice change - // ⇒ never a silent stale reuse. - // - AUXILIARY: `!touched` (old-range `touchesRange`) — a conservative - // belt-and-suspenders early-out in OLD coords. CM's `touchesRange` is - // boundary-inclusive, so for table geometries both guards are mutually - // shadowing: a blank-line deletion that merges two tables is adjacent to the - // second table's start → `touched=true` there, AND its mapped range falls - // inside the ±1-line span. Neither guard is independently isolable by a - // table geometry test; both are retained for belt-and-suspenders safety and - // consistency with `imageBlockField` (where the ±1 span IS load-bearing). - for (const m of prev) { - const touched = tr.changes.touchesRange(m.from, m.to) !== false; - const nf = tr.changes.mapPos(m.from, 1); - const nt = tr.changes.mapPos(m.to, -1); - if (!touched && !intersects(span, nf, nt)) { - byFrom.set(nf, { - from: nf, - to: nt, - blockFrom: tr.changes.mapPos(m.blockFrom, 1), - blockTo: tr.changes.mapPos(m.blockTo, -1), - slice: m.slice, - table: m.table, - }); - } - } - // Re-walk + re-parse `Table` nodes INSIDE each span interval (re-walk wins on - // same node `from`). - const tree = syntaxTree(tr.state); - for (const s of span) { - for (const r of collectTableRanges(tree, s.from, s.to)) { - byFrom.set(r.from, buildModel(tr.state, r.from, r.to)); - } - } - return [...byFrom.values()].sort((a, b) => a.from - b.from); -} - -// NOTE: legacy name — this field now carries full `TableModel[]` (range + block -// range + slice + parse), not a bare node-range "skeleton". The name is kept to -// avoid churn across editor.ts / index.ts / the table-field tests -// that import it; see the header doc for what it holds. -export const tableSkeletonField = StateField.define({ - create: (state) => tableModels(state), - update: (prev, tr) => { - if (tr.docChanged) { - // A `Table` node can vanish or appear here with no edit to its own bytes, driven by - // a structural reparse outside the changed span with a COMPLETE frontier — see - // requiresFullBoundedRebuild in ../structural-guard.ts. - if (requiresFullBoundedRebuild(tr)) { - return tableModels(tr.state); - } - return boundedUpdate(prev, tr, extendedSpan(tr)); - } - if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) { - return tableModels(tr.state); // background-parse publication self-heal - } - return prev; // selection-only / no-op: tree unchanged → models unchanged - }, -}); diff --git a/src/webview/cm/table/table-widget.ts b/src/webview/cm/table/table-widget.ts index cf37c852..ca479904 100644 --- a/src/webview/cm/table/table-widget.ts +++ b/src/webview/cm/table/table-widget.ts @@ -152,9 +152,9 @@ const armedRelease = new WeakMap(); * grep cell-point.ts describes finds one claim to audit instead of three. * * A cast, not a check, and what it asserts is traceable: `docFrom` reaches the - * constructor from `TableModel.blockFrom` (table-field.ts), which is + * constructor from `TableBlock.from` (table-field.ts), which is * `state.doc.lineAt(nodeFrom).from` or that value remapped through - * `tr.changes.mapPos` (table-skeleton.ts) — CodeMirror document positions, so + * `tr.changes.mapPos` (structural-blocks.ts) — CodeMirror document positions, so * there is nothing further to validate, only to name. * * ⚠️ Read `widget.docFrom` here, NEVER `widget.nodeFrom` — same-typed, @@ -162,7 +162,7 @@ const armedRelease = new WeakMap(); * tells them apart. `nodeFrom` is the Lezer `Table` node start (the CELL * caret base), not the block line-start; the two differ whenever the node * START is not a line start — any 1-3-space-indented or list-nested table - * (table-skeleton.ts's `blockFrom` snaps to `doc.lineAt(nodeFrom).from`, + * (table-field.ts's block `from` snaps to `doc.lineAt(nodeFrom).from`, * which is earlier than `nodeFrom` there) — and reading `nodeFrom` here * would land a margin click inside the table instead of at its first byte. * Pinned by "keeps docFrom (margin) and nodeFrom (cell base) independent @@ -480,7 +480,7 @@ export class TableBlockWidget extends QuollWidget { constructor( readonly table: Table, - /** LF-normalised source slice (table-skeleton's `m.slice`) — sameAs() key. + /** LF-normalised source slice (the table block's `slice`) — sameAs() key. * A byte change rebuilds; matches the pre-existing widget identity. */ readonly slice: string, /** Absolute LF-internal doc offset of the widget's first byte (block @@ -497,7 +497,7 @@ export class TableBlockWidget extends QuollWidget { * TS2345, forcing an explicit `asAbsoluteOffset` cast at each caller. * Leaving it unbranded keeps that crossing inside this module, at one * site (`blockStartOf`), instead of spreading it to every constructor - * caller — the trade the deferred paragraph below declines for now. + * caller. * * The actual staleness hazard is upstream of the type system, at the * two READ sites (the document `mouseup` seam, the root `click` @@ -508,13 +508,7 @@ export class TableBlockWidget extends QuollWidget { * reads the WeakMap FIRST and only falls back to the constructor value * on a miss. Freshness is `blockStartCaret`'s job, not this field's type. * The block start enters the branded space through `blockStartOf`, at - * one site, on purpose. - * - * (Deferred, out of scope here: minting at `TableModel.blockFrom`'s - * origin in table-skeleton.ts instead would move the crossing to the - * true source, turn a `docFrom`/`nodeFrom` constructor-argument swap - * into a compile error, and remove `blockStartOf` entirely — it ripples - * into the model, so not done in this PR.) */ + * one site, on purpose. */ readonly docFrom: number, /** Absolute LF-internal doc offset of the Lezer `Table` node start — base * for each cell's caret offset (`nodeFrom + cell.from`). CodeMirror is diff --git a/src/webview/editor.ts b/src/webview/editor.ts index 31aeb563..f85bf565 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -79,7 +79,7 @@ import { splitToCmText, } from "./cm/seed.js"; import { quollSwitchEditor } from "./cm/switch-editor.js"; -import { tableBlockField, tableSkeletonField } from "./cm/table/index.js"; +import { tableBlockField } from "./cm/table/index.js"; import { quollTaskCheckboxKeymap } from "./cm/task-checkbox/task-checkbox-command.js"; import { quollBlockStyleTheme, @@ -687,9 +687,6 @@ export function mountEditor(opts: EditorOptions): EditorHandle { // invalidation policy (always a top-level re-walk — see the field header). // Display-only. Publishes to neither zone facet, for the reason given above. blockquoteCollapseField, - // Bounded Table-node skeleton — precedes tableBlockField so buildAll - // reads it via state.field() instead of a per-keystroke full walk. - tableSkeletonField, // C6b: block widget for GFM tables. Publishes its range to // quollBlockReplaceZones so quollSyntaxReveal above drops inline // marks inside the widget; CodeMirror requires block widgets diff --git a/test/build/frontier-gate-needs-structural-guard.test.ts b/test/build/frontier-gate-needs-structural-guard.test.ts index 43141ec1..099e416e 100644 --- a/test/build/frontier-gate-needs-structural-guard.test.ts +++ b/test/build/frontier-gate-needs-structural-guard.test.ts @@ -6,7 +6,8 @@ // frontier term alone, so a field that imported it as its whole admission test would be // exactly the hand-spelled frontier-only gate this guard exists to stop. // -// WHY: two bounded fields (imageBlockField, tableSkeletonField) shipped with only this +// WHY: two bounded fields (the image and table block fields, now `imageBlockField` and +// `tableBlockField` on the structural block contract) shipped with only this // frontier half of the admission test and no structural-reparse half, because the OR was // hand-spelled per field instead of named once. `requiresFullBoundedRebuild` (structural- // guard.ts) is the fix — a single function that pairs both terms so a caller cannot invoke diff --git a/test/webview-browser/helpers/table-drag-harness.ts b/test/webview-browser/helpers/table-drag-harness.ts index 94f55c83..93db3d7d 100644 --- a/test/webview-browser/helpers/table-drag-harness.ts +++ b/test/webview-browser/helpers/table-drag-harness.ts @@ -19,7 +19,7 @@ import { expect } from "vitest"; import { userEvent } from "vitest/browser"; import "../../../src/webview/styles.css"; import type { CaretResolver } from "../../../src/webview/cm/table/cell-point.js"; -import { tableBlockField, tableSkeletonField } from "../../../src/webview/cm/table/index.js"; +import { tableBlockField } from "../../../src/webview/cm/table/index.js"; import { quollTheme } from "../../../src/webview/cm/theme.js"; // Row 1's cells are plain text, so their source map is one identity run and a @@ -42,8 +42,7 @@ export const TABLE_BLOCK_START = DOC.indexOf("| Alpha"); * gesture "select the table plus the paragraph below". */ export const TAIL = DOC.indexOf("tail"); -/** Production extension order for the table island (editor.ts: skeleton field - * BEFORE the block field). Caret parked at doc end so the line-level reveal is +/** Production extension set for the table island (editor.ts). Caret parked at doc end so the line-level reveal is * not already firing when a gesture starts. `extra` exists only so a suite can * pin the OTHER caret-from-point arm through `quollTableCaretResolver.of(...)` * — pass nothing and the widget runs on `defaultCaretResolver`, which is what @@ -58,13 +57,7 @@ export function mount(extra: Extension = []): EditorView { doc: DOC, // DOC has no CRLF, so string length IS Text length. selection: EditorSelection.cursor(DOC.length), - extensions: [ - quollTheme, - markdown({ base: markdownLanguage }), - tableSkeletonField, - tableBlockField, - extra, - ], + extensions: [quollTheme, markdown({ base: markdownLanguage }), tableBlockField, extra], }), parent, }); diff --git a/test/webview/cm-block-widget-bounded.test.ts b/test/webview/cm-block-widget-bounded.test.ts index 189bbecb..f2c7bffc 100644 --- a/test/webview/cm-block-widget-bounded.test.ts +++ b/test/webview/cm-block-widget-bounded.test.ts @@ -58,9 +58,9 @@ interface Edit { // The two ways a `changes` object normalises to an empty ChangeSet are pinned directly by // the door-guard test below (`{ changes: { from: 0 } }` and `{ changes: { from: 0, to: 0 } }`, // with and without an explicit `insert: ""`), not restated here. An inert `changes` alone -// only kills imageBlockField.update's docChanged arm (the `if (tr.docChanged)` branch) — -// paired with a real `selection` on the same `Edit`, it can still reach `computeBounded` -// through the selection arm (the tail of `update`); the door guard below requires +// only kills the field's docChanged arm (the `if (tr.docChanged)` branch of the contract in +// structural-blocks.ts) — paired with a real `selection` on the same `Edit`, it can still +// reach the selection (reveal) arm (the tail of `update`); the door guard below requires // `!e.selection` too. (Named by symbol, not by line: cross-file line numbers in this file // drifted twice within one PR.) const inertChanges = (c: Edit["changes"]) => @@ -68,7 +68,7 @@ const inertChanges = (c: Edit["changes"]) => // Where the settles go, and — just as load-bearing — where they do NOT. // -// The MOUNT settles: imageBlockField.create() reads syntaxTree(state) (image-field.ts), +// The MOUNT settles: imageBlockField.create() reads syntaxTree(state) (structural-blocks.ts), // so on a truncated init snapshot the field's INITIAL value is built over a partial tree // and the first edit's bounded path starts from a wrong `prev`. A bare ensureSyntaxTree // cannot fix that — it advances the parse CONTEXT and leaves the field's published @@ -79,19 +79,19 @@ const inertChanges = (c: Edit["changes"]) => // to nothing, which is exactly what two bare ensureSyntaxTree calls used to produce. // // The EDIT LOOP must NOT settle. `forceParsing` dispatches whenever the completed tree -// differs from the published snapshot, and that dispatch drives imageBlockField.update's -// tree-identity branch into computeFreshFull — silently turning this bounded-vs-full +// differs from the published snapshot, and that dispatch drives the contract's +// tree-identity branch into a full rebuild — silently turning this bounded-vs-full // compare into full-vs-full. That trades a flake for a vacuous pass, which is worse. // The pin below is what keeps the bounded result trustworthy without a settle. // // ⚠️ The comparison is ATTEMPTED rather than asserted on the first try, which is why this // runs through `withUnstarvedFrontier` (helpers/unstarved-frontier.ts) rather than a local // loop. CodeMirror gives its post-edit reparse a 20ms WALL-CLOCK budget, and under CPU -// starvation that window can elapse while this process is descheduled; image-field.ts's G2 -// arm then self-heals with a full recompute, so the bounded path is not what ran and there +// starvation that window can elapse while this process is descheduled; the contract's +// frontier arm then takes a full rebuild, so the bounded path is not what ran and there // is nothing to compare. Retrying from a fresh view neither hides a regression (a real // bounded bug reds every attempt that gets far enough to compare — measured by breaking -// computeBounded) nor passes vacuously (an all-starved run throws, and that throw is pinned +// the bounded update) nor passes vacuously (an all-starved run throws, and that throw is pinned // in helpers/unstarved-frontier.test.ts rather than living unguarded here, which is the // whole reason the loop moved), which a vitest-level `{ retry: n }` would fail on both // counts. @@ -107,21 +107,20 @@ function checkEquivalence(initial: string, edits: Edit[], oracleSlots: number): // `.every(...)`, so a MIXED array — even one live edit among otherwise-inert ones — passes // the door; that is deliberate, since one live edit is enough for the comparison below to // be ABLE to exercise a bounded arm. Whether it actually does is decided downstream, by - // image-field.ts's own gates — see "What this does NOT rule out" below. + // the contract's own gates (structural-blocks.ts) — see "What this does NOT rule out" below. // What this does NOT rule out: a `selection`/`cursorAtEnd` edit that dispatches something // real but whose selection LINE SPAN happens not to change. On a non-docChanged - // transaction, reaching image-field.ts's `computeBounded` requires first surviving its G3 - // `leadingFrontmatterEnd` check and its `syntaxTree` identity check, and only then finding + // transaction the contract (structural-blocks.ts) keeps its records unless the tree + // changed, and re-filters the decorations only on // `!selectionLineSpansEqual(tr.startState, tr.state)` — so that inequality is a - // NECESSARY condition for the bounded arm, not a sufficient one, and deciding it at the - // door would mean reimplementing all three checks here (none are exported from - // image-field.ts). Unlike the `changes` shape above — which `inertChanges` decides from + // NECESSARY condition for the selection arm, not a sufficient one, and deciding it at the + // door would mean reimplementing the contract's checks here. Unlike the `changes` shape above — which `inertChanges` decides from // the `Edit` literal alone — whether a selection move crosses a line boundary depends on // both transaction states, which this predicate does not have. So this guard is a floor, // not a guarantee: it catches the fully inert call, not every selection-only call that // fails to cross a line boundary. The "selection-only onto then off an image" case below // is deliberately NOT inert — moving the cursor onto and then off the image's line - // crosses that boundary and exercises computeBounded via the selection arm, not the + // crosses that boundary and exercises the selection (reveal) arm, not the // docChanged one. throw new Error( "checkEquivalence: at least one edit with `changes`, `selection`, or `cursorAtEnd` is required to exercise the bounded path" @@ -157,9 +156,9 @@ function checkEquivalence(initial: string, edits: Edit[], oracleSlots: number): // shape and the guarantee breaks with it. // // ⚠️ What passing the gate rules out is the STARVED-frontier full walk, and nothing - // more. imageBlockField.update takes its G3 arm — computeFreshFull — whenever - // leadingFrontmatterEnd changes, BEFORE this predicate is ever consulted - // (image-field.ts), so passing does not mean the bounded path ran. The seven "G3" + // more. The contract takes a full rebuild whenever the leading frontmatter's end + // moves (`frontmatterMoved`, structural-blocks.ts), whatever the frontier, so + // passing does not mean the bounded path ran. The seven "G3" // rows below take that arm, and what they compare there is the field's // INCREMENTALLY parsed full walk against the oracle's freshly parsed one — not // bounded against full. That is not a hole: it is how those rows pin the arm, since @@ -181,8 +180,8 @@ function checkEquivalence(initial: string, edits: Edit[], oracleSlots: number): }) ); assertEquivalent( - slots(view.state.field(imageBlockField)), - slots(oracle.field(imageBlockField)), + slots(view.state.field(imageBlockField).decorations), + slots(oracle.field(imageBlockField).decorations), oracleSlots ); }, @@ -191,7 +190,10 @@ function checkEquivalence(initial: string, edits: Edit[], oracleSlots: number): const IMG = "![alt](https://example.com/a.png)"; -// Three G3 fixture pairs follow. Each crosses `leadingFrontmatterEnd` by a different +// "G3" names the pre-contract image field's frontmatter arm; in the structural block +// contract its job is done by `frontmatterMoved` (structural-blocks.ts) plus the structural +// guard, and the "Measured 2026-09-02" notes below were taken against the pre-contract +// field. Three G3 fixture pairs follow. Each crosses `leadingFrontmatterEnd` by a different // mechanism — the closer fence MOVES to another line, line 1's own fence identity FLIPS, or // the closer APPEARS/DISAPPEARS while line 1 stays a fence — and each pair is one document // plus the edit that produces the other, which is what lets a single pair cover both @@ -300,10 +302,10 @@ describe("imageBlockField bounded ≡ full", () => { oracleSlots: 1, }, // The six rows below — three pairs, one per fixture pair above — are the ones that pin - // image-field.ts's G3 arm; the length-shift row above does not, and cannot. A length-only + // full rebuild a moved frontmatter end needs; the length-shift row above does not, and cannot. A length-only // edit INSIDE the fences moves leadingFrontmatterEnd by a couple of characters but never // past the image, so eligibility does not flip: with the arm hypothetically deleted, - // computeExtendedSpan covers only the frontmatter's own lines, computeBounded re-emits + // the bounded span covers only the frontmatter's own lines, the bounded update re-emits // the untouched widget byte-identically, and the row stays green. Each of the six instead // moves the image ACROSS leadingFrontmatterEnd, and every pair covers both directions // because the two failure shapes are different: the enclosing direction leaves a STALE @@ -335,7 +337,7 @@ describe("imageBlockField bounded ≡ full", () => { oracleSlots: 1, }, // The OPENER-FLIP pair: line 1 itself moves, so `detectLeadingFrontmatterInState` returns - // a span on one side and `null` on the other, and computeExtendedSpan covers line 1 and + // a span on one side and `null` on the other, and the bounded span covers line 1 and // its neighbour but never the image four lines below. Measured 2026-09-02: narrowing the // G3 check to a closer-only comparison — one that fires only when BOTH states have a span // and their `to` differs — reds both rows below while the closer-fence rows stay green @@ -404,7 +406,7 @@ describe("imageBlockField bounded ≡ full", () => { // A structural reparse: an unclosed ``` typed on line 1 swallows the image below // into a FencedCode node, so the Image node vanishes with NO edit inside its own // bytes and with a COMPLETE frontier — the crossing neither the bounded reuse rule - // nor G2 can see. Measured on the unguarded field: a stale widget survived at 10-43 + // nor the frontier arm can see. Measured on the unguarded field: a stale widget survived at 10-43 // against an oracle holding none. name: "structural reparse — an unclosed fence above swallows the image", initial: `intro\n\n${IMG}\n\ntail\n`, @@ -436,12 +438,11 @@ describe("imageBlockField bounded ≡ full", () => { // The guard's breadth (structural-guard.ts) routes many of the rows above to the FULL arm // — a newline delta or a blank-line flip is enough. That is sound, and those rows keep // their value as full-vs-oracle checks, but it thins out the ones that still reach - // `computeBounded` through the docChanged arm. Pin the two below so a widened guard or a + // the bounded update through the docChanged arm. Pin the two below so a widened guard or a // re-fixtured row cannot quietly leave this file blind to a bounded regression. // - // NOT a claim that these are the only rows on the bounded path: the selection-driven rows - // further down reach `computeBounded` through the SELECTION arm, which this guard does not - // touch. This pins two; it does not census the file. + // NOT a claim that these are the only rows off the full arm: the selection-driven rows + // further down take the SELECTION (reveal) arm, which this guard does not touch. This pins two; it does not census the file. // // The G3 rows are deliberately NOT here: their edit moves `leadingFrontmatterEnd`, so the // field returns from the frontmatter arm ABOVE this condition and never consults it — diff --git a/test/webview/cm-block-widget-byte-identity.test.ts b/test/webview/cm-block-widget-byte-identity.test.ts index 54e9a8cd..046e2e85 100644 --- a/test/webview/cm-block-widget-byte-identity.test.ts +++ b/test/webview/cm-block-widget-byte-identity.test.ts @@ -53,11 +53,7 @@ import { } from "../../src/webview/cm/frontmatter/index.js"; import { hostDocumentReseed } from "../../src/webview/cm/host-reseed.js"; import { quollDocumentEol, serializeDocument, splitToCmText } from "../../src/webview/cm/seed.js"; -import { - TableBlockWidget, - tableBlockField, - tableSkeletonField, -} from "../../src/webview/cm/table/index.js"; +import { TableBlockWidget, tableBlockField } from "../../src/webview/cm/table/index.js"; import { settledMount, settledView } from "./helpers/settled-view.js"; import { withUnstarvedFrontier } from "./helpers/unstarved-frontier.js"; @@ -107,7 +103,7 @@ function assertEquivalent(actual: Slot[], oracle: Slot[]): void { * fixtures are LF-only + non-indented, so the node range equals the block * range and the CRLF→LF normalisation is a no-op → an exact byte comparison. */ function assertTableByteAnchored(state: EditorState): void { - for (const s of slots(state.field(tableBlockField))) { + for (const s of slots(state.field(tableBlockField).decorations)) { const w = s.widget as TableBlockWidget; expect(w.slice).toBe(state.sliceDoc(s.from, s.to)); } @@ -139,18 +135,14 @@ interface Edit { // ── TABLE oracle ────────────────────────────────────────────────────────────── // -// tableBlockField reads its widget slices from the bounded-maintained -// tableSkeletonField, so BOTH must be registered for the bounded path to run -// (absent, buildAll falls back to a full walk and the oracle would be vacuous). // A live view settled through `settledView` is required: settling advances the // parse to the doc end AND republishes the tree snapshot, and the field then // converges to the fully-parsed result via the tree-identity self-heal branch, -// exactly as cm-table-skeleton.test.ts drives it. +// exactly as cm-table-blocks.test.ts drives it. const tableExts = (): Extension[] => [ EditorState.allowMultipleSelections.of(true), markdown({ base: markdownLanguage }), - tableSkeletonField, tableBlockField, ]; @@ -166,7 +158,7 @@ function tableFullSlots(doc: string, selection: EditorSelection): Slot[] { }); try { settledView(view, 10_000); - return slots(view.state.field(tableBlockField)); + return slots(view.state.field(tableBlockField).decorations); } finally { view.destroy(); } @@ -183,7 +175,7 @@ function checkTableEquivalence(initial: string, edits: Edit[]): void { settledView(view, 10_000); // create() correctness on the fully-parsed initial doc. assertEquivalent( - slots(view.state.field(tableBlockField)), + slots(view.state.field(tableBlockField).decorations), tableFullSlots(view.state.doc.toString(), view.state.selection) ); assertTableByteAnchored(view.state); @@ -194,21 +186,21 @@ function checkTableEquivalence(initial: string, edits: Edit[]): void { } const len = view.state.doc.length; // Pre-self-heal bounded assertion: when the post-edit tree is already - // complete, boundedUpdate ran during dispatch — check its output BEFORE + // complete, the bounded update ran during dispatch — check its output BEFORE // settling so a self-heal can't mask a bounded byte bug (Codex R2-2, - // cm-table-skeleton.test.ts). Whether or not the tree is synchronously + // cm-table-blocks.test.ts). Whether or not the tree is synchronously // available here is harness-dependent, so this stays conditional and the // guaranteed bounded-path pin lives in its own test below. if (syntaxTreeAvailable(view.state, len)) { assertEquivalent( - slots(view.state.field(tableBlockField)), + slots(view.state.field(tableBlockField).decorations), tableFullSlots(view.state.doc.toString(), view.state.selection) ); assertTableByteAnchored(view.state); } settledView(view, 10_000); // publish → converge (also covers the G2 path) assertEquivalent( - slots(view.state.field(tableBlockField)), + slots(view.state.field(tableBlockField).decorations), tableFullSlots(view.state.doc.toString(), view.state.selection) ); assertTableByteAnchored(view.state); @@ -296,16 +288,16 @@ describe("tableBlockField byte-identity: bounded ≡ full", () => { it(c.name, () => checkTableEquivalence(c.initial, c.edits)); } - // Guaranteed bounded-path pin (mirrors cm-table-skeleton.test.ts's no-self-heal + // Guaranteed bounded-path pin (mirrors cm-table-blocks.test.ts's no-self-heal // anchor): a small in-place edit on a complete tree keeps the frontier at doc end, - // so boundedUpdate ran during dispatch. REQUIRE the frontier to be unstarved — rather + // so the bounded update ran during dispatch. REQUIRE the frontier to be unstarved — rather // than tolerate a starved one as the matrix's conditional guard does — and check the // field's output plus its byte-anchor BEFORE any settling, so a broken bounded reuse // can't be masked by a self-heal. This is the case the matrix cannot guarantee runs. it("exercises the bounded path without self-heal masking (revert-check anchor)", () => { const doc = `${T}\n\nprose\n\n${T}`; withUnstarvedFrontier({ - what: "boundedUpdate's pre-self-heal output and byte anchor", + what: "the bounded update's pre-self-heal output and byte anchor", mount: (parent) => settledMount( { state: EditorState.create({ doc, extensions: tableExts() }), parent }, @@ -320,10 +312,10 @@ describe("tableBlockField byte-identity: bounded ≡ full", () => { // dispatch, so the bounded output this test exists to check never existed. Abandon // the attempt; an all-starved run throws rather than passing having measured nothing. requireUnstarvedFrontier(); - // boundedUpdate's output, pre-self-heal, must equal the full walk AND stay + // The bounded update's output, pre-self-heal, must equal the full walk AND stay // anchored to the document bytes. assertEquivalent( - slots(view.state.field(tableBlockField)), + slots(view.state.field(tableBlockField).decorations), tableFullSlots(view.state.doc.toString(), view.state.selection) ); assertTableByteAnchored(view.state); @@ -471,7 +463,7 @@ describe("frontmatterBlockField byte-identity: bounded ≡ full", () => { // // • TABLE — EOL-SENSITIVE, and this is the case that earns the CRLF fixture. // The widget slice is DELIBERATELY LF-normalised at capture -// (table-skeleton.ts: `sliceDoc(...).replace(/\r\n?/g, "\n")`, so a cell's +// (table-field.ts: `sliceDoc(...).replace(/\r\n?/g, "\n")`, so a cell's // `raw` never carries an embedded `\r` the DOM would render as stray // whitespace), so CR is not expected to survive into `w.slice`. The // load-bearing read is the OUTBOUND one: `serializeDocument(doc, eol)` over @@ -521,7 +513,7 @@ describe("CRLF-seeded byte-identity: line-ending-aware widget anchors", () => { try { settledView(view, 10_000); const st = view.state; - const emitted = slots(st.field(tableBlockField)); + const emitted = slots(st.field(tableBlockField).decorations); expect(emitted.length).toBe(1); // the table renders (guards against vacuity) const s = emitted[0]; const w = s.widget as TableBlockWidget; diff --git a/test/webview/cm-raw-html-inert.test.ts b/test/webview/cm-raw-html-inert.test.ts index cf8fd04b..0c943a5f 100644 --- a/test/webview/cm-raw-html-inert.test.ts +++ b/test/webview/cm-raw-html-inert.test.ts @@ -34,7 +34,7 @@ const RAW_HTML: readonly string[] = [ // reveal-on-caret" describe in test/webview/image/cm-image-field.test.ts). function imageWidgetCount(state: EditorState): number { let n = 0; - const cur = state.field(imageBlockField).iter(); + const cur = state.field(imageBlockField).decorations.iter(); while (cur.value) { if ((cur.value.spec as { widget?: unknown }).widget instanceof ImageBlockWidget) { n++; @@ -153,7 +153,7 @@ describe("C8 enrollment under the full widget set (combined doc, all block field it("the table field builds a widget over the combined doc", () => { const { view, parent } = combinedView(); try { - expect(view.state.field(tableBlockField).size).toBeGreaterThan(0); + expect(view.state.field(tableBlockField).decorations.size).toBeGreaterThan(0); } finally { view.destroy(); parent.remove(); diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 5fcde354..d7a41f00 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -35,7 +35,7 @@ import { settledMount } from "./helpers/settled-view.js"; // One switch for both throwing seams. A mock only throws while `armed` is true, so the // fields' `create` (which already calls both seams) never throws at mount. -const seam = vi.hoisted(() => ({ armed: false, armedCalls: 0 })); +const seam = vi.hoisted(() => ({ armed: false, armedCalls: 0, log: [] as string[] })); // The specifier the table block field imports `parseTable` from. vi.mock("../../src/markdown/table/index.js", async (importOriginal) => { @@ -43,6 +43,7 @@ vi.mock("../../src/markdown/table/index.js", async (importOriginal) => { return { ...real, parseTable: (source: string, from: number, to: number) => { + seam.log.push(source); if (seam.armed) { seam.armedCalls++; if (source.includes("BOOM")) { @@ -60,6 +61,7 @@ vi.mock("../../src/webview/cm/inline/inline-ir.js", async (importOriginal) => { return { ...real, commonMarkAltText: (raw: string) => { + seam.log.push(raw); if (seam.armed) { seam.armedCalls++; if (raw.includes("BOOM")) { @@ -74,6 +76,7 @@ vi.mock("../../src/webview/cm/inline/inline-ir.js", async (importOriginal) => { afterEach(() => { seam.armed = false; seam.armedCalls = 0; + seam.log.length = 0; vi.restoreAllMocks(); }); @@ -290,6 +293,204 @@ describe("structural block widget identity", () => { }); }); +/** The production table + image fields over the production parser, from one module + * graph (the rows below compare widget classes with `instanceof`). */ +async function productionBlocks() { + const { tableBlockField, TableBlockWidget } = await import("../../src/webview/cm/table/index.js"); + const { imageBlockField, ImageBlockWidget } = await import("../../src/webview/cm/image/index.js"); + const { quollMarkdownLanguage } = await import("../../src/webview/cm/markdown.js"); + const extensions: Extension[] = [quollMarkdownLanguage(), tableBlockField, imageBlockField]; + const create = (doc: string) => settledState(EditorState.create({ doc, extensions })); + /** `(from, to, widget class)` of every table + image decoration, in order. */ + const shape = (state: EditorState) => + [...widgetSlots(state, TableBlockWidget), ...widgetSlots(state, ImageBlockWidget)] + .sort((a, b) => a.from - b.from) + .map((s) => [s.from, s.to, s.widget.constructor.name]); + return { + tableBlockField, + TableBlockWidget, + imageBlockField, + ImageBlockWidget, + extensions, + create, + shape, + }; +} + +const BLOCKS_TAIL = [ + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "![pic](https://example.com/p.png)", + "", + "Tail paragraph.", + "", +]; + +describe("structural blocks: ownership (production fields)", () => { + const FM_DOC = [ + "---", + "title: x", + "| a | b |", + "| - | - |", + "| 1 | 2 |", + "", + "![pic](https://example.com/p.png)", + "---", + "", + "Body.", + "", + ].join("\n"); + + async function ownershipRow(kind: "table" | "image") { + const p = await productionBlocks(); + const cls = kind === "table" ? p.TableBlockWidget : p.ImageBlockWidget; + const st0 = p.create(FM_DOC); + // Precondition: the frontmatter really is recognised (it spans the closer). + expect(widgetSlots(st0, cls)).toEqual([]); + + const closer = FM_DOC.lastIndexOf("---\n"); + const open = st0.update({ changes: { from: closer, to: closer + 4 } }).state; + const needle = kind === "table" ? "| a |" : "![pic]"; + expect(widgetSlots(open, cls).map((s) => s.from)).toEqual([ + open.doc.toString().indexOf(needle), + ]); + + const closed = open.update({ changes: { from: closer, insert: "---\n" } }).state; + expect(closed.doc.toString()).toBe(FM_DOC); + expect(widgetSlots(closed, cls)).toEqual([]); + } + + it("ownership table: a table inside frontmatter has no widget until the closer goes", async () => { + await ownershipRow("table"); + }); + + it("ownership image: an image inside frontmatter has no widget until the closer goes", async () => { + await ownershipRow("image"); + }); +}); + +describe("structural blocks: frontmatter shift stays bounded (production fields)", () => { + const FM_SHIFT_DOC = ["---", "title: x", "note: y", "---", "", ...BLOCKS_TAIL].join("\n"); + const NO_FM_DOC = ["Top.", "", "Second.", "", ...BLOCKS_TAIL].join("\n"); + + /** The table/image positions the widgets were given, per record. */ + async function positions(state: EditorState) { + const p = await productionBlocks(); + const t = state + .field(p.tableBlockField) + .records.map(({ block }) => [block.from, block.to, block.from + block.nodeOffset]); + const i = state + .field(p.imageBlockField) + .records.map(({ block, deco }) => [ + block.from, + block.to, + (deco.spec.widget as InstanceType).docFrom, + ]); + return { t, i }; + } + + async function shiftRow(doc: string, change: { from: number; to?: number; insert?: string }) { + const p = await productionBlocks(); + const st0 = p.create(doc); + // Precondition: both seams built their blocks at mount. + expect(seam.log.some((s) => s.includes("| a | b |"))).toBe(true); + expect(seam.log).toContain("pic"); + expect(p.shape(st0)).toHaveLength(2); + seam.log.length = 0; + + const tr = st0.update({ changes: change }); + const st1 = tr.state; + expect(requiresFullBoundedRebuild(tr)).toBe(false); // precondition: no full reason + expect(p.shape(st1)).toHaveLength(2); + // Bounded rebase: neither block was rebuilt. + expect(seam.log).toEqual([]); + // Every widget-visible position equals a fresh full build's. + const fresh = p.create(st1.doc.toString()); + expect(await positions(st1)).toEqual(await positions(fresh)); + expect(p.shape(st1)).toEqual(p.shape(fresh)); + } + + it("frontmatter body insert: one character inside the body rebases the blocks", async () => { + await shiftRow(FM_SHIFT_DOC, { from: FM_SHIFT_DOC.indexOf("x\n"), insert: "z" }); + }); + + it("frontmatter body delete: one character out of the body rebases the blocks", async () => { + const at = FM_SHIFT_DOC.indexOf("x\n"); + await shiftRow(FM_SHIFT_DOC, { from: at, to: at + 1 }); + }); + + it("no frontmatter offset-0 insert: a keystroke at offset 0 rebases the blocks", async () => { + await shiftRow(NO_FM_DOC, { from: 0, insert: "z" }); + }); +}); + +describe("structural blocks: an edit outside the viewport (production fields)", () => { + async function viewportRow(kind: "table" | "image") { + const p = await productionBlocks(); + const prose: string[] = []; + for (let i = 0; i < 400; i++) { + prose.push(`Paragraph ${i} of plain prose between the blocks and the edit.`, ""); + } + const doc = ["Top.", "", ...BLOCKS_TAIL, ...prose, "Last line."].join("\n"); + const view = mountView(EditorState.create({ doc, extensions: p.extensions })); + try { + const blocks = (): readonly StructuralBlock[] => + (kind === "table" + ? view.state.field(p.tableBlockField) + : view.state.field(p.imageBlockField) + ).records.map((r) => r.block); + const before = blocks(); + expect(before).toHaveLength(1); // precondition + const at = doc.length; + // Precondition: the edit is beyond the reported viewport and after every block, + // with at least one line between the last block and the edited line. + expect(at).toBeGreaterThan(view.viewport.to); + const lastBlockLine = view.state.doc.lineAt( + Math.max(...p.shape(view.state).map((s) => Number(s[1]))) + ).number; + expect(view.state.doc.lineAt(at).number - lastBlockLine).toBeGreaterThan(1); + + view.dispatch({ changes: { from: at, insert: "x" } }); + const after = blocks(); + expect(after).toHaveLength(1); + expect(after[0]).toBe(before[0]); + expect(p.shape(view.state)).toEqual(p.shape(p.create(view.state.doc.toString()))); + } finally { + view.destroy(); + } + } + + it("viewport outside table: the table block object is reused and matches a fresh build", async () => { + await viewportRow("table"); + }); + + it("viewport outside image: the image block object is reused and matches a fresh build", async () => { + await viewportRow("image"); + }); +}); + +describe("structural blocks: reveal (production fields)", () => { + it("reveal table: a caret on a table line hides its widget, leaving shows the SAME widget", async () => { + const p = await productionBlocks(); + const doc = ["Top.", "", ...BLOCKS_TAIL].join("\n"); + const st0 = p.create(doc); + const before = widgetSlots(st0, p.TableBlockWidget); + expect(before).toHaveLength(1); // precondition: caret at 0 reveals nothing + + const entered = st0.update({ selection: { anchor: before[0].from + 2 } }).state; + expect(widgetSlots(entered, p.TableBlockWidget)).toEqual([]); + // The image is on its own line, so it stays rendered. + expect(widgetSlots(entered, p.ImageBlockWidget)).toHaveLength(1); + + const left = entered.update({ selection: { anchor: 0 } }).state; + const after = widgetSlots(left, p.TableBlockWidget); + expect(after.map((s) => s.from)).toEqual([before[0].from]); + expect(after[0].widget).toBe(before[0].widget); + }); +}); + /** Prose long enough that `EditorState.create` publishes a PARTIAL snapshot (its init * parse stops near a 3000-char viewport), followed by `tail`. No fixed offset is relied * on: the partial snapshot is asserted where it matters. */ diff --git a/test/webview/cm-structural-guard-fuzz.test.ts b/test/webview/cm-structural-guard-fuzz.test.ts index 2b0a44c0..1126f626 100644 --- a/test/webview/cm-structural-guard-fuzz.test.ts +++ b/test/webview/cm-structural-guard-fuzz.test.ts @@ -24,7 +24,7 @@ import { imageBlockField } from "../../src/webview/cm/image/image-field.js"; import { ImageBlockWidget } from "../../src/webview/cm/image/image-widget.js"; import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js"; import { requiresFullBoundedRebuild } from "../../src/webview/cm/structural-guard.js"; -import { tableSkeletonField } from "../../src/webview/cm/table/table-skeleton.js"; +import { tableBlockField } from "../../src/webview/cm/table/table-field.js"; import { settledState } from "./helpers/settled-state.js"; import { SHAPE_CORPUS } from "./helpers/table-shape-corpus.js"; @@ -32,7 +32,7 @@ const extensions = (): Extension[] => [ quollMarkdownLanguage(), quollFolding(), // already contains the three fold gutter fields — do not mount them twice imageBlockField, - tableSkeletonField, + tableBlockField, calloutMarkerConcealField, ]; @@ -73,8 +73,8 @@ function serializeImages(set: ReturnType) { function snapshot(state: EditorState): Record { const callout = state.field(calloutMarkerConcealField); return { - table: JSON.stringify(state.field(tableSkeletonField)), - image: serializeImages(state.field(imageBlockField)), + table: JSON.stringify(state.field(tableBlockField).records.map((r) => r.block)), + image: serializeImages(state.field(imageBlockField).decorations), // The WHOLE callout value: `records` / `markers` / `zones` are what the selection-only // path reuses, so comparing `decorations` alone would miss a stale record. calloutRecords: JSON.stringify(callout.records), @@ -159,8 +159,8 @@ describe("structural guard — differential fuzz over the real consumers", () => // arm too (Codex rev.2, Confidence 94). Ask the table field itself whether the edit // landed inside a real `Table` node. const inTable = state - .field(tableSkeletonField) - .some((m) => pos >= m.blockFrom && pos <= m.blockTo); + .field(tableBlockField) + .records.some(({ block }) => pos >= block.from && pos <= block.to); // ⚠️ "inside a `Table` node" alone is NOT enough either: a table's trailing // overshoot line carries no pipe, and such an edit takes the bounded path under the // OLD presence arm too (measured: `inCellBounded` reached 23 with the arm reverted diff --git a/test/webview/cm-url-security-matrix.test.ts b/test/webview/cm-url-security-matrix.test.ts index 09c6d06c87bfd36deaebcb5bc08189152ec7b451..fbff93bfd52bbab7dc5fd417db0bcd257401f9b7 100644 GIT binary patch delta 24 fcmaDE{xy8VHE!;d)a3l4#FEVXyyDGQxkVHJl5q-) delta 12 Tcmeww{xW>SHSW!ixP=t}E_em; diff --git a/test/webview/cm-widget-containment.test.ts b/test/webview/cm-widget-containment.test.ts index d52b5187..e6e8117c 100644 --- a/test/webview/cm-widget-containment.test.ts +++ b/test/webview/cm-widget-containment.test.ts @@ -24,6 +24,25 @@ const postMessage = vi.fn(); const editPosts = () => postMessage.mock.calls.map((c) => c[0]).filter((m) => (m as { type?: string })?.type === "edit"); +// The specifier the table block field imports `parseTable` from: a pass-through that +// throws for the "BOOM" table only while `tableSeam.armed` is true, so mount succeeds. +const tableSeam = vi.hoisted(() => ({ armed: false, armedCalls: 0 })); +vi.mock("../../src/markdown/table/index.js", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + parseTable: (source: string, from: number, to: number) => { + if (tableSeam.armed) { + tableSeam.armedCalls++; + if (source.includes("BOOM")) { + throw new Error("parseTable armed to throw"); + } + } + return real.parseTable(source, from, to); + }, + }; +}); + vi.mock("../../src/webview/host.js", () => ({ getHost: () => ({ postMessage }), subscribeToHost: () => () => {}, @@ -62,6 +81,8 @@ afterEach(() => { } vi.useRealTimers(); vi.restoreAllMocks(); + tableSeam.armed = false; + tableSeam.armedCalls = 0; }); function mount(opts: { onDispatch?: (action: Action) => void } = {}): { @@ -131,6 +152,35 @@ it("a throwing widget render leaves the session fully settled", () => { expect(editPosts().at(-1)?.baseDocVersion).toBe(8); }); +it("structural build failure keeps sync: a throwing parseTable never stops edit-sync", () => { + const { handle, view } = mount(); + vi.spyOn(console, "error").mockImplementation(() => {}); + // Prose first so the reseed's caret at 0 stays off the table lines. + const content = "intro\n\n| BOOM | b |\n| - | - |\n| 1 | 2 |\n\ntail\n"; + handle.applyDocument({ content, eol: "\n", canWrite: true, docVersion: 7, ...PAIR }); + // Precondition: the table rendered before arming. + expect(document.querySelector("table")).not.toBeNull(); + + tableSeam.armed = true; + const inCell = content.indexOf("BOOM") + 4; + expect(() => view.dispatch({ changes: { from: inCell, insert: "x" } })).not.toThrow(); + expect(tableSeam.armedCalls).toBeGreaterThan(0); // the armed seam really ran + handle.flushPending(); + const first = editPosts().at(-1) as { content: string; baseDocVersion: number }; + expect(first.content).toBe(hostBytes(view)); + expect(first.content).toContain("| BOOMx | b |"); + expect(first.baseDocVersion).toBe(7); + + // The failure stays armed: the next edit inside the same table still syncs. + const again = view.state.doc.toString().indexOf("BOOMx") + 5; + expect(() => view.dispatch({ changes: { from: again, insert: "y" } })).not.toThrow(); + handle.flushPending(); + const second = editPosts().at(-1) as { content: string; baseDocVersion: number }; + expect(second.content).toBe(hostBytes(view)); + expect(second.content).toContain("| BOOMxy | b |"); + expect(second.baseDocVersion).toBe(7); +}); + it("a StateField block widget: a throwing table render settles the same way", () => { // The entry names a StateField widget, and `tableBlockField` is one // (cm-block-widget-statefield-guard.test.ts pins that). This case also gives @@ -146,8 +196,9 @@ it("a StateField block widget: a throwing table render settles the same way", () throw new Error("forced table render failure"); }); // ⚠️ The document MUST start with prose and the caret MUST stay out of the - // table: `table-field.ts:128` hides a table the caret overlaps, and a reseed - // leaves the caret at 0. Measured with a bare table document: render 0 calls, + // table: the contract's reveal (structural-blocks.ts) hides a table the caret + // overlaps, and a reseed leaves the caret at 0. Measured with a bare table + // document: render 0 calls, // patch 0 calls, 0 table elements — every assertion below would have been // vacuous. const table = "intro\n\n| a | b |\n| - | - |\n| 1 | 2 |\n"; diff --git a/test/webview/decorations/cm-block-zone-arrow-keymap.test.ts b/test/webview/decorations/cm-block-zone-arrow-keymap.test.ts index 08d831fb..dcef7fad 100644 --- a/test/webview/decorations/cm-block-zone-arrow-keymap.test.ts +++ b/test/webview/decorations/cm-block-zone-arrow-keymap.test.ts @@ -67,14 +67,14 @@ describe("blockZoneArrowDown", () => { const view = mount(DOC, EditorSelection.cursor(blankLineAboveTable)); try { // Pre-state: widget is rendered (caret is off the table). - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); const handled = blockZoneArrowDown(view); expect(handled).toBe(true); // Caret lands at the widget's first byte = first source line.from. expect(view.state.selection.main.head).toBe(TABLE_FROM); // Widget reveals (line-level overlap with the caret). - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -234,7 +234,7 @@ describe("mount — forces parse readiness (lazy-parse flake guard)", () => { // Caret at doc start — off the table, so the reveal does not hide the widget. const view = mount(bigDoc, EditorSelection.cursor(0)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); } finally { view.destroy(); } @@ -248,14 +248,14 @@ describe("blockZoneArrowUp", () => { const blankLineBelowTable = TABLE_TO + 1; const view = mount(DOC, EditorSelection.cursor(blankLineBelowTable)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); const handled = blockZoneArrowUp(view); expect(handled).toBe(true); // Caret lands at the widget's last byte = last source line.to. expect(view.state.selection.main.head).toBe(TABLE_TO); // Widget reveals (line-level overlap with the caret). - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } 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 18007ee4..1a3c6677 100644 --- a/test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts +++ b/test/webview/fenced-code/cm-fenced-collapse-bounded.test.ts @@ -107,7 +107,7 @@ function run(initial: string, edits: Edit[]): void { const len = view.state.doc.length; // Assert BEFORE forceParsing when the post-edit tree is already complete — the // bounded branch ran during dispatch, so this catches a bounded bug a self-heal - // would mask (tableSkeletonField R2-2 / Codex finding 5). + // would mask (the pre-contract table field's R2-2 / Codex finding 5). if (syntaxTreeAvailable(view.state, len)) { assertEquivalent(view); } diff --git a/test/webview/fold/cm-fold-blockquote.test.ts b/test/webview/fold/cm-fold-blockquote.test.ts index 6fea92e6..40309047 100644 --- a/test/webview/fold/cm-fold-blockquote.test.ts +++ b/test/webview/fold/cm-fold-blockquote.test.ts @@ -5,23 +5,28 @@ // mounts — so this pins the delivered contract, and also DETECTS a lang-markdown // upgrade that re-enables a subtracted chevron (see plan Constraints). import { codeFolding, foldable, syntaxTree } from "@codemirror/language"; -import { EditorState } from "@codemirror/state"; +import { EditorState, type Extension } from "@codemirror/state"; import { describe, expect, it } from "vitest"; import { quollMarkdownLanguage } from "../../../src/webview/cm/markdown.js"; +import { tableBlockField } from "../../../src/webview/cm/table/table-field.js"; import { settledState } from "../helpers/settled-state.js"; const lang = quollMarkdownLanguage(); -function stateFor(doc: string): EditorState { - return EditorState.create({ doc, extensions: [lang, codeFolding()] }); +function stateFor(doc: string, extra: readonly Extension[] = []): EditorState { + return EditorState.create({ doc, extensions: [lang, codeFolding(), ...extra] }); } // Query the fold over a SETTLED parse: `foldable()` resolves in the language field's // tree snapshot, which a bare `ensureSyntaxTree` leaves truncated — see // ../helpers/settled-state.ts. The truncation this closes is pinned deterministically // by the "reads a settled parse" describe at the bottom of this file. -function foldableAt(doc: string, at: number): { from: number; to: number } | null { - const state = settledState(stateFor(doc)); +function foldableAt( + doc: string, + at: number, + extra: readonly Extension[] = [] +): { from: number; to: number } | null { + const state = settledState(stateFor(doc, extra)); const line = state.doc.lineAt(at); return foldable(state, line.from, line.to); } @@ -81,24 +86,34 @@ describe("fold ranges subtract Blockquote, Paragraph, code blocks, and tables", // (the marker line start), so the widget SWALLOWS the `-` marker and the ListItem // chevron visually lands on the table. The ListItem foldNodeProp override in // cm/markdown.ts suppresses the fold ONLY for this shape — the item's first content -// child is a Table starting on the marker line — while every genuine list fold -// (table on continuation lines, plain multi-line body) is preserved. +// child is a Table starting on the marker line AND tableBlockField holds a widget record +// there — while every genuine list fold (table on continuation lines, plain multi-line +// body, a table with no widget) is preserved. The suppression reads the field, so the +// widget-backed fixtures register it (`withTable`); a state without the field has no +// widget and keeps the fold. describe("a list item with a table on its marker line is NOT foldable", () => { const tight = "- | a | b |\n | - | - |\n | 1 | 2 |\n\nafter\n"; + const withTable = [tableBlockField]; it("the marker line yields NO foldable range (widget swallows the marker)", () => { - expect(foldableAt(tight, 0)).toBeNull(); + expect(foldableAt(tight, 0, withTable)).toBeNull(); + }); + + it("without tableBlockField registered the marker line STILL folds (no widget)", () => { + expect(foldableAt(tight, 0)).not.toBeNull(); }); it("an ordered-list variant is suppressed the same way", () => { - expect(foldableAt("1. | a | b |\n | - | - |\n | 1 | 2 |\n\nafter\n", 0)).toBeNull(); + expect( + foldableAt("1. | a | b |\n | - | - |\n | 1 | 2 |\n\nafter\n", 0, withTable) + ).toBeNull(); }); it("a table on a CONTINUATION line still folds (marker line carries text)", () => { // Regression guard: the suppression is scoped to the marker line. A table // that starts below the marker keeps the genuine list fold on `- intro:`. const doc = "- intro:\n\n | a | b |\n | - | - |\n | 1 | 2 |\n"; - expect(foldableAt(doc, 0)).not.toBeNull(); + expect(foldableAt(doc, 0, withTable)).not.toBeNull(); }); it("a plain multi-line list item still folds", () => { @@ -114,22 +129,22 @@ describe("a list item with a table on its marker line is NOT foldable", () => { // keep the list fold (the emit guard in listItemFold); the blockquote-ancestor // rule now wins first, so the visible `> - | a | b |` line shows no chevron. const doc = "> - | a | b |\n> | - | - |\n> | 1 | 2 |\n\nafter\n"; - expect(foldableAt(doc, doc.indexOf("- | a | b |"))).toBeNull(); + expect(foldableAt(doc, doc.indexOf("- | a | b |"), withTable)).toBeNull(); }); it("an ordinary (indented) tight table still suppresses the fold (widget emitted)", () => { // Companion to the guard above: a 2-space-indented list-continuation table // DOES parse + emit a widget, so the marker-line suppression still fires. - expect(foldableAt("- | a | b |\n | - | - |\n | 1 | 2 |\n\nafter\n", 0)).toBeNull(); + expect(foldableAt("- | a | b |\n | - | - |\n | 1 | 2 |\n\nafter\n", 0, withTable)).toBeNull(); }); it("a marker-line table inside leading frontmatter STILL folds (widget suppressed by fmEnd)", () => { // Codex Conf-74: Lezer parses the fence body as a ListItem with a marker-line // Table whose slice parses, but tableBlockField skips the widget because the - // table sits inside leading frontmatter (m.from < fmEnd) — so the list fold on + // table sits inside leading frontmatter (node.from < fmEnd) — so the list fold on // the revealed raw source must be KEPT, the same lockstep the emit guard owns. const doc = "---\n- | a | b |\n | - | - |\n | 1 | 2 |\n---\n\nafter\n"; - expect(foldableAt(doc, doc.indexOf("- | a | b |"))).not.toBeNull(); + expect(foldableAt(doc, doc.indexOf("- | a | b |"), withTable)).not.toBeNull(); }); }); diff --git a/test/webview/image/cm-image-field.test.ts b/test/webview/image/cm-image-field.test.ts index fbf204f0..bc539204 100644 --- a/test/webview/image/cm-image-field.test.ts +++ b/test/webview/image/cm-image-field.test.ts @@ -73,7 +73,7 @@ describe("imageBlockField — emission", () => { it("emits exactly one block replace for a standalone allowlisted image", () => { const view = mount(`${SAFE}\n`); try { - const w = widgetsOf(view.state.field(imageBlockField)); + const w = widgetsOf(view.state.field(imageBlockField).decorations); expect(w).toHaveLength(1); expect(w[0].safeUrl).toBe("https://x.test/a.png"); expect(w[0].alt).toBe("logo"); @@ -85,7 +85,7 @@ describe("imageBlockField — emission", () => { it("covers whole-line boundaries", () => { const view = mount(`${SAFE}\n`); try { - const ranges = rangesOf(view.state.field(imageBlockField)); + const ranges = rangesOf(view.state.field(imageBlockField).decorations); expect(ranges).toHaveLength(1); expect(ranges[0].from).toBe(0); expect(ranges[0].to).toBeGreaterThanOrEqual(SAFE.length); @@ -97,7 +97,7 @@ describe("imageBlockField — emission", () => { it("emits a (blocked) widget with safeUrl=null for a standalone javascript: image", () => { const view = mount("![x](javascript:alert(1))\n"); try { - const w = widgetsOf(view.state.field(imageBlockField)); + const w = widgetsOf(view.state.field(imageBlockField).decorations); expect(w).toHaveLength(1); expect(w[0].safeUrl).toBeNull(); } finally { @@ -114,7 +114,7 @@ describe("imageBlockField — emission", () => { ])("blocks %s (safeUrl=null, render-gate via shared decode→gate)", (_label, src) => { const view = mount(`${src}\n`); try { - const w = widgetsOf(view.state.field(imageBlockField)); + const w = widgetsOf(view.state.field(imageBlockField).decorations); expect(w).toHaveLength(1); expect(w[0].safeUrl).toBeNull(); } finally { @@ -127,7 +127,7 @@ describe("imageBlockField — standalone eligibility", () => { it("does NOT emit for an inline image surrounded by text", () => { const view = mount(`here is ${SAFE} inline\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -136,7 +136,7 @@ describe("imageBlockField — standalone eligibility", () => { it("does NOT emit for two images on one line", () => { const view = mount(`${SAFE} ${SAFE}\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -147,7 +147,7 @@ describe("imageBlockField — standalone eligibility", () => { // line would split the paragraph. Excluded by the parent-paragraph check. const view = mount(`${SAFE}\nmore text in the same paragraph\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -156,7 +156,7 @@ describe("imageBlockField — standalone eligibility", () => { it("does NOT emit for an image inside a blockquote (> prefix marker)", () => { const view = mount(`> ${SAFE}\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -165,7 +165,7 @@ describe("imageBlockField — standalone eligibility", () => { it("does NOT emit for an image inside a list item (- prefix marker)", () => { const view = mount(`- ${SAFE}\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -174,7 +174,7 @@ describe("imageBlockField — standalone eligibility", () => { it("DOES emit for a 1-3 space indented standalone image (insignificant indent)", () => { const view = mount(` ${SAFE}\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(1); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(1); } finally { view.destroy(); } @@ -185,7 +185,7 @@ describe("imageBlockField — standalone eligibility", () => { // it as content so the line-trim check excludes it (Codex re-review N1). const view = mount(` ${SAFE}\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -197,7 +197,7 @@ describe("imageBlockField — standalone eligibility", () => { // it — else the line would be mis-promoted (Codex re-review N4). const view = mount(` ${SAFE}\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -206,7 +206,7 @@ describe("imageBlockField — standalone eligibility", () => { it("does NOT emit for a reference image ![alt][ref] (no URL child)", () => { const view = mount("![alt][ref]\n\n[ref]: https://x.test/a.png\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -217,7 +217,7 @@ describe("imageBlockField — standalone eligibility", () => { // destination is harmless and is left to the user to complete). const view = mount("![alt]()\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -230,7 +230,7 @@ describe("imageBlockField — standalone eligibility", () => { // the image as its own paragraph rather than a setext heading. const view = mount(`---\n${SAFE}\n\n---\n\n# body\n`); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -241,7 +241,7 @@ describe("imageBlockField — alt extraction", () => { it("extracts plain alt text", () => { const view = mount("![my logo](https://x.test/a.png)\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].alt).toBe("my logo"); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].alt).toBe("my logo"); } finally { view.destroy(); } @@ -250,7 +250,7 @@ describe("imageBlockField — alt extraction", () => { it("preserves an empty alt", () => { const view = mount("![](https://x.test/a.png)\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].alt).toBe(""); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].alt).toBe(""); } finally { view.destroy(); } @@ -259,7 +259,7 @@ describe("imageBlockField — alt extraction", () => { it("CommonMark-normalizes alt: flattens emphasis markers (*em* -> em)", () => { const view = mount("![*em*](https://x.test/a.png)\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].alt).toBe("em"); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].alt).toBe("em"); } finally { view.destroy(); } @@ -268,7 +268,7 @@ describe("imageBlockField — alt extraction", () => { it("CommonMark-normalizes alt: decodes a backslash escape (a\\*b -> a*b)", () => { const view = mount("![a\\*b](https://x.test/a.png)\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].alt).toBe("a*b"); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].alt).toBe("a*b"); } finally { view.destroy(); } @@ -277,7 +277,7 @@ describe("imageBlockField — alt extraction", () => { it("CommonMark-normalizes alt: decodes a character entity (a&b -> a&b)", () => { const view = mount("![a&b](https://x.test/a.png)\n"); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].alt).toBe("a&b"); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].alt).toBe("a&b"); } finally { view.destroy(); } @@ -288,7 +288,7 @@ describe("imageBlockField — reveal-on-caret", () => { it("hides the widget when the caret is on the image line", () => { const view = mount(`${SAFE}\n`, EditorSelection.cursor(3)); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -297,7 +297,7 @@ describe("imageBlockField — reveal-on-caret", () => { it("hides when caret is AT the line's first character (boundary)", () => { const view = mount(`${SAFE}\n`, EditorSelection.cursor(0)); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(0); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -307,7 +307,7 @@ describe("imageBlockField — reveal-on-caret", () => { const doc = `${SAFE}\n\ntail\n`; const view = mount(doc, EditorSelection.cursor(doc.length)); try { - expect(widgetsOf(view.state.field(imageBlockField))).toHaveLength(1); + expect(widgetsOf(view.state.field(imageBlockField).decorations)).toHaveLength(1); } finally { view.destroy(); } @@ -328,7 +328,7 @@ describe("imageBlockField — reveal-on-caret", () => { ) ); try { - const w = widgetsOf(view.state.field(imageBlockField)); + const w = widgetsOf(view.state.field(imageBlockField).decorations); expect(w).toHaveLength(1); expect(w[0].docFrom).toBe(0); // first image stays; second is revealed } finally { @@ -376,7 +376,7 @@ describe("imageBlockField — relative resolution", () => { // caret at top would reveal an image on line 1; put the image on line 3. const view = mountWithBase("text\n\n![d](./img.png)\n", BASE); try { - const w = widgetsOf(view.state.field(imageBlockField)); + const w = widgetsOf(view.state.field(imageBlockField).decorations); expect(w).toHaveLength(1); expect(w[0].safeUrl).toBe("https://csp/ws/notes/img.png"); } finally { @@ -396,7 +396,7 @@ describe("imageBlockField — relative resolution", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const view = mountWithBase("text\n\n![d](sub/p.png)\n\n![e](../x.png)\n", BASE); try { - const urls = widgetsOf(view.state.field(imageBlockField)).map((w) => w.safeUrl); + const urls = widgetsOf(view.state.field(imageBlockField).decorations).map((w) => w.safeUrl); expect(urls).toEqual(["https://csp/ws/notes/sub/p.png", null]); } finally { view.destroy(); @@ -407,7 +407,7 @@ describe("imageBlockField — relative resolution", () => { it("renders a fragment-only ![](#frag) image inert (would resolve to the document file)", () => { const view = mountWithBase("text\n\n![d](#frag)\n", BASE); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].safeUrl).toBeNull(); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].safeUrl).toBeNull(); } finally { view.destroy(); } @@ -416,7 +416,7 @@ describe("imageBlockField — relative resolution", () => { it("renders a query-only ![](?x=1) image inert (would resolve to the document file)", () => { const view = mountWithBase("text\n\n![d](?x=1)\n", BASE); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].safeUrl).toBeNull(); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].safeUrl).toBeNull(); } finally { view.destroy(); } @@ -425,7 +425,9 @@ describe("imageBlockField — relative resolution", () => { it("passes an absolute http(s) URL through unchanged (remote, CSP decides)", () => { const view = mountWithBase("text\n\n![d](https://x.test/a.png)\n", BASE); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].safeUrl).toBe("https://x.test/a.png"); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].safeUrl).toBe( + "https://x.test/a.png" + ); } finally { view.destroy(); } @@ -434,7 +436,7 @@ describe("imageBlockField — relative resolution", () => { it("leaves a relative image unresolved (safeUrl=null) when no base is set", () => { const view = mountWithBase("text\n\n![d](./img.png)\n", ""); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].safeUrl).toBeNull(); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].safeUrl).toBeNull(); } finally { view.destroy(); } @@ -443,7 +445,7 @@ describe("imageBlockField — relative resolution", () => { it("blocks a javascript: image even with a base set (gate runs first)", () => { const view = mountWithBase("text\n\n![d](javascript:alert(1))\n", BASE); try { - expect(widgetsOf(view.state.field(imageBlockField))[0].safeUrl).toBeNull(); + expect(widgetsOf(view.state.field(imageBlockField).decorations)[0].safeUrl).toBeNull(); } finally { view.destroy(); } diff --git a/test/webview/table/cm-table-skeleton.test.ts b/test/webview/table/cm-table-blocks.test.ts similarity index 64% rename from test/webview/table/cm-table-skeleton.test.ts rename to test/webview/table/cm-table-blocks.test.ts index 4d60d31c..f2b42c78 100644 --- a/test/webview/table/cm-table-skeleton.test.ts +++ b/test/webview/table/cm-table-blocks.test.ts @@ -5,27 +5,27 @@ import { EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; import { touchesStructuralReparse } from "../../../src/webview/cm/structural-guard.js"; -import { - type TableModel, - tableModels, - tableSkeletonField, -} from "../../../src/webview/cm/table/table-skeleton.js"; +import { type TableBlock, tableBlockField } from "../../../src/webview/cm/table/table-field.js"; import { settledMount, settledView } from "../helpers/settled-view.js"; import { withUnstarvedFrontier } from "../helpers/unstarved-frontier.js"; -const exts = (): Extension[] => [markdown({ base: markdownLanguage }), tableSkeletonField]; +const exts = (): Extension[] => [markdown({ base: markdownLanguage }), tableBlockField]; -/** Oracle: a fresh, fully-parsed view's full walk → models (ranges + block - * ranges + slice + parse). Comparing the WHOLE model (incl. `table`) makes the - * `bounded ≡ fullWalk` battery pin parse-reuse soundness: a boundedUpdate that - * reused a stale parse for a table whose bytes changed would deep-differ here. */ -function freshOracle(doc: string): TableModel[] { +/** The field's canonical records, as blocks. */ +const blocks = (state: EditorState): TableBlock[] => + state.field(tableBlockField).records.map((r) => r.block); + +/** Oracle: a fresh, fully-parsed view's full walk → blocks (block range + node offset + + * slice + parse). Comparing the WHOLE block (incl. `table`) makes the `bounded ≡ fullWalk` + * battery pin parse-reuse soundness: a bounded update that reused a stale parse for a + * table whose bytes changed would deep-differ here. */ +function freshOracle(doc: string): TableBlock[] { const p = document.createElement("div"); document.body.appendChild(p); const v = new EditorView({ state: EditorState.create({ doc, extensions: exts() }), parent: p }); try { settledView(v, 10_000); - return tableModels(v.state); + return blocks(v.state); } finally { v.destroy(); } @@ -44,24 +44,18 @@ function checkEquivalence( try { settledView(view, 10_000); // create() correctness on the fully-parsed initial doc. - expect([...view.state.field(tableSkeletonField)]).toEqual( - freshOracle(view.state.doc.toString()) - ); + expect(blocks(view.state)).toEqual(freshOracle(view.state.doc.toString())); for (const e of edits) { view.dispatch({ changes: e }); const len = view.state.doc.length; - // R2-2: when the post-edit tree is already complete, the boundedUpdate + // R2-2: when the post-edit tree is already complete, the bounded update // branch ran during dispatch — assert its output BEFORE forceParsing so a // self-heal can't mask a bounded bug. (Codex finding 3 + R2-2.) if (syntaxTreeAvailable(view.state, len)) { - expect([...view.state.field(tableSkeletonField)]).toEqual( - freshOracle(view.state.doc.toString()) - ); + expect(blocks(view.state)).toEqual(freshOracle(view.state.doc.toString())); } settledView(view, 10_000); // publish → converge (also covers the G2 path) - expect([...view.state.field(tableSkeletonField)]).toEqual( - freshOracle(view.state.doc.toString()) - ); + expect(blocks(view.state)).toEqual(freshOracle(view.state.doc.toString())); } } finally { view.destroy(); @@ -70,7 +64,7 @@ function checkEquivalence( const T = "| H | I |\n| - | - |\n| a | b |\n"; -describe("tableSkeletonField bounded ≡ fullWalk", () => { +describe("tableBlockField bounded ≡ fullWalk", () => { const cases: Array<{ name: string; initial: string; @@ -147,24 +141,23 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { } // R2-2: explicit bounded-path pins — NO settling after the edit, so a broken - // boundedUpdate cannot be masked by self-heal. + // bounded update cannot be masked by self-heal. // - // `boundedUpdate` has two halves and one anchor reaches only one of them, so there are - // two. The REUSE half maps a model whose range falls outside the span; the RE-WALK half - // runs `collectTableRanges` + `buildModel` over the span and lets that result win on a - // colliding node `from`. An anchor whose span holds no `Table` node exercises the first - // and leaves the second running on an empty range (measured: zero nodes collected), which - // is green whatever the re-walk does. The sibling below puts the span ON a table so the - // model is NOT reused and the re-walk is what produces it. + // The contract's bounded update has two halves and one anchor reaches only one of them, + // so there are two. The REUSE half rebases a record whose block falls outside the span; + // the RE-WALK half walks the span and builds a block per matched node. An anchor whose + // span holds no `Table` node exercises the first and leaves the second running on an + // empty range, which is green whatever the re-walk does. The sibling below puts the span + // ON a table so the record is NOT reused and the re-walk is what produces it. it("exercises the bounded path without self-heal masking (revert-check anchor)", () => { const doc = `${T}\n\nprose\n\n${T}`; withUnstarvedFrontier({ - what: "boundedUpdate's pre-self-heal output", + what: "the bounded update's pre-self-heal output", mount: (parent) => settledMount({ state: EditorState.create({ doc, extensions: exts() }), parent }, 10_000), observe: (view, requireUnstarvedFrontier) => { // Offset chosen to land INSIDE the `prose` paragraph, i.e. OUTSIDE both tables' - // node ranges. That is what keeps this test on the REUSE half of `boundedUpdate` + // node ranges. That is what keeps this test on the REUSE half of the bounded update // (the sibling below puts the edit's span ON a table to exercise the RE-WALK half // instead) — it is no longer about pipe presence: the narrowed TABLE-DELIM arm is // a per-line SHAPE delta (`tableRowShapeChanged`), not "any `|` on the line", so an @@ -176,34 +169,31 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { expect(touchesStructuralReparse(tr)).toBe(false); // this edit really is on the bounded arm view.dispatch(tr); // A small in-place edit on a complete tree normally keeps the frontier at doc end, - // which is what makes boundedUpdate the branch that ran. Under CPU starvation the + // which is what makes the bounded update the branch that ran. Under CPU starvation the // 20ms reparse budget can elapse before any parse work happens; the field then // takes its G2 fallback and full-walks DURING the dispatch, so there is no bounded // output to compare and this attempt is abandoned rather than red. An all-starved // run throws. requireUnstarvedFrontier(); - // boundedUpdate's output, pre-self-heal, must equal the full walk. - expect([...view.state.field(tableSkeletonField)]).toEqual( - freshOracle(view.state.doc.toString()) - ); + // The bounded update's output, pre-self-heal, must equal the full walk. + expect(blocks(view.state)).toEqual(freshOracle(view.state.doc.toString())); }, }); }); it("exercises the bounded RE-WALK arm without self-heal masking (revert-check anchor)", () => { - // The table starts on the line directly below the edit, so G1's ±1-line expansion pulls - // the table's FIRST LINE into the span (measured: span `{0,16}` against a model at - // `{7,36}`). That is enough on both sides: the mapped model intersects the span, so it - // is not reused — and that term is the ONLY thing carrying this side here, because the - // sibling `touchesRange` guard measures `false` (the insert ends at old offset 5, one - // short of the old model's `from` of 6, and CM's `touchesRange` needs `end >= from`); - // and `collectTableRanges` returns every Table node OVERLAPPING the interval — not only - // contained ones — so the span re-walk plus `buildModel` rebuilds the whole model. Same + // The table starts on the line directly below the edit, so the ±1-line span expansion + // pulls the table's FIRST LINE into the span. That is enough on both sides: the mapped + // block intersects the span, so it is not reused — and that term is the ONLY thing + // carrying this side here, because the sibling `touchesRange` guard measures `false` + // (the insert ends at old offset 5, one short of the old block's `from` of 6, and CM's + // `touchesRange` needs `end >= from`); and the span walk visits every Table node + // OVERLAPPING the interval — not only contained ones — so it rebuilds the whole block. Same // offset as the settled `insert a char in prose immediately before a table` row above; // what this adds is the absence of settling, so the self-heal cannot supply the answer. const doc = `intro\n${T}`; withUnstarvedFrontier({ - what: "boundedUpdate's pre-self-heal output on the re-walk arm", + what: "the bounded update's pre-self-heal output on the re-walk arm", mount: (parent) => settledMount({ state: EditorState.create({ doc, extensions: exts() }), parent }, 10_000), observe: (view, requireUnstarvedFrontier) => { @@ -215,9 +205,7 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { expect(touchesStructuralReparse(tr)).toBe(false); view.dispatch(tr); requireUnstarvedFrontier(); - expect([...view.state.field(tableSkeletonField)]).toEqual( - freshOracle(view.state.doc.toString()) - ); + expect(blocks(view.state)).toEqual(freshOracle(view.state.doc.toString())); }, }); }); @@ -226,7 +214,7 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { // the post-edit frontier stays COMPLETE. Neither guard the field had could see it — the // bounded reuse rule (`!touched && !intersects(span, …)`) only looks at the changed // lines ±1, and G2 never fires because the frontier is not starved. Both directions are - // covered: a node that VANISHES leaves a stale model behind, and a node that APPEARS is + // covered: a node that VANISHES leaves a stale record behind, and a node that APPEARS is // missed entirely. Each row's mechanism is distinct at the Lezer level (fence pairing, // HTML-block absorption, fence RE-pairing), not three spellings of one bug. // @@ -257,7 +245,7 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { for (const c of structuralCases) { it(`structural reparse: ${c.name}`, () => { withUnstarvedFrontier({ - what: "the post-structural-edit models, pre-self-heal", + what: "the post-structural-edit records, pre-self-heal", mount: (parent) => settledMount( { state: EditorState.create({ doc: c.doc, extensions: exts() }), parent }, @@ -266,9 +254,7 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { observe: (view, requireUnstarvedFrontier) => { view.dispatch({ changes: c.edit }); requireUnstarvedFrontier(); - expect([...view.state.field(tableSkeletonField)]).toEqual( - freshOracle(view.state.doc.toString()) - ); + expect(blocks(view.state)).toEqual(freshOracle(view.state.doc.toString())); }, }); }); @@ -276,67 +262,56 @@ describe("tableSkeletonField bounded ≡ fullWalk", () => { }); describe("list-nested table detection (real Lezer language)", () => { - it("emits an emitting TableModel (table !== null) for a table nested in a list item", () => { + it("emits a block for a table nested in a list item", () => { const doc = "- item intro:\n\n | A | B |\n |---|---|\n | 1 | 2 |\n"; - const models = freshOracle(doc); - expect(models).toHaveLength(1); - const m = models[0]; - expect(m.table).not.toBeNull(); - if (!m.table) { - return; - } - expect(m.table.header.cells).toHaveLength(2); + const found = freshOracle(doc); + expect(found).toHaveLength(1); + const b = found[0]; + expect(b.table.header.cells).toHaveLength(2); // The block range covers whole lines including the header line's own indent. - expect(doc.slice(m.blockFrom, m.blockTo)).toBe(" | A | B |\n |---|---|\n | 1 | 2 |"); - // Full-doc caret offset (what table-widget uses: nodeFrom + cell.from = m.from - // + cell.from). Pins the header-indent-outside-node / body-indent-inside-node + expect(doc.slice(b.from, b.to)).toBe(" | A | B |\n |---|---|\n | 1 | 2 |"); + // Full-doc caret offset (what table-widget uses: nodeFrom + cell.from, with nodeFrom = + // from + nodeOffset). Pins the header-indent-outside-node / body-indent-inside-node // asymmetry end-to-end, so click-to-reveal lands the caret on the right byte. - expect(m.from + m.table.header.cells[0].from).toBe(doc.indexOf("A")); - expect(m.from + m.table.rows[0].cells[0].from).toBe(doc.indexOf("1")); + const nodeFrom = b.from + b.nodeOffset; + expect(nodeFrom + b.table.header.cells[0].from).toBe(doc.indexOf("A")); + expect(nodeFrom + b.table.rows[0].cells[0].from).toBe(doc.indexOf("1")); }); it("emits for a top-level 1-3-space-indented table too (same shape)", () => { const doc = " | A | B |\n |---|---|\n | 1 | 2 |\n"; - const models = freshOracle(doc); - expect(models).toHaveLength(1); - expect(models[0].table).not.toBeNull(); - expect(models[0].table?.header.cells).toHaveLength(2); + const found = freshOracle(doc); + expect(found).toHaveLength(1); + expect(found[0].table.header.cells).toHaveLength(2); }); - it("stays non-emitting for a blockquote-nested table (out of scope)", () => { + it("yields no block for a blockquote-nested table (out of scope)", () => { const doc = "> | A | B |\n> |---|---|\n> | 1 | 2 |\n"; - const models = freshOracle(doc); - expect(models).toHaveLength(1); - // Continuation lines carry `>` markers, not whitespace → still not a table. - expect(models[0].table).toBeNull(); + // Continuation lines carry `>` markers, not whitespace → `parseTable` rejects the node, + // so it stays raw source. + expect(freshOracle(doc)).toEqual([]); }); }); // Local copy of the identity check used by test/extension/types-equality.test.ts's -// "table model type pins" describe block. NOT re-exported from there: when this -// was written, importing TableModel into that file would have pulled -// @codemirror/* into the host-side unit program -// (test/extension/tsconfig.unit.json). That program reaches @codemirror/* through -// other suites now; the copy stays local. +// "table model type pins" describe block. Kept local so this file does not depend on that +// program's imports. type AssertEqual = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; -describe("TableModel readonly contract (type-only compile pin)", () => { - it("keeps every field of TableModel readonly", () => { - // `slice` is the widget's eq() key (see the field doc in table-skeleton.ts) — - // it must stay readonly so a model can never be mutated after construction. - // The other fields (from/to/blockFrom/blockTo/table) carry the same - // never-patched-after-build contract (see buildModel / boundedUpdate). +describe("TableBlock readonly contract (type-only compile pin)", () => { + it("keeps every field of TableBlock readonly", () => { + // `slice` is the widget's eq() key (see the field doc in table-field.ts) — it must stay + // readonly so a block can never be mutated after construction; the other fields carry + // the same never-patched-after-build contract (the contract rebases a reused block by + // copying it, never by writing into it). // - // `Readonly` is homomorphic, so `AssertEqual>` holds only - // when EVERY field of T is already readonly — including fields added later, - // which a per-field `@ts-expect-error` (the previous shape of this pin) would - // stop covering the moment the shape grows: it caught `slice` alone, so - // `from`/`to`/`blockFrom`/`blockTo`/`table` could each lose `readonly` - // without failing here. Revert-check: drop `readonly` from any single field - // in table-skeleton.ts and this assertion resolves to `false`, failing the - // `= true` assignment. - const _check: AssertEqual> = true; + // `Readonly` is homomorphic, so `AssertEqual>` holds only when EVERY + // field of T is already readonly — including fields added later. Revert-check: drop + // `readonly` from any single field in table-field.ts (or structural-blocks.ts's + // `StructuralBlock`) and this assertion resolves to `false`, failing the `= true` + // assignment. + const _check: AssertEqual> = true; expect(_check).toBe(true); }); }); diff --git a/test/webview/table/cm-table-fallback-warn.test.ts b/test/webview/table/cm-table-fallback-warn.test.ts deleted file mode 100644 index eb31fc3f..00000000 --- a/test/webview/table/cm-table-fallback-warn.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Pins the dev-visible warning tableBlockField emits when its bounded source — -// tableSkeletonField — is not registered and it falls back to the unbounded -// full walk+parse (the PERF.md bounding invariant is then not held). A single -// test drives the whole warn-once lifecycle: registered → silent, first -// fallback → one warning, second fallback → still one (latch holds). Kept in -// its own file so the module-scoped warn-once latch starts fresh (vitest -// isolates module state per test file). -// -// Cross-file note: other suites that mount tableBlockField WITHOUT the skeleton -// field (e.g. cm-table-field.test.ts, which exercises widget/reveal behaviour on -// the fallback path) also trip this warn once per process. That is intentional — -// the warn correctly flags an unregistered field — and harmless: the default -// vitest reporter swallows console output for passing tests and there is no -// fail-on-console gate. This dedicated file mocks console.warn to assert on it. -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { EditorState } from "@codemirror/state"; -import { describe, expect, it, vi } from "vitest"; -import { tableBlockField, tableSkeletonField } from "../../../src/webview/cm/table/index.js"; - -const TABLE = "| H1 | H2 |\n| -- | -- |\n| a1 | a2 |"; -const WARN_NEEDLE = "tableSkeletonField not registered"; - -/** Count of console.warn calls whose first arg mentions the missing field — - * filters out any unrelated warnings so the assertion pins OUR signal. */ -function fallbackWarnings(spy: ReturnType): number { - return spy.mock.calls.filter((c: unknown[]) => String(c[0]).includes(WARN_NEEDLE)).length; -} - -describe("tableBlockField — missing tableSkeletonField fallback warning", () => { - it("warns exactly once on the fallback path and never when the field is registered", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - try { - const base = [markdown({ base: markdownLanguage }), tableBlockField]; - - // Registered → bounded path taken, no fallback, no warning. - EditorState.create({ doc: TABLE, extensions: [...base, tableSkeletonField] }); - expect(fallbackWarnings(warn)).toBe(0); - - // Field absent → fallback fires (create path) → exactly one dev-visible warning. - const live = EditorState.create({ doc: TABLE, extensions: base }); - expect(fallbackWarnings(warn)).toBe(1); - - // Fallback again (create path) → the module-scoped latch holds; still exactly one. - EditorState.create({ doc: TABLE, extensions: base }); - expect(fallbackWarnings(warn)).toBe(1); - - // Update path (defence-in-depth): dispatch doc-changing transactions on the - // live no-skeleton instance. Each runs update → computeFresh → buildAll → - // resolveModels, the same shared choke point the create path hits — so the - // create-path assertions above already catch a per-call latch regression. - // This pins that the update/per-keystroke branch re-enters that choke point - // without re-warning, guarding a future refactor that split the update path - // onto its own resolve/latch. - let state = live; - for (let i = 0; i < 3; i++) { - state = state.update({ changes: { from: state.doc.length, insert: "\nx" } }).state; - } - expect(fallbackWarnings(warn)).toBe(1); - } finally { - warn.mockRestore(); - } - }); -}); diff --git a/test/webview/table/cm-table-field.test.ts b/test/webview/table/cm-table-field.test.ts index 11a47233..babaa6e7 100644 --- a/test/webview/table/cm-table-field.test.ts +++ b/test/webview/table/cm-table-field.test.ts @@ -64,10 +64,8 @@ function mount( ...(extraExtensions as never[]), ], }); - // Settle: these extensions register tableBlockField WITHOUT tableSkeletonField, - // so resolveModels() takes its fallback arm into tableModels(state) — the - // syntaxTree(state) read (table-skeleton.ts). A mounted view never self-heals - // under happy-dom, so force the full tree at mount time. + // Settle: tableBlockField walks syntaxTree(state). A mounted view never self-heals under + // happy-dom, so force the full tree at mount time. return settledMount({ state, parent }); } @@ -77,7 +75,7 @@ describe("tableBlockField", () => { it("emits exactly one block Decoration.replace per well-formed Table node", () => { const view = mount(`${TABLE}\n`); try { - const set = view.state.field(tableBlockField); + const set = view.state.field(tableBlockField).decorations; expect(rangesOf(set)).toHaveLength(1); } finally { view.destroy(); @@ -87,7 +85,7 @@ describe("tableBlockField", () => { it("emits the widget across whole-line boundaries (covers the source)", () => { const view = mount(`${TABLE}\n`); try { - const set = view.state.field(tableBlockField); + const set = view.state.field(tableBlockField).decorations; const ranges = rangesOf(set); expect(ranges).toHaveLength(1); const { from, to } = ranges[0]; @@ -106,7 +104,7 @@ describe("tableBlockField", () => { it("hides the widget when caret is inside the table (line-level reveal)", () => { const view = mount(`${TABLE}\n`, EditorSelection.cursor(3)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -117,7 +115,7 @@ describe("tableBlockField", () => { // because r.from == r.to == from. Line-level overlap MUST fire. const view = mount(`${TABLE}\n`, EditorSelection.cursor(0)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -126,7 +124,7 @@ describe("tableBlockField", () => { it("hides the widget when caret is AT the table's last content character (line.to boundary)", () => { const view = mount(`${TABLE}\n\nbelow`, EditorSelection.cursor(TABLE.length)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -136,7 +134,7 @@ describe("tableBlockField", () => { const doc = `${TABLE}\n\nbelow`; const view = mount(doc, EditorSelection.cursor(TABLE.length + 2)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); } finally { view.destroy(); } @@ -145,9 +143,9 @@ describe("tableBlockField", () => { it("re-emits the widget after the caret moves off the table", () => { const view = mount(`${TABLE}\n\nbelow`, EditorSelection.cursor(3)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); view.dispatch({ selection: { anchor: view.state.doc.length } }); - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); } finally { view.destroy(); } @@ -171,7 +169,7 @@ describe("tableBlockField", () => { ); try { // First table hidden (caret inside), second table visible (no cursor). - const ranges = rangesOf(view.state.field(tableBlockField)); + const ranges = rangesOf(view.state.field(tableBlockField).decorations); expect(ranges).toHaveLength(1); expect(ranges[0].from).toBeGreaterThanOrEqual(secondTableFirstCharOffset); } finally { @@ -184,7 +182,7 @@ describe("tableBlockField", () => { try { const zones = view.state.facet(quollBlockReplaceZones); expect(zones).toHaveLength(1); - const fieldRanges = rangesOf(view.state.field(tableBlockField)); + const fieldRanges = rangesOf(view.state.field(tableBlockField).decorations); expect(zones[0]).toEqual(fieldRanges[0]); } finally { view.destroy(); @@ -203,7 +201,7 @@ describe("tableBlockField", () => { it("ignores a malformed table (header/delimiter cell-count mismatch)", () => { const view = mount("| A | B |\n| - |\n"); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); expect(view.state.facet(quollBlockReplaceZones)).toHaveLength(0); } finally { view.destroy(); @@ -214,7 +212,7 @@ describe("tableBlockField", () => { const doc = `${TABLE}\n\n${TABLE}\n`; const view = mount(doc); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(2); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(2); expect(view.state.facet(quollBlockReplaceZones)).toHaveLength(2); } finally { view.destroy(); @@ -224,11 +222,11 @@ describe("tableBlockField", () => { it("updates after a doc edit that introduces a new table", () => { const view = mount("plain text"); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: `${TABLE}\n` }, }); - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); } finally { view.destroy(); } @@ -247,7 +245,7 @@ describe("tableBlockField", () => { it("handles a Table node with no trailing newline (line-snap precondition)", () => { const view = mount(`${TABLE}\n\nafter`); try { - const ranges = rangesOf(view.state.field(tableBlockField)); + const ranges = rangesOf(view.state.field(tableBlockField).decorations); expect(ranges).toHaveLength(1); // Widget spans line-1.from (0) .. line-3.to (TABLE.length) — the // table's last source line ends at TABLE.length (the `|` byte) and @@ -264,7 +262,7 @@ describe("tableBlockField", () => { const crlfTable = "| A | B |\r\n| - | - |\r\n| 1 | 2 |\r\n"; const view = mount(crlfTable); try { - const ranges = rangesOf(view.state.field(tableBlockField)); + const ranges = rangesOf(view.state.field(tableBlockField).decorations); // Lezer's GFM parser ignores CRLF tables in the version we pin // (Caveat 2 in test/markdown/table/lezer-parity.test.ts), so the // current contract is "either 0 widgets, or, if a future Lezer @@ -289,10 +287,10 @@ describe("tableBlockField", () => { const doc = `${TABLE}\n\nbelow with some text`; const view = mount(doc, EditorSelection.cursor(doc.length)); try { - const before = view.state.field(tableBlockField); + const before = view.state.field(tableBlockField).decorations; // Move caret to a different "below"-line position (still off the table). view.dispatch({ selection: { anchor: doc.length - 3 } }); - const after = view.state.field(tableBlockField); + const after = view.state.field(tableBlockField).decorations; expect(after).toBe(before); } finally { view.destroy(); @@ -303,9 +301,9 @@ describe("tableBlockField", () => { const doc = `${TABLE}\n\nbelow`; const view = mount(doc, EditorSelection.cursor(doc.length)); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); view.dispatch({ selection: { anchor: 3 } }); - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -331,7 +329,7 @@ describe("tableBlockField × C4a orchestrator integration", () => { const merged = sources.map((s) => (typeof s === "function" ? s(view) : s)); const insideTable: Array<{ from: number; to: number }> = []; const outsideTable: Array<{ from: number; to: number }> = []; - const widgetRanges = rangesOf(view.state.field(tableBlockField)); + const widgetRanges = rangesOf(view.state.field(tableBlockField).decorations); for (const set of merged) { const iter = set.iter(); while (iter.value !== null) { @@ -387,7 +385,7 @@ describe("tableBlockField × C4a orchestrator integration", () => { // the exact count is 2 — a weaker `> 0` would not distinguish // "filter is correctly inactive" from "filter is inactive AND // half the marks went missing for another reason". - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); const sources = view.state.facet(EditorView.decorations); const merged = sources.map((s) => (typeof s === "function" ? s(view) : s)); const headerMarks: Array<{ from: number; to: number }> = []; @@ -417,7 +415,7 @@ describe("tableBlockField — frontmatter exclusion (C8a guard)", () => { // underline absorbing the table. const view = mount("---\n| a | b |\n| - | - |\n\n---\n\n# body\n"); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -429,11 +427,11 @@ describe("tableBlockField — reveal and offset pins", () => { // Default cursor (doc end) is off the table → widget present. const view = mount("text\n\n| a | b |\n| - | - |\n\nmore"); try { - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(1); // Move caret onto the first table line → widget hides. const onTable = view.state.doc.line(3).from; // "| a | b |" view.dispatch({ selection: { anchor: onTable } }); - expect(rangesOf(view.state.field(tableBlockField))).toHaveLength(0); + expect(rangesOf(view.state.field(tableBlockField).decorations)).toHaveLength(0); } finally { view.destroy(); } @@ -446,7 +444,7 @@ describe("tableBlockField — reveal and offset pins", () => { const lfText = splitToCmText(raw); const parent = document.createElement("div"); document.body.appendChild(parent); - // Settle: same tableModels(state)/syntaxTree(state) fallback read as mount() above. + // Settle: same syntaxTree(state) read as mount() above. const view = settledMount({ parent, state: EditorState.create({ @@ -460,7 +458,7 @@ describe("tableBlockField — reveal and offset pins", () => { }), }); try { - const set = view.state.field(tableBlockField); + const set = view.state.field(tableBlockField).decorations; let widget: TableBlockWidget | null = null; set.between(0, view.state.doc.length, (_f, _t, deco) => { if (widget === null) { @@ -500,9 +498,9 @@ describe("tableBlockField — reveal and offset pins", () => { rangeView = mount(doc, EditorSelection.single(from, from + 5)); // Control: a selection entirely outside the table keeps the widget. outsideView = mount(doc, EditorSelection.single(0, 3)); - expect(rangesOf(caretView.state.field(tableBlockField))).toHaveLength(0); - expect(rangesOf(rangeView.state.field(tableBlockField))).toHaveLength(0); - expect(rangesOf(outsideView.state.field(tableBlockField))).toHaveLength(1); + expect(rangesOf(caretView.state.field(tableBlockField).decorations)).toHaveLength(0); + expect(rangesOf(rangeView.state.field(tableBlockField).decorations)).toHaveLength(0); + expect(rangesOf(outsideView.state.field(tableBlockField).decorations)).toHaveLength(1); } finally { caretView?.destroy(); rangeView?.destroy(); From 091738a6502e9542143fbaaffa3941f6e1af81e5 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 12:49:57 +0900 Subject: [PATCH 09/14] test(cm): gate the frontier rows on an unstarved frontier; let fenced import frontierIncomplete The full suite went red on two guards the earlier steps did not run: - no-bare-unstarved-gate flagged the two bare expect(syntaxTreeAvailable(...)).toBe(true) gates in the frontier-advance rows. They now go through withUnstarvedFrontierState, the repo's load-robust form. Non-vacuity rechecked: with the snapshot term of frontierIncomplete forced to false, all four frontier-advance rows go red. - cm-lezer-table-internals-tripwire pinned that fenced-code-collapse.ts never imports structural-guard.js, which the frontierIncomplete step made false by design. It now pins that the one import names frontierIncomplete alone (red when requiresFullBoundedRebuild is added to it). --- .../cm-lezer-table-internals-tripwire.test.ts | 24 ++-- test/webview/cm-structural-blocks.test.ts | 111 ++++++++++-------- 2 files changed, 81 insertions(+), 54 deletions(-) diff --git a/test/webview/cm-lezer-table-internals-tripwire.test.ts b/test/webview/cm-lezer-table-internals-tripwire.test.ts index f6b15164..ac6c3439 100644 --- a/test/webview/cm-lezer-table-internals-tripwire.test.ts +++ b/test/webview/cm-lezer-table-internals-tripwire.test.ts @@ -85,14 +85,22 @@ describe("the fenced-code field keeps its own narrower guard", () => { "utf8" ); - it("does not import the shared guard", () => { - // ⚠️ Assert the IMPORT FORM, not the bare path. `fenced-code-collapse.ts:434` already - // contains the literal `../structural-guard.js` inside a COMMENT ("the fields that - // import ../structural-guard.js"), so `not.toContain("structural-guard.js")` reds on a - // correct file. That is the mirror image of the vacuous-grep trap in docs/LEARNING.md - // (a comment literal satisfying a source contract) — here a comment literal VIOLATES - // one. Verified against the shipped file, 2026-09-05. - expect(fencedSrc).not.toMatch(/from "\.\.\/structural-guard\.js"/); + it("imports only the shared frontier term, never the shared structural guard", () => { + // The fenced field composes the shared frontier predicate (`frontierIncomplete`) with + // its OWN structural predicate (`fencedCollapseSpec.docChangePlan`), so its one import + // from the shared guard must name `frontierIncomplete` alone: importing + // `touchesStructuralReparse` or `requiresFullBoundedRebuild` would put its in-fence hot + // path behind the wider shared STRUCTURAL. + // ⚠️ Assert the IMPORT FORM, not the bare path: the file also carries the literal + // `../structural-guard.js` inside a COMMENT, so a bare-path match reads a comment. That + // is the mirror image of the vacuous-grep trap in docs/LEARNING.md (a comment literal + // satisfying a source contract). + const imports = [ + ...fencedSrc.matchAll( + /import\s*(?:type\s*)?\{([^}]*)\}\s*from "\.\.\/structural-guard\.js"/g + ), + ].map((m) => m[1].trim()); + expect(imports).toEqual(["frontierIncomplete"]); }); it("keeps an ATX / Setext / underscore-free regex, unlike the shared one", () => { diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index d7a41f00..835ed988 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -9,7 +9,7 @@ // // Precondition asserts are separate `expect`s from the behaviour assert, so a harness // failure is never read as "behaviour absent". -import { ensureSyntaxTree, foldable, syntaxTree, syntaxTreeAvailable } from "@codemirror/language"; +import { ensureSyntaxTree, foldable, syntaxTree } from "@codemirror/language"; import { EditorState, type Extension, type StateField } from "@codemirror/state"; import { EditorView, type WidgetType } from "@codemirror/view"; import { type Tree, TreeFragment } from "@lezer/common"; @@ -32,6 +32,7 @@ import { import { QuollWidget } from "../../src/webview/cm/widget-base.js"; import { settledState } from "./helpers/settled-state.js"; import { settledMount } from "./helpers/settled-view.js"; +import { withUnstarvedFrontierState } from "./helpers/unstarved-frontier.js"; // One switch for both throwing seams. A mock only throws while `armed` is true, so the // fields' `create` (which already calls both seams) never throws at mount. @@ -522,34 +523,43 @@ describe("structural blocks beyond the initial parse frontier", () => { "End.", "", ]); - const state = EditorState.create({ - doc, - extensions: [quollMarkdownLanguage(), tableBlockField, imageBlockField], + // The post-edit reparse has a 20ms wall-clock budget, so the run goes through the + // starvation-retrying gate (no-bare-unstarved-gate.test.ts); each attempt rebuilds its + // states from scratch. + withUnstarvedFrontierState({ + what: "the frontier-advance publication of the table and image blocks", + observe: (requireUnstarvedFrontier) => { + const state = EditorState.create({ + doc, + extensions: [quollMarkdownLanguage(), tableBlockField, imageBlockField], + }); + // Precondition: the init snapshot is partial and stops before both blocks. + expect(syntaxTree(state).length).toBeLessThan(doc.indexOf("| a |")); + expect(widgetSlots(state, TableBlockWidget)).toEqual([]); + expect(widgetSlots(state, ImageBlockWidget)).toEqual([]); + + // Advance the parse CONTEXT to the end without republishing the snapshot. + expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); + expect(syntaxTree(state).length).toBeLessThan(state.doc.length); + + // A single letter inside the first plain paragraph line, far above both blocks. + const tr = state.update({ changes: { from: 4, insert: "x" } }); + // Preconditions: the post-edit snapshot is complete, and HEAD's admission test sends + // this transaction down the bounded path. + requireUnstarvedFrontier(tr.state); + expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); + expect(touchesStructuralReparse(tr)).toBe(false); + + const text = tr.state.doc.toString(); + expect(widgetSlots(tr.state, TableBlockWidget).map((s) => s.from)).toEqual([ + text.indexOf("| a |"), + ]); + expect(widgetSlots(tr.state, ImageBlockWidget).map((s) => s.from)).toEqual([ + text.indexOf("![alt]"), + ]); + return tr.state; + }, }); - // Precondition: the init snapshot is partial and stops before both blocks. - expect(syntaxTree(state).length).toBeLessThan(doc.indexOf("| a |")); - expect(widgetSlots(state, TableBlockWidget)).toEqual([]); - expect(widgetSlots(state, ImageBlockWidget)).toEqual([]); - - // Advance the parse CONTEXT to the end without republishing the snapshot. - expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); - expect(syntaxTree(state).length).toBeLessThan(state.doc.length); - - // A single letter inside the first plain paragraph line, far above both blocks. - const tr = state.update({ changes: { from: 4, insert: "x" } }); - // Preconditions: the post-edit snapshot is complete, and HEAD's admission test sends - // this transaction down the bounded path. - expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); - expect(touchesStructuralReparse(tr)).toBe(false); - expect(syntaxTreeAvailable(tr.state, tr.state.doc.length)).toBe(true); - - const text = tr.state.doc.toString(); - expect(widgetSlots(tr.state, TableBlockWidget).map((s) => s.from)).toEqual([ - text.indexOf("| a |"), - ]); - expect(widgetSlots(tr.state, ImageBlockWidget).map((s) => s.from)).toEqual([ - text.indexOf("![alt]"), - ]); }); }); @@ -563,25 +573,34 @@ function expectFrontierAdvancePublishes( read: (state: EditorState) => unknown[] ): void { const doc = beyondInitFrontier(tail); - const state = EditorState.create({ doc, extensions: [quollMarkdownLanguage(), ...extensions] }); - expect(syntaxTree(state).length).toBeLessThan(doc.indexOf(needle)); // partial snapshot - expect(read(state)).toEqual([]); - expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); - expect(syntaxTree(state).length).toBeLessThan(state.doc.length); // snapshot/context split - const tr = state.update({ changes: { from: 4, insert: "x" } }); - expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); - expect(touchesStructuralReparse(tr)).toBe(false); - expect(syntaxTreeAvailable(tr.state, tr.state.doc.length)).toBe(true); - const oracle = read( - settledState( - EditorState.create({ - doc: tr.state.doc, + withUnstarvedFrontierState({ + what: `the frontier-advance publication of ${needle}`, + observe: (requireUnstarvedFrontier) => { + const state = EditorState.create({ + doc, extensions: [quollMarkdownLanguage(), ...extensions], - }) - ) - ); - expect(oracle.length).toBeGreaterThan(0); // non-vacuity: the target exists when fully parsed - expect(read(tr.state)).toEqual(oracle); + }); + expect(syntaxTree(state).length).toBeLessThan(doc.indexOf(needle)); // partial snapshot + expect(read(state)).toEqual([]); + expect(ensureSyntaxTree(state, state.doc.length, 10_000)).not.toBeNull(); + expect(syntaxTree(state).length).toBeLessThan(state.doc.length); // snapshot/context split + const tr = state.update({ changes: { from: 4, insert: "x" } }); + requireUnstarvedFrontier(tr.state); + expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); + expect(touchesStructuralReparse(tr)).toBe(false); + const oracle = read( + settledState( + EditorState.create({ + doc: tr.state.doc, + extensions: [quollMarkdownLanguage(), ...extensions], + }) + ) + ); + expect(oracle.length).toBeGreaterThan(0); // non-vacuity: the target exists when fully parsed + expect(read(tr.state)).toEqual(oracle); + return tr.state; + }, + }); } describe("bounded consumers beyond the initial parse frontier", () => { From 6fd1dd25b72fea80cbba66b6cba5c27aa854adfc Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Tue, 10 Nov 2026 13:38:43 +0900 Subject: [PATCH 10/14] refactor(cm): make the decoration set the structural block record store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-9R shape kept a parallel records array next to the DecorationSet and allocated fresh block/record objects for every shifted block on each edit. Measured cost: ~1.8-2.1 MB retained over HEAD; image-1200 prose@mid retained 8580 KB on the branch vs 6439 KB on HEAD, failing the Step 10 heap bar. Now the DecorationSet is the store: the position-free payload rides deco.spec.structural, StructuralRecordSet brands it at compile time, recordAt scans records.between, a moved record reuses its payload, an all-unchanged update returns the previous set, and reveal shares the record set as the decoration set when no selection meets a block. Revert-checks (9R-d): - payload spread on rebase -> "toy rebase": AssertionError: expected { text: '```\nalpha\n```' } to be { text: '```\nalpha\n```' } // Object.is equality - meetsSelection short-circuit removed -> "reveal": AssertionError: expected RangeSet{ chunkPos: [ 18 ], …(3) } to be RangeSet{ chunkPos: [ 18 ], …(3) } // Object.is equality --- src/webview/cm/image/image-field.ts | 14 +- src/webview/cm/markdown.ts | 20 +- src/webview/cm/structural-blocks.ts | 298 +++++++++++------- src/webview/cm/table/table-field.ts | 17 +- test/webview/cm-structural-blocks.test.ts | 205 ++++++++---- test/webview/cm-structural-guard-fuzz.test.ts | 9 +- test/webview/helpers/structural-records.ts | 23 ++ test/webview/table/cm-table-blocks.test.ts | 20 +- 8 files changed, 389 insertions(+), 217 deletions(-) create mode 100644 test/webview/helpers/structural-records.ts diff --git a/src/webview/cm/image/image-field.ts b/src/webview/cm/image/image-field.ts index 881e2d87..1c517aa8 100644 --- a/src/webview/cm/image/image-field.ts +++ b/src/webview/cm/image/image-field.ts @@ -29,19 +29,19 @@ // flatten) so `` and the blocked placeholder's `aria-label` carry the rendered // text, not the raw markup. // -// The block stores no absolute position besides `from`/`to`, so a reused block rebases by a -// uniform shift with no re-parse. Performance numbers: .claude/docs/PERF.md. +// The payload stores no document position (the block range lives in the record set), so a +// shifted block keeps the same payload object with no re-parse. Performance numbers: .claude/docs/PERF.md. import type { EditorState } from "@codemirror/state"; import type { SyntaxNodeRef } from "@lezer/common"; import { renderSafeMarkdownDestination } from "../../../markdown/render-safe-markdown-destination.js"; import type { AllowlistedUrl } from "../../../markdown/url-allowlist.js"; import { commonMarkAltText } from "../inline/inline-ir.js"; -import { type StructuralBlock, structuralBlockWidgets } from "../structural-blocks.js"; +import { type BuiltBlock, structuralBlockWidgets } from "../structural-blocks.js"; import { ImageBlockWidget } from "./image-widget.js"; import { quollResourceBaseUri, resolveAgainstBase } from "./resource-base.js"; -export interface ImageBlock extends StructuralBlock { +export interface ImageBlock { readonly alt: string; readonly safeUrl: AllowlistedUrl | null; /** Trimmed image source: the widget's eq() key. */ @@ -60,7 +60,7 @@ function trimAsciiWs(s: string): string { return s.replace(/^[ \t\n\r]+/, "").replace(/[ \t\n\r]+$/, ""); } -function buildImageBlock(state: EditorState, node: SyntaxNodeRef): ImageBlock | null { +function buildImageBlock(state: EditorState, node: SyntaxNodeRef): BuiltBlock | null { // (1) inline form only. const urlNode = node.node.getChild("URL"); if (!urlNode) { @@ -100,12 +100,12 @@ function buildImageBlock(state: EditorState, node: SyntaxNodeRef): ImageBlock | const bracketIdx = parenIdx >= 0 ? gap.lastIndexOf("]", parenIdx) : gap.lastIndexOf("]"); const altRaw = bracketIdx >= 0 ? gap.slice(0, bracketIdx) : ""; const alt = commonMarkAltText(altRaw); - return { from: startLine.from, to: endLine.to, alt, safeUrl, imgText }; + return { from: startLine.from, to: endLine.to, block: { alt, safeUrl, imgText } }; } export const imageBlockField = structuralBlockWidgets({ name: "image", node: "Image", build: buildImageBlock, - widget: (b) => new ImageBlockWidget(b.alt, b.safeUrl, b.imgText, b.from), + widget: (b, from) => new ImageBlockWidget(b.alt, b.safeUrl, b.imgText, from), }); diff --git a/src/webview/cm/markdown.ts b/src/webview/cm/markdown.ts index 35bbd2a7..abc0a8cf 100644 --- a/src/webview/cm/markdown.ts +++ b/src/webview/cm/markdown.ts @@ -72,18 +72,18 @@ function firstContentChild(node: SyntaxNode): SyntaxNode | null { } // True when the `Table` node starting at `from` renders as a block widget: the table -// field's record list (../structural-blocks.ts, sorted by `block.from`) holds a record -// whose node starts there. Reading the field rather than re-deriving its gates keeps +// field's record set (../structural-blocks.ts) holds a record at its block start whose +// node starts there. Reading the field rather than re-deriving its gates keeps // fold-suppression and widget emission in lockstep by construction — a table inside // leading frontmatter, one `parseTable` rejects, or one whose build threw has no record, -// so its list fold stays; a state without the field has no widget at all. `recordAt` is -// a binary search, so a list of N table-bearing items stays O(N log N) per fold query. +// so its list fold stays; a state without the field has no widget at all. `recordAt` is a +// chunk scan of the set, O(N/250) per fold query (Contract 2; a linear `.some()` would +// make a list of N table-bearing items O(N²)); the whole-document cost is accepted by the +// Performance gate's fold row (.claude/docs/PERF.md), not by this argument. function tableEmitsBlockWidget(state: EditorState, from: number): boolean { - const r = recordAt( - state.field(tableBlockField, false)?.records ?? [], - state.doc.lineAt(from).from - ); - return r !== undefined && r.block.from + r.block.nodeOffset === from; + const records = state.field(tableBlockField, false)?.records; + const r = records && recordAt(records, state.doc.lineAt(from).from); + return r !== undefined && r.from + r.block.nodeOffset === from; } // True when `node` sits anywhere inside a Blockquote (a callout is a Blockquote @@ -110,7 +110,7 @@ function insideBlockquote(node: SyntaxNode): boolean { // child is a GFM Table that starts on the marker line AND actually emits a block // widget. In that tight shape (`- | a | b |\n | - | - |\n…`) the table's block // widget is line-snapped to the marker-line start (the table spec in -// table/table-field.ts snaps block.from = lineAt(Table.from).from), so it SWALLOWS the `-`/`1.` marker and the +// table/table-field.ts snaps the block `from` = lineAt(Table.from).from), so it SWALLOWS the `-`/`1.` marker and the // ListItem chevron would visually land on the table — a meaningless affordance. // Returning null suppresses that lone chevron. The emit guard is load-bearing: a // marker-line table that emits NO widget (inside leading frontmatter, a slice diff --git a/src/webview/cm/structural-blocks.ts b/src/webview/cm/structural-blocks.ts index bf626cb2..cb43a22e 100644 --- a/src/webview/cm/structural-blocks.ts +++ b/src/webview/cm/structural-blocks.ts @@ -8,40 +8,49 @@ // display-only: decorations only, never a document change → byte-identical round-trip. // // The field value is `{ records, decorations }`. `records` is the canonical, -// selection-independent state: one record per block that HAS a widget, sorted by -// `block.from`, each carrying its own decoration (precedent: collapse/line-collapse-field.ts). -// `decorations` is the selection-filtered view of it. Updates derive exclusively from the -// previous field value; the field closure holds only the diagnostic latch. EditorState is -// persistent — two transactions branching from one state never share mutable update data. +// selection-independent state: a DecorationSet holding one block decoration per block that +// HAS a widget, whose range is the block range and whose `spec.structural` is the +// consumer's POSITION-FREE payload. The set is the only place a block position lives, so a +// shifted block keeps its payload object and only its range moves — a shift allocates what +// a plain mapped DecorationSet would (a widget + a decoration), nothing more. +// `decorations` is the selection-filtered view of it: the SAME set object whenever no +// record meets a selection line. Updates derive exclusively from the previous field value; +// the field closure holds only the diagnostic latch. EditorState is persistent — two +// transactions branching from one state never share mutable update data. // // 1. Ownership. A block whose node starts before `leadingFrontmatterEnd(state)` is not -// emitted (frontmatter owns [0, fmEnd]). A built block with `from >= to`, or one that -// does not contain its node, is dropped (the `Decoration.replace({ block: true })` -// precondition and the containment precondition live at this one call site). fmEnd -// "moved" → full rebuild; see `frontmatterMoved`. +// emitted (frontmatter owns [0, fmEnd]). A built block out of document bounds, with +// `from >= to`, not whole lines, or not containing its node, is dropped and reported as +// `invalid-range` (the `Decoration.replace({ block: true })`, line-alignment and +// containment preconditions live at this one call site; reveal relies on alignment). +// fmEnd "moved" → full rebuild; see `frontmatterMoved`. // 2. Change range / tree identity / determinacy. On a doc change, // `requiresFullBoundedRebuild` (../structural-guard.ts — the single home of every // full-rebuild reason) → full; otherwise every prev record whose block is untouched and -// outside the span (changed ranges ±1 line) is reused — the SAME record when unmoved, -// else rebased through `makeRecord` — and only the span is re-walked, each matched node -// processed at most once per rebuild (`visited`). Reused records and walked nodes are -// disjoint by construction: the reuse guard drops every record whose mapped range meets -// the span, every walked node meets the span, and a block contains its node. No doc -// change but a new tree (the parser reached further) → full. Else prev. +// outside the span (changed ranges ±1 line) is reused — the SAME decoration when unmoved, +// else rebased through `makeRecord` with the SAME payload — and only the span is +// re-walked, each matched node processed at most once per rebuild (`visited`). Reused +// records and walked nodes are disjoint by construction: the reuse guard drops every +// record whose mapped range meets the span, every walked node meets the span, and a +// block contains its node. An update that reuses every record unmoved and adds none +// returns the previous set itself. No doc change but a new tree (the parser reached +// further) → full. Else prev. // 3. Failure → raw. `spec.build` is called only in the walk and `spec.widget` only in // `makeRecord`, each inside one try/catch. A throw → no record: the raw source stays // editable, sibling blocks, the transaction and edit-sync proceed. A dropped block is // retried only when a changed span touches it or a full rebuild happens. The report is // latched once per `${name}:${arm}` and reads NO property of `err` (a throwing getter // inside the catch would escape `StateField.update` — the failure this arm contains; -// same rule as widget-base.ts `reportOnce`). DOM lifecycle failures stay owned by +// same rule as widget-base.ts `reportOnce`). Overlapping blocks (the `sortedDisjoint` +// backstop) report under their own `overlap` arm. DOM lifecycle failures stay owned by // widget-base.ts. -// 4. Reveal. `decorations` = the record decorations whose block does not line-overlap any -// selection range. Recomputed only when the records or the selection line spans change; -// when neither did, the field returns `prev` itself. +// 4. Reveal. `decorations` = the records whose block does not line-overlap any selection +// range. Records are whole lines, so the raw selection ranges suffice. Recomputed only +// when the records or the selection line spans change; when neither did, the field +// returns `prev` itself. import { syntaxTree } from "@codemirror/language"; -import { type EditorState, StateField, type Transaction } from "@codemirror/state"; +import { type EditorState, type Range, StateField, type Transaction } from "@codemirror/state"; import { Decoration, type DecorationSet, EditorView } from "@codemirror/view"; import type { SyntaxNodeRef } from "@lezer/common"; import { @@ -58,59 +67,63 @@ import { leadingFrontmatterEnd } from "./frontmatter/detect.js"; import { requiresFullBoundedRebuild } from "./structural-guard.js"; import type { QuollWidget } from "./widget-base.js"; -/** A line-aligned document range one consumer owns as a block widget. Any other - * position a consumer stores is an offset from `from`, so the contract can - * rebase a reused block by a uniform shift ({ ...block, from, to }). */ -export interface StructuralBlock { +/** One built block: its line-aligned document range plus the consumer's POSITION-FREE + * payload. Any position the payload needs is an offset from `from`, so a shifted block + * keeps the same payload object and only its range (owned by the set) moves. Also the + * shape `recordAt` reads back out of the set (one block interface). */ +export interface BuiltBlock { readonly from: number; readonly to: number; + readonly block: B; } -export interface StructuralBlockSpec { +export interface StructuralBlockSpec { /** For the failure log. */ readonly name: string; /** Lezer node name that may yield a block; descendants of a match are not visited. */ readonly node: string; - /** The block for one matched node, or null when ineligible. It MUST contain the node - * (`from <= node.from && node.to <= to`): liveness is judged on the block range, so a block - * narrower than its node would reuse a record an in-node edit invalidated. May throw: that block stays raw. */ - build(state: EditorState, node: SyntaxNodeRef): B | null; - /** Widget for a block object. May throw: that block stays raw. */ - widget(block: B): QuollWidget; + /** The block for one matched node, or null when ineligible. Its range MUST be whole lines + * (`from` a line start, `to` a line end — reveal reads raw selection ranges) and MUST + * contain the node (`from <= node.from && node.to <= to`): liveness is judged on the + * block range, so a block narrower than its node would reuse a record an in-node edit + * invalidated. May throw: that block stays raw. */ + build(state: EditorState, node: SyntaxNodeRef): BuiltBlock | null; + /** Widget for a payload at document position `from`. The `from` argument is TEMPORARY: + * it exists only because the table and image widgets still carry absolute positions + * (follow-up TODO: position-free widgets, then `widget(block)`). May throw: that block + * stays raw. */ + widget(block: B, from: number): QuollWidget; } -export interface StructuralBlockRecord { - readonly block: B; - readonly deco: Decoration; -} +declare const payload: unique symbol; +/** A DecorationSet whose `spec.structural` payloads are B — the brand is compile-time only + * (repo precedent: the `AbsoluteOffset` brand in `table/cell-point.ts`). */ +export type StructuralRecordSet = DecorationSet & { readonly [payload]?: B }; -export interface StructuralBlockState { - /** Exactly the blocks that HAVE a widget (build and widget both succeeded), sorted by `block.from`. */ - readonly records: readonly StructuralBlockRecord[]; +export interface StructuralBlockState { + /** Exactly the blocks that HAVE a widget (build and widget both succeeded); each + * decoration's `spec.structural` is its payload. */ + readonly records: StructuralRecordSet; readonly decorations: DecorationSet; } -/** The record whose block starts exactly at `blockFrom`, or undefined. A binary search: - * `records` is sorted by `block.from` (Contract 2). */ -export function recordAt( - records: readonly StructuralBlockRecord[], +/** The record whose block starts exactly at `blockFrom`, or undefined. `between` scans the + * set's chunk list linearly and searches inside a chunk, so a query is O(N/250 + log 250) + * — not an index; its whole-document fold cost is accepted by measurement + * (.claude/docs/PERF.md). */ +export function recordAt( + records: StructuralRecordSet, blockFrom: number -): StructuralBlockRecord | undefined { - let lo = 0; - let hi = records.length - 1; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - const from = records[mid].block.from; - if (from === blockFrom) { - return records[mid]; - } - if (from < blockFrom) { - lo = mid + 1; - } else { - hi = mid - 1; +): BuiltBlock | undefined { + let hit: BuiltBlock | undefined; + records.between(blockFrom, blockFrom, (from, to, deco) => { + if (from !== blockFrom) { + return undefined; } - } - return undefined; + hit = { from, to, block: deco.spec.structural as B }; + return false; + }); + return hit; } /** Did the leading frontmatter's end move? `0` is the no-frontmatter sentinel, not a @@ -122,66 +135,116 @@ function frontmatterMoved(tr: Transaction, oldEnd: number, newEnd: number): bool return oldEnd === 0 || newEnd === 0 ? oldEnd !== newEnd : tr.changes.mapPos(oldEnd, 1) !== newEnd; } -/** Records in `from` order with any block that overlaps an earlier one dropped. Reused - * and walked records are disjoint by construction (header, Contract 2), so the drop is a - * backstop for a spec whose blocks overlap each other: overlapping block replaces would - * otherwise reach `Decoration.set`. */ -function sortedDisjoint( - records: StructuralBlockRecord[] -): StructuralBlockRecord[] { - records.sort((a, b) => a.block.from - b.block.from); - const out: StructuralBlockRecord[] = []; - for (const r of records) { - const last = out[out.length - 1]; - if (last === undefined || r.block.from > last.block.to) { - out.push(r); +/** Does any record share a position with any selection range? Closed intervals, like + * `intersects`; records are whole lines, so the raw ranges need no line expansion. */ +function meetsSelection(records: DecorationSet, state: EditorState): boolean { + let met = false; + for (const r of state.selection.ranges) { + records.between(r.from, r.to, () => { + met = true; + return false; + }); + if (met) { + return true; } } - return out; + return false; } -function assemble( - records: readonly StructuralBlockRecord[], - state: EditorState -): DecorationSet { - const ranges = []; - for (const r of records) { - if (!lineRangeOverlapsSelection(state.selection, r.block.from, r.block.to)) { - ranges.push(r.deco.range(r.block.from, r.block.to)); - } +function assemble(records: DecorationSet, state: EditorState): DecorationSet { + if (!meetsSelection(records, state)) { + return records; + } + const { ranges } = state.selection; + let filterFrom = ranges[0].from; + let filterTo = ranges[0].to; + for (const r of ranges) { + filterFrom = Math.min(filterFrom, r.from); + filterTo = Math.max(filterTo, r.to); } - // Records are sorted and disjoint, so the ranges are too: no sort requested. - return Decoration.set(ranges); + return records.update({ + filter: (from, to) => !lineRangeOverlapsSelection(state.selection, from, to), + filterFrom, + filterTo, + }); } -export function structuralBlockWidgets( +export function structuralBlockWidgets( spec: StructuralBlockSpec ): StateField> { const reported = new Set(); - /** `errKind` is `typeof err` (or "invalid-range") — computing it reads nothing off `err`. */ - const report = (arm: "build" | "widget", errKind: string): void => { + const latch = (arm: string): boolean => { const key = `${spec.name}:${arm}`; if (reported.has(key)) { - return; + return false; } reported.add(key); - console.error("[quoll] structural block hook threw; leaving it as raw source", { - name: spec.name, - arm, - errKind, - }); + return true; + }; + /** `errKind` is `typeof err` (or "invalid-range") — computing it reads nothing off `err`. */ + const report = (arm: "build" | "widget", errKind: string): void => { + if (latch(arm)) { + console.error("[quoll] structural block hook threw; leaving it as raw source", { + name: spec.name, + arm, + errKind, + }); + } + }; + const reportOverlap = (): void => { + if (latch("overlap")) { + console.error("[quoll] structural blocks overlap; dropping the later one", { + name: spec.name, + arm: "overlap", + }); + } }; - /** The one call site of `spec.widget`; also mints the block decoration. */ - const makeRecord = (block: B): StructuralBlockRecord | null => { + /** The one call site of `spec.widget`; also mints the block decoration carrying the + * payload. */ + const makeRecord = (block: B, from: number, to: number): Range | null => { let widget: QuollWidget; try { - widget = spec.widget(block); + widget = spec.widget(block, from); } catch (err) { report("widget", typeof err); return null; } - return { block, deco: Decoration.replace({ widget, block: true }) }; + return Decoration.replace({ widget, block: true, structural: block }).range(from, to); + }; + + /** Ranges in `from` order with any range that overlaps an earlier one dropped (and + * reported). Reused and walked records are disjoint by construction (header, Contract 2), + * so the drop is a backstop for a spec whose blocks overlap each other: overlapping block + * replaces would otherwise reach `Decoration.set`. One sort over the whole merged list, + * one O(N) pass, then `Decoration.set` without its sort flag. */ + const sortedDisjoint = (ranges: Range[]): DecorationSet => { + ranges.sort((a, b) => a.from - b.from); + const out: Range[] = []; + for (const r of ranges) { + const last = out[out.length - 1]; + if (last === undefined || r.from > last.to) { + out.push(r); + } else { + reportOverlap(); + } + } + return Decoration.set(out); + }; + + /** Contract 1's range preconditions. Bounds first, so `lineAt` never throws outside the + * `build` try/catch. */ + const validRange = (state: EditorState, b: BuiltBlock, node: SyntaxNodeRef): boolean => { + const { doc } = state; + return ( + b.from >= 0 && + b.to <= doc.length && + b.from < b.to && + b.from === doc.lineAt(b.from).from && + b.to === doc.lineAt(b.to).to && + b.from <= node.from && + node.to <= b.to + ); }; /** Walk [from, to] and append a record per eligible matched node not yet in `visited`. @@ -192,7 +255,7 @@ export function structuralBlockWidgets( to: number, fmEnd: number, visited: Set, - out: StructuralBlockRecord[] + out: Range[] ): void => { syntaxTree(state).iterate({ from, @@ -205,21 +268,21 @@ export function structuralBlockWidgets( return false; } visited.add(node.from); - let block: B | null; + let built: BuiltBlock | null; try { - block = spec.build(state, node); + built = spec.build(state, node); } catch (err) { report("build", typeof err); return false; } - if (block === null) { + if (built === null) { return false; } - if (block.from >= block.to || block.from > node.from || node.to > block.to) { + if (!validRange(state, built, node)) { report("build", "invalid-range"); return false; } - const record = makeRecord(block); + const record = makeRecord(built.block, built.from, built.to); if (record !== null) { out.push(record); } @@ -228,35 +291,31 @@ export function structuralBlockWidgets( }); }; - const full = (state: EditorState): StructuralBlockRecord[] => { - const out: StructuralBlockRecord[] = []; + const full = (state: EditorState): DecorationSet => { + const out: Range[] = []; walk(state, 0, state.doc.length, leadingFrontmatterEnd(state), new Set(), out); return sortedDisjoint(out); }; - const bounded = ( - prev: readonly StructuralBlockRecord[], - tr: Transaction, - fmEnd: number - ): readonly StructuralBlockRecord[] => { + const bounded = (prev: DecorationSet, tr: Transaction, fmEnd: number): DecorationSet => { const raw: Interval[] = []; tr.changes.iterChangedRanges((_fa, _ta, fromB, toB) => { raw.push(lineExpandWithNeighbours(tr.state, fromB, toB)); }); const span = mergeIntervals(raw); let changed = false; - const next: StructuralBlockRecord[] = []; - for (const r of prev) { - const { from, to } = r.block; + const next: Range[] = []; + for (const it = prev.iter(); it.value !== null; it.next()) { + const { from, to, value } = it; const nf = tr.changes.mapPos(from, 1); const nt = tr.changes.mapPos(to, -1); - if (tr.changes.touchesRange(from, to) !== false || intersects(span, nf, nt)) { + if (nf >= nt || tr.changes.touchesRange(from, to) !== false || intersects(span, nf, nt)) { changed = true; } else if (nf === from && nt === to) { - next.push(r); + next.push(value.range(from, to)); } else { changed = true; - const rebased = makeRecord({ ...r.block, from: nf, to: nt }); + const rebased = makeRecord(value.spec.structural as B, nf, nt); if (rebased !== null) { next.push(rebased); } @@ -273,10 +332,7 @@ export function structuralBlockWidgets( return sortedDisjoint(next); }; - const nextRecords = ( - prev: readonly StructuralBlockRecord[], - tr: Transaction - ): readonly StructuralBlockRecord[] => { + const nextRecords = (prev: DecorationSet, tr: Transaction): DecorationSet => { if (tr.docChanged) { const newEnd = leadingFrontmatterEnd(tr.state); if ( @@ -303,7 +359,11 @@ export function structuralBlockWidgets( if (records === prev.records && selectionLineSpansEqual(tr.startState, tr.state)) { return prev; } - return { records, decorations: assemble(records, tr.state) }; + const decorations = assemble(records, tr.state); + if (records === prev.records && decorations === prev.decorations) { + return prev; + } + return { records, decorations }; }, provide: (f) => [ EditorView.decorations.from(f, (s) => s.decorations), diff --git a/src/webview/cm/table/table-field.ts b/src/webview/cm/table/table-field.ts index 96b55abb..ffb4c351 100644 --- a/src/webview/cm/table/table-field.ts +++ b/src/webview/cm/table/table-field.ts @@ -6,8 +6,8 @@ // // A block is the node's whole-line range (`block: true` widgets must cover complete lines; // Lezer's Table range usually IS line-aligned, but doc-final-no-newline edges may not be), -// plus everything the widget needs, stored as offsets from `from` so a reused block rebases -// by a uniform shift: `nodeOffset` (the Lezer node start, the base for per-cell offsets), +// plus a POSITION-FREE payload carrying everything the widget needs, stored as offsets from +// the block start so a shifted block keeps the same payload object: `nodeOffset` (the Lezer node start, the base for per-cell offsets), // the per-node LF source slice (the widget's eq() key) and the parse. A `Table` node // `parseTable` rejects — a blockquote-nested table whose continuation lines carry `>` // markers, or a cell-count mismatch — yields no block and stays raw source. @@ -20,11 +20,12 @@ import type { EditorState } from "@codemirror/state"; import type { SyntaxNodeRef } from "@lezer/common"; import { parseTable, type Table } from "../../../markdown/table/index.js"; -import { type StructuralBlock, structuralBlockWidgets } from "../structural-blocks.js"; +import { type BuiltBlock, structuralBlockWidgets } from "../structural-blocks.js"; import { TableBlockWidget } from "./table-widget.js"; -export interface TableBlock extends StructuralBlock { - /** Lezer `Table` node start minus `from` — the widget's `nodeFrom` is `from + nodeOffset`. */ +export interface TableBlock { + /** Lezer `Table` node start minus the block start — the widget's `nodeFrom` is + * `from + nodeOffset`. */ readonly nodeOffset: number; /** Per-node source slice: the widget's eq() key and the input `table` was parsed from. * LF-only, because the CM document itself is: `cm/seed.ts`'s `splitToCmText` owns that @@ -34,7 +35,7 @@ export interface TableBlock extends StructuralBlock { readonly table: Table; } -function buildTableBlock(state: EditorState, node: SyntaxNodeRef): TableBlock | null { +function buildTableBlock(state: EditorState, node: SyntaxNodeRef): BuiltBlock | null { // The `\r\n?` replace matches nothing today (see `slice` above); it is a belt-and-braces // normaliser, not a sign that the source doc carries CRLF. const slice = state.sliceDoc(node.from, node.to).replace(/\r\n?/g, "\n"); @@ -44,7 +45,7 @@ function buildTableBlock(state: EditorState, node: SyntaxNodeRef): TableBlock | } const from = state.doc.lineAt(node.from).from; const to = state.doc.lineAt(Math.min(node.to, state.doc.length)).to; - return { from, to, nodeOffset: node.from - from, slice, table }; + return { from, to, block: { nodeOffset: node.from - from, slice, table } }; } export const tableBlockField = structuralBlockWidgets({ @@ -53,5 +54,5 @@ export const tableBlockField = structuralBlockWidgets({ build: buildTableBlock, // `from` (block line-start) is docFrom; `from + nodeOffset` (Lezer node start) is // nodeFrom, the base for per-cell offsets. All LF-internal. - widget: (b) => new TableBlockWidget(b.table, b.slice, b.from, b.from + b.nodeOffset), + widget: (b, from) => new TableBlockWidget(b.table, b.slice, from, from + b.nodeOffset), }); diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 835ed988..7d810769 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -11,27 +11,31 @@ // failure is never read as "behaviour absent". import { ensureSyntaxTree, foldable, syntaxTree } from "@codemirror/language"; import { EditorState, type Extension, type StateField } from "@codemirror/state"; -import { EditorView, type WidgetType } from "@codemirror/view"; +import { Decoration, EditorView, type WidgetType } from "@codemirror/view"; import { type Tree, TreeFragment } from "@lezer/common"; import { afterEach, describe, expect, it, vi } from "vitest"; import { calloutMarkerConcealField } from "../../src/webview/cm/decorations/callout-marker-conceal.js"; import { fencedCodeCollapseField } from "../../src/webview/cm/fenced-code/fenced-code-collapse.js"; import { headingFoldGutterLineClass } from "../../src/webview/cm/fold/index.js"; +import type { imageBlockField } from "../../src/webview/cm/image/image-field.js"; import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js"; import { + type BuiltBlock, recordAt, - type StructuralBlock, type StructuralBlockSpec, type StructuralBlockState, + type StructuralRecordSet, structuralBlockWidgets, } from "../../src/webview/cm/structural-blocks.js"; import { requiresFullBoundedRebuild, touchesStructuralReparse, } from "../../src/webview/cm/structural-guard.js"; +import type { TableBlock } from "../../src/webview/cm/table/table-field.js"; import { QuollWidget } from "../../src/webview/cm/widget-base.js"; import { settledState } from "./helpers/settled-state.js"; import { settledMount } from "./helpers/settled-view.js"; +import { recordsOf, recordWidgetsOf } from "./helpers/structural-records.js"; import { withUnstarvedFrontierState } from "./helpers/unstarved-frontier.js"; // One switch for both throwing seams. A mock only throws while `armed` is true, so the @@ -379,16 +383,18 @@ describe("structural blocks: frontmatter shift stays bounded (production fields) /** The table/image positions the widgets were given, per record. */ async function positions(state: EditorState) { const p = await productionBlocks(); - const t = state - .field(p.tableBlockField) - .records.map(({ block }) => [block.from, block.to, block.from + block.nodeOffset]); - const i = state - .field(p.imageBlockField) - .records.map(({ block, deco }) => [ - block.from, - block.to, - (deco.spec.widget as InstanceType).docFrom, - ]); + const t = recordsOf(state.field(p.tableBlockField).records).map(({ from, to, block }) => [ + from, + to, + from + block.nodeOffset, + ]); + const images = state.field(p.imageBlockField).records; + const widgets = recordWidgetsOf(images); + const i = recordsOf(images).map(({ from, to }, n) => [ + from, + to, + (widgets[n] as InstanceType).docFrom, + ]); return { t, i }; } @@ -437,11 +443,15 @@ describe("structural blocks: an edit outside the viewport (production fields)", const doc = ["Top.", "", ...BLOCKS_TAIL, ...prose, "Last line."].join("\n"); const view = mountView(EditorState.create({ doc, extensions: p.extensions })); try { - const blocks = (): readonly StructuralBlock[] => + const records = (): StructuralRecordSet => (kind === "table" ? view.state.field(p.tableBlockField) : view.state.field(p.imageBlockField) - ).records.map((r) => r.block); + ).records; + const blocks = (): unknown[] => recordsOf(records()).map((r) => r.block); + const widgets = (): unknown[] => recordWidgetsOf(records()); + const beforeRecords = records(); + const beforeWidgets = widgets(); const before = blocks(); expect(before).toHaveLength(1); // precondition const at = doc.length; @@ -457,6 +467,9 @@ describe("structural blocks: an edit outside the viewport (production fields)", const after = blocks(); expect(after).toHaveLength(1); expect(after[0]).toBe(before[0]); + expect(widgets()[0]).toBe(beforeWidgets[0]); + // A bounded update that reuses every record unchanged returns the previous set. + expect(records()).toBe(beforeRecords); expect(p.shape(view.state)).toEqual(p.shape(p.create(view.state.doc.toString()))); } finally { view.destroy(); @@ -647,7 +660,7 @@ describe("bounded consumers beyond the initial parse frontier", () => { // Toy spec: the contract arms exercised directly through `structuralBlockWidgets`. // A toy block is a fenced code block snapped to whole lines. -interface ToyBlock extends StructuralBlock { +interface ToyBlock { readonly text: string; } @@ -671,14 +684,14 @@ class ToyWidget extends QuollWidget { interface ToyOptions { /** Runs after the default block is computed; may throw, transform or veto it. */ - build?: (block: ToyBlock) => ToyBlock | null; + build?: (built: BuiltBlock) => BuiltBlock | null; /** Runs before the widget is made; may throw. */ - widget?: (block: ToyBlock) => void; + widget?: (block: ToyBlock, from: number) => void; } interface Toy { field: StateField>; - /** `block.from` of every hook call, in call order. */ + /** The block start of every hook call, in call order. */ calls: { build: number[]; widget: number[] }; } @@ -691,13 +704,13 @@ function toy(opts: ToyOptions = {}): Toy { const from = state.doc.lineAt(node.from).from; const to = state.doc.lineAt(node.to).to; calls.build.push(from); - const block: ToyBlock = { from, to, text: state.sliceDoc(from, to) }; - return opts.build ? opts.build(block) : block; + const built: BuiltBlock = { from, to, block: { text: state.sliceDoc(from, to) } }; + return opts.build ? opts.build(built) : built; }, - widget(block) { - calls.widget.push(block.from); - opts.widget?.(block); - return new ToyWidget(block.from, block.text); + widget(block, from) { + calls.widget.push(from); + opts.widget?.(block, from); + return new ToyWidget(from, block.text); }, }; return { field: structuralBlockWidgets(spec), calls }; @@ -713,7 +726,7 @@ function toyState(doc: string, t: Toy, extra: Extension[] = []): EditorState { } function ranges(state: EditorState, t: Toy): Array<[number, number]> { - return state.field(t.field).records.map((r) => [r.block.from, r.block.to]); + return recordsOf(state.field(t.field).records).map((r) => [r.from, r.to]); } /** The FencedCode node set of a fully-parsed state, line-snapped, in order. */ @@ -760,13 +773,16 @@ describe("structural block contract (toy spec)", () => { expect(expected).toHaveLength(3); // precondition expect(ranges(state, t)).toEqual(expected); const records = state.field(t.field).records; - for (const r of records) { - expect(recordAt(records, r.block.from)).toBe(r); + const list = recordsOf(records); + for (const r of list) { + const hit = recordAt(records, r.from); + expect(hit).toEqual(r); + expect(hit?.block).toBe(r.block); } expect(recordAt(records, 0)).toBeUndefined(); - expect(recordAt(records, records[0].block.from + 1)).toBeUndefined(); - expect(recordAt(records, records[2].block.to + 1)).toBeUndefined(); - expect(recordAt([], 0)).toBeUndefined(); + expect(recordAt(records, list[0].from + 1)).toBeUndefined(); + expect(recordAt(records, list[2].to + 1)).toBeUndefined(); + expect(recordAt(Decoration.none, 0)).toBeUndefined(); expect(widgetSlots(state, ToyWidget).map((s) => s.from)).toEqual(expected.map((e) => e[0])); }); @@ -774,7 +790,7 @@ describe("structural block contract (toy spec)", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); const t = toy({ build: (b) => { - if (b.text.includes("BOOM")) { + if (b.block.text.includes("BOOM")) { throw new Error("toy build"); } return b; @@ -822,10 +838,10 @@ describe("structural block contract (toy spec)", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); const t = toy({ build: (b) => { - if (b.text.includes("NARROW")) { + if (b.block.text.includes("NARROW")) { return { ...b, from: b.from + 4 }; // does not contain its node } - if (b.text.includes("EMPTY")) { + if (b.block.text.includes("EMPTY")) { return { ...b, to: b.from }; // from >= to } return b; @@ -872,6 +888,57 @@ describe("structural block contract (toy spec)", () => { }); }); +describe("structural block contract preconditions (toy spec)", () => { + it("toy overlap dropped: of two overlapping blocks the later one goes, its sibling survives", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const betaEnd = TOY_DOC.indexOf("```\n\n```\ngamma") + 3; + const t = toy({ + // alpha's block reaches over beta: whole lines, contains its node, overlaps beta. + build: (b) => (b.block.text.includes("alpha") ? { ...b, to: betaEnd } : b), + }); + const state = toyState(TOY_DOC, t); + const all = fencedRanges(state); + expect(all).toHaveLength(3); // precondition + expect(state.doc.lineAt(betaEnd).to).toBe(betaEnd); // precondition: whole lines + expect(ranges(state, t)).toEqual([[all[0][0], betaEnd], all[2]]); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]).toEqual([ + "[quoll] structural blocks overlap; dropping the later one", + { name: "toy", arm: "overlap" }, + ]); + }); + + it("toy non-aligned dropped: a block that does not end at a line end gets no record", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const t = toy({ + // alpha's block ends 3 characters into "Middle paragraph.": contains its node, in + // bounds, from < to — only the whole-lines precondition rejects it. + build: (b) => (b.block.text.includes("alpha") ? { ...b, to: b.to + 3 } : b), + }); + const state = toyState(TOY_DOC, t); + const all = fencedRanges(state); + expect(all).toHaveLength(3); // precondition + expect(ranges(state, t)).toEqual([all[1], all[2]]); + expect(hookLogCount(spy)).toBe(1); + expect(spy.mock.calls.find((c) => String(c[0]).startsWith(HOOK_LOG))?.[1]).toEqual({ + name: "toy", + arm: "build", + errKind: "invalid-range", + }); + }); + + it("brand pin: one field's records are not another field's record set", () => { + // Type-level only (a tsc program covers this file): the brand on StructuralRecordSet + // makes the assignment below a compile error, so recordAt infers the right payload. + const assign = (state: EditorState, field: typeof imageBlockField) => { + // @ts-expect-error — image records are not a StructuralRecordSet + const tables: StructuralRecordSet = state.field(field).records; + return tables; + }; + expect(typeof assign).toBe("function"); + }); +}); + describe("structural block contract updates (toy spec)", () => { function fromOf(state: EditorState, needle: string): number { const i = state.doc.toString().indexOf(needle); @@ -882,50 +949,59 @@ describe("structural block contract updates (toy spec)", () => { it("toy bounded update: an edit clear of a block reuses its record; an edit inside rebuilds only it", () => { const t = toy(); const state = toyState(TOY_DOC, t); - const before = state.field(t.field).records; + const beforeSet = state.field(t.field).records; + const before = recordsOf(beforeSet); expect(before).toHaveLength(3); // precondition const clearTr = state.update({ changes: { from: state.doc.toString().indexOf("Tail paragraph.") + 4, insert: "x" }, }); expect(requiresFullBoundedRebuild(clearTr)).toBe(false); // precondition: bounded path - const clear = clearTr.state.field(t.field).records; - clear.forEach((r, i) => { - expect(r).toBe(before[i]); - }); + // Every record reused unchanged and none added: the previous set itself. + expect(clearTr.state.field(t.field).records).toBe(beforeSet); expect(t.calls.build).toEqual([]); // Inside the LAST block, so no other block moves (a moved block is rebased: "toy rebase"). - const gamma = before[2].block.from; + const gamma = before[2].from; const insideTr = state.update({ changes: { from: state.doc.toString().indexOf("gamma") + 2, insert: "x" }, }); expect(requiresFullBoundedRebuild(insideTr)).toBe(false); // precondition: bounded path - const inside = insideTr.state.field(t.field).records; + const insideSet = insideTr.state.field(t.field).records; + const inside = recordsOf(insideSet); expect(t.calls.build).toEqual([gamma]); - expect(inside[2]).not.toBe(before[2]); + expect(inside[2].block).not.toBe(before[2].block); expect(inside[2].block.text).toContain("gaxmma"); - expect(inside[0]).toBe(before[0]); - expect(inside[1]).toBe(before[1]); + expect(inside[0].block).toBe(before[0].block); + expect(inside[1].block).toBe(before[1].block); + const beforeWidgets = recordWidgetsOf(beforeSet); + const insideWidgets = recordWidgetsOf(insideSet); + expect(insideWidgets[0]).toBe(beforeWidgets[0]); + expect(insideWidgets[1]).toBe(beforeWidgets[1]); }); it("toy rebase: an insertion before a block mints a shifted record without rebuilding it", () => { const t = toy(); const state = toyState(TOY_DOC, t); - const before = state.field(t.field).records; + const beforeSet = state.field(t.field).records; + const before = recordsOf(beforeSet); const tr = state.update({ changes: { from: 4, insert: "x" } }); expect(requiresFullBoundedRebuild(tr)).toBe(false); // precondition: bounded path - const after = tr.state.field(t.field).records; - expect(after.map((r) => [r.block.from, r.block.to])).toEqual( - before.map((r) => [r.block.from + 1, r.block.to + 1]) - ); + const afterSet = tr.state.field(t.field).records; + const after = recordsOf(afterSet); + expect(after.map((r) => [r.from, r.to])).toEqual(before.map((r) => [r.from + 1, r.to + 1])); + // The shifted record keeps the SAME payload object; only its widget is re-minted. after.forEach((r, i) => { - expect(r).not.toBe(before[i]); + expect(r.block).toBe(before[i].block); + }); + const beforeWidgets = recordWidgetsOf(beforeSet); + recordWidgetsOf(afterSet).forEach((w, i) => { + expect(w).not.toBe(beforeWidgets[i]); }); expect(t.calls.build).toEqual([]); - expect(t.calls.widget).toEqual(before.map((r) => r.block.from + 1)); + expect(t.calls.widget).toEqual(before.map((r) => r.from + 1)); expect(widgetSlots(tr.state, ToyWidget).map((s) => (s.widget as ToyWidget).at)).toEqual( - before.map((r) => r.block.from + 1) + before.map((r) => r.from + 1) ); }); @@ -933,8 +1009,8 @@ describe("structural block contract updates (toy spec)", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); const gamma = TOY_DOC.indexOf("```\ngamma"); const t = toy({ - widget: (b) => { - if (b.from === gamma + 1) { + widget: (_b, from) => { + if (from === gamma + 1) { throw new Error("toy widget on rebase"); } }, @@ -992,23 +1068,25 @@ describe("structural block contract updates (toy spec)", () => { const a = state.update({ changes: { from: text.indexOf("gamma") + 1, insert: "x" } }).state; const b = state.update({ changes: { from: text.indexOf("Tail") + 1, insert: "y" } }).state; expect(state.field(t.field).records).toBe(base); - const ra = a.field(t.field).records; - const rb = b.field(t.field).records; - expect(ra[2]).not.toBe(base[2]); - expect(ra[0]).toBe(base[0]); + const baseList = recordsOf(base); + const ra = recordsOf(a.field(t.field).records); + const rb = recordsOf(b.field(t.field).records); + expect(ra[2].block).not.toBe(baseList[2].block); + expect(ra[0].block).toBe(baseList[0].block); rb.forEach((r, i) => { - expect(r).toBe(base[i]); + expect(r.block).toBe(baseList[i].block); }); + expect(b.field(t.field).records).toBe(base); expect(ra[2].block.text).toContain("gxamma"); expect(rb[2].block.text).not.toContain("gxamma"); - expect(base[2].block.text).not.toContain("gxamma"); + expect(recordsOf(state.field(t.field).records)[2].block.text).not.toContain("gxamma"); // A second state built with the same field instance is untouched by updates to the first. const other = toyState(["Other.", "", ...fence(["delta"]), ""].join("\n"), t); const otherRecords = other.field(t.field).records; state.update({ changes: { from: 0, insert: "\n\n" } }); expect(other.field(t.field).records).toBe(otherRecords); - expect(otherRecords.map((r) => r.block.text)).toEqual(["```\ndelta\n```"]); + expect(recordsOf(otherRecords).map((r) => r.block.text)).toEqual(["```\ndelta\n```"]); }); it("toy seeded oracle: after each of 20 seeded edits the records equal a fresh full build", () => { @@ -1044,6 +1122,13 @@ describe("structural block reveal (toy spec)", () => { const before = widgetSlots(state, ToyWidget); expect(before).toHaveLength(3); // precondition: caret at 0 reveals nothing const alpha = before[0]; + // No record meets the selection: the selection-filtered view IS the record set. + expect(state.field(t.field).decorations).toBe(state.field(t.field).records); + // A caret move between two prose lines changes no verdict: the field value itself. + const prose = state.update({ + selection: { anchor: TOY_DOC.indexOf("Middle paragraph.") }, + }).state; + expect(prose.field(t.field)).toBe(state.field(t.field)); const entered = state.update({ selection: { anchor: alpha.from + 1 } }).state; expect(widgetSlots(entered, ToyWidget).map((s) => s.from)).toEqual([ diff --git a/test/webview/cm-structural-guard-fuzz.test.ts b/test/webview/cm-structural-guard-fuzz.test.ts index 1126f626..1cdaf9c3 100644 --- a/test/webview/cm-structural-guard-fuzz.test.ts +++ b/test/webview/cm-structural-guard-fuzz.test.ts @@ -26,6 +26,7 @@ import { quollMarkdownLanguage } from "../../src/webview/cm/markdown.js"; import { requiresFullBoundedRebuild } from "../../src/webview/cm/structural-guard.js"; import { tableBlockField } from "../../src/webview/cm/table/table-field.js"; import { settledState } from "./helpers/settled-state.js"; +import { recordsOf } from "./helpers/structural-records.js"; import { SHAPE_CORPUS } from "./helpers/table-shape-corpus.js"; const extensions = (): Extension[] => [ @@ -73,7 +74,7 @@ function serializeImages(set: ReturnType) { function snapshot(state: EditorState): Record { const callout = state.field(calloutMarkerConcealField); return { - table: JSON.stringify(state.field(tableBlockField).records.map((r) => r.block)), + table: JSON.stringify(recordsOf(state.field(tableBlockField).records)), image: serializeImages(state.field(imageBlockField).decorations), // The WHOLE callout value: `records` / `markers` / `zones` are what the selection-only // path reuses, so comparing `decorations` alone would miss a stale record. @@ -158,9 +159,9 @@ describe("structural guard — differential fuzz over the real consumers", () => // pipe in prose or in a code fence satisfies it and would satisfy it under the OLD // arm too (Codex rev.2, Confidence 94). Ask the table field itself whether the edit // landed inside a real `Table` node. - const inTable = state - .field(tableBlockField) - .records.some(({ block }) => pos >= block.from && pos <= block.to); + const inTable = recordsOf(state.field(tableBlockField).records).some( + (r) => pos >= r.from && pos <= r.to + ); // ⚠️ "inside a `Table` node" alone is NOT enough either: a table's trailing // overshoot line carries no pipe, and such an edit takes the bounded path under the // OLD presence arm too (measured: `inCellBounded` reached 23 with the arm reverted diff --git a/test/webview/helpers/structural-records.ts b/test/webview/helpers/structural-records.ts new file mode 100644 index 00000000..1a2932a1 --- /dev/null +++ b/test/webview/helpers/structural-records.ts @@ -0,0 +1,23 @@ +// Test-side readers for a structural block field's record set +// (src/webview/cm/structural-blocks.ts). Production reads payloads only through +// `recordAt`; tests that compare whole record lists or widgets go through these. +import type { WidgetType } from "@codemirror/view"; +import type { BuiltBlock, StructuralRecordSet } from "../../../src/webview/cm/structural-blocks.js"; + +/** Every record of the set as `{ from, to, block }`, in document order. */ +export function recordsOf(set: StructuralRecordSet): BuiltBlock[] { + const out: BuiltBlock[] = []; + for (const it = set.iter(); it.value !== null; it.next()) { + out.push({ from: it.from, to: it.to, block: it.value.spec.structural as B }); + } + return out; +} + +/** The widget of every record of the set, in document order. */ +export function recordWidgetsOf(set: StructuralRecordSet): WidgetType[] { + const out: WidgetType[] = []; + for (const it = set.iter(); it.value !== null; it.next()) { + out.push(it.value.spec.widget as WidgetType); + } + return out; +} diff --git a/test/webview/table/cm-table-blocks.test.ts b/test/webview/table/cm-table-blocks.test.ts index f2b42c78..12a9cb01 100644 --- a/test/webview/table/cm-table-blocks.test.ts +++ b/test/webview/table/cm-table-blocks.test.ts @@ -4,22 +4,24 @@ import { syntaxTreeAvailable } from "@codemirror/language"; import { EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; +import type { BuiltBlock } from "../../../src/webview/cm/structural-blocks.js"; import { touchesStructuralReparse } from "../../../src/webview/cm/structural-guard.js"; import { type TableBlock, tableBlockField } from "../../../src/webview/cm/table/table-field.js"; import { settledMount, settledView } from "../helpers/settled-view.js"; +import { recordsOf } from "../helpers/structural-records.js"; import { withUnstarvedFrontier } from "../helpers/unstarved-frontier.js"; const exts = (): Extension[] => [markdown({ base: markdownLanguage }), tableBlockField]; -/** The field's canonical records, as blocks. */ -const blocks = (state: EditorState): TableBlock[] => - state.field(tableBlockField).records.map((r) => r.block); +/** The field's canonical records: block range + payload. */ +const blocks = (state: EditorState): BuiltBlock[] => + recordsOf(state.field(tableBlockField).records); /** Oracle: a fresh, fully-parsed view's full walk → blocks (block range + node offset + * slice + parse). Comparing the WHOLE block (incl. `table`) makes the `bounded ≡ fullWalk` * battery pin parse-reuse soundness: a bounded update that reused a stale parse for a * table whose bytes changed would deep-differ here. */ -function freshOracle(doc: string): TableBlock[] { +function freshOracle(doc: string): BuiltBlock[] { const p = document.createElement("div"); document.body.appendChild(p); const v = new EditorView({ state: EditorState.create({ doc, extensions: exts() }), parent: p }); @@ -267,22 +269,22 @@ describe("list-nested table detection (real Lezer language)", () => { const found = freshOracle(doc); expect(found).toHaveLength(1); const b = found[0]; - expect(b.table.header.cells).toHaveLength(2); + expect(b.block.table.header.cells).toHaveLength(2); // The block range covers whole lines including the header line's own indent. expect(doc.slice(b.from, b.to)).toBe(" | A | B |\n |---|---|\n | 1 | 2 |"); // Full-doc caret offset (what table-widget uses: nodeFrom + cell.from, with nodeFrom = // from + nodeOffset). Pins the header-indent-outside-node / body-indent-inside-node // asymmetry end-to-end, so click-to-reveal lands the caret on the right byte. - const nodeFrom = b.from + b.nodeOffset; - expect(nodeFrom + b.table.header.cells[0].from).toBe(doc.indexOf("A")); - expect(nodeFrom + b.table.rows[0].cells[0].from).toBe(doc.indexOf("1")); + const nodeFrom = b.from + b.block.nodeOffset; + expect(nodeFrom + b.block.table.header.cells[0].from).toBe(doc.indexOf("A")); + expect(nodeFrom + b.block.table.rows[0].cells[0].from).toBe(doc.indexOf("1")); }); it("emits for a top-level 1-3-space-indented table too (same shape)", () => { const doc = " | A | B |\n |---|---|\n | 1 | 2 |\n"; const found = freshOracle(doc); expect(found).toHaveLength(1); - expect(found[0].table.header.cells).toHaveLength(2); + expect(found[0].block.table.header.cells).toHaveLength(2); }); it("yields no block for a blockquote-nested table (out of scope)", () => { From acfce4c0ec8ef32840ccdcd3a2240448dd677e61 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 11 Nov 2026 00:36:19 +0900 Subject: [PATCH 11/14] refactor(cm): tidy table record lookup and rewrap long comments Return early from tableEmitsBlockWidget when the table field is absent, and rewrap comment lines past 100 columns. No behaviour change. --- src/webview/cm/markdown.ts | 19 +++++++++++-------- src/webview/cm/structural-guard.ts | 5 +++-- src/webview/cm/table/table-field.ts | 5 +++-- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/webview/cm/markdown.ts b/src/webview/cm/markdown.ts index abc0a8cf..f0d1620c 100644 --- a/src/webview/cm/markdown.ts +++ b/src/webview/cm/markdown.ts @@ -82,8 +82,11 @@ function firstContentChild(node: SyntaxNode): SyntaxNode | null { // Performance gate's fold row (.claude/docs/PERF.md), not by this argument. function tableEmitsBlockWidget(state: EditorState, from: number): boolean { const records = state.field(tableBlockField, false)?.records; - const r = records && recordAt(records, state.doc.lineAt(from).from); - return r !== undefined && r.from + r.block.nodeOffset === from; + if (records === undefined) { + return false; + } + const record = recordAt(records, state.doc.lineAt(from).from); + return record !== undefined && record.from + record.block.nodeOffset === from; } // True when `node` sits anywhere inside a Blockquote (a callout is a Blockquote @@ -110,12 +113,12 @@ function insideBlockquote(node: SyntaxNode): boolean { // child is a GFM Table that starts on the marker line AND actually emits a block // widget. In that tight shape (`- | a | b |\n | - | - |\n…`) the table's block // widget is line-snapped to the marker-line start (the table spec in -// table/table-field.ts snaps the block `from` = lineAt(Table.from).from), so it SWALLOWS the `-`/`1.` marker and the -// ListItem chevron would visually land on the table — a meaningless affordance. -// Returning null suppresses that lone chevron. The emit guard is load-bearing: a -// marker-line table that emits NO widget (inside leading frontmatter, a slice -// parseTable rejects, or a build that threw) renders as raw source, so its list fold must be kept. An -// item inside a blockquote never folds at all, whatever its body — that is the +// table/table-field.ts snaps the block `from` = lineAt(Table.from).from), so it +// SWALLOWS the `-`/`1.` marker and the ListItem chevron would visually land on the +// table — a meaningless affordance. Returning null suppresses that lone chevron. +// The emit guard is load-bearing: a marker-line table that emits NO widget (inside +// leading frontmatter, a slice parseTable rejects, or a build that threw) renders +// as raw source, so its list fold must be kept. An item inside a blockquote never folds at all, whatever its body — that is the // gate in `nonFoldableBlocks`, not a check here. Every other genuine list fold (a // table on a continuation line, a plain multi-line body) keeps the default range. // lang-markdown folds ListItem via its broad `type => …` Block foldNodeProp, and diff --git a/src/webview/cm/structural-guard.ts b/src/webview/cm/structural-guard.ts index 87948d78..92e84339 100644 --- a/src/webview/cm/structural-guard.ts +++ b/src/webview/cm/structural-guard.ts @@ -263,7 +263,8 @@ export function tableRowShapeChanged(oldText: string, newText: string): boolean * PRESENCE test may be: pinning both sides of a comparison to `true` erases the flip it * exists to detect, turning a cheap false positive into a missed rebuild. * Performance numbers and consumer-specific costs: .claude/docs/PERF.md. - * Exported so the negative-assertion tests can call it directly (pinning that plain prose typing stays on the bounded hot path). + * Exported so the negative-assertion tests can call it directly (pinning that plain + * prose typing stays on the bounded hot path). * Memoised per Transaction so every consumer shares one dual-slice scan per * dispatch (one scan per dispatch under CM 6.6.x). */ const structuralMemo = new WeakMap(); @@ -314,7 +315,7 @@ export function touchesStructuralReparse(tr: Transaction): boolean { // `Table` appear or vanish and can CLOSE an enclosing list — re-shaping block structure // OUTSIDE the changed run, which SHAPE and the other arms all miss. Keying on the DELTA // of pipe presence and cell count, rather than on the mere PRESENCE of a `|` on the - // line, is what keeps typing inside a table cell — the table spec's (`tableBlockField`) own primary + // line, is what keeps typing inside a table cell — `tableBlockField`'s own primary // scenario — on the bounded path; the delimiter ROW itself stays on presence. if (tableRowShapeChanged(oldLine.text, newLine.text)) { hit = true; diff --git a/src/webview/cm/table/table-field.ts b/src/webview/cm/table/table-field.ts index ffb4c351..31b80c65 100644 --- a/src/webview/cm/table/table-field.ts +++ b/src/webview/cm/table/table-field.ts @@ -7,8 +7,9 @@ // A block is the node's whole-line range (`block: true` widgets must cover complete lines; // Lezer's Table range usually IS line-aligned, but doc-final-no-newline edges may not be), // plus a POSITION-FREE payload carrying everything the widget needs, stored as offsets from -// the block start so a shifted block keeps the same payload object: `nodeOffset` (the Lezer node start, the base for per-cell offsets), -// the per-node LF source slice (the widget's eq() key) and the parse. A `Table` node +// the block start so a shifted block keeps the same payload object: `nodeOffset` (the Lezer +// node start, the base for per-cell offsets), the per-node LF source slice (the widget's +// eq() key) and the parse. A `Table` node // `parseTable` rejects — a blockquote-nested table whose continuation lines carry `>` // markers, or a cell-count mismatch — yields no block and stays raw source. // From 2b3990fad90cd5506f92c8babf1e03985dc6002b Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 11 Nov 2026 00:56:39 +0900 Subject: [PATCH 12/14] docs(cm): correct stale and overstated comments in the structural block contract --- src/webview/cm/bounded-recompute.ts | 5 +++-- src/webview/cm/markdown.ts | 2 +- src/webview/cm/structural-blocks.ts | 10 ++++++---- src/webview/cm/table/table-widget.ts | 3 ++- test/webview/table/cm-table-blocks.test.ts | 7 +++---- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/webview/cm/bounded-recompute.ts b/src/webview/cm/bounded-recompute.ts index 60c179f2..8da6d00a 100644 --- a/src/webview/cm/bounded-recompute.ts +++ b/src/webview/cm/bounded-recompute.ts @@ -64,8 +64,9 @@ export function intersects(intervals: readonly Interval[], from: number, to: num } /** True iff any range in `selection` overlaps the inclusive `[from, to]` line - * interval. `from`/`to` are line-aligned by the block fields' `buildAll`, so - * the closed interval catches caret-at-boundary cases half-open would miss. + * interval. `from`/`to` are line-aligned by each structural block's `build` + * (structural-blocks.ts `validRange`), so the closed interval catches + * caret-at-boundary cases half-open would miss. * A `SelectionRange` is structurally `{ from, to }`, so this is just * `intersects` over the selection's ranges. */ export function lineRangeOverlapsSelection( diff --git a/src/webview/cm/markdown.ts b/src/webview/cm/markdown.ts index f0d1620c..bc8c2b6e 100644 --- a/src/webview/cm/markdown.ts +++ b/src/webview/cm/markdown.ts @@ -77,7 +77,7 @@ function firstContentChild(node: SyntaxNode): SyntaxNode | null { // fold-suppression and widget emission in lockstep by construction — a table inside // leading frontmatter, one `parseTable` rejects, or one whose build threw has no record, // so its list fold stays; a state without the field has no widget at all. `recordAt` is a -// chunk scan of the set, O(N/250) per fold query (Contract 2; a linear `.some()` would +// chunk scan of the set, O(N/250) per fold query (see `recordAt` in structural-blocks.ts; a linear `.some()` would // make a list of N table-bearing items O(N²)); the whole-document cost is accepted by the // Performance gate's fold row (.claude/docs/PERF.md), not by this argument. function tableEmitsBlockWidget(state: EditorState, from: number): boolean { diff --git a/src/webview/cm/structural-blocks.ts b/src/webview/cm/structural-blocks.ts index cb43a22e..b0dcdd41 100644 --- a/src/webview/cm/structural-blocks.ts +++ b/src/webview/cm/structural-blocks.ts @@ -11,8 +11,9 @@ // selection-independent state: a DecorationSet holding one block decoration per block that // HAS a widget, whose range is the block range and whose `spec.structural` is the // consumer's POSITION-FREE payload. The set is the only place a block position lives, so a -// shifted block keeps its payload object and only its range moves — a shift allocates what -// a plain mapped DecorationSet would (a widget + a decoration), nothing more. +// shifted block keeps its payload object and only its range moves — a shift allocates one +// widget + one decoration (the widget only because `spec.widget` still takes `from`), and +// never re-runs `build`. // `decorations` is the selection-filtered view of it: the SAME set object whenever no // record meets a selection line. Updates derive exclusively from the previous field value; // the field closure holds only the diagnostic latch. EditorState is persistent — two @@ -25,8 +26,9 @@ // containment preconditions live at this one call site; reveal relies on alignment). // fmEnd "moved" → full rebuild; see `frontmatterMoved`. // 2. Change range / tree identity / determinacy. On a doc change, -// `requiresFullBoundedRebuild` (../structural-guard.ts — the single home of every -// full-rebuild reason) → full; otherwise every prev record whose block is untouched and +// `requiresFullBoundedRebuild` (../structural-guard.ts — the home of the structural +// and frontier full-rebuild reasons; the frontmatter (Contract 1) and tree-identity +// reasons live in this file) → full; otherwise every prev record whose block is untouched and // outside the span (changed ranges ±1 line) is reused — the SAME decoration when unmoved, // else rebased through `makeRecord` with the SAME payload — and only the span is // re-walked, each matched node processed at most once per rebuild (`visited`). Reused diff --git a/src/webview/cm/table/table-widget.ts b/src/webview/cm/table/table-widget.ts index ca479904..b8174b59 100644 --- a/src/webview/cm/table/table-widget.ts +++ b/src/webview/cm/table/table-widget.ts @@ -152,7 +152,8 @@ const armedRelease = new WeakMap(); * grep cell-point.ts describes finds one claim to audit instead of three. * * A cast, not a check, and what it asserts is traceable: `docFrom` reaches the - * constructor from `TableBlock.from` (table-field.ts), which is + * constructor from the block's `from` (`BuiltBlock.from`, returned by table-field.ts + * `buildTableBlock`), which is * `state.doc.lineAt(nodeFrom).from` or that value remapped through * `tr.changes.mapPos` (structural-blocks.ts) — CodeMirror document positions, so * there is nothing further to validate, only to name. diff --git a/test/webview/table/cm-table-blocks.test.ts b/test/webview/table/cm-table-blocks.test.ts index 12a9cb01..ee7efa74 100644 --- a/test/webview/table/cm-table-blocks.test.ts +++ b/test/webview/table/cm-table-blocks.test.ts @@ -306,13 +306,12 @@ describe("TableBlock readonly contract (type-only compile pin)", () => { // `slice` is the widget's eq() key (see the field doc in table-field.ts) — it must stay // readonly so a block can never be mutated after construction; the other fields carry // the same never-patched-after-build contract (the contract rebases a reused block by - // copying it, never by writing into it). + // keeping the same payload object and moving only its range, never by writing into it). // // `Readonly` is homomorphic, so `AssertEqual>` holds only when EVERY // field of T is already readonly — including fields added later. Revert-check: drop - // `readonly` from any single field in table-field.ts (or structural-blocks.ts's - // `StructuralBlock`) and this assertion resolves to `false`, failing the `= true` - // assignment. + // `readonly` from any single field of `TableBlock` in table-field.ts and this + // assertion resolves to `false`, failing the `= true` assignment. const _check: AssertEqual> = true; expect(_check).toBe(true); }); From 53318e7c56d97d431424d496e685bdd991560d5d Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 11 Nov 2026 00:56:57 +0900 Subject: [PATCH 13/14] test(cm): pin the four unpinned validRange conjuncts; fix a precondition comment --- test/webview/cm-structural-blocks.test.ts | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/test/webview/cm-structural-blocks.test.ts b/test/webview/cm-structural-blocks.test.ts index 7d810769..25df11f8 100644 --- a/test/webview/cm-structural-blocks.test.ts +++ b/test/webview/cm-structural-blocks.test.ts @@ -557,8 +557,9 @@ describe("structural blocks beyond the initial parse frontier", () => { // A single letter inside the first plain paragraph line, far above both blocks. const tr = state.update({ changes: { from: 4, insert: "x" } }); - // Preconditions: the post-edit snapshot is complete, and HEAD's admission test sends - // this transaction down the bounded path. + // Preconditions: the post-edit snapshot is complete, and the structural half of the + // admission test (`touchesStructuralReparse`) does not fire, so only the frontier + // term's start-state snapshot clause can send this transaction to a full rebuild. requireUnstarvedFrontier(tr.state); expect(syntaxTree(tr.state).length).toBe(tr.state.doc.length); expect(touchesStructuralReparse(tr)).toBe(false); @@ -927,6 +928,45 @@ describe("structural block contract preconditions (toy spec)", () => { }); }); + // One row per `validRange` conjunct the rows above leave unpinned. Each drops alpha's + // block (its siblings survive) on create AND on a bounded update that re-walks alpha, and + // logs `invalid-range` exactly once without throwing out of `StateField.update`. + const ALPHA_CLOSER = "```\nalpha".length; // offset of alpha's body line end from its block start + it.each([ + ["from < 0", (b: BuiltBlock) => ({ ...b, from: -1 })], + // +2: the update's 1-char insert grows the doc to length + 1, so +1 would turn valid. + ["to past the document end", (b: BuiltBlock) => ({ ...b, to: TOY_DOC.length + 2 })], + [ + // from - 2 is the end of "Intro paragraph." — before the node, but not a line start. + "from mid-line (to at a line end)", + (b: BuiltBlock) => ({ ...b, from: b.from - 2 }), + ], + [ + "to at a line end before the node end", + (b: BuiltBlock) => ({ ...b, to: b.from + ALPHA_CLOSER }), + ], + ])("toy invalid range (%s): dropped on create and on a bounded update", (_name, mutate) => { + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const t = toy({ build: (b) => (b.block.text.includes("alpha") ? mutate(b) : b) }); + const state = toyState(TOY_DOC, t); + const all = fencedRanges(state); + expect(all).toHaveLength(3); // precondition + expect(ranges(state, t)).toEqual([all[1], all[2]]); + expect(hookLogCount(spy)).toBe(1); + expect(spy.mock.calls.find((c) => String(c[0]).startsWith(HOOK_LOG))?.[1]).toEqual({ + name: "toy", + arm: "build", + errKind: "invalid-range", + }); + + // An edit inside alpha's body: bounded, and the span re-walks (re-validates) alpha. + const tr = state.update({ changes: { from: TOY_DOC.indexOf("alpha"), insert: "x" } }); + expect(requiresFullBoundedRebuild(tr)).toBe(false); // precondition + expect(t.calls.build).toContain(all[0][0]); // alpha was re-walked + expect(ranges(tr.state, t)).toEqual(fencedRanges(tr.state).slice(1)); + expect(hookLogCount(spy)).toBe(1); + }); + it("brand pin: one field's records are not another field's record set", () => { // Type-level only (a tsc program covers this file): the brand on StructuralRecordSet // makes the assignment below a compile error, so recordAt infers the right payload. From 679e16248a4bc2df0f9575b15c6bb27870c8ff32 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 11 Nov 2026 01:02:45 +0900 Subject: [PATCH 14/14] docs(cm): rewrap two over-wide comment lines --- src/webview/cm/markdown.ts | 7 ++++--- src/webview/cm/structural-blocks.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/webview/cm/markdown.ts b/src/webview/cm/markdown.ts index bc8c2b6e..49522102 100644 --- a/src/webview/cm/markdown.ts +++ b/src/webview/cm/markdown.ts @@ -77,9 +77,10 @@ function firstContentChild(node: SyntaxNode): SyntaxNode | null { // fold-suppression and widget emission in lockstep by construction — a table inside // leading frontmatter, one `parseTable` rejects, or one whose build threw has no record, // so its list fold stays; a state without the field has no widget at all. `recordAt` is a -// chunk scan of the set, O(N/250) per fold query (see `recordAt` in structural-blocks.ts; a linear `.some()` would -// make a list of N table-bearing items O(N²)); the whole-document cost is accepted by the -// Performance gate's fold row (.claude/docs/PERF.md), not by this argument. +// chunk scan of the set, O(N/250) per fold query (see `recordAt` in structural-blocks.ts; +// a linear `.some()` would make a list of N table-bearing items O(N²)); the whole-document +// cost is accepted by the Performance gate's fold row (.claude/docs/PERF.md), not by this +// argument. function tableEmitsBlockWidget(state: EditorState, from: number): boolean { const records = state.field(tableBlockField, false)?.records; if (records === undefined) { diff --git a/src/webview/cm/structural-blocks.ts b/src/webview/cm/structural-blocks.ts index b0dcdd41..06238ad8 100644 --- a/src/webview/cm/structural-blocks.ts +++ b/src/webview/cm/structural-blocks.ts @@ -28,8 +28,8 @@ // 2. Change range / tree identity / determinacy. On a doc change, // `requiresFullBoundedRebuild` (../structural-guard.ts — the home of the structural // and frontier full-rebuild reasons; the frontmatter (Contract 1) and tree-identity -// reasons live in this file) → full; otherwise every prev record whose block is untouched and -// outside the span (changed ranges ±1 line) is reused — the SAME decoration when unmoved, +// reasons live in this file) → full; otherwise every prev record whose block is untouched +// and outside the span (changed ranges ±1 line) is reused — the SAME decoration when unmoved, // else rebased through `makeRecord` with the SAME payload — and only the span is // re-walked, each matched node processed at most once per rebuild (`visited`). Reused // records and walked nodes are disjoint by construction: the reuse guard drops every