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

32. Recursive descent parsing

Turn a grammar into code by giving each rule its own function. The functions call each other, the call stack does the bookkeeping, and out comes a tree in which * binds tighter than + without anyone ever comparing the two.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain precedence and associativity, and show the tree each one produces.
  • Write a grammar with one rule per precedence level.
  • Turn that grammar into a recursive descent parser, one function per rule.
  • Explain why a loop, not left recursion, gives left associativity.

Two trees for one line

What is 1 + 2 * 3? You learned the answer at school: 7, because multiplication happens first. But the tokens alone don’t say that. Both of these trees fit them:

Two possible trees for 1 + 2 * 3. Left, the tree Drip builds, which evaluates as 1 + 6 = 7. Right, the other grouping, which evaluates as 3 × 3 = 9.✓ DRIP BUILDS THIS✗ NOT THIS+1*23*+1231 + 6 = 73 × 3 = 9
Multiplication must end up deeper in the tree than addition. Two ways to group the tokens 1 + 2 * 3. In a tree, deeper operations happen first. Drip, like JavaScript and school arithmetic, builds the left one.

The program that reads tokens and builds the tree is the parser, and it has to pick the left tree every time. The rule it follows is called precedence.

Definition

A parser turns a list of tokens into a syntax tree, following a grammar. Precedence says which operators bind tighter: in Drip, * and / bind tighter than + and -, so in 1 + 2 * 3 the product becomes an operand of the sum.

A grammar with levels

Chapter 30’s grammar had one rule for every expression. To build in precedence, give each precedence level its own rule, loosest first, and let each rule build its operands from the next, tighter rule:

expression → equality
equality   → comparison
               ( EQ_OP comparison )*
comparison → term ( CMP_OP term )*
term       → factor ( ADD_OP factor )*
factor     → unary ( MUL_OP unary )*
unary      → ( "!" | "-" ) unary
           | primary
primary    → NUMBER | STRING | "true"
           | "false" | "nil"
           | "(" expression ")"

EQ_OP  → "==" | "!="
CMP_OP → "<" | "<=" | ">" | ">="
ADD_OP → "+" | "-"
MUL_OP → "*" | "/"
Intuition

Think of a sum written on a whiteboard as a row of boxes joined by +. Each box holds a product, and the products are worked out inside their boxes before anyone looks at the + signs. A term is that row of boxes; each factor is one box. The lower a rule sits in the list, the tighter its operators bind.

So term can only join finished factors. When it sees the + in 1 + 2 * 3, the 2 * 3 on its right is one factor, built as a unit. The last rule, primary, is the base case: a single literal, or a whole expression in brackets, which starts again from the loosest level. That is why brackets can override precedence.

One function per rule

Now the trick that gives this chapter its name. Write one function for each rule. Where a rule mentions another rule, call that function. Where it says *, write a loop. Here tokens are plain strings, like '1 + 2 * 3'.split(' '), and i is the index of the next one:

const at = (...ops) =>
  ops.includes(ts[i]);
const next = () => ts[i++];

function term() {
  let left = factor();
  while (at('+', '-')) {
    const op = next();
    const right = factor();
    left = bin(left, op, right);
  }
  return left;
}

bin makes a binary node, and factor is the same shape with * and /, calling unary. The last two rules are just as direct:

function unary() {
  if (at('!', '-')) {
    const op = next();
    const right = unary();
    return { kind: 'unary', op, right };
  }
  return primary();
}

function primary() {
  const t = next();
  if (t === '(') {
    const e = expression();
    if (next() !== ')') {
      throw new Error("Expect ')'");
    }
    return { kind: 'group', e };
  }
  return lit(Number(t)); // and true...
}

unary calls itself, so - - 1 and !!x work. Watch the functions call each other on 1 + 2 * 3:

Step throughParsing 1 + 2 * 3 by recursive descent
The tokens with a cursor, the stack of parsing function calls, and the syntax tree built so far1+2*3end↑ nextCALL STACK (newest on top)termleft = factor()…THE TREE SO FAR
Parsing starts at term, the loosest level in this grammar. Its first job is to get a factor. No token has been used yet.
1/11
Definition

Recursive descent is a way to write a parser by hand: one function per grammar rule. A rule’s alternatives become ifs, * becomes a while loop, and each nonterminal becomes a call. The parser starts at the top rule and descends through the calls, and since rules refer to each other (and to themselves), so do the functions.

Here is the whole parser, all six levels, with a tree printer:

Try itA complete expression parser
function parse(ts) {
  let i = 0;
  const at = (...ops) =>
    ops.includes(ts[i]);
  const next = () => ts[i++];
  const bin = (left, op, right) => ({
    kind: 'binary', left, op, right,
  });

  function equality() {
    let left = comparison();
    while (at('==', '!=')) {
      const op = next();
      const right = comparison();
      left = bin(left, op, right);
    }
    return left;
  }
  function comparison() {
    let left = term();
    while (at('<', '<=', '>', '>=')) {
      const op = next();
      const right = term();
      left = bin(left, op, right);
    }
    return left;
  }
  function term() {
    let left = factor();
    while (at('+', '-')) {
      const op = next();
      const right = factor();
      left = bin(left, op, right);
    }
    return left;
  }
  function factor() {
    let left = unary();
    while (at('*', '/')) {
      const op = next();
      const right = unary();
      left = bin(left, op, right);
    }
    return left;
  }
  function unary() {
    if (at('!', '-')) {
      const op = next();
      const right = unary();
      return {
        kind: 'unary', op, right,
      };
    }
    return primary();
  }
  function primary() {
    const t = next();
    if (t === '(') {
      const e = equality();
      if (next() !== ')') {
        throw new Error("Expect ')'");
      }
      return { kind: 'group', e };
    }
    if (t === 'true') return lit(true);
    if (t === 'false') {
      return lit(false);
    }
    if (t === 'nil') return lit(null);
    if (/^[0-9]/.test(t)) {
      return lit(Number(t));
    }
    throw new Error('Expect expr');
  }
  const lit = (value) =>
    ({ kind: 'literal', value });

  const tree = equality();
  if (i < ts.length) {
    throw new Error('Extra ' + ts[i]);
  }
  return tree;
}

function show(e) {
  switch (e.kind) {
    case 'literal':
      return e.value === null
        ? 'nil' : String(e.value);
    case 'group':
      return `(group ${show(e.e)})`;
    case 'unary':
      return `(${e.op} ` +
        `${show(e.right)})`;
    case 'binary':
      return `(${e.op} ` +
        `${show(e.left)} ` +
        `${show(e.right)})`;
  }
}

const src = '1 + 2 * 3 == 7';
const tree = parse(src.split(' '));
console.log(show(tree));
console.log(show(parse(
  '1 - 2 - 3'.split(' '))));
console.log(show(parse(
  '- ( 1 + 2 ) * 3 < 4'.split(' '))));

// Try: '8 / 2 / 2', '! true == false'
// Try a mistake: '1 + * 2'

Which way does a chain lean?

Precedence doesn’t settle 1 - 2 - 3. Both operators are on the same level, and the two groupings give different answers:

Two possible trees for 1 - 2 - 3. Left, the tree Drip builds, which evaluates as -1 - 3 = -4. Right, the other grouping, which evaluates as 1 - (-1) = 2.✓ DRIP BUILDS THIS✗ NOT THIS--123-1-23-1 - 3 = -41 - (-1) = 2
Same-level operators group to the left. 1 - 2 - 3 grouped two ways. Drip, like arithmetic, groups to the left, so the first subtraction is deeper and happens first.

The loop in term does this for free. After 1 - 2, left holds the node (- 1 2). The loop goes round again, sees the second -, and builds a new node with that whole subtraction on its left. Each pass wraps the tree so far, so the chain leans left.

Definition

Associativity says how a chain of operators on the same level groups. Drip’s binary operators are left-associative: a - b - c means (a - b) - c. Assignment is the exception, and is right-associative: a = b = 1 means a = (b = 1).

Why not write the rule the way chapter 30 first did, term → term "-" factor? Turned into code, the first thing term() would do is call term(), with the same token still next. That call would do the same, forever, until the stack overflows. A rule whose first symbol is its own name can’t be coded directly in recursive descent, which is why the grammar uses * and the code uses a loop.

The companion’s parser

Drip’s statements are parsed in exactly this style, in companion/ts/src/parser.ts. Here is the rule for if, and its function (error messages shortened):

ifStmt → "if" "(" expression ")" statement
         ( "else" statement )?
function ifStatement(): Stmt {
  consume('(', "Expect '('...");
  const condition = expression();
  consume(')', "Expect ')'...");
  const then = statement();
  const otherwise = match('else')
    ? statement() : null;
  return { kind: 'if', condition,
           then, else: otherwise };
}

consume takes a token that must be there, or reports an error; match takes a token only if it is there, which is how ? becomes code. statement() may be another if, so the functions recurse just as the rules do.

For expressions, though, the companion does something different. It uses a Pratt parser (chapter 33), which replaces the six level functions with one loop and a table of binding powers. It builds the same trees: a differential test (chapter 15) that gives random expressions to both this chapter’s parser and the companion’s finds no differences.

