Skip to content

Traverse

The traverser is how you do real work on a Yuku AST. It walks the tree for you, calls your visitor hooks at every node, and (depending on the mode you pick) hands you the surrounding lexical scopes, the symbol table, or a mutable handle on the tree itself.

ModeWhat you getReturns
BasicPath from root, plus the full immutable treenothing
ScopedPath + lexical scopes (with strict-mode tracking)ScopeTree
SemanticPath + scopes + symbols and referencesScopeTree + SymbolTable
TransformPath + a mutable *Tree for in-place rewritesnothing

The three read-only modes (basic, scoped, semantic) hand your visitor a *const Tree, so the type system guarantees you cannot accidentally mutate the AST while analysing it. Transform is the only mode that gives you *Tree. This is intentional: tracked state (scopes, symbols) and tree mutation cannot safely coexist in a single pass, so the API splits them.

A visitor is just a struct with enter_* and exit_* methods. Pick a node type, write a method named after it, and the walker will call you when it gets there.

const std = @import("std");
const parser = @import("parser");
const ast = parser.ast;
const traverser = parser.traverser;
const basic = traverser.basic;
const Counter = struct {
functions: u32 = 0,
pub fn enter_function(
self: *Counter,
_: ast.Function,
_: ast.NodeIndex,
_: *basic.Ctx,
) traverser.Action {
self.functions += 1;
return .proceed;
}
};
var tree = try parser.parse(allocator, source, .{});
defer tree.deinit();
var counter = Counter{};
try basic.traverse(Counter, &tree, &counter);
std.debug.print("found {d} functions\n", .{counter.functions});

That is the entire shape of every traverser-based tool you will write. The rest of this page is “what else you can do inside a hook”.

Every hook follows the same four-argument shape:

pub fn enter_<node_type>(
self: *V, // your visitor
payload: ast.<NodeType>, // the node's unpacked payload
index: ast.NodeIndex, // the node's index in the tree
ctx: *<Mode>.Ctx, // mode-specific context
) traverser.Action { ... }
pub fn exit_<node_type>(
self: *V,
payload: ast.<NodeType>,
index: ast.NodeIndex,
ctx: *<Mode>.Ctx,
) void { ... }

The hook name must match a field in ast.NodeData. Misspell it (enter_funciton) or use the wrong payload type and Zig produces a clear compile error at the traverse call site. There are no silent mismatches, and no runtime “method not found” surprises.

Enter hooks may return either traverser.Action or Allocator.Error!traverser.Action. Both are accepted, so a hook that never allocates can write return .proceed; directly, while one that calls addNode writes return .proceed; from inside a try block.

Exit hooks always return void.

Each typed hook receives its node’s payload pre-extracted from the tagged union, so you never write a switch to “unwrap” the node you just matched on:

pub fn enter_binary_expression(
_: *V,
expr: ast.BinaryExpression, // already unpacked, no switch needed
_: ast.NodeIndex,
_: *basic.Ctx,
) traverser.Action {
if (expr.operator == .add) { ... }
return .proceed;
}

When you do need to peek at a child node before the walker reaches it, switch on ctx.tree.data(child_index):

pub fn enter_call_expression(
_: *V,
call: ast.CallExpression,
_: ast.NodeIndex,
ctx: *basic.Ctx,
) traverser.Action {
switch (ctx.tree.data(call.callee)) {
.member_expression => |mem| {
// callee looks like obj.method(...)
_ = mem;
},
.identifier_reference => |id| {
const name = ctx.tree.string(id.name);
// callee is a bare identifier
_ = name;
},
else => {},
}
return .proceed;
}

See the AST reference for every node type and its fields.

If you want one hook that fires for every node regardless of type, define enter_node and/or exit_node. The payload is ast.NodeData (the full union):

pub fn enter_node(
self: *V,
data: ast.NodeData,
index: ast.NodeIndex,
ctx: *basic.Ctx,
) traverser.Action {
return .proceed;
}
pub fn exit_node(
self: *V,
data: ast.NodeData,
index: ast.NodeIndex,
ctx: *basic.Ctx,
) void {}

When both a typed hook and a catch-all are defined, the order is:

  • Enter: enter_node first, then enter_<type>
  • Exit: exit_<type> first, then exit_node

