diff --git a/src/webview/cm/bounded-recompute.ts b/src/webview/cm/bounded-recompute.ts index 97a06fc7..8da6d00a 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 @@ -71,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/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/image/image-field.ts b/src/webview/cm/image/image-field.ts index 07ce7e08..1c517aa8 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 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 { 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 BuiltBlock, 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 { + 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): BuiltBlock | 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, block: { 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, 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 4742b7d8..49522102 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,23 @@ 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)) { +// True when the `Table` node starting at `from` renders as a block widget: the table +// 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 +// 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) { 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; + 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 @@ -119,13 +113,13 @@ 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 -// 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 -// item inside a blockquote never folds at all, whatever its body — that is the +// 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 // 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 @@ -137,7 +131,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-blocks.ts b/src/webview/cm/structural-blocks.ts new file mode 100644 index 00000000..06238ad8 --- /dev/null +++ b/src/webview/cm/structural-blocks.ts @@ -0,0 +1,375 @@ +// 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: 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 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 +// 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 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 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 +// 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`). Overlapping blocks (the `sortedDisjoint` +// backstop) report under their own `overlap` arm. DOM lifecycle failures stay owned by +// widget-base.ts. +// 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, type Range, 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"; + +/** 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 { + /** 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. 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; +} + +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); each + * decoration's `spec.structural` is its payload. */ + readonly records: StructuralRecordSet; + readonly decorations: DecorationSet; +} + +/** 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 +): BuiltBlock | undefined { + let hit: BuiltBlock | undefined; + records.between(blockFrom, blockFrom, (from, to, deco) => { + if (from !== blockFrom) { + 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 + * 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; +} + +/** 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 false; +} + +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); + } + return records.update({ + filter: (from, to) => !lineRangeOverlapsSelection(state.selection, from, to), + filterFrom, + filterTo, + }); +} + +export function structuralBlockWidgets( + spec: StructuralBlockSpec +): StateField> { + const reported = new Set(); + const latch = (arm: string): boolean => { + const key = `${spec.name}:${arm}`; + if (reported.has(key)) { + return false; + } + reported.add(key); + 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 carrying the + * payload. */ + const makeRecord = (block: B, from: number, to: number): Range | null => { + let widget: QuollWidget; + try { + widget = spec.widget(block, from); + } catch (err) { + report("widget", typeof err); + return null; + } + 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`. + * The one call site of `spec.build`. */ + const walk = ( + state: EditorState, + from: number, + to: number, + fmEnd: number, + visited: Set, + out: Range[] + ): 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 built: BuiltBlock | null; + try { + built = spec.build(state, node); + } catch (err) { + report("build", typeof err); + return false; + } + if (built === null) { + return false; + } + if (!validRange(state, built, node)) { + report("build", "invalid-range"); + return false; + } + const record = makeRecord(built.block, built.from, built.to); + if (record !== null) { + out.push(record); + } + return false; + }, + }); + }; + + 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: 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: 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 (nf >= nt || tr.changes.touchesRange(from, to) !== false || intersects(span, nf, nt)) { + changed = true; + } else if (nf === from && nt === to) { + next.push(value.range(from, to)); + } else { + changed = true; + const rebased = makeRecord(value.spec.structural as B, nf, 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: DecorationSet, tr: Transaction): DecorationSet => { + 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; + } + 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), + quollBlockReplaceZones.from(f, (s) => extractRanges(s.decorations)), + ], + }); +} diff --git a/src/webview/cm/structural-guard.ts b/src/webview/cm/structural-guard.ts index 1146babc..92e84339 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 @@ -26,7 +23,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"; @@ -265,26 +262,9 @@ 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 +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 — `tableSkeletonField`'s 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; @@ -359,7 +339,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 +351,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/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..31b80c65 100644 --- a/src/webview/cm/table/table-field.ts +++ b/src/webview/cm/table/table-field.ts @@ -1,167 +1,59 @@ -// 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 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. // -// 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 BuiltBlock, 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 { + /** 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 + * 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): 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"); + 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, block: { 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, from) => new TableBlockWidget(b.table, b.slice, from, 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..b8174b59 100644 --- a/src/webview/cm/table/table-widget.ts +++ b/src/webview/cm/table/table-widget.ts @@ -152,9 +152,10 @@ 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 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` (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 +163,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 +481,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 +498,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 +509,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 17853e16..e6a87185 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -74,7 +74,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, @@ -685,9 +685,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 a9ecad3d..099e416e 100644 --- a/test/build/frontier-gate-needs-structural-guard.test.ts +++ b/test/build/frontier-gate-needs-structural-guard.test.ts @@ -1,9 +1,13 @@ // 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 +// 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 @@ -28,10 +32,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 +46,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 +68,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 +91,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 +116,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 +133,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 +235,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-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-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-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 new file mode 100644 index 00000000..25df11f8 --- /dev/null +++ b/test/webview/cm-structural-blocks.test.ts @@ -0,0 +1,1295 @@ +// @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 { ensureSyntaxTree, foldable, syntaxTree } from "@codemirror/language"; +import { EditorState, type Extension, type StateField } from "@codemirror/state"; +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 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 +// fields' `create` (which already calls both seams) never throws at mount. +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) => { + const real = await importOriginal(); + return { + ...real, + parseTable: (source: string, from: number, to: number) => { + seam.log.push(source); + 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) => { + seam.log.push(raw); + 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; + seam.log.length = 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); + }); +}); + +/** 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 = 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 }; + } + + 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 records = (): StructuralRecordSet => + (kind === "table" + ? view.state.field(p.tableBlockField) + : view.state.field(p.imageBlockField) + ).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; + // 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(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(); + } + } + + 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. */ +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.", + "", + ]); + // 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 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); + + 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; + }, + }); + }); +}); + +/** 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); + withUnstarvedFrontierState({ + what: `the frontier-advance publication of ${needle}`, + observe: (requireUnstarvedFrontier) => { + 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" } }); + 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", () => { + 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. + +interface ToyBlock { + 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?: (built: BuiltBlock) => BuiltBlock | null; + /** Runs before the widget is made; may throw. */ + widget?: (block: ToyBlock, from: number) => void; +} + +interface Toy { + field: StateField>; + /** The block start 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 built: BuiltBlock = { from, to, block: { text: state.sliceDoc(from, to) } }; + return opts.build ? opts.build(built) : built; + }, + widget(block, from) { + calls.widget.push(from); + opts.widget?.(block, from); + return new ToyWidget(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 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. */ +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; + 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, 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])); + }); + + 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.block.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.block.text.includes("NARROW")) { + return { ...b, from: b.from + 4 }; // does not contain its node + } + if (b.block.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]); + }); +}); + +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", + }); + }); + + // 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. + 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); + 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 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 + // 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].from; + const insideTr = state.update({ + changes: { from: state.doc.toString().indexOf("gamma") + 2, insert: "x" }, + }); + expect(requiresFullBoundedRebuild(insideTr)).toBe(false); // precondition: bounded path + const insideSet = insideTr.state.field(t.field).records; + const inside = recordsOf(insideSet); + expect(t.calls.build).toEqual([gamma]); + expect(inside[2].block).not.toBe(before[2].block); + expect(inside[2].block.text).toContain("gaxmma"); + 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 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 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.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.from + 1)); + expect(widgetSlots(tr.state, ToyWidget).map((s) => (s.widget as ToyWidget).at)).toEqual( + before.map((r) => r.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, from) => { + if (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 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.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(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(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", () => { + 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)); + } + }); +}); + +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]; + // 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([ + 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)); + }); +}); + +// --------------------------------------------------------------------------------------- +// 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"); + }); +}); diff --git a/test/webview/cm-structural-guard-fuzz.test.ts b/test/webview/cm-structural-guard-fuzz.test.ts index 2b0a44c0..1cdaf9c3 100644 --- a/test/webview/cm-structural-guard-fuzz.test.ts +++ b/test/webview/cm-structural-guard-fuzz.test.ts @@ -24,15 +24,16 @@ 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 { recordsOf } from "./helpers/structural-records.js"; import { SHAPE_CORPUS } from "./helpers/table-shape-corpus.js"; const extensions = (): Extension[] => [ quollMarkdownLanguage(), quollFolding(), // already contains the three fold gutter fields — do not mount them twice imageBlockField, - tableSkeletonField, + tableBlockField, calloutMarkerConcealField, ]; @@ -73,8 +74,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(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. calloutRecords: JSON.stringify(callout.records), @@ -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(tableSkeletonField) - .some((m) => pos >= m.blockFrom && pos <= m.blockTo); + 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/cm-url-security-matrix.test.ts b/test/webview/cm-url-security-matrix.test.ts index 09c6d06c..fbff93bf 100644 Binary files a/test/webview/cm-url-security-matrix.test.ts and b/test/webview/cm-url-security-matrix.test.ts differ 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/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/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..ee7efa74 100644 --- a/test/webview/table/cm-table-skeleton.test.ts +++ b/test/webview/table/cm-table-blocks.test.ts @@ -4,28 +4,30 @@ 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 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 { recordsOf } from "../helpers/structural-records.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: 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): BuiltBlock[] { 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 +46,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 +66,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 +143,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 +171,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 +207,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 +216,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 +247,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 +256,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 +264,55 @@ 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.block.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.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 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].block.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 + // 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, - // 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 of `TableBlock` in table-field.ts 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();