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, results: ParseTree[], ): Promise { 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, ): Promise { 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, ) { 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, catchVisitorErrors = false, ): Promise { 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; }