Skip to content
Select theme

A Pratt parser for expressions

The grammar article put every birch operator on a precedence ladder; the scanner article produced the flat token list. This step joins the two: by the end, birch reads expressions and groups them correctly — 1 + 2 * 3 comes back as (1 + (2 * 3)) — and it happens without the pile of near-identical functions that a straight recursive-descent parser needs for this grammar. The parse result is still text; Representing a program as a tree replaces the strings with real nodes one step from now.

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

Terminal window
git checkout interpreter-ch02
cargo run -q

That prints the token table from last time. Everything this article adds goes into the same single file, src/main.rs: the scanner stays as it is, the parser joins it, and main changes. The scanner has exactly one line changed — TokenKind also derives PartialEq, because the parser compares token kinds constantly — and that is the only edit to code you have already seen. At the end of the article, the same two commands at the interpreter-ch03 tag print grouped expressions instead.

Written as recursive descent, the expression half of the grammar turns into one function per row:

expression → assignment
assignment → IDENT "=" assignment | logicOr
logicOr → logicAnd ( "or" logicAnd )*
logicAnd → equality ( "and" equality )*
equality → comparison ( ( "!=" | "==" ) comparison )*
comparison → term ( ( ">" | ">=" | "<" | "<=" ) term )*
term → factor ( ( "+" | "-" ) factor )*
factor → unary ( ( "*" | "/" | "%" ) unary )*
unary → ( "!" | "-" ) unary | postfix
postfix → primary ( "(" arguments? ")" | "[" expression "]" )*

Each of those functions has the same skeleton:

fn term(&mut self) -> Result<Expr, ParseError> {
let mut lhs = self.factor()?;
while the next token is "+" or "-" {
advance;
let rhs = self.factor()?;
lhs = combine(lhs, op, rhs);
}
Ok(lhs)
}

factor is byte-for-byte the same shape with *, / and %. comparison is the same shape again with four operators instead of two. Six of the nine rows are this one function with a different operator list, and every new operator means editing two places: the function that accepts it and the row above that calls it. The only genuinely distinct row is assignment, which is right-associative and only accepts a variable on its left — the rest is scaffolding around the same loop.

A Pratt parser’s move is to notice that the differences between those functions are data: which tokens count as operators, how tightly each binds, and which side it associates towards. Write that down as a table and one loop reads it.

The table maps a token kind to a pair of numbers, (left, right), called binding powers. The left number answers “should I take this operator at my current level?”, the right number answers “how tightly does its right side bind?” — and associativity falls out of the gap between them:

Assign => (1, 0) // right-associative
Or => (2, 3)
And => (3, 4)
EqualEqual | BangEqual => (4, 5)
Less | LessEqual | Greater | GreaterEqual => (5, 6)
Plus | Minus => (6, 7)
Star | Slash | Percent => (7, 8)
anything else => None // not an infix operator here

For every row except assignment, the right power is left + 1. Parsing the right side with that higher minimum means it stops before meeting another operator of the same row — so 10 - 4 - 3 groups as (10 - 4) - 3, which is what subtraction means. Assignment is the one right-associative row: its right power sits below its left, so the right side is allowed to meet another = and keep going, and a = b = c groups as a = (b = c).

Operators missing from the table — (, [, ,, ;, a closing anything — get None, which is how the loop knows the expression is over. The closing parenthesis of a grouping does not end the expression by being consumed; it ends it by not being in the table, so the recursive call unwinds and the caller that opened the paren consumes its match.

expression is the whole algorithm. It parses one prefix form, then loops: look the next token up in the table, and while its left power clears the current minimum, consume it, parse the right side, and wrap what came before. The minimum starts at 0 — anything in the table is welcome — and rises with every descent:

fn expression(&mut self, min_prec: u8) -> ... {
let mut lhs = self.prefix()?;
loop {
let Some((left, right)) = binding_power(next token) else { break };
if left < min_prec { break; }
advance;
let rhs = self.expression(right)?;
lhs = combine(lhs, op, rhs);
}
Ok(lhs)
}

Trace 1 + 2 * 3 through it: prefix gives 1; + has powers (6, 7), so the right side is parsed at minimum 7; inside that, prefix gives 2 and * has powers (7, 8), so 7 clears the minimum and the multiplication is taken; the multiplication’s right side runs at minimum 8, finds 3 and then nothing in the table. The additions and multiplications nest exactly as the numbers dictate, and no function ever mentioned * by name.

Two forms do not go through the table at all. Calls and indexes are postfix — they bind tighter than anything, so the loop checks for ( and [ before consulting the table, whatever the minimum is, and keeps checking: makeCounter(10)() is a call on a call. Grouping parentheses, meanwhile, live in the prefix parser, and they are the cheapest case there is: parse an expression inside, expect ), and return the inner text unchanged — the source already supplied the grouping, and the binary reprints supply their own.

The prefix parser handles everything an expression can start with: the literals, a variable, (, [, and the two unary operators. Unary is the one place with a hand-picked number: the operand parses at a minimum above every binding power in the table, so -n * 2 groups as ((- n) * 2) — the minus hugs n, not the whole product.

What comes out is the expression printed back with every grouping the parser chose made explicit. That is a deliberate stand-in, not the finish line: the real tree is the next step’s subject. But the parser already needs one structural fact — = is only allowed a bare variable on its left — and a string cannot carry structure, so expression returns a bool alongside the text, threaded through every level: true only for a prefix variable, cleared by every operator that wraps. 1 = 2 and tick() = 3 fail with invalid assignment target, pointed at the =. That bool is the small, honest awkwardness that proves the point: the parser is already dragging information about the tree through every level, and strings are the wrong vehicle for it.

The scanner collected every mistake in one pass; the parser stops at the first one. A parse error carries the line and column of the token that broke the grammar, and the message says what was expected and what was found:

1 +
parse error: 1:4 expected an expression, found the end of input

One error per run is not the long-term plan — recovering and syncing to the next statement is how a real parser survives a typo-ridden file — but recovery belongs to the statement half of the grammar, which arrives with the tree. The expression parser’s job here is to be correct first.

This is the complete src/main.rs for this step. The scanner is unchanged apart from the PartialEq derive; the parser and the new main are all new:

Each snippet prints the expression back with every grouping explicit, or a located error:

Terminal window
cargo run -q
1 + 2 * 3
= (1 + (2 * 3))
(1 + 2) * 3
= ((1 + 2) * 3)
10 - 4 - 3
= ((10 - 4) - 3)
-n * 2
= ((- n) * 2)
!done and count < 10
= ((! done) and (count < 10))
n = n + 1
= (n = (n + 1))
names[i + 1]
= (names[(i + 1)])
makeCounter(10)()
= ((makeCounter(10))())
1 +
parse error: 1:4 expected an expression, found the end of input
1 = 2
parse error: 1:3 invalid assignment target

Check the ones that look boring. 10 - 4 - 3 groups left, because the right side parsed at left + 1 refused the second -. (1 + 2) * 3 needed no new parentheses around the group — the source provided them, and the loop’s reprint wrapped the rest. And -n * 2 put the minus inside the product, purely because the unary operand parsed above every binding power in the table.

The tree at interpreter-ch03 contains a cargo project that scans and parses expressions — correctly, with positions on every error — and proves the grouping by printing it. The strings are scaffolding, and the next step, Representing a program as a tree, tears them out: one enum per node, Box exactly where the grammar recurses, and the statement half of the grammar arriving as more variants. Until then, the track overview has the full itinerary.