152. A Pratt compiler in Rust, part 2
The TypeScript compiler is one big function whose helpers are closures sharing a handful of variables. Rust won’t let closures share mutable state like that, so drip-rs turns the variables into a struct and the closures into methods on &mut self.
- Explain why the TypeScript compiler’s shared closures don’t carry over to Rust.
- Read drip-rs’s Compiler struct, including its two lifetimes.
- Explain why nested functions are a Vec of states instead of a linked list.
- Fix the borrow errors that come from calling a &mut self helper inside an expression.
Closures that share
The TypeScript compiler from chapter 48 is one function, compile. It
declares its state as local variables, and every helper is a closure
over them (trimmed):
let current = { type: 'eof', ... };
let previous = current;
let hadError = false;
function advance() {
previous = current;
current = scanner.scanToken();
}
const number = () =>
emitConstant(Number(previous.lexeme));
Dozens of helpers read and write the same few variables, and call each
other freely, even recursively: expression calls parsePrecedence,
which calls binary, which calls parsePrecedence again. clox does the
same with global variables[1].
Try it in Rust with two closures:
let mut pos = 0;
let mut code = Vec::new();
let mut advance = || pos += 1;
let mut number = || {
code.push(bytes[pos]);
advance();
};
rustc 1.97 (trimmed):
error[E0502]: cannot borrow `pos` as
immutable because it is also
borrowed as mutable
5 | let mut advance = || pos += 1;
| -- --- first
| borrow occurs due to use of `pos`
6 | let mut number = || {
| ^^ immutable
| borrow occurs here
A closure that changes pos holds a mutable borrow of it for as long as
the closure may still be called (chapter 134). No other closure may even read pos
in that time. And a closure can’t call itself, so the recursion is out
too.
Picture one workbench with all the tools and the half-built bytecode on it. Instead of everyone reaching across at once, the bench is handed from worker to worker. Whoever holds it can use everything. Calling a helper means handing the bench over and waiting until it comes back.
The state becomes a struct
So drip-rs collects the variables into one struct. Adapted from
src/compiler.rs:
struct Compiler<'src, 'a> {
scanner: Scanner<'src>,
current: Token<'src>,
previous: Token<'src>,
had_error: bool,
panic_mode: bool,
functions: Vec<FunctionState<'src>>,
classes: Vec<ClassState>,
heap: &'a mut Heap,
roots: &'a dyn Trace,
errors: &'a mut Vec<String>,
}
Every helper becomes a method taking &mut self. A method that calls
another lends it the same &mut, and gets it back when the call
returns. There is only ever one user, so there is nothing to conflict
with.
The two lifetimes (chapter 133) say what the compiler borrows.
'src is the source text. A Token<'src> holds its lexeme as a &str
slice of the source, so tokens are small, Copy, and never copy text.
Local variable names are slices of the source too. 'a is the caller’s
heap, its GC roots and its error list. compile builds the Compiler
on its own stack and drops it at the end, which ends those borrows.
Threading compiler state through &mut self means putting all of
the compiler’s mutable state in one struct and writing every parse and
emit helper as a method on &mut self. Each call lends the whole state
to the callee for the length of the call. This replaces closures over
shared variables, which the borrow checker can’t allow.
A stack instead of a linked list
When fn bodies nest, the TypeScript compiler links one FnCompiler
per function through an enclosing field. Resolving an upvalue walks
that list and changes entries in it along the way, so it would need
mutable access to several of them at once. In Rust that is a fight with
the borrow checker. drip-rs keeps the functions in a Vec, innermost
last, and names a function by its index:
fn state(&mut self)
-> &mut FunctionState<'src> {
self.functions.last_mut()
.expect("compiling a function")
}
fn chunk(&mut self) -> &mut Chunk {
&mut self.state().chunk
}
resolve_upvalue(level, name) looks in self.functions[level - 1], and
recurses with level - 1 until it runs out of functions. The level is a
plain number, and it borrows nothing. Classes nest the same way, in
classes: Vec<ClassState>.
Read first, then borrow
One habit shows up all over compiler.rs. Here is a first try at
emitting a byte with its line number:
fn emit(&mut self, b: u8) {
self.chunk().code.push(b);
self.chunk().lines.push(self.line);
}
rustc 1.97 rejects the second line (trimmed):
error[E0503]: cannot use `self.line`
because it was mutably borrowed
15 | self.chunk().lines.push(self.line);
| ---- ---- ^^^^^^^^^
| | use of borrowed
| `*self` is borrowed here
self.chunk() borrows all of self mutably, because the method’s
signature says so, and that borrow lasts until push is done. Reading
self.line for the argument falls inside it. The fix is to read first:
fn emit(&mut self, byte: u8) {
let line = self.previous.line;
self.chunk().write(byte, line);
}
Token and TokenKind are Copy, so reading them into locals is cheap.
binary begins the same way, with
let operator = self.previous.kind;. There it is needed for meaning
too: parsing the right operand moves previous on.
Errors, and the end
had_error and panic_mode are fields, used exactly as in chapter
34’s panic-mode recovery.
Messages go into the borrowed errors list. When the script is done,
compile ends with:
let (function, _) = compiler.end_function();
(!compiler.had_error).then_some(function)
then_some turns true into Some(function) and false into
None, where TypeScript returned null.
A struct passed by &mut costs no more than globals. At run time a
reference is an address, so every method receives one pointer to the
compiler’s state. The closures and globals of the other two versions
reach the same kind of state in their own ways. The struct also does a
job for the garbage collector: functions still being compiled are
roots. Before allocating, the compiler calls
self.heap.collect(&[self.roots, &self.functions]), and the
FunctionStates in that Vec are traced like any other root
(chapter 159).
Your turn
Here is a tiny compiler struct in drip-rs's style: its state (src, pos, code) is in one struct, and its helpers are methods on &mut self. Numbers are single digits and there are no spaces, so "1+2*3" is a whole program.
Write expr(min), a Pratt loop: compile one number, then, while the next character is an operator whose precedence (prec, given) is at least min, consume it, compile its right operand, and emit its Op. The output is in stack order: "1+2*3" compiles to [Num(1), Num(2), Num(3), Mul, Add].
Operators of equal precedence group from the left: "8-3-2" is (8-3)-2. prec returns 0 for anything that isn't an operator, including the end of the input.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Num(f64),
Add,
Sub,
Mul,
Div,
}
struct Compiler {
src: Vec<char>,
pos: usize,
code: Vec<Op>,
}
fn prec(c: char) -> u8 {
match c {
'+' | '-' => 1,
'*' | '/' => 2,
_ => 0,
}
}
impl Compiler {
fn peek(&self) -> char {
let i = self.pos;
self.src.get(i).copied()
.unwrap_or('\0')
}
fn advance(&mut self) -> char {
let c = self.peek();
self.pos += 1;
c
}
fn number(&mut self) {
let c = self.advance();
let d = c.to_digit(10).unwrap();
let n = d as f64;
self.code.push(Op::Num(n));
}
// Compile operators of precedence
// at least min, after one operand.
fn expr(&mut self, min: u8) {
todo!()
}
}
fn compile(s: &str) -> Vec<Op> {
let mut c = Compiler {
src: s.chars().collect(),
pos: 0,
code: Vec::new(),
};
c.expr(1);
c.code
}Now Op has a Neg instruction, and expr starts with self.prefix() instead of self.number(). Write prefix: if the next character is -, it's a unary minus. Consume it, compile its operand, and emit Neg. Otherwise compile a number.
Unary minus binds tighter than *: "-2*3" is (-2)*3. The binary operators have precedences 1 and 2, so unary is 3.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Num(f64),
Add,
Sub,
Mul,
Div,
Neg,
}
struct Compiler {
src: Vec<char>,
pos: usize,
code: Vec<Op>,
}
fn prec(c: char) -> u8 {
match c {
'+' | '-' => 1,
'*' | '/' => 2,
_ => 0,
}
}
impl Compiler {
fn peek(&self) -> char {
let i = self.pos;
self.src.get(i).copied()
.unwrap_or('\0')
}
fn advance(&mut self) -> char {
let c = self.peek();
self.pos += 1;
c
}
fn number(&mut self) {
let c = self.advance();
let d = c.to_digit(10).unwrap();
let n = d as f64;
self.code.push(Op::Num(n));
}
// Compile operators of precedence
// at least min, after one operand.
fn expr(&mut self, min: u8) {
self.prefix();
loop {
let op = self.peek();
let p = prec(op);
if p == 0 || p < min {
break;
}
self.advance();
self.expr(p + 1);
self.code.push(match op {
'+' => Op::Add,
'-' => Op::Sub,
'*' => Op::Mul,
_ => Op::Div,
});
}
}
// A '-' here is unary minus.
fn prefix(&mut self) {
todo!()
}
}
fn compile(s: &str) -> Vec<Op> {
let mut c = Compiler {
src: s.chars().collect(),
pos: 0,
code: Vec::new(),
};
c.expr(1);
c.code
}What you learned
- Closures that share mutable variables, the TypeScript compiler’s style, don’t pass the borrow checker. A closure that writes a variable borrows it mutably for as long as the closure may still be called.
- Compiler state lives in one
Compilerstruct, and every helper is a method on&mut self. A call lends the whole state, and it comes back on return. 'srclets tokens and names borrow the source;'acovers the borrowed heap, roots and errors.- Nested functions are a
Vec<FunctionState>, innermost last, named by index, instead of anenclosinglist. - Read what you need into locals before calling a
&mut selfhelper such aschunk()(E0503).
Check your understanding
0/6- 1.
Why does this fail to compile?
let mut advance = || pos += 1; let mut number = || { code.push(bytes[pos]); advance(); }; - 2.
In drip-rs, where does
previous, the TypeScript compiler’s local variable, live? - 3.
What does the lifetime
'srcinToken<'src>let drip-rs avoid? - 4.
Why is nesting a
Vec<FunctionState>in drip-rs, rather than a linked list throughenclosing? - 5.
Which version of
emitcompiles? - 6.
In
binary, why islet operator = self.previous.kind;the first line?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
- [2] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)