33. Pratt parsing
One loop and a small table of numbers can parse every operator in Drip, at every precedence level, grouped the right way. This is how the real Drip parser reads expressions.
- Explain what a binding power is, and read Drip’s table of them.
- Trace the Pratt loop by hand on an expression like 1 + 2 * 3 - 4.
- Tell prefix and infix positions apart, even for a token that can be both.
- Make an operator group to the left or to the right by changing one number.
A ladder with too many rungs
In chapter 32 you parsed expressions by recursive descent, with one function per precedence level. Each function handles its own operators and calls the next, tighter level for its operands. It works, and it reads just like the grammar.
Now count what happens when it parses the lone number 7. Drip has six
levels of binary operators (or, and, equality, comparison, + -,
* /), then unary, then calls. Every level’s function is entered, finds
no operator, and returns, before anyone reaches the 7. And adding an
operator means adding a rung to the ladder.
The real Drip parser in companion/ts/src/parser.ts reads expressions
differently: with one small table of numbers and one loop. The technique
is named after Vaughan Pratt, who described it in 1973 as “top down
operator precedence”[2].
Binding power
Think of every operator as a magnet. In 1 + 2 * 3, the 2 sits
between two magnets: the + pulls it left, the * pulls it right. The
* is the stronger magnet, so the 2 sticks to it, and 2 * 3 becomes
one chunk before the + gets anything.
The strength of each magnet is a number:
Here is the same table as it appears in the code:
// companion/ts/src/parser.ts (excerpt)
const BP = {
or: 1,
and: 2,
'==': 3, '!=': 3,
'<': 4, '<=': 4, '>': 4, '>=': 4,
'+': 5, '-': 5,
'*': 6, '/': 6,
'(': 8, '.': 8,
};
const UNARY_BP = 7;
The whole precedence ladder from chapter 32 is now data. A new operator is one more line in a table, not one more function.
A binding power is a number attached to an operator that says how strongly it holds the operands beside it. When two operators compete for the same operand, the one with the higher binding power gets it. A Pratt parser parses expressions by comparing binding powers in a loop, instead of using one function per precedence level.
Prefix and infix
One more idea before the loop. The parser meets a token in one of two
situations. Either it has nothing yet, and is looking for the start
of an operand: a number, a name, a (, or a - as in -x. Or it
already holds a finished operand, and the token comes after it: a
+, a *, or a - as in a - b.
function prefix(): Expr {
if (match('!', '-')) {
const operator = previous();
const right = pratt(UNARY_BP);
return {
kind: 'unary', operator, right,
};
}
return primary(); // number, name, ( … )
}
The parser never has to guess what a - means. If it holds no left
operand yet, the - must be “negate”. If it does, it must be
“subtract”. In Drip a call f(x) and a property access p.x are infix
too: ( and . come after an operand, with binding power 8, the
strongest of all. A ( at the start of an operand is grouping instead.
A token is in prefix position when it starts an operand, with
nothing on its left (-x, !ok, (1 + 2), 7). It is in infix
position when it follows a complete operand and joins it to what comes
next (a - b, f(x)). The same token can mean different things in the
two positions.
The loop
Here is the heart of the parser, nearly as it is in the repository:
function pratt(minBp: number): Expr {
let left = prefix();
for (;;) {
const bp = BP[peek().type];
if (bp === undefined || bp <= minBp)
break;
const op = advance();
left = infix(left, op, bp);
}
return left;
}
For an ordinary binary operator, infix builds a node whose right side
is another call to pratt, passing the operator’s own binding power:
// inside infix(left, op, bp)
return {
kind: 'binary', left, operator: op,
right: pratt(bp),
};
Read minBp as a promise to the caller: “I am the right operand of an
operator this strong, so I only take operators that are stronger.” The
first call, pratt(0), has no such operator, so it takes everything.
Step through it on 1 + 2 * 3 - 4:
function pratt(minBp) {let left = prefix();for (;;) {const bp = BP[peek().type];if (!bp || bp <= minBp) break;const op = advance();left = infix(left, op, bp);}return left;}// infix: { left, op, right: pratt(bp) }
Notice the order the tree forms in. The * node is built first, from
the inside out, even though + came first in the text. The call stack
holds the unfinished + while the * finishes.
Grouping, one number at a time
Left associativity falls out of that <=. In 1 - 2 - 3, the right
operand of the first - is parsed by pratt(5). When it meets the
second -, also 5, the test 5 <= 5 stops it, so it returns only
2. The first - becomes (- 1 2), and the outer loop then takes the
second -: (- (- 1 2) 3), which is -4.
Unary minus calls pratt(7). That is stronger than *, so -2 * 3
groups as (* (- 2) 3). Run these and check each grouping in your head:
print 1 + 2 * 3 - 4; print 1 - 2 - 3; print -2 * 3; print (1 + 2) * 3; print 2 * 3 == 6; print 8 / 4 / 2;
To make an operator group to the right instead, parse its right side
with bp - 1. Then an equal operator on the right is stronger than the
promise, and it nests inward. You will do exactly that in the extra
exercise.
Drip’s one right-associative operator, =, is handled just outside the
loop:
function assignment(): Expr {
const expr = pratt(0);
if (match('=')) {
const value = assignment();
if (expr.kind === 'variable')
return { kind: 'assign',
name: expr.name, value };
// a.b = … becomes a 'set' node
// anything else: an error
}
return expr;
}
There are two reasons. Calling assignment() again for the value makes
a = b = 5 nest to the right. And the left side has to be checked
after it is parsed: in a + b = 3, the parser reads a + b before it
sees the =, and only then learns the target is not a name. That error
is the next chapter’s business.
Drip is built twice in this book. The second version, the bytecode
compiler in Part IV (companion/ts/src/vm/compiler.ts), parses
expressions with the same idea: a table gives each token type a prefix
rule, an infix rule and a precedence, and one loop keeps absorbing infix
operators while they are strong enough. The difference is what the
rules do. They emit bytecode instead of building tree nodes. Both
versions follow Lox, the language from Crafting Interpreters that
Drip is a dialect of[1].
Your turn
The core exercise is the whole Pratt loop in miniature: plain JavaScript, tokens as strings, nodes as arrays. The extra one adds a right-associative power operator.
Finish parse(ts). The tokens are strings like ['1', '+', '2']. Return a tree where a number is its string and an operator node is an array [op, left, right]. So 1 + 2 * 3 gives ['+', '1', ['*', '2', '3']]. Use the binding powers in BP, and make every operator left-associative.
const BP = { '+': 5, '-': 5,
'*': 6, '/': 6 };
function parse(ts) {
let i = 0;
function pratt(minBp) {
let left = ts[i++];
// your loop here
return left;
}
return pratt(0);
}Add ^ (power) to the parser. It binds tighter than * (give it 7), and it groups to the right: 2 ^ 3 ^ 2 must be ['^', '2', ['^', '3', '2']]. Every other operator stays left-associative.
const BP = { '+': 5, '-': 5,
'*': 6, '/': 6, '^': 7 };
function parse(ts) {
let i = 0;
function pratt(minBp) {
let left = ts[i++];
for (;;) {
const bp = BP[ts[i]];
if (!bp || bp <= minBp) break;
const op = ts[i++];
left = [op, left, pratt(bp)];
}
return left;
}
return pratt(0);
}What you learned
- A binding power says how tightly an operator holds its operands. The higher number wins a contested operand.
- A Pratt parser replaces the ladder of precedence functions with
a table and one loop: parse a prefix, then keep absorbing
infix operators whose power is greater than
minBp. - The right operand of an operator is parsed with that operator’s power
as the new
minBp, so it only takes tighter operators. bp <= minBpstops on equals, which makes operators left-associative. Recursing withbp - 1makes one right-associative.- In Drip, calls and
.are infix operators, and assignment is parsed just outside the loop so its target can be checked.
Next: what the parser does when the tokens make no sense at all.
Check your understanding
0/6- 1.
Using Drip’s binding powers, how does
1 * 2 + 3 * 4group? - 2.
The loop is inside
pratt(5)(parsing the right side of a+) and peeks at a-. What happens? - 3.
Why does
-2 * 3parse as(-2) * 3in Drip? - 4.
In Drip, what is the
(inf(1)? - 5.
You want a new operator
@to be right-associative with power 6. What do you pass when parsing its right operand? - 6.
Why is assignment handled in
assignment()instead of being another entry in theBPtable?
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] Vaughan R. Pratt (1973). Top Down Operator Precedence. First ACM Symposium on Principles of Programming Languages (POPL), pp. 41–51. tdop.github.io/ (checked 2026-11-01)