Skip to content
Select theme

Representing a program as a tree

Last step the Pratt parser grouped expressions correctly — but it grouped strings, and it only handled the expression half of the grammar. This step finishes the job it was scaffolding for: the parser now builds real nodes, one enum variant per shape the grammar allows, with Box exactly where a field would otherwise make the type infinite. The statement half of the grammar arrives at the same time, as more variants of a second enum. By the end, while (count < 3) { count = count + 1; } parses into a While node holding a condition tree and a block of statements — and every {:#?} print makes the structure visible.

The previous step is published and tagged, so start from its end state:

Terminal window
git checkout interpreter-ch03
cargo run -q

That prints the grouped expressions from last time. This article changes three things, all in the same single file, src/main.rs: the scanner gets exactly one new token — Else, because the grammar’s else keyword was never scanned as anything but an identifier until now, and nothing consumed it; the parser’s return type changes from strings to nodes and grows a statement half; and main parses a whole program instead of one expression. The scanner’s logic is otherwise untouched. At the end, the same two commands at the interpreter-ch04 tag print syntax trees.

The last article was explicit that the string output was a stand-in, and it named the symptom: expression returned a (String, bool) pair, with the bool answering “is the left side of = a bare variable?” threaded through every level of the recursion. A string cannot carry structure, so a fact about the tree had to travel as a side channel.

Three enums fix all of it at once:

  • Grouping disappears as a node. ((1 + 2) * 3) and (1 + 2) * 3 produce byte-identical strings in the last step only because the reprint wrapped everything. In a real tree there is nothing to wrap — the nesting is the grouping, and the parentheses the source supplied have done their job once the inner expression is built.
  • The assignment-target check becomes a pattern match. Before building an Assign, the parser matches the left side: let Expr::Variable(name) = lhs else { /* invalid assignment target */ }. The bool is gone because the tree answers the question itself.
  • Statements fit at all. A string printer had no way to grow a while loop; a Stmt enum grows one variant.

The expression tree is one enum with a variant per shape an expression can take:

enum Expr {
Number(f64),
Str(String),
Bool(bool),
Nil,
Variable(String),
Unary { op: TokenKind, operand: Box<Expr> },
Binary { op: TokenKind, left: Box<Expr>, right: Box<Expr> },
Logical { op: TokenKind, left: Box<Expr>, right: Box<Expr> },
Assign { name: String, value: Box<Expr> },
Call { callee: Box<Expr>, args: Vec<Expr> },
Index { object: Box<Expr>, index: Box<Expr> },
List(Vec<Expr>),
}

The rule for Box is not taste, it is arithmetic: a field of type Expr directly inside Expr would make the type infinitely large — the compiler says so, with E0072 asking for a box — while a field behind a Vec or an Option already breaks the cycle. So every operator holds its operands boxed, and List and Call’s argument vector need no box at all. Operators are stored as TokenKind — the enum the scanner already produced — because at this stage the operator is the token; the evaluator will read it back out.

and and or get their own Logical variant even though they sit in the same binding-power table as everything else. They are the two operators that will short-circuit when the evaluator arrives, and the tree is the place where that difference becomes data.

The statement enum follows the grammar’s statement half:

enum Stmt {
Var { name: String, init: Expr },
Fun { name: String, params: Vec<String>, body: Vec<Stmt> },
Expression(Expr),
If { condition: Expr, then_branch: Box<Stmt>, else_branch: Option<Box<Stmt>> },
While { condition: Expr, body: Box<Stmt> },
Return { value: Option<Expr> },
Block(Vec<Stmt>),
}

Two details come straight from the grammar article: var always has an initializer (varDecl → "var" IDENT "=" expression ";" — no bare var x;), and the bodies of if and while are a single statement, not a block — the braces the source shows arrive as a Block statement that happens to be there. That is why then_branch and body are Box<Stmt>: the statement type recurses too, through those two fields, while body: Vec<Stmt> in Fun and Block needs no box for the same Vec reason as before.

The heart of the parser barely changes. The binding-power table is byte-identical; the postfix loop for calls and indexes now builds Call and Index nodes; the infix loop builds Binary, and splits and/or off into Logical. The one structural change is assignment, where the threaded bool used to live:

if op.kind == TokenKind::Assign {
let Expr::Variable(name) = lhs else {
return Err(... "invalid assignment target" at the `=` ...);
};
let value = self.expression(right)?;
lhs = Expr::Assign { name, value: Box::new(value) };
continue;
}

The error still points at the = before the right side is parsed, so 1 = 2 fails exactly where it did before — but now the check is a question about the node the parser is holding, not a fact smuggled alongside it.

Around the loop, the statement functions are plain recursive descent: declaration peeks for var or fun, statement peeks for if, while, return or {, and everything else is an expression followed by ;. Literals do their one-time conversion here — the number’s lexeme is parsed into an f64 now, the string’s quotes are stripped now — so the evaluator never re-parses text.

This is the complete src/main.rs for this step. The scanner is unchanged apart from the new Else token; the two enums, the statement functions and the new main are new; the expression functions change only in what they build:

Terminal window
cargo run -q

The program parses into a Vec<Stmt>, and the {:#?} formatter — free, from the Debug derive — prints the whole structure with the nesting the parser chose:

var count = 0;
while (count < 3) {
count = count + 1;
}
if (count == 3 and !done) {
var label = "hot";
}
fun twice(n) { return n * 2; }
var xs = [twice(count), twice(count * 10)];
var first = xs[0];
[
Var {
name: "count",
init: Number(
0.0,
),
},
While {
condition: Binary {
op: Less,
left: Variable(
"count",
),
right: Number(
3.0,
),
},
body: Block(
[
Expression(
Assign {
name: "count",
value: Binary {
op: Plus,
left: Variable(
"count",
),
right: Number(
1.0,
),
},
},
),
],
),
},
If {
condition: Logical {
op: And,
left: Binary {
op: EqualEqual,
left: Variable(
"count",
),
right: Number(
3.0,
),
},
right: Unary {
op: Bang,
operand: Variable(
"done",
),
},
},
then_branch: Block(
[
Var {
name: "label",
init: Str(
"hot",
),
},
],
),
else_branch: None,
},
Fun {
name: "twice",
params: [
"n",
],
body: [
Return {
value: Some(
Binary {
op: Star,
left: Variable(
"n",
),
right: Number(
2.0,
),
},
),
},
],
},
Var {
name: "xs",
init: List(
[
Call {
callee: Variable(
"twice",
),
args: [
Variable(
"count",
),
],
},
Call {
callee: Variable(
"twice",
),
args: [
Binary {
op: Star,
left: Variable(
"count",
),
right: Number(
10.0,
),
},
],
},
],
),
},
Var {
name: "first",
init: Index {
object: Variable(
"xs",
),
index: Number(
0.0,
),
},
},
]
var = 5;
parse error: 1:5 expected a variable name, found `=`

Worth checking against the source. count = count + 1; is not an expression statement around some special assignment form — it is one Assign node whose value field is an ordinary Binary tree, because assignment is just the (1, 0) row of the table from last step. The if’s condition is a Logical node holding an EqualEqual Binary and a Unary Bang, which is the precedence ladder rendered as nesting. And xs[0] is an Index whose object is a bare Variable — the tree says nothing yet about whether indexing a variable is legal; that question belongs to the evaluator.

One thing cargo will say while building: dead_code warnings, one per tree field nobody reads yet — Number’s f64, Call’s args, and so on. That is the tree doing its job with nobody home to use it, and it is exactly the next step’s job: the warnings disappear the moment an evaluator starts walking the nodes.

The tree at interpreter-ch04 contains a cargo project that scans, parses the whole birch grammar — statements and expressions — and prints the syntax tree it built, with positions still attached to every error. The next step, Evaluating expressions, gives those nodes something to do: a tree-walking evaluator that reads every field the compiler is currently warning about. Until then, the track overview has the full itinerary.