That ordering lets a catch-all enter “gate” a subtree (return .skip to opt out before the typed hooks run) and a catch-all exit “summarise” what just happened.

Every enter hook returns one of three actions:

ActionEffect
.proceedWalk into this node’s children
.skipDo not descend, move to the next sibling
.stopEnd the entire traversal immediately

.skip is what you return after hand-walking a subtree yourself, or after a transform that should not be re-entered. .stop is how you implement “find the first X and exit”.

Sometimes the only thing you want from the traverser is its output: a ScopeTree from the scoped mode, or a ScopeTree + SymbolTable from the semantic mode. In that case you do not need to define any hooks. Pass an empty struct as the visitor and the walker still runs end-to-end, the trackers still produce their result, and nothing fires in between.

const NoopVisitor = struct {};
var noop = NoopVisitor{};
// Just the scope tree:
const scope_tree = try scoped.traverse(NoopVisitor, &tree, &noop);
// Scope tree + symbol table:
var result = try sem.traverse(NoopVisitor, &tree, &noop);
try result.symbol_table.resolveAll(result.scope_tree);

The empty struct makes the absence of hooks visible in the code, which is why there is no hidden analyze-style helper inside the traverser. If a tool wants the result of a walk without any per-node logic, the caller spells that out exactly.

Every mode tracks the path from the root down to the current node. The path is a small fixed-capacity stack of NodeIndex values you can read at any time through ctx.path.

ctx.path.parent() // immediate parent NodeIndex, or null at root
ctx.path.ancestor(0) // current node
ctx.path.ancestor(1) // parent (same as parent())
ctx.path.ancestor(2) // grandparent
ctx.path.depth() // 0 at root, grows as you descend
var it = ctx.path.ancestors();
while (it.next()) |idx| {
// walks from current node up to root
}

Combined with the full tree (always available as ctx.tree), the path lets you navigate freely:

pub fn enter_identifier_reference(
_: *V,
id: ast.IdentifierReference,
_: ast.NodeIndex,
ctx: *basic.Ctx,
) traverser.Action {
// is this identifier the callee of a call expression?
if (ctx.path.parent()) |parent_idx| {
if (ctx.tree.data(parent_idx) == .call_expression) {
const name = ctx.tree.string(id.name);
_ = name;
}
}
return .proceed;
}

The path has a hard cap of 256 entries. In practice this is far beyond any realistic ECMAScript nesting depth, so you can treat it as effectively unbounded.

The minimum: path tracking plus the full immutable tree. No allocator needed, nothing returned.

Use it for tools that only need structural pattern matching:

  • eslint-style rules that look at “this node and its parent”
  • counters and statistics
  • AST-shape assertions in tests
  • pretty-printers that read but never write the tree
const basic = traverser.basic;
var visitor = MyVisitor{};
try basic.traverse(MyVisitor, &tree, &visitor);

basic.Ctx carries:

ctx.tree // *const ast.Tree, full read access
ctx.path // NodePath, the current path stack

That is it. Step up to scoped or semantic when you need to know what bindings are in scope.

Scoped mode adds automatic lexical scope tracking. Whenever the walker enters a scope-creating node, the tracker pushes a new scope. On exit, it pops. Your hooks see ctx.scope.currentScope() already pointing at the right place.

const scoped = traverser.scoped;
var visitor = MyVisitor{};
const scope_tree = try scoped.traverse(MyVisitor, &tree, &visitor);
// scope_tree contains every scope the walk produced

The tracker recognises every construct in the spec that introduces a new lexical environment, plus the TypeScript-specific ones that scope type parameters and infer bindings.

NodeScope kind
Programglobal (plus a child module if source_type is module)
Function declaration / expressionfunction
Arrow functionfunction
Block statementblock
for / for...in / for...ofblock
catch clauseblock (the body block reuses this scope)
switch statementblock
Class declaration / expressionclass (always strict per spec)
Class static blockstatic_block
Named function or class expressionexpression_name wrapping a function / class scope
TS interface / type aliasblock
TS function/constructor type, call/construct/index signatureblock
TS method signatureblock
TS mapped / conditional typeblock (conditional isolates infer T per branch)
TS namespace bodyts_module (its own kind, see below)

A few details worth pinning down because they trip up first-time readers:

