234 lines · 11.3 KB · TypeScript
RawDownload
/** * 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]],    ['<Callout title="Exact">\n  Body\n</Callout>', "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 &amp; 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](<a b.md> "title (1)") b.', " b."],    ["[wiki](https://en.wikipedia.org/wiki/A_(b)). end", ". end"],    ["> [quoted](x.md\n> 'x.md') .md", " .md"],    ["Mail <https://a.b/c.d>.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<Callout>\nInside\n</Callout>");    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 = ['<!-- lattice-table-layout:v1 {"spans":[[0,0,1,2]]} -->', "", "| 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);  });});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t