Viscosity
Review
Part III · A tree-walking interpreter · Day 9

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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:

Drip's binding powers as a bar chart. or 1, and 2, equality 3, comparison 4, plus and minus 5, times and divide 6, prefix minus and not 7, call and dot 8.OPERATORBINDING POWER (tighter →)or1and2== !=3< <= > >=4+ -5* /6-x !x (prefix)7f(…) a.b8
Precedence becomes a table of numbers. Drip’s binding powers. When two operators compete for the operand between them, the longer bar wins.

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.

Definition

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.

Definition

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:

Step throughThe Pratt loop 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) }
Parsing 1 + 2 * 3 - 4 with the Pratt loop: the tokens, the stack of pratt calls with their minBp, the binding-power comparison, and the tree as it forms.1+bp 52*bp 63-bp 54endnextCALLS OF pratt (newest at the bottom)pratt(minBp 0)left = …THE TREE SO FAR-+41*23
The parser calls pratt(0). A minBp of 0 means “no operator is too weak for me”: this call will take every operator it meets.
1/18

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:

Try itHow these group
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.

Under the hood

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.

ExerciseA Pratt parser in twenty lines

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);
}
Extra practiceA right-associative power operator

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 <= minBp stops on equals, which makes operators left-associative. Recursing with bp - 1 makes 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. 1.

    Using Drip’s binding powers, how does 1 * 2 + 3 * 4 group?

  2. 2.

    The loop is inside pratt(5) (parsing the right side of a +) and peeks at a -. What happens?

  3. 3.

    Why does -2 * 3 parse as (-2) * 3 in Drip?

  4. 4.

    In Drip, what is the ( in f(1)?

  5. 5.

    You want a new operator @ to be right-associative with power 6. What do you pass when parsing its right operand?

  6. 6.

    Why is assignment handled in assignment() instead of being another entry in the BP table?

References

  1. [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. [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)