Catch clauses share their body block. Per spec section 14.15.2 the catch parameter and the block body live in the same catchEnv. The tracker pushes one block scope on catch_clause, and the body’s block_statement reuses it instead of pushing a second one. This is what lets findInScopeOrHoisted naturally detect the early-error case where a var inside the body collides with the parameter.

Named function and class expressions create two scopes. For const x = function foo() { ... }, ECMAScript section 15.2.5 wraps the body in an extra environment that holds an immutable binding for foo:

outer scope (x lives here)
expression_name (foo lives here, immutable)
function scope (body bindings live here)

Without this, const foo = 1 inside the body would conflict with the expression name. Same pattern for const C = class D { ... } per section 15.7.14.

ts_module is its own scope kind, not a block. TypeScript namespace bodies act as a var-hoist target, so a var inside a namespace stays inside the namespace instead of escaping to the surrounding scope. That difference is encoded as a separate Scope.Kind so Kind.isHoistTarget() returns true for it.

Strict mode propagates automatically:

  • Module scopes are always strict.
  • Class scopes are always strict.
  • A "use strict" directive at the top of any scope sets flags.strict on that scope.
  • Child scopes inherit strict mode from their parent.
  • Functions whose body opens with "use strict" get strict = true set at function-scope creation time, before any parameter hooks fire. This is needed because the directive applies retroactively to the parameter list, where rules like “no duplicate parameters” only kick in under strict mode.

Inside a hook, ctx.scope is a live ScopeTracker you can interrogate:

pub fn enter_node(
_: *V,
_: ast.NodeData,
_: ast.NodeIndex,
ctx: *scoped.Ctx,
) traverser.Action {
const id = ctx.scope.currentScopeId(); // ScopeId of current scope
const cur = ctx.scope.currentScope(); // Scope value (kind, flags, parent, ...)
const hoist = ctx.scope.currentHoistScopeId(); // where `var` declarations would land
const strict = ctx.scope.isStrict();
if (cur.kind == .function and !strict) { ... }
var it = ctx.scope.ancestors(id);
while (it.next()) |ancestor_id| {
const ancestor = ctx.scope.getScope(ancestor_id);
_ = ancestor;
}
return .proceed;
}

getScope(id) and getScopeMut(id) look up any scope by id. currentScopeMut() is there if you need to flip a flag on the active scope yourself (rarely needed, the tracker handles "use strict" automatically).

scoped.traverse returns an immutable ScopeTree containing every scope that was created:

const scope_tree = try scoped.traverse(MyVisitor, &tree, &visitor);
const root = scope_tree.getScope(.root); // ScopeId.root is always 0
_ = root;
var it = scope_tree.ancestors(some_scope_id);
while (it.next()) |id| {
const scope = scope_tree.getScope(id);
_ = scope;
}

The tree is backed by the parser’s arena, so it lives as long as the source Tree does. Calling tree.deinit() invalidates it.

Semantic mode is the full-power one: path, scopes, symbols, references, redeclaration handling, TypeScript context tracking.

const sem = traverser.semantic;
var visitor = MyVisitor{};
const result = try sem.traverse(MyVisitor, &tree, &visitor);
// result.scope_tree - every scope
// result.symbol_table - every symbol and reference

sem.Ctx exposes:

ctx.tree // *const ast.Tree
ctx.path // NodePath
ctx.scope // ScopeTracker (same API as scoped mode)
ctx.symbols // SymbolTracker
ctx.inTypePosition() // true inside a TS type-only subtree
ctx.inTsNamespace() // true inside a TS `namespace` body

Symbol declaration is split across two phases per node:

  1. Phase 1, on enter: when entering a parent declaration node (variable_declaration, function, class, import_declaration, formal_parameters, catch_clause, ts_interface_declaration, etc.), the tracker records what kind of binding the next binding_identifier should produce: its flags, its redeclaration excludes, and its target scope. This happens before your enter hook runs.

  2. Phase 2, on post_enter: after your enter hook returns, but before the walker descends into children, the tracker materialises the actual symbol or reference. binding_identifier becomes a Symbol, identifier_reference becomes a Reference.

Why the split? It guarantees a useful invariant for your visitor:

pub fn enter_binding_identifier(
_: *V,
id: ast.BindingIdentifier,
_: ast.NodeIndex,
ctx: *sem.Ctx,
) !traverser.Action {
const flags = ctx.symbols.currentBindingFlags(); // what the new symbol will be
const excludes = ctx.symbols.currentBindingExcludes(); // what it conflicts with
const target = ctx.symbols.currentTarget(); // which scope it lands in
const name = ctx.tree.string(id.name);
if (ctx.symbols.findInScopeOrHoisted(target, name)) |existing_id| {
const existing = ctx.symbols.getSymbol(existing_id);
if (existing.flags.intersects(excludes)) {
// genuine conflict: emit a redeclaration diagnostic
} else {
// compatible merge (e.g. function overload, class + interface,
// namespace + value). The tracker will merge them automatically
// in post_enter.
}
}
return .proceed;
}

currentBindingFlags, currentBindingExcludes, and currentTarget are the three readers for the pending state. Reading them inside an enter hook on a binding_identifier is always safe. Reading them at any other node is undefined.

Once a binding is materialised, you get a Symbol:

pub const Symbol = struct {
name: String, // index into the tree's string pool
flags: Flags, // declaration kind + modifiers (see below)
scope: ScopeId, // the scope this symbol was declared in
decls: Range, // every declarator of this symbol, as a slice
// into SymbolTable.decl_nodes. Use
// table.symbolDecls(id) to read it.
};

A single symbol can collect multiple declarators when the language allows merging: TS function overloads, class + interface merging, namespace + enum merging, ambient module patterns. The tracker records every declarator node, so a renamer or a “go to definition” feature can show all of them.

Symbol.Flags describes everything the tracker knows about a binding. A single symbol can carry several flags at once: a class lives in both value and type space, an exported var is both function_scoped_var and exported, and an interface and a class of the same name merge into a single symbol that satisfies both kinds.

The flags group into three categories.

Declaration kind (what created the binding):

symbol.flags.function_scoped_var // var, parameter, catch_var
symbol.flags.block_scoped_var // let, const, using, await_using
symbol.flags.function // function declaration / expression
symbol.flags.class // class declaration / expression
symbol.flags.interface // TS interface
symbol.flags.type_alias // TS type alias
symbol.flags.type_parameter // TS <T>, infer T, mapped key
symbol.flags.regular_enum // TS enum
symbol.flags.const_enum // TS const enum
symbol.flags.value_module // TS namespace whose body has runtime content
symbol.flags.namespace_module // TS namespace (any kind)
symbol.flags.import // value or unspecified-kind import
symbol.flags.type_import // `import type ...` or `import { type x }`

Modifiers (qualifiers on the binding):

symbol.flags.const_var // const or using binding
symbol.flags.parameter // function/method parameter
symbol.flags.catch_var // catch (e) binding
symbol.flags.ambient // TS `declare`
symbol.flags.exported // exported from a module
symbol.flags.is_default // default export

Helpers on the flag struct itself:

flags.intersects(other) // true if `flags` and `other` share at least one flag
flags.merge(other) // union of two flag sets (used when merging compatible declarations)
flags.isHoistingVar() // true for a real `var` (not a parameter, not a catch_var)
flags.toString() // human-readable category for diagnostics

Space predicates. A symbol can occupy JS value space (visible at runtime), TS type space (referenced from annotations), or both (a class straddles them by design). These are common enough questions that they have direct predicates, no manual flag combinations needed:

flags.inValueSpace() // var, let, const, function, class, enum, value namespace
flags.inTypeSpace() // class, enum, interface, type alias, type parameter
flags.isBlockScopedLike() // names a hoisting `var` cannot pass through
// (block_scoped_var, class, function)

class and regular_enum deliberately satisfy both inValueSpace and inTypeSpace. That is what makes “use a class as a type” work without special-casing.

For each declaration kind, the tracker has a precomputed Symbol.Excludes.X flag set. The rule is uniform:

A new declaration with Excludes.X conflicts with any existing symbol whose flags intersect Excludes.X. Otherwise the two declarations merge into a single symbol with the union of their flags.

Symbol.Excludes.block_var // let / const / using
Symbol.Excludes.function_var // var
Symbol.Excludes.function // function (allows overloads in TS, var-merge in sloppy)
Symbol.Excludes.class
Symbol.Excludes.interface
Symbol.Excludes.type_alias
Symbol.Excludes.regular_enum
Symbol.Excludes.const_enum
Symbol.Excludes.value_module
Symbol.Excludes.namespace_module
Symbol.Excludes.import_binding
Symbol.Excludes.parameter
Symbol.Excludes.catch_param
Symbol.Excludes.type_parameter

