Files
plainleaf/plug-api/lib/tree.test.ts
T

335 lines
9.6 KiB
TypeScript

import { expect, test, vi } from "vitest";
import {
addParentPointers,
cleanTree,
cloneTree,
collectNodesMatching,
collectNodesOfType,
findNodeMatching,
findNodeOfType,
findParentMatching,
nodeAtPos,
normalizeTableRow,
type ParseTree,
removeParentPointers,
renderToText,
replaceNodesMatching,
traverseTree,
traverseTreeAsync,
} from "./tree.ts";
import { parse } from "../../client/markdown_parser/parse_tree.ts";
import { extendedMarkdownLanguage } from "../../client/markdown_parser/parser.ts";
const mdTest1 = `
# Heading
## Sub _heading_ cool
Hello, this is some **bold** text and *italic*. And [a link](http://zef.me).
%% My comment here
%% And second line
And an @mention
http://zef.plus
- This is a list [[PageLink]]
- With another item
- TODOs:
- [ ] A task that's not yet done
- [x] Hello
- And a _third_ one [[Wiki Page]] yo
`;
const mdTest3 = `
\`\`\`yaml
name: something
\`\`\`
`;
test("Test parsing", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
addParentPointers(mdTree);
// console.log(JSON.stringify(mdTree, null, 2));
const wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
expect(wikiLink.type).toEqual("WikiLinkPage");
expect(
findParentMatching(wikiLink, (n) => n.type === "BulletList"),
).not.toEqual(null);
const allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
expect(allTodos.length).toEqual(2);
// Render back into markdown should be equivalent
expect(renderToText(mdTree)).toEqual(mdTest1);
removeParentPointers(mdTree);
replaceNodesMatching(mdTree, (n) => {
if (n.type === "Task") {
return {
type: "Tosk",
};
}
});
// console.log(JSON.stringify(mdTree, null, 2));
parse(extendedMarkdownLanguage, mdTest3);
// console.log(JSON.stringify(mdTree3, null, 2));
});
test("traverseTree stops traversal when callback returns true", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const visited: string[] = [];
traverseTree(mdTree, (n) => {
if (n.type) {
visited.push(n.type);
}
// Stop at first WikiLink — should not descend into its children
return n.type === "WikiLink";
});
// Should find WikiLink nodes but not their WikiLinkPage children
expect(visited).toContain("WikiLink");
expect(visited).not.toContain("WikiLinkPage");
});
test("traverseTree visits all nodes when callback returns false", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const types = new Set<string>();
traverseTree(mdTree, (n) => {
if (n.type) {
types.add(n.type);
}
return false;
});
// Should have visited deep into the tree
expect(types.has("Document")).toBe(true);
expect(types.has("WikiLinkPage")).toBe(true);
expect(types.has("ATXHeading1")).toBe(true);
});
test("traverseTree isolates visitor errors per-node when opted in", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const seenHeadings: string[] = [];
traverseTree(
mdTree,
(n) => {
if (n.type === "ATXHeading1") {
throw new Error("boom on first heading");
}
if (n.type === "ATXHeading2") {
seenHeadings.push(n.type);
}
return false;
},
true,
);
expect(seenHeadings).toContain("ATXHeading2");
expect(errorSpy).toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
test("traverseTree propagates visitor errors by default", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
expect(() =>
traverseTree(mdTree, (n) => {
if (n.type === "ATXHeading1") {
throw new Error("boom on first heading");
}
return false;
}),
).toThrow("boom on first heading");
});
test("traverseTreeAsync isolates visitor errors per-node when opted in", async () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const seenHeadings: string[] = [];
await traverseTreeAsync(
mdTree,
(n) => {
if (n.type === "ATXHeading1") {
return Promise.reject(new Error("boom on first heading"));
}
if (n.type === "ATXHeading2") {
seenHeadings.push(n.type);
}
return Promise.resolve(false);
},
true,
);
expect(seenHeadings).toContain("ATXHeading2");
expect(errorSpy).toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
test("traverseTreeAsync propagates visitor errors by default", async () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
await expect(
traverseTreeAsync(mdTree, (n) => {
if (n.type === "ATXHeading1") {
return Promise.reject(new Error("boom on first heading"));
}
return Promise.resolve(false);
}),
).rejects.toThrow("boom on first heading");
});
test("collectNodesOfType collects all nodes of given type", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const headings = collectNodesOfType(mdTree, "ATXHeading1");
expect(headings.length).toBe(1);
expect(renderToText(headings[0])).toContain("Heading");
const wikiLinks = collectNodesOfType(mdTree, "WikiLink");
expect(wikiLinks.length).toBe(2);
// Non-existent type returns empty
expect(collectNodesOfType(mdTree, "NonExistent")).toEqual([]);
});
test("findNodeOfType finds first node of given type", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const heading = findNodeOfType(mdTree, "ATXHeading1");
expect(heading).toBeDefined();
expect(heading!.type).toBe("ATXHeading1");
// Returns null for non-existent type
expect(findNodeOfType(mdTree, "NonExistent")).toBeNull();
});
test("findNodeMatching finds first matching node", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const found = findNodeMatching(mdTree, (n) => n.type === "ATXHeading2");
expect(found).not.toBeNull();
expect(found!.type).toBe("ATXHeading2");
// Returns null when nothing matches
expect(findNodeMatching(mdTree, () => false)).toBeNull();
});
test("cloneTree creates independent deep copy without parent pointers", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
addParentPointers(mdTree);
const cloned = cloneTree(mdTree);
// Content should be identical
expect(renderToText(cloned)).toEqual(renderToText(mdTree));
// Should be a different object
expect(cloned).not.toBe(mdTree);
expect(cloned.children![0]).not.toBe(mdTree.children![0]);
// Root has no parent
expect(cloned.parent).toBeUndefined();
// deepClone with ignoreKeys: ["parent"] shallow-copies parent refs (they point to original tree)
// This is expected behavior — cloneTree is used for trees without parent pointers set
// Mutating clone should not affect original
cloned.children![0] = { type: "Modified", children: [] };
expect(mdTree.children![0].type).not.toBe("Modified");
});
test("cleanTree removes comments and trimmable whitespace", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
const cleaned = cleanTree(mdTree);
// Should not contain Comment nodes
const comments = collectNodesOfType(cleaned, "Comment");
expect(comments.length).toBe(0);
// Should still have structure
expect(cleaned.type).toBe("Document");
expect(cleaned.children!.length).toBeGreaterThan(0);
});
test("cleanTree throws on parse errors", () => {
const errorTree: ParseTree = {
type: "⚠",
from: 5,
to: 10,
};
expect(() => cleanTree(errorTree)).toThrow("Parse error at pos 5");
});
test("normalizeTableRow inserts empty cells between consecutive delimiters", () => {
const row: ParseTree = {
type: "TableRow",
children: [
{ type: "TableDelimiter", children: [{ text: "|" }] },
{ type: "TableCell", children: [{ text: "a" }] },
{ type: "TableDelimiter", children: [{ text: "|" }] },
{ type: "TableDelimiter", children: [{ text: "|" }] }, // missing cell
{ type: "TableCell", children: [{ text: "c" }] },
],
};
normalizeTableRow(row);
const cells = row.children!.filter((c) => c.type === "TableCell");
expect(cells.length).toBe(3); // original 2 + 1 inserted empty
});
test("normalizeTableRow pads to match column count", () => {
const row: ParseTree = {
type: "TableRow",
children: [
{ type: "TableDelimiter", children: [{ text: "|" }] },
{ type: "TableCell", children: [{ text: "a" }] },
{ type: "TableDelimiter", children: [{ text: "|" }] },
],
};
normalizeTableRow(row, 3);
const cells = row.children!.filter((c) => c.type === "TableCell");
expect(cells.length).toBe(3);
});
test("replaceNodesMatching can delete nodes (return null)", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
// Count tasks before
const tasksBefore = collectNodesOfType(mdTree, "Task");
expect(tasksBefore.length).toBe(2);
// Delete all Task nodes
replaceNodesMatching(mdTree, (n) => {
if (n.type === "Task") {
return null;
}
});
const tasksAfter = collectNodesOfType(mdTree, "Task");
expect(tasksAfter.length).toBe(0);
});
test("replaceNodesMatching can replace nodes", () => {
const mdTree = parse(extendedMarkdownLanguage, mdTest1);
replaceNodesMatching(mdTree, (n) => {
if (n.type === "ATXHeading1") {
return { type: "ReplacedHeading", children: [{ text: "replaced" }] };
}
});
expect(findNodeOfType(mdTree, "ATXHeading1")).toBeNull();
const replaced = findNodeOfType(mdTree, "ReplacedHeading");
expect(replaced).not.toBeNull();
expect(renderToText(replaced!)).toBe("replaced");
});