Under the hood

JavaScript’s specification layers its operators the same way. It defines an AdditiveExpression as a MultiplicativeExpression, or an AdditiveExpression followed by + or - and a MultiplicativeExpression[2]. That self-reference on the left is the left recursion you just saw: in a grammar it means left-associative, and in a hand-written parser it becomes a loop. Drip’s expression grammar, with its rule names, follows Lox’s in Crafting Interpreters[1].

Your turn

ExerciseParse 1 + 2 * 3

Finish the parser by writing term and factor. Their rules are in the comments: a term is factors joined by + or -, and a factor is primaries joined by * or /. Build each operator into a binary node with bin(left, op, right). Both levels must be left-associative.

function parse(ts) {
  let i = 0;
  const at = (...ops) =>
    ops.includes(ts[i]);
  const next = () => ts[i++];
  const bin = (left, op, right) => ({
    kind: 'binary', left, op, right,
  });

  // term → factor (("+" | "-") factor)*
  function term() {
    // your code here
    return factor();
  }
  // factor → primary
  //          (("*" | "/") primary)*
  function factor() {
    // your code here
    return primary();
  }
  function primary() {
    const t = next();
    if (t === '(') {
      const e = term();
      next(); // the ')'
      return { kind: 'group', e };
    }
    return {
      kind: 'literal', value: Number(t),
    };
  }
  return term();
}

function show(e) {
  if (e.kind === 'literal') {
    return String(e.value);
  }
  if (e.kind === 'group') {
    return `(group ${show(e.e)})`;
  }
  return `(${e.op} ${show(e.left)} ` +
    `${show(e.right)})`;
}

const ts = '1 + 2 * 3'.split(' ');
console.log(show(parse(ts)));
Extra practiceAdd a comparison level

Add the rule comparison → term ( ( "<" | "<=" | ">" | ">=" ) term )* to a finished parser. parse and the bracket case in primary already start from comparison; write its body.

function parse(ts) {
  let i = 0;
  const at = (...ops) =>
    ops.includes(ts[i]);
  const next = () => ts[i++];
  const bin = (left, op, right) => ({
    kind: 'binary', left, op, right,
  });

  // comparison → term
  //   (("<" | "<=" | ">" | ">=") term)*
  function comparison() {
    // your code here
    return term();
  }
  function term() {
    let left = factor();
    while (at('+', '-')) {
      const op = next();
      const right = factor();
      left = bin(left, op, right);
    }
    return left;
  }
  function factor() {
    let left = primary();
    while (at('*', '/')) {
      const op = next();
      const right = primary();
      left = bin(left, op, right);
    }
    return left;
  }
  function primary() {
    const t = next();
    if (t === '(') {
      const e = comparison();
      next(); // the ')'
      return { kind: 'group', e };
    }
    return {
      kind: 'literal', value: Number(t),
    };
  }
  return comparison();
}

function show(e) {
  if (e.kind === 'literal') {
    return String(e.value);
  }
  if (e.kind === 'group') {
    return `(group ${show(e.e)})`;
  }
  return `(${e.op} ${show(e.left)} ` +
    `${show(e.right)})`;
}


const ts = '1 + 2 < 4'.split(' ');
console.log(show(parse(ts)));

What you learned

  • A parser turns tokens into a syntax tree.
  • Precedence: one grammar rule per level, loosest first. Tighter operators end up deeper in the tree, so they happen first.
  • Recursive descent: one function per rule; alternatives are ifs, * is a loop, nonterminals are calls. Brackets restart at the top.
  • Associativity: the loop wraps the tree so far, so a - b - c leans left. Left recursion can’t be coded directly: it never stops.
  • Drip’s statements use recursive descent; its expressions use Pratt parsing, which builds the same trees.

Check your understanding

0/6
  1. 1.

    A grammar lists term (for + -) above factor (for * /), each built from the rule below. What tree does it give 2 * 3 + 4?

  2. 2.

    In recursive descent, what does the grammar rule factor → unary ( ( "*" | "/" ) unary )* become?

  3. 3.

    How does Drip group 10 - 4 - 3, and what is its value?

  4. 4.

    You code the rule term → term "-" factor as function term() { const l = term(); ... }. What happens?

  5. 5.

    In this chapter’s parser, why does primary call expression() (the top rule) for (?

  6. 6.

    Which statement about the companion’s parser (companion/ts/src/parser.ts) is true?

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] Ecma International, TC39. ECMAScript Language Specification (ECMA-262), “Multiplicative Operators” and “Additive Operators”. Ecma International; living draft at tc39.es/ecma262. tc39.es/ecma262/#sec-additive-operators (checked 2026-10-31)