This single mechanism handles function overloads, class + interface declaration merging, namespace + enum merging, and ambient module patterns without any per-construct branching. If you ever need to teach the tracker a new merging rule, you change one flag set, not a tangle of if statements.

Two booleans on sem.Ctx track whether the walker is currently inside TS-only territory:

pub fn enter_identifier_reference(
_: *V,
id: ast.IdentifierReference,
_: ast.NodeIndex,
ctx: *sem.Ctx,
) traverser.Action {
if (ctx.inTypePosition()) {
// Inside a type annotation, type reference, type parameter,
// type literal, mapped/conditional type, etc.
// References here are tagged as `.type` automatically.
}
if (ctx.inTsNamespace()) {
// Inside a TS `namespace` body.
}
_ = id;
return .proceed;
}

inTypePosition() is also what the tracker uses internally to decide that a binding_identifier inside a function-type or index signature is a parameter label (not a real declaration). Only type_parameter bindings are real in type position.

Every identifier_reference node produces one Reference. Each reference carries a kind:

pub const Reference = struct {
name: String,
scope: ScopeId,
node: ast.NodeIndex,
kind: Kind = .value, // .value or .type
};

.value means the reference is a runtime use (the receiver of a property access, an argument, the LHS of an assignment, etc.). .type means it appears inside a type annotation, an extends / implements clause, a type argument, or any other TS type-position context. Rename-aware tooling distinguishes the two so it can change a value without touching a same-named type, and vice versa.

During traversal, references are recorded but not yet linked to their declarations. After traversal, call resolveAll with the scope tree to walk every reference up its chain and build the cross-index:

var result = try sem.traverse(MyVisitor, &tree, &visitor);
try result.symbol_table.resolveAll(result.scope_tree);

Once resolved you have the full bidirectional map. Crucially, the reverse iterators yield NodeIndex values directly, so you can rewrite or annotate the source without an extra lookup:

const table = result.symbol_table;
// Forward: what does this reference point to?
const sym_id = table.referenceSymbol(some_ref_id);
if (sym_id != .none) {
const sym = table.getSymbol(sym_id);
_ = sym;
}
// Every declaration site of a symbol. Returns a slice you can index.
for (table.symbolDecls(my_sym_id)) |decl_node| {
_ = decl_node;
}
// Every use site of a symbol.
var uses = table.symbolUses(my_sym_id);
while (uses.next()) |use_node| {
_ = use_node;
}
// Every site (declarations first in source order, then uses).
// This is the iterator a renamer wants.
var sites = table.symbolSites(my_sym_id);
while (sites.next()) |node| {
_ = node;
}
// Quick check: is this binding used at all?
if (!table.isReferenced(my_sym_id)) {
// candidate for an "unused variable" diagnostic
}
// References that did not resolve to any local binding.
// These are globals, undeclared names, or free variables.
var it = table.iterUnresolved();
while (it.next()) |entry| {
_ = entry; // entry.id, entry.reference
}

iterUnresolved is exactly what a “no-undef” linter wants: every name the parser saw that is not bound anywhere in the tree.

You can also resolve a name manually from any starting scope:

if (table.resolve(result.scope_tree, scope_id, "myVar")) |found| {
const sym = table.getSymbol(found);
_ = sym;
}

resolve walks up the scope chain just like JavaScript does at runtime, also matching hoisted vars passing through intermediate block scopes.

For tools that do not want a full chain walk (a minifier checking “is this name shadowed in this exact scope”), both the tracker and the table expose tight single-scope lookups:

findInScope(scope, name) // bindings declared directly in `scope`
findInScopeOrHoisted(scope, name) // also matches a `var` passing through `scope`

findInScopeOrHoisted is the same lookup the tracker uses internally to detect block-scoped redeclarations of names that a hoisting var is travelling through.

Three iterators walk the whole table. Each yields an entry so you never reach back through the table for a lookup you were just handed:

var syms = table.iterSymbols();
while (syms.next()) |entry| {
_ = entry; // entry.id, entry.symbol
}
var refs = table.iterReferences();
while (refs.next()) |entry| {
_ = entry; // entry.id, entry.reference
}
var unresolved = table.iterUnresolved();
while (unresolved.next()) |entry| {
_ = entry; // unresolved refs only, requires resolveAll
}

For tight per-scope loops (a minifier checking shadowing in one scope, a renamer enumerating bindings in a function body), scopeSymbols(scope_id) yields raw *SymbolIds straight out of the per-scope hash map, so you avoid copying full Symbol structs:

// During traversal (live tracker):
var it = ctx.symbols.scopeSymbols(scope_id);
while (it.next()) |sym_id_ptr| {
const sym = ctx.symbols.getSymbol(sym_id_ptr.*);
const name = ctx.tree.string(sym.name);
_ = name;
}
// After traversal (immutable table):
var it2 = result.symbol_table.scopeSymbols(scope_id);
while (it2.next()) |sym_id_ptr| {
const sym = result.symbol_table.getSymbol(sym_id_ptr.*);
_ = sym;
}

The table’s public surface, in one place:

table.symbols // []const Symbol, in declaration order
table.references // []const Reference, in source order
table.getSymbol(sym_id) // Symbol by id
table.getReference(ref_id) // Reference by id
table.iterSymbols() // (id, symbol) entries
table.iterReferences() // (id, reference) entries
table.iterUnresolved() // unresolved (id, reference), requires resolveAll
table.scopeSymbols(scope_id) // *SymbolId per binding in the scope
table.findInScope(scope, name) // single-scope lookup
table.findInScopeOrHoisted(scope, name)// + hoisting var passing through
table.resolve(scope_tree, scope, name) // scope-chain lookup
try table.resolveAll(scope_tree) // build the cross-index
table.referenceSymbol(ref_id) // forward (ref -> sym), after resolveAll
table.symbolDecls(sym_id) // []const NodeIndex of declarators
table.symbolUses(sym_id) // iterator over use sites, after resolveAll
table.symbolSites(sym_id) // iterator over decls + uses, after resolveAll
table.isReferenced(sym_id) // shorthand for "any uses?"

The table aliases the tree’s arena, so it lives as long as the tree. Calling tree.deinit() invalidates everything inside.

Transform mode is for rewrites: codemods, desugaring passes, AST-level optimisations. Your visitor receives *Tree (mutable) and can call setData, setSpan, setIdentifierName, addNode, and addExtra from inside any hook.

const transform = traverser.transform;
var visitor = MyTransform{};
try transform.traverse(MyTransform, &tree, &visitor);

transform.Ctx is intentionally minimal:

ctx.tree // *ast.Tree, full read AND write access
ctx.path // NodePath

There is no scope or symbol tracking in this mode. Mutating the tree would invalidate any tracked state mid-walk, so the design splits “analyse” from “rewrite” at the type level. If you need both, run two passes (see Combining Modes below).

The simplest transform replaces a node’s data inside its enter hook. The walker re-reads the node after every enter, so the replacement’s children are walked automatically:

pub fn enter_binary_expression(
_: *MyTransform,
expr: ast.BinaryExpression,
index: ast.NodeIndex,
ctx: *transform.Ctx,
) traverser.Action {
if (expr.operator == .add) {
ctx.tree.setData(index, .{ .binary_expression = .{
.left = expr.left,
.right = expr.right,
.operator = .multiply,
}});
}
return .proceed;
}

setIdentifierName rewrites the name field of any identifier-shaped node (binding_identifier, identifier_reference, identifier_name, label_identifier, private_identifier, jsx_identifier) without changing its variant or span:

const new_name = try ctx.tree.addString("a");
ctx.tree.setIdentifierName(node_index, new_name);

This is the primitive Yuku’s minifier uses to rename every site of a symbol in lock-step. Combined with symbolSites, you can rewrite a whole binding in a few lines.

Use addNode to append a brand-new node and get its index. Use addExtra to allocate variable-length child lists for fields typed as IndexRange:

const lit = try ctx.tree.addNode(
.{ .numeric_literal = .{ .raw = "42" } },
.none, // span: .none if it has no source location
);
const args = try ctx.tree.addExtra(&.{ child1, child2, child3 });

Both are safe to call during traversal and use the tree’s arena, so there is nothing to free.

