Viscosity
Review
Part XI · Viscosity in Rust · Day 44

173. Lowering oxc’s AST to VIR

The TypeScript lowering walks “all the children” of any ESTree node with Object.keys. Rust has no Object.keys, and oxc’s AST has hundreds of node types. The answer is a visitor: a trait that walks everything, where you override only what you care about.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain the visitor pattern, and how oxc’s Visit trait walks children in source order.
  • Map resolve.ts and lower.ts to resolve.rs and lower.rs, and name the design differences.
  • Say why the port keys tables by spans and records JsNode facts instead of keeping AST nodes.
  • Write a small visitor that overrides two methods and lets the defaults walk the rest.

All the children

In chapter 93, lower.ts turned anything it didn’t model into an opaque instruction taking every variable the subtree mentions. It found them with one small function from estree.ts (simplified):

function children(n: Node): Node[] {
  const out: Node[] = [];
  for (const key of Object.keys(n)) {
    const v = n[key];
    if (v && typeof v.type === 'string')
      out.push(v);
    // (and arrays of nodes)
  }
  return out.sort(byStart);
}

An ESTree node is just an object, so you can ask it for its keys. oxc’s AST (chapter 172) is a set of Rust types: enums with dozens of variants and a struct per node kind. A struct has no Object.keys. Yet the port walks “all the children” in several places: the resolver, hoisting, the mentioned collector. oxc solves this once for everyone.

Intuition

A visitor is a tour guide who walks every room of a building in order. You hand the guide a short list (“in the kitchen, count the chairs; skip the cellar”), and every other room gets the standard walk-through.

One method per node kind

Here is the idea in a toy, shaped like oxc’s Visit trait from the oxc_ast_visit crate:

trait Visit: Sized {
    fn visit_expr(&mut self, e: &Expr) {
        walk_expr(self, e);
    }
    fn visit_name(&mut self, _: &str) {}
    fn visit_func(&mut self, b: &Expr) {
        self.visit_expr(b);
    }
}

Every method has a default body. walk_expr is the standard walk-through: a match that calls visit_* on each child, left to right. (: Sized lets default methods pass self to a generic function like walk_expr; oxc’s trait says the same.)

The real trait has more than 250 visit methods, all generated, and each default calls the matching walk_* function, which visits that node’s children[1]. The port’s README notes that this walk goes in source order, exactly what children() gave with its sort. That matters: the order in which variables are found decides an opaque instruction’s arguments, and so the value numbers.

Step throughA visitor walks f(a, () => b)
impl Visit for Mentions {
fn visit_name(&mut self, n: &str) {
self.found.push((n.into(), self.inner));
}
fn visit_arrow(&mut self, b: &Expr) {
let saved = self.inner;
self.inner = true;
self.visit_expr(b);
self.inner = saved;
}
}
The tree of f(a, () => b), the node being visited, and what Mentions has foundCallName fName aArrowName binnerfalsefound
visit_expr(call). Mentions doesn’t override visit_expr, so the trait’s default runs: walk_expr matches on the node and visits its children in source order.
1/7

Here is the real Mentions from lower.rs, trimmed (walk_arrow_fn stands for oxc’s much longer walk::walk_arrow_function_expression):

impl<'a> Visit<'a> for Mentions {
    fn visit_identifier_reference(
        &mut self,
        it: &IdentifierReference<'a>,
    ) {
        let at = (it.span, self.inner);
        self.found.push(at);
    }
    fn visit_arrow_function_expression(
        &mut self,
        it: &ArrowFunctionExpression<'a>,
    ) {
        let saved = std::mem::replace(
            &mut self.inner,
            true,
        );
        walk_arrow_fn(self, it);
        self.inner = saved;
    }
}

It overrides visit_binding_identifier and visit_function the same way: four methods cover all of JavaScript. std::mem::replace stores true and returns the old value.

Definition

The visitor pattern separates walking a tree from what you do at each node. A trait has one method per node kind. Each default method calls a walk function that visits the node’s children. A visitor overrides only the kinds it cares about. An override replaces the default, so it must call the walk itself if it still wants the children.

