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.
| 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 | ScopeTree + SymbolTable |
| 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.
Your First Visitor
Section titled â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â.
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
Section titled â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
Section titled â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.
Actions
Section titled â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 | End 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â.
Running Without Hooks
Section titled âRunning Without Hooksâ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.
The Path
Section titled â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 rootctx.path.ancestor(0) // current nodectx.path.ancestor(1) // parent (same as parent())ctx.path.ancestor(2) // grandparentctx.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.
Basic Traverser
Section titled âBasic Traverserâ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 accessctx.path // NodePath, the current path stackThat is it. Step up to scoped or semantic when you need to know what bindings are in scope.
Scoped Traverser
Section titled â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 producedWhat Creates a Scope
Section titled âWhat Creates a Scopeâ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.
| Node | Scope kind |
|---|---|
| Program | global (plus a child module if source_type is module) |
| Function declaration / expression | function |
| Arrow function | function |
| Block statement | block |
for / for...in / for...of | block |
catch clause | block (the body block reuses this scope) |
switch statement | block |
| Class declaration / expression | class (always strict per spec) |
| Class static block | static_block |
| Named function or class expression | expression_name wrapping a function / class scope |
| TS interface / type alias | block |
| TS function/constructor type, call/construct/index signature | block |
| TS method signature | block |
| TS mapped / conditional type | block (conditional isolates infer T per branch) |
| TS namespace body | ts_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
Section titled âStrict ModeâStrict mode propagates automatically:
- Module scopes are always strict.
- Class scopes are always strict.
- A
"use strict"directive at the top of any scope setsflags.stricton that scope. - Child scopes inherit strict mode from their parent.
- Functions whose body opens with
"use strict"getstrict = trueset 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.
Querying the Tracker
Section titled â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.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).
Using the ScopeTree After Traversal
Section titled âUsing the ScopeTree After Traversalâ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 Traverser
Section titled âSemantic Traverserâ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 referencesem.Ctx exposes:
ctx.tree // *const ast.Treectx.path // NodePathctx.scope // ScopeTracker (same API as scoped mode)ctx.symbols // SymbolTrackerctx.inTypePosition() // true inside a TS type-only subtreectx.inTsNamespace() // true inside a TS `namespace` bodyTwo-Phase Binding
Section titled â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:
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.
The Symbol
Section titled âThe Symbolâ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
Section titled âSymbol Flagsâ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_varsymbol.flags.block_scoped_var // let, const, using, await_usingsymbol.flags.function // function declaration / expressionsymbol.flags.class // class declaration / expressionsymbol.flags.interface // TS interfacesymbol.flags.type_alias // TS type aliassymbol.flags.type_parameter // TS <T>, infer T, mapped keysymbol.flags.regular_enum // TS enumsymbol.flags.const_enum // TS const enumsymbol.flags.value_module // TS namespace whose body has runtime contentsymbol.flags.namespace_module // TS namespace (any kind)symbol.flags.import // value or unspecified-kind importsymbol.flags.type_import // `import type ...` or `import { type x }`Modifiers (qualifiers on the binding):
symbol.flags.const_var // const or using bindingsymbol.flags.parameter // function/method parametersymbol.flags.catch_var // catch (e) bindingsymbol.flags.ambient // TS `declare`symbol.flags.exported // exported from a modulesymbol.flags.is_default // default exportHelpers on the flag struct itself:
flags.intersects(other) // true if `flags` and `other` share at least one flagflags.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 diagnosticsSpace 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 namespaceflags.inTypeSpace() // class, enum, interface, type alias, type parameterflags.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.
Per-Kind Redeclaration Excludes
Section titled âPer-Kind Redeclaration ExcludesâFor each declaration kind, the tracker has a precomputed Symbol.Excludes.X flag set. The rule is uniform:
A new declaration with
Excludes.Xconflicts with any existing symbol whose flags intersectExcludes.X. Otherwise the two declarations merge into a single symbol with the union of their flags.
Symbol.Excludes.block_var // let / const / usingSymbol.Excludes.function_var // varSymbol.Excludes.function // function (allows overloads in TS, var-merge in sloppy)Symbol.Excludes.classSymbol.Excludes.interfaceSymbol.Excludes.type_aliasSymbol.Excludes.regular_enumSymbol.Excludes.const_enumSymbol.Excludes.value_moduleSymbol.Excludes.namespace_moduleSymbol.Excludes.import_bindingSymbol.Excludes.parameterSymbol.Excludes.catch_paramSymbol.Excludes.type_parameterThis 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.
TypeScript Context Flags
Section titled â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.
References and Their Kind
Section titled âReferences and Their Kindâ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.
Resolving References
Section titled âResolving Referencesâ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.
Single-Scope Lookups
Section titled âSingle-Scope Lookupsâ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.
Iterating the Table
Section titled âIterating the Tableâ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;}SymbolTable Reference
Section titled âSymbolTable ReferenceâThe tableâs public surface, in one place:
table.symbols // []const Symbol, in declaration ordertable.references // []const Reference, in source order
table.getSymbol(sym_id) // Symbol by idtable.getReference(ref_id) // Reference by id
table.iterSymbols() // (id, symbol) entriestable.iterReferences() // (id, reference) entriestable.iterUnresolved() // unresolved (id, reference), requires resolveAlltable.scopeSymbols(scope_id) // *SymbolId per binding in the scope
table.findInScope(scope, name) // single-scope lookuptable.findInScopeOrHoisted(scope, name)// + hoisting var passing throughtable.resolve(scope_tree, scope, name) // scope-chain lookup
try table.resolveAll(scope_tree) // build the cross-indextable.referenceSymbol(ref_id) // forward (ref -> sym), after resolveAlltable.symbolDecls(sym_id) // []const NodeIndex of declaratorstable.symbolUses(sym_id) // iterator over use sites, after resolveAlltable.symbolSites(sym_id) // iterator over decls + uses, after resolveAlltable.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 Traverser
Section titled âTransform Traverserâ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 accessctx.path // NodePathThere 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).
Replacing a Node In Place
Section titled â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
Section titled â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 symbolSites, you can rewrite a whole binding in a few lines.
Creating New Nodes
Section titled â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
Section titled âWrapping a Nodeâ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.
Self-Reference Safety
Section titled âSelf-Reference Safetyâ// 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
Section titled â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
Section titled â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
Section titled â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 treevar 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.