A common pattern: “take this node, move it to a fresh node, replace this one with a wrapper pointing at the moved copy”. Useful for parenthesising expressions, wrapping in await, etc.

pub fn enter_binary_expression(
_: *MyTransform,
expr: ast.BinaryExpression,
index: ast.NodeIndex,
ctx: *transform.Ctx,
) !traverser.Action {
const span = ctx.tree.span(index);
// 1. Move the original data into a new node, keeping its span.
const inner = try ctx.tree.addNode(
.{ .binary_expression = expr },
span,
);
// 2. Replace the current node with a wrapper that points at the moved copy.
ctx.tree.setData(index, .{ .parenthesized_expression = .{ .expression = inner } });
// 3. Skip so the walker does not re-enter and re-wrap the moved node.
return .skip;
}

Returning .skip here is essential. If you let the walker descend, it will re-read the new wrapper, find its child (the moved copy), and call enter_binary_expression again on the same data, infinitely.

// WRONG: cycle. The wrapper points to its own index.
const wrapper = try ctx.tree.addNode(
.{ .parenthesized_expression = .{ .expression = index } },
span,
);
ctx.tree.setData(index, ctx.tree.data(wrapper));
// RIGHT: move original data to a fresh node, wrap that.
const inner = try ctx.tree.addNode(original_data, span);
ctx.tree.setData(index, .{ .parenthesized_expression = .{ .expression = inner } });

Tree.initEmpty(allocator) creates a tree with no source text, intended for programmatic AST construction. Because there is no source backing it, every string must be created with tree.addString(...).

A valid tree starts from a program root node:

var out = ast.Tree.initEmpty(allocator);
defer out.deinit();
const hello = try out.addString("hello");
const lit = try out.addNode(
.{ .string_literal = .{ .value = hello } },
.none,
);
const stmt = try out.addNode(
.{ .expression_statement = .{ .expression = lit } },
.none,
);
const body = try out.addExtra(&.{stmt});
out.root = try out.addNode(
.{ .program = .{ .source_type = .module, .body = body } },
.none,
);

That is enough for a tree that any read-only consumer (a printer, an emitter, another traverser pass) will accept.

A particularly powerful pattern, central to transpilers and source-to-source compilers, is walking an input tree with full semantic context (scopes, symbols, path) while building a completely separate output tree:

const sem = traverser.semantic;
const Transpiler = struct {
out: *ast.Tree,
pub fn enter_function(
self: *Transpiler,
func: ast.Function,
index: ast.NodeIndex,
ctx: *sem.Ctx,
) !traverser.Action {
// Read context from the *source* tree:
const is_strict = ctx.scope.isStrict();
const span = ctx.tree.span(index);
_ = is_strict;
_ = func;
// Build into the *output* tree:
const name = try self.out.addString("transpiledFn");
const id = try self.out.addNode(
.{ .binding_identifier = .{ .name = name } },
span,
);
_ = id;
return .proceed;
}
};
var source_tree = try parser.parse(allocator, source, .{});
defer source_tree.deinit();
var out = ast.Tree.initEmpty(allocator);
defer out.deinit();
var transpiler = Transpiler{ .out = &out };
_ = try sem.traverse(Transpiler, &source_tree, &transpiler);
// `out` is a fresh AST you built using full knowledge of the source's
// scopes, symbols, and structure. The two trees have independent arenas.

The two trees never touch each other’s storage, and either can be freed independently. This is the recommended structure for any tool that produces a transformed AST from an input AST without mutating the input.

The four modes compose via multiple passes. A typical pipeline looks like:

var tree = try parser.parse(allocator, source, .{});
defer tree.deinit();
// Pass 1: rewrite syntax (sugar lowering, JSX transform, etc.)
var rewriter = MyTransform{};
try transform.traverse(MyTransform, &tree, &rewriter);
// Pass 2: semantic analysis on the rewritten tree
var analyser = MyAnalyser{};
var result = try sem.traverse(MyAnalyser, &tree, &analyser);
try result.symbol_table.resolveAll(result.scope_tree);
// Pass 3: emit, lint, minify, etc.

Because read-only modes hand visitors a *const Tree and transform hands a *Tree, the type system enforces that you cannot accidentally smuggle a tracking pass into a mutation pass. If a function compiles with *const Tree you have a mathematical guarantee it will not change the AST.