Try itTwo visitors, one walk
enum Expr {
    Num(f64),
    Name(String),
    Add(Box<Expr>, Box<Expr>),
    Func(Box<Expr>),
}

trait Visit: Sized {
    fn visit_expr(&mut self, e: &Expr) {
        walk_expr(self, e);
    }
    fn visit_name(&mut self, _: &str) {}
    fn visit_func(&mut self, b: &Expr) {
        self.visit_expr(b);
    }
}

fn walk_expr<V: Visit>(
    v: &mut V,
    e: &Expr,
) {
    use Expr::*;
    match e {
        Num(_) => {}
        Name(n) => v.visit_name(n),
        Add(a, b) => {
            v.visit_expr(a);
            v.visit_expr(b);
        }
        Func(b) => v.visit_func(b),
    }
}

// Overrides one method.
struct Names(Vec<String>);

impl Visit for Names {
    fn visit_name(&mut self, n: &str) {
        self.0.push(n.into());
    }
}

// Also overrides visit_func, and does
// not walk the body.
struct Outer(Vec<String>);

impl Visit for Outer {
    fn visit_name(&mut self, n: &str) {
        self.0.push(n.into());
    }
    fn visit_func(&mut self, _: &Expr) {
    }
}

fn name(s: &str) -> Box<Expr> {
    Box::new(Expr::Name(s.into()))
}

fn main() {
    // a + (() => b + 1)
    let body = Expr::Add(
        name("b"),
        Box::new(Expr::Num(1.0)),
    );
    let e = Expr::Add(
        name("a"),
        Box::new(Expr::Func(
            Box::new(body),
        )),
    );
    let mut all = Names(vec![]);
    all.visit_expr(&e);
    println!("all:   {:?}", all.0);
    let mut out = Outer(vec![]);
    out.visit_expr(&e);
    println!("outer: {:?}", out.0);
}
Open in Rust Playground ↗

Two passes, two files each

As in TypeScript, the plugin makes two passes. resolve.rs pairs every identifier with its binding (chapter 42), then lower.rs builds VIR. The resolver implements Visit and overrides the node kinds that resolve.ts handles in its own switch: functions, blocks, loops, switch, try (it counts its depth, for assigned_in_try), catch, classes, declarations, identifiers, assignments, imports and exports.

The bigger change is how things are named. resolve.ts keys its maps by node objects: refs: Map<Node, JsBinding>, and a binding’s owner is the function node itself. JavaScript compares objects by identity, so that is free. The port keys everything by plain values instead. An owner is FnKey::Program or FnKey::Func(Span) (a function’s span is unique), refs is a HashMap<Span, JsBindingId>, and bindings live in a Vec, named by a typed index (chapter 170). No table holds a reference into the AST, so none needs the AST’s lifetime.

Lowering, function by function

lower.rs uses the same rule as lower.ts: a variable stays an SSA value only if it is a simple kind, used in no other function, never assigned in a try, and not initialized with an array.

In TypeScript, lowerFunction holds its helpers (read, write, expr…) as closures sharing the function builder fb. Rust closures can’t share mutable state that freely, so the port has one Lowerer struct, with the per-function state in one field, f. A nested function swaps in its own with std::mem::replace(&mut self.f, state), lowers its body, and puts the outer state back.

Real output, printed by a ten-line program of mine (not in the repo) that calls JsPlugin.lower and print_func:

let n = 0;
const ps = [];
function add(v) {
  ps.push({ x: v });
  n++;
}
add(2);
console.log(this, n);
func add(%0):
  b0:
    %0 = param
    %1 = load ps
    %2 = record {x} %0
    %3 = push %1, %2
    %4 = load n
    %5 = unop + %4
    %6 = const 1
    %7 = binop + %5, %6
    store n, %7
    return

v stays an SSA value (%0). ps is array-initialized and n is used inside add, so both are bindings. In <module>, this becomes %20 = opaque(ThisExpression): ast.rs maps oxc’s variant back to the ESTree type name, so explain reads as before.

No AST after lowering

