/** * The visual engine's source map (spec R-SRC-1โ€“4, R-SRC-11): editor positions * against the host's text, both ways, from the R-SRC fixtures. * * Clean implementation for Lattice; spec: docs/visual-editor-spec.md. */ import type { Node as PmNode } from "@tiptap/pm/model"; import type { Node as MdastNode } from "mdast"; import { describe, expect, it } from "vitest"; import { engineSchema } from "./engine-schema"; import { openMarkdown, type OpenOptions } from "./markdown-document"; import { corpus } from "./corpus-test-utils"; import { parseMarkdownTree } from "./markdown-syntax"; import { SourceMap } from "./source-map"; const schema = engineSchema(); function mapOf(text: string, options: OpenOptions = {}) { const opened = openMarkdown(text, schema, options); if ("unavailable" in opened) throw new Error(opened.unavailable); return new SourceMap(opened.doc, opened.baseline, text); } /** The position `offset` characters into the first text node that is (or starts with) `text`. */ function inText(doc: PmNode, text: string, offset: number): number { let found = -1; doc.descendants((node, pos) => { if (found < 0 && node.isText && node.text?.startsWith(text)) found = pos + offset; return found < 0; }); if (found < 0) throw new Error(`no text ${text}`); return found; } /** The innermost element-free description of where a position lands: its parent node and the text before it there. */ function landing(map: SourceMap, pos: number | null) { if (pos == null) return null; const $pos = map.doc.resolve(pos); return { parent: $pos.parent.type.name, before: $pos.parent.textBetween(0, $pos.parentOffset, "", "@") }; } describe("caret to source (R-SRC-1)", () => { it.each([ ["# Hello", "Hello", 2, [0, 4]], ["**bold**", "bold", 2, [0, 4]], ["**bold**\n\n- one\n- two ๐Ÿ˜€", "two ๐Ÿ˜€", "two ๐Ÿ˜€".length, [3, 8]], ["A\r\n\r\nB\r\n", "B", 1, [2, 1]], ['\n Body\n', "Body", 2, [1, 4]], ["> quoted **strong** text", "strong", 3, [0, 14]], ["1. first\n2. second item", "second", 3, [1, 6]], ["Escaped \\_under\\_ score", "Escaped _under_ score", 17, [0, 19]], ["Fish & chips", "Fish & chips", 7, [0, 11]], ["```js\nconst value = 1\n```", "const", 5, [1, 5]], ] as const)("maps a caret in %j", (text, node, offset, expected) => { const map = mapOf(text); expect(map.positionToRowColumn(inText(map.doc, node, offset))).toEqual(expected); }); it("gives offsets in the host's text, after a byte-order mark and in CRLF", () => { const text = "๏ปฟTitle\r\n\r\nSecond line"; const map = mapOf(text); const offset = map.positionToOffset(inText(map.doc, "Second", 3)); expect(offset).toBe(text.indexOf("Second") + 3); expect(map.offsetToPosition(offset!)).toBe(inText(map.doc, "Second", 3)); }); it("maps through escapes, and has no place inside an escape or an atom", () => { const text = "A \\*star\\* and $x^2$ here"; const map = mapOf(text); const run = inText(map.doc, "A *star*", 0); expect(map.positionToOffset(run + 2)).toBe(2); expect(map.positionToOffset(run + 3)).toBe(4); expect(map.offsetToPosition(3)).toBeNull(); expect(map.offsetToPosition(3, "forward")).toBe(run + 3); const math = text.indexOf("$x^2$"); expect(map.offsetToPosition(math)).toBe(inText(map.doc, "A *star*", "A *star* and ".length)); expect(map.offsetToPosition(math + 2)).toBeNull(); }); }); describe("text after a link (R-SRC-3)", () => { it.each([ ["See [docs](https://example.com).", "."], ["(see [docs](url))", ")"], ['A [link]( "title (1)") b.', " b."], ["[wiki](https://en.wikipedia.org/wiki/A_(b)). end", ". end"], ["> [quoted](x.md\n> 'x.md') .md", " .md"], ["Mail .d", ".d"], ["[**a**](b)b", "b"], ["| [c](u.v) .v |\n| --- |", " .v"], ])("places the text after the link in %j", (text, tail) => { const map = mapOf(text); const pos = inText(map.doc, tail, 0); const offset = text.lastIndexOf(tail); expect(map.positionToOffset(pos)).toBe(offset); expect(map.offsetToPosition(offset)).toBe(pos); }); }); describe("source to position (R-SRC-2, R-SRC-3)", () => { it("lands a heading cursor after the visible hash, not the heading's own marker", () => { const map = mapOf("# # Title"); expect(landing(map, map.rowColumnToPosition(0, 3))).toEqual({ parent: "heading", before: "#" }); }); it("lands inside code, but never on a fence line", () => { const map = mapOf("```js\nconst value = 1\n```"); expect(landing(map, map.rowColumnToPosition(1, 5))).toEqual({ parent: "codeBlock", before: "const" }); expect(landing(map, map.rowColumnToPosition(1, 11))).toEqual({ parent: "codeBlock", before: "const value" }); expect(map.rowColumnToPosition(0, 1)).toBeNull(); expect(map.rowColumnToPosition(2, 1)).toBeNull(); }); it("never places a cursor inside an image atom", () => { expect(mapOf("![Alt](image.png)").rowColumnToPosition(0, 10)).toBeNull(); }); it("stays aligned after an inferred paper table and omits cells the inference merged", () => { const map = mapOf("| Group | Group | Metric |\n| --- | --- | --- |\n| Group | Group | 1 |\n\nAfter table", { paperSpans: true }); expect(landing(map, map.rowColumnToPosition(4, 5))).toEqual({ parent: "paragraph", before: "After" }); expect(map.rowColumnToPosition(2, 12)).toBeNull(); }); it("maps list, quote and component content through their prefixes", () => { const map = mapOf("- one\n- two\n\n> quoted\n> more\n\n\nInside\n"); expect(landing(map, map.rowColumnToPosition(1, 4))).toEqual({ parent: "paragraph", before: "tw" }); expect(landing(map, map.rowColumnToPosition(4, 4))).toEqual({ parent: "paragraph", before: "quoted@mo" }); expect(landing(map, map.rowColumnToPosition(7, 3))).toEqual({ parent: "paragraph", before: "Ins" }); expect(map.rowColumnToPosition(6, 3)).toBeNull(); }); it("snaps a range end that falls on syntax to the nearest content when asked", () => { const map = mapOf("Some **bold** text"); const star = "Some ".length; expect(map.offsetToPosition(star)).toBe(inText(map.doc, "Some ", 5)); expect(map.offsetToPosition(star + 1)).toBeNull(); expect(map.offsetToPosition(star + 1, "forward")).toBe(inText(map.doc, "bold", 0)); expect(map.offsetToPosition(star + 1, "backward")).toBe(inText(map.doc, "Some ", 5)); }); }); describe("table coordinates (R-SRC-4)", () => { const SIMPLE = "| Left | Right |\n| --- | --- |\n| A | B |"; const cellAt = (map: SourceMap, row: number, column: number) => { const pos = map.rowColumnToPosition(row, column); if (pos == null) return null; const $pos = map.doc.resolve(pos); for (let depth = $pos.depth; depth > 0; depth -= 1) { const node = $pos.node(depth); if (String(node.type.spec.tableRole).includes("cell")) return { type: node.type.name, text: node.textContent, colspan: node.attrs.colspan as number }; } return null; }; it.each([ ["the cell under a body cursor", SIMPLE, 2, 3, { type: "tableCell", text: "A" }], ["the header cell for a delimiter-row cursor", SIMPLE, 1, 3, { type: "tableHeader", text: "Left" }], ["the header cell below an escaped-pipe header", "| A \\| B | C |\n| --- | --- |\n| x | y |", 1, 3, { type: "tableHeader", text: "A | B" }], ["the header cell below an empty header", "| | Right |\n| --- | --- |\n| x | y |", 1, 3, { type: "tableHeader", text: "" }], ["the header cell of a one-column table", "| Only |\n| --- |\n| x |", 1, 3, { type: "tableHeader", text: "Only" }], ["a dash-only body row as a body row", "| Left | Right |\n| --- | --- |\n| --- | --- |", 2, 3, { type: "tableCell", text: "---" }], ])("anchors %s", (_label, text, row, column, expected) => { expect(cellAt(mapOf(text), row, column)).toMatchObject(expected); }); it.each([3, 11])("maps an explicitly merged cell's source column %i to its origin", (column) => { const text = ['', "", "| Group | Group | Metric |", "| --- | --- | --- |", "| A | B | 1 |"].join("\n"); expect(cellAt(mapOf(text), 2, column)).toEqual({ type: "tableHeader", text: "Group", colspan: 2 }); }); it("maps a caret in a cell back to its source column", () => { const map = mapOf(SIMPLE); expect(map.positionToRowColumn(inText(map.doc, "B", 1))).toEqual([2, 7]); expect(map.positionToRowColumn(inText(map.doc, "Right", 2))).toEqual([0, 11]); }); }); describe("block labels (R-SRC-11)", () => { it("gives each block its 1-based line and text range", () => { const text = "# Heading\n\nFirst paragraph.\n\nTarget paragraph."; expect(mapOf(text).labels().map(({ line, from, to }) => ({ line, from, to }))).toEqual([ { line: 1, from: 0, to: 9 }, { line: 3, from: 11, to: 27 }, { line: 5, from: text.indexOf("Target"), to: text.length }, ]); }); }); /** Each link's syntax after its text, `](destination "title")` or `>`, as source ranges. */ function linkTails(node: MdastNode): [number, number][] { const children = "children" in node ? (node.children as MdastNode[]) : []; const tails = children.flatMap(linkTails); const textEnd = children.at(-1)?.position?.end.offset; const end = node.position?.end.offset; if (node.type === "link" && textEnd != null && end != null && textEnd < end) tails.push([textEnd, end]); return tails; } describe("the corpus", () => { it.each(corpus)("places every mapped caret of %s on its own character, both ways", (name, text) => { const opened = openMarkdown(text, schema, { paperSpans: name.includes("paper") }); if ("unavailable" in opened) return; const map = new SourceMap(opened.doc, opened.baseline, text); // A character can match by coincidence inside a link's own syntax; no caret belongs there. const linkSyntax = linkTails(parseMarkdownTree(text)); let mapped = 0; let checked = 0; opened.doc.descendants((node, pos) => { if (!node.isText) return; const value = node.text ?? ""; // Every position of short runs; a sample of long ones keeps the corpus fast. const step = Math.max(1, Math.floor(value.length / 24)); for (let index = 0; index < value.length; index += step) { // Between the halves of a surrogate pair is no caret position. if (index > 0 && /[\uD800-\uDBFF]/.test(value[index - 1]!)) continue; checked += 1; const offset = map.positionToOffset(pos + index); if (offset == null) continue; mapped += 1; const char = text[offset]; // The source shows the character itself, or the escape or reference that writes it. if (char !== value[index] && char !== "\\" && char !== "&" && !(value[index] === " " && /\s/.test(char ?? ""))) { throw new Error(`${name}: position ${pos + index} (${JSON.stringify(value.slice(index, index + 12))}) maps to ${JSON.stringify(text.slice(offset, offset + 12))}`); } if (linkSyntax.some(([from, to]) => offset >= from && offset < to)) { throw new Error(`${name}: position ${pos + index} (${JSON.stringify(value.slice(index, index + 12))}) maps into link syntax at ${offset}`); } expect(map.offsetToPosition(offset)).toBe(pos + index); } }); // Almost all visible text has an exact place. if (checked > 50) expect(mapped / checked).toBeGreaterThan(0.9); }); });