@taml/ast
Version:
Abstract Syntax Tree types and utilities for TAML (Terminal ANSI Markup Language)
314 lines (267 loc) • 6.6 kB
text/typescript
/**
* Tree traversal utilities for TAML AST
*/
import type { ElementNode, TamlNode, TextNode } from "./nodes.js";
import { isDocumentNode, isElementNode, isTextNode } from "./nodes.js";
import type { TamlTag } from "./types.js";
/**
* Walk through all nodes in the tree (depth-first, pre-order)
*/
export function walk(node: TamlNode, callback: (node: TamlNode) => void): void {
callback(node);
if (isDocumentNode(node) || isElementNode(node)) {
for (const child of node.children) {
walk(child, callback);
}
}
}
/**
* Walk through all nodes asynchronously
*/
export async function walkAsync(
node: TamlNode,
callback: (node: TamlNode) => Promise<void>,
): Promise<void> {
await callback(node);
if (isDocumentNode(node) || isElementNode(node)) {
for (const child of node.children) {
await walkAsync(child, callback);
}
}
}
/**
* Find all nodes that match a predicate
*/
export function findAll(
root: TamlNode,
predicate: (node: TamlNode) => boolean,
): TamlNode[] {
const results: TamlNode[] = [];
walk(root, (node) => {
if (predicate(node)) {
results.push(node);
}
});
return results;
}
/**
* Find the first node that matches a predicate
*/
export function findFirst(
root: TamlNode,
predicate: (node: TamlNode) => boolean,
): TamlNode | null {
let result: TamlNode | null = null;
try {
walk(root, (node) => {
if (predicate(node)) {
result = node;
throw new Error("Found"); // Break out of walk
}
});
} catch {
// Expected when we find a match
}
return result;
}
/**
* Filter nodes by predicate (alias for findAll)
*/
export function filter(
root: TamlNode,
predicate: (node: TamlNode) => boolean,
): TamlNode[] {
return findAll(root, predicate);
}
/**
* Get all text content from a node and its descendants
*/
export function getAllText(root: TamlNode): string {
const textParts: string[] = [];
walk(root, (node) => {
if (isTextNode(node)) {
textParts.push(node.content);
}
});
return textParts.join("");
}
/**
* Get all element nodes with a specific tag name
*/
export function getElementsWithTag(
root: TamlNode,
tagName: TamlTag,
): ElementNode[] {
return findAll(
root,
(node): node is ElementNode =>
isElementNode(node) && node.tagName === tagName,
) as ElementNode[];
}
/**
* Get all text nodes
*/
export function getTextNodes(root: TamlNode): TextNode[] {
return findAll(root, isTextNode) as TextNode[];
}
/**
* Get all element nodes
*/
export function getElementNodes(root: TamlNode): ElementNode[] {
return findAll(root, isElementNode) as ElementNode[];
}
/**
* Check if a node contains another node
*/
export function contains(ancestor: TamlNode, descendant: TamlNode): boolean {
if (ancestor === descendant) {
return true;
}
if (isDocumentNode(ancestor) || isElementNode(ancestor)) {
for (const child of ancestor.children) {
if (contains(child, descendant)) {
return true;
}
}
}
return false;
}
/**
* Get the common ancestor of two nodes
*/
export function getCommonAncestor(
node1: TamlNode,
node2: TamlNode,
): TamlNode | null {
// Get all ancestors of node1
const ancestors1 = new Set<TamlNode>();
let current: TamlNode | undefined = node1;
while (current) {
ancestors1.add(current);
current = current.parent;
}
// Find first common ancestor in node2's ancestry
current = node2;
while (current) {
if (ancestors1.has(current)) {
return current;
}
current = current.parent;
}
return null;
}
/**
* Get siblings of a node (nodes with the same parent)
*/
export function getSiblings(node: TamlNode): TamlNode[] {
if (
!node.parent ||
(!isDocumentNode(node.parent) && !isElementNode(node.parent))
) {
return [];
}
return node.parent.children.filter((child) => child !== node);
}
/**
* Get the previous sibling of a node
*/
export function getPreviousSibling(node: TamlNode): TamlNode | null {
if (
!node.parent ||
(!isDocumentNode(node.parent) && !isElementNode(node.parent))
) {
return null;
}
const index = node.parent.children.indexOf(node);
return index > 0 ? (node.parent.children[index - 1] ?? null) : null;
}
/**
* Get the next sibling of a node
*/
export function getNextSibling(node: TamlNode): TamlNode | null {
if (
!node.parent ||
(!isDocumentNode(node.parent) && !isElementNode(node.parent))
) {
return null;
}
const index = node.parent.children.indexOf(node);
return index < node.parent.children.length - 1
? (node.parent.children[index + 1] ?? null)
: null;
}
/**
* Check if a node is empty (has no children or only empty text)
*/
export function isEmpty(node: TamlNode): boolean {
if (isTextNode(node)) {
return node.content.trim() === "";
}
if (isDocumentNode(node) || isElementNode(node)) {
return (
node.children.length === 0 ||
node.children.every((child) => isEmpty(child))
);
}
return false;
}
/**
* Count nodes by type
*/
export function countNodes(root: TamlNode): {
document: number;
element: number;
text: number;
} {
const counts = { document: 0, element: 0, text: 0 };
walk(root, (node) => {
if (isDocumentNode(node)) {
counts.document++;
} else if (isElementNode(node)) {
counts.element++;
} else if (isTextNode(node)) {
counts.text++;
}
});
return counts;
}
/**
* Get the maximum depth of the tree
*/
export function getMaxDepth(root: TamlNode): number {
let maxDepth = 0;
function traverse(node: TamlNode, depth: number): void {
maxDepth = Math.max(maxDepth, depth);
if (isDocumentNode(node) || isElementNode(node)) {
for (const child of node.children) {
traverse(child, depth + 1);
}
}
}
traverse(root, 0);
return maxDepth;
}
/**
* Flatten the tree into an array of nodes (depth-first order)
*/
export function flatten(root: TamlNode): TamlNode[] {
const nodes: TamlNode[] = [];
walk(root, (node) => nodes.push(node));
return nodes;
}
/**
* Create a map from node to its depth in the tree
*/
export function createDepthMap(root: TamlNode): Map<TamlNode, number> {
const depthMap = new Map<TamlNode, number>();
function traverse(node: TamlNode, depth: number): void {
depthMap.set(node, depth);
if (isDocumentNode(node) || isElementNode(node)) {
for (const child of node.children) {
traverse(child, depth + 1);
}
}
}
traverse(root, 0);
return depthMap;
}