The TS engine keeps ESTree nodes in SourceRef.node, and the emitter asks them questions, such as (through a parent map) “is this a[i] the object of a member expression?”. oxc allocates its AST in the parse’s arena, so keeping it would put a lifetime on the whole Module. Instead node is a NodeRef, an index into a side table of facts (trimmed):

pub enum JsNode {
    Plain,
    Member {
        object: Span,
        property: Span,
        simple_property: bool,
        // ...
        direct: bool,
    },
    // Write, Object, Call, ForOf ...
}

direct answers the parent question: lowering sets a flag just before it lowers a member expression’s object. Chapter 174 uses these facts.

Under the hood

lower.rs names, in comments, the ESTree case each oxc case mirrors. oxc splits identifiers into references, declarations, property names and labels; the reference only ever resolves the first two. oxc keeps "use strict" in a separate directives list, where ESTree has an expression statement that the TS engine lowers to a string constant. The port lowers it too: nothing uses it, but every later value keeps its %n. One mismatch is kept on purpose, marked NOTE: as in the reference, assigning to the private p.#x is treated as the field x.

Your turn

ExerciseThe Mentions visitor

Lowering an unmodelled node needs every variable it mentions, in source order, and whether each one sits inside a nested function. Implement Visit for Mentions: visit_name records (name, self.inner), and visit_func walks the function body with inner set to true, then puts back the value it had before (functions nest).

enum Expr {
    Num(f64),
    Name(String),
    Add(Box<Expr>, Box<Expr>),
    Call(Box<Expr>, Vec<Expr>),
    Func(Box<Expr>),
}

trait Visit: Sized {
    fn visit_expr(&mut self, e: &Expr) {
        walk_expr(self, e);
    }
    fn visit_name(&mut self, _: &str) {}
    fn visit_func(&mut self, b: &Expr) {
        self.visit_expr(b);
    }
}

fn walk_expr<V: Visit>(
    v: &mut V,
    e: &Expr,
) {
    use Expr::*;
    match e {
        Num(_) => {}
        Name(n) => v.visit_name(n),
        Add(a, b) => {
            v.visit_expr(a);
            v.visit_expr(b);
        }
        Call(f, args) => {
            v.visit_expr(f);
            for a in args {
                v.visit_expr(a);
            }
        }
        Func(b) => v.visit_func(b),
    }
}

// Each name, and whether it is inside
// a nested function.
#[derive(Default)]
struct Mentions {
    found: Vec<(String, bool)>,
    inner: bool,
}

impl Visit for Mentions {
    fn visit_name(&mut self, n: &str) {
        todo!()
    }
    fn visit_func(&mut self, b: &Expr) {
        todo!()
    }
}
Open in Rust Playground ↗

What you learned

  • The visitor pattern: a trait with one method per node kind, whose defaults walk the children. You override only what you need. Overriding replaces the walk, so call it yourself if you want the children.
  • oxc’s Visit walks in source order, like children(), so value numbers match the reference.
  • resolve.rs and lower.rs port their TS files case by case. Node identity becomes spans and typed ids (FnKey, HashMap<Span, _>). Closures sharing fb become one Lowerer with a swappable FnState.
  • Instructions point into a side table of JsNode facts, not into a live AST.

Check your understanding

0/6
  1. 1.

    The TS lowering finds “every child” of an ESTree node with Object.keys. What does the Rust port use instead?

  2. 2.

    A visitor overrides visit_function and does not call walk::walk_function. What does it see of a function’s body?

  3. 3.

    Why does the port use FnKey::Func(Span) where the TS code uses the function node object as a Map key?

  4. 4.

    Which variable stays an SSA value?

  5. 5.

    What replaces the TS emitter’s res.parent.get(n) check (“is this a[i] the object of a member expression?”)?

  6. 6.

    Why does lower.rs lower a "use strict" directive to an unused string constant?

References

  1. [1] Boshen, Oxc contributors. oxc_ast_visit: the generated Visit trait (crates/oxc_ast_visit/src/generated/visit.rs). The Oxc project, github.com/oxc-project/oxc (version 0.156 on crates.io is the one engine/rust uses). github.com/oxc-project/oxc/blob/main/crates/oxc_ast_visit/src/generated/visit.rs (checked 2026-10-31)