Files
2026-04-26 17:24:37 +02:00

384 lines
9.0 KiB
TypeScript

export type ParseTree = {
type?: string; // undefined === text node
from?: number;
to?: number;
text?: string;
children?: ParseTree[];
// Only present after running addParentPointers
parent?: ParseTree;
};
export function addParentPointers(tree: ParseTree) {
if (!tree.children) {
return;
}
for (const child of tree.children) {
child.parent = tree;
addParentPointers(child);
}
}
export function removeParentPointers(tree: ParseTree) {
tree.parent = undefined;
if (!tree.children) {
return;
}
for (const child of tree.children) {
removeParentPointers(child);
}
}
export function findParentMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
let node = tree.parent;
while (node) {
if (matchFn(node)) {
return node;
}
node = node.parent;
}
return null;
}
export function collectNodesOfType(
tree: ParseTree,
nodeType: string,
): ParseTree[] {
return collectNodesMatching(tree, (n) => n.type === nodeType);
}
function collectNodesMatchingInternal(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
results: ParseTree[],
): void {
if (matchFn(tree)) {
results.push(tree);
return;
}
if (tree.children) {
for (const child of tree.children) {
collectNodesMatchingInternal(child, matchFn, results);
}
}
}
export function collectNodesMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree[] {
const results: ParseTree[] = [];
collectNodesMatchingInternal(tree, matchFn, results);
return results;
}
async function collectNodesMatchingAsyncInternal(
tree: ParseTree,
matchFn: (tree: ParseTree) => Promise<boolean>,
results: ParseTree[],
): Promise<void> {
if (await matchFn(tree)) {
results.push(tree);
return;
}
if (tree.children) {
for (const child of tree.children) {
await collectNodesMatchingAsyncInternal(child, matchFn, results);
}
}
}
export async function collectNodesMatchingAsync(
tree: ParseTree,
matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<ParseTree[]> {
const results: ParseTree[] = [];
await collectNodesMatchingAsyncInternal(tree, matchFn, results);
return results;
}
// return value: returning undefined = not matched, continue, null = delete, new node = replace
export function replaceNodesMatching(
tree: ParseTree,
substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
) {
if (tree?.children) {
let i = 0;
while (i < tree.children.length) {
const child = tree.children[i];
const subst = substituteFn(child);
if (subst !== undefined) {
if (subst) {
tree.children[i] = subst;
i++;
} else {
// null = delete
tree.children.splice(i, 1);
// don't increment i — next child shifted into this position
}
} else {
replaceNodesMatching(child, substituteFn);
i++;
}
}
}
}
export async function replaceNodesMatchingAsync(
tree: ParseTree,
substituteFn: (tree: ParseTree) => Promise<ParseTree | null | undefined>,
) {
if (tree.children) {
let i = 0;
while (i < tree.children.length) {
const child = tree.children[i];
const subst = await substituteFn(child);
if (subst !== undefined) {
if (subst) {
tree.children[i] = subst;
i++;
} else {
// null = delete
tree.children.splice(i, 1);
}
} else {
await replaceNodesMatchingAsync(child, substituteFn);
i++;
}
}
}
}
export function findNodeMatching(
tree: ParseTree,
matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
if (matchFn(tree)) {
return tree;
}
if (tree.children) {
for (const child of tree.children) {
const result = findNodeMatching(child, matchFn);
if (result) {
return result;
}
}
}
return null;
}
export function findNodeOfType(
tree: ParseTree,
nodeType: string,
): ParseTree | null {
if (tree.type === nodeType) {
return tree;
}
if (tree.children) {
for (const child of tree.children) {
const result = findNodeOfType(child, nodeType);
if (result) {
return result;
}
}
}
return null;
}
export function traverseTree(
tree: ParseTree,
// Return value = should stop traversal into children?
matchFn: (tree: ParseTree) => boolean,
// Just log errors on exceptions, but don't stop traversal
catchVisitorErrors = false,
): void {
let stop = false;
if (catchVisitorErrors) {
try {
stop = matchFn(tree);
} catch (e: any) {
console.error(
`traverseTree visitor failed at node ${tree.type}@${tree.from}:`,
e?.stack ?? e,
);
return;
}
} else {
stop = matchFn(tree);
}
if (stop) {
return;
}
if (tree.children) {
for (const child of tree.children) {
traverseTree(child, matchFn, catchVisitorErrors);
}
}
}
export async function traverseTreeAsync(
tree: ParseTree,
// Return value = should stop traversal into children?
matchFn: (tree: ParseTree) => Promise<boolean>,
catchVisitorErrors = false,
): Promise<void> {
let stop = false;
if (catchVisitorErrors) {
try {
stop = await matchFn(tree);
} catch (e: any) {
console.error(
`traverseTreeAsync visitor failed at node ${tree.type}@${tree.from}:`,
e?.stack ?? e,
);
return;
}
} else {
stop = await matchFn(tree);
}
if (stop) {
return;
}
if (tree.children) {
for (const child of tree.children) {
await traverseTreeAsync(child, matchFn, catchVisitorErrors);
}
}
}
export function cloneTree(tree: ParseTree): ParseTree {
if (tree.text !== undefined) {
return {
from: tree.from,
to: tree.to,
text: tree.text,
};
}
const clone: ParseTree = {
type: tree.type,
from: tree.from,
to: tree.to,
};
if (tree.children) {
clone.children = new Array(tree.children.length);
for (let i = 0; i < tree.children.length; i++) {
clone.children[i] = cloneTree(tree.children[i]);
}
}
return clone;
}
// Finds non-text node at position
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
if (pos < tree.from! || pos >= tree.to!) {
return null;
}
if (!tree.children) {
return tree;
}
for (const child of tree.children) {
const n = nodeAtPos(child, pos);
if (n && n.text !== undefined) {
// Got a text node, let's return its parent
return tree;
}
if (n) {
// Got it
return n;
}
}
return null;
}
// Ensure a TableRow/TableHeader has a TableCell between every pair of
// TableDelimiters, and optionally pad to match columnCount.
// headerHasLeadingDelim indicates whether the header starts with a delimiter.
export function normalizeTableRow(
row: ParseTree,
columnCount?: number,
headerHasLeadingDelim?: boolean,
): void {
const children = row.children;
if (!children) return;
const normalized: ParseTree[] = [];
let lookingForCell = false;
for (const child of children) {
if (child.type === "TableDelimiter" && lookingForCell) {
normalized.push({ type: "TableCell", children: [] });
}
if (child.type === "TableDelimiter") {
lookingForCell = true;
}
if (child.type === "TableCell") {
lookingForCell = false;
}
normalized.push(child);
}
row.children = normalized;
// Fix leading-pipe mismatch: row has leading delimiter but header doesn't
if (headerHasLeadingDelim === false) {
if (row.children.length > 0 && row.children[0].type === "TableDelimiter") {
// Insert empty cell after the leading delimiter
row.children.splice(1, 0, { type: "TableCell", children: [] });
}
}
// Pad trailing empty cells to match header column count
if (columnCount !== undefined) {
let cellCount = 0;
for (const child of row.children) {
if (child.type === "TableCell") cellCount++;
}
while (cellCount < columnCount) {
row.children.push({ type: "TableCell", children: [] });
cellCount++;
}
}
}
// Turn ParseTree back into text
export function renderToText(tree?: ParseTree): string {
if (!tree) {
return "";
}
if (tree.text !== undefined) {
return tree.text;
}
const children = tree.children!;
if (children.length === 1) {
return renderToText(children[0]);
}
let result = "";
for (const child of children) {
result += renderToText(child);
}
return result;
}
export function cleanTree(tree: ParseTree, omitTrimmable = true): ParseTree {
if (tree.type === "⚠") {
throw new Error(`Parse error at pos ${tree.from}`);
}
if (tree.text !== undefined) {
return tree;
}
const ast: ParseTree = {
type: tree.type,
children: [],
from: tree.from,
to: tree.to,
};
for (const node of tree.children!) {
if (node.type && node.type !== "Comment") {
ast.children!.push(cleanTree(node, omitTrimmable));
}
if (node.text && ((omitTrimmable && node.text.trim()) || !omitTrimmable)) {
ast.children!.push(node);
}
}
return ast;
}