Yuku
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 full semantic model, or a mutable handle on the tree itself.
| Mode | What you get | Returns |
|---|---|---|
| Basic | Path from root, plus the full immutable tree | nothing |
| Scoped | Path + lexical scopes (with strict-mode tracking) | ScopeTree |
| Semantic | Path + scopes + symbols and references | Semantic |
| Transform | Path + a mutable *Tree for in-place rewrites | nothing |
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.
This page covers the hooks, the path, the four modes, and the transform patterns. The data the walks produce, with scopes, symbols, and resolved references, is documented in Semantic Analysis.
The traverser is stable and powers Yuku’s semantic checker in production. Breaking changes are called out in release notes.
Your First Visitor
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”.
Hooks
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.
Receiving the Payload Already Unpacked
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.
Catch-All Hooks
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_nodefirst, thenenter_<type> - Exit:
exit_<type>first, thenexit_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.
Walk Order
The walk is a depth-first, source-order traversal with two guarantees you can build on:
- Enter hooks fire pre-order, exit hooks post-order. A parent’s enter runs before any of its children, and a parent’s exit runs after all of its children.
- Siblings are visited in source order. Node payloads declare their child fields in source order and the walker visits fields in declaration order, an invariant enforced across the whole parser corpus. The one exception is template literals, whose quasis and expressions interleave in the source but are visited field by field, matching every ESTree walker.
Actions
Every enter hook returns one of three actions:
| Action | Effect |
|---|---|
.proceed | Walk into this node’s children |
.skip | Do not descend, move to the next sibling |
.stop | No further enter hooks fire anywhere |
Enter and exit hooks are always paired. Once a node’s enter hooks have run, its exit hooks run too. .skip skips the children but still fires the node’s own exit hooks. After a .stop, the exit hooks of every already-entered node still run as the walk unwinds to the root. The pairing is what keeps path, scope, and symbol tracking balanced, so a scoped or semantic traversal that skipped or stopped still returns a consistent result covering everything that was visited.
.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”.
Running Without Hooks
Sometimes the only thing you want from the traverser is its output, a ScopeTree from the scoped mode or the full Semantic model 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);
// The full semantic model:
const semantic = try sem.traverse(NoopVisitor, &tree, &noop);
The empty struct makes the absence of hooks explicit. A tool that wants the result of a walk without any per-node logic spells that out at the call site.
The Path
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 records up to 256 entries. Deeper nodes are still walked and depth() stays accurate, but parent() and ancestor() return null past the recorded depth. 256 levels is far beyond any realistic ECMAScript nesting, so in practice you can treat the path as unbounded.
Basic Traverser
The lightest mode, with 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 Traverser
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 pushes a scope for every construct that introduces a lexical environment and tracks strict mode as it goes. Scopes documents the full list of scope-creating constructs, the scope kinds, and the strict-mode rules.
Querying the Tracker
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.current; // ScopeId of the current scope
const cur = ctx.scope.currentScope(); // Scope value (kind, flags, parent, ...)
const hoist = ctx.scope.hoistTarget(); // 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.get(ancestor_id);
_ = ancestor;
}
return .proceed;
}
Using the ScopeTree After Traversal
scoped.traverse returns an immutable ScopeTree containing every scope that was created. get(id) returns a Scope and ancestors(start) walks from a scope up to the root:
const scope_tree = try scoped.traverse(MyVisitor, &tree, &visitor);
const root = scope_tree.get(.root);
var it = scope_tree.ancestors(some_scope_id);
while (it.next()) |id| {
const scope = scope_tree.get(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 Traverser
Semantic mode is the full-power one, with path, scopes, symbols, references, redeclaration handling, and TypeScript context tracking.
const sem = traverser.semantic;
var visitor = MyVisitor{};
const semantic = try sem.traverse(MyVisitor, &tree, &visitor);
It returns a Semantic, the complete model of the tree with every reference resolved to its declaring symbol. This is the same model semantic.analyze returns. Drive the traverser directly when you want your own hooks to run during the analysis walk.
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
Querying the Symbol Tracker
Inside a hook, ctx.symbols is the live symbol tracker, and ctx.scope works exactly as in scoped mode. The tracker answers name questions about everything declared so far:
pub fn enter_identifier_reference(
_: *V,
id: ast.IdentifierReference,
_: ast.NodeIndex,
ctx: *sem.Ctx,
) traverser.Action {
const name = ctx.tree.string(id.name);
// is this name bound anywhere in the current scope chain?
var it = ctx.scope.ancestors(ctx.scope.current);
while (it.next()) |scope_id| {
if (ctx.symbols.binding(scope_id, name)) |sym_id| {
const symbol = ctx.symbols.symbol(sym_id); // name, flags, scope
const first = ctx.symbols.firstDeclOf(sym_id); // its first declaration
_ = symbol;
_ = first;
break;
}
}
return .proceed;
}
The live surface:
ctx.symbols.pending // the pending binding (see below)
ctx.symbols.symbol(id) // Symbol by id
ctx.symbols.binding(scope, name) // binding at one scope, including a
// hoisting `var` passing through
ctx.symbols.ownBinding(scope, name) // declared directly in `scope` only
ctx.symbols.firstDeclOf(id) // binding_identifier node of the
// first declaration
The trackers are live, so they know only what the walk has visited so far. A name declared later in the file is not bound yet, and the full Semantic model (resolved references, use indexes, node queries) exists only after traverse returns. Query the trackers for the mid-walk view. Run the traversal first and query the model when a tool needs whole-file answers.
Two-Phase Binding
Symbol declaration is split across two phases per node:
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 nextbinding_identifiershould produce: its flags, its redeclaration excludes, and its target scope. This happens before your enter hook runs.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_identifierbecomes aSymbol,identifier_referencebecomes aReference.
Why the split? It guarantees a useful invariant for your visitor:
Your enter hook on a binding_identifier sees the scope state before that binding has been declared. You can inspect what is about to be declared, look up whether something with the same name already exists, and decide what to do, before the tracker commits the new symbol.
pub fn enter_binding_identifier(
_: *V,
id: ast.BindingIdentifier,
_: ast.NodeIndex,
ctx: *sem.Ctx,
) !traverser.Action {
const pending = ctx.symbols.pending;
// pending.flags - what the new symbol will be
// pending.excludes - what it conflicts with
// pending.scope - which scope it lands in
const name = ctx.tree.string(id.name);
if (ctx.symbols.binding(pending.scope, name)) |existing_id| {
const existing = ctx.symbols.symbol(existing_id);
if (existing.flags.intersects(pending.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;
}
ctx.symbols.pending is the pending state. Reading it inside an enter hook on a binding_identifier is always safe. Reading it at any other node is undefined. The flags and excludes it carries are documented in Symbol Flags and Redeclaration Excludes.
TypeScript Context Flags
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.
Transform Traverser
Transform mode is for rewrites such as codemods, desugaring passes, and 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).
A Semantic model built before a transform describes the tree as it was, so nodes you add and rewrites you make are not in it. Run analyze again after mutating when a later pass needs fresh semantics.
Replacing a Node In Place
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;
}
Renaming an Identifier
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 decls and uses on the semantic model, you can rewrite a whole binding in a few lines.
Creating New Nodes
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.
Wrapping a Node
A common pattern is “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.
Self-Reference Safety
Never set a node’s child to its own index. The walker re-reads node data after every enter hook, so a self-referential node causes infinite recursion.
// 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 } });
Building ASTs From Scratch
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.
Building One Tree While Walking Another
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.
Combining Modes
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{};
const semantic = try sem.traverse(MyAnalyser, &tree, &analyser);
// Pass 3: emit, lint, minify, etc.
Because read-only modes hand visitors a *const Tree and transform hands a *Tree, the type system keeps analysis and mutation apart. A function that takes *const Tree cannot change the AST.