You have an
It is called descent because it starts at the grammar's start symbol and works downward toward the leaves, and recursive because a nonterminal that appears inside its own definition becomes a function that (directly or indirectly) calls itself. The structure of the parser mirrors the structure of the grammar so faithfully that you can practically read one off the other.
Take a fragment of grammar for arithmetic expressions, written to encode precedence (a
Each nonterminal on the left becomes a parsing function. Each alternative on the right becomes a branch
chosen by peeking at the current token (the lookahead); each terminal becomes a
match; each nonterminal reference becomes a function call. The Kleene stars
(while loops. Nothing else is needed —
the grammar is the control flow.
There are two flavours of recursive descent, and the difference is everything.
| Predictive (the good kind) | Backtracking (the slow kind) | |
|---|---|---|
| How it chooses a production | peeks at a fixed number of tokens (usually 1) and commits — no take-backs | tries an alternative, and if it fails, rewinds the input and tries the next |
| Requires | an LL(1) grammar (disjoint FIRST sets; no left recursion) | works on more grammars, even ambiguous ones |
| Speed | linear, |
can be exponential in the worst case |
Predictive parsing is what you almost always want: with disjoint FIRST sets the lookahead token uniquely selects the production, so the parser never guesses wrong and never backtracks. Backtracking descent (the ancestor of modern PEG and packrat parsers) is more powerful but pays for it: without memoisation it can blow up, and its "first match wins" ordering silently hides ambiguity. This page is about the predictive kind.
peek, next, and expectEvery recursive-descent parser rests on three tiny helpers over the token stream. Keep them boring and correct and the rest writes itself:
peek() — return the current token without consuming it. This
is the one-token lookahead that drives every branch decision.next() — consume and return the current token, advancing the
cursor.expect(t) (a.k.a. match) — if the current token equals
t, consume it; otherwise raise a syntax error. This is where a closing
) that never arrives gets caught.
Notice that expect is the only place errors are detected in a predictive parser: the parser
predicts what must come next, and a mismatch between prediction and reality is a syntax error.
The quality of that error message is entirely in your hands here — a theme the
A recogniser answers only "is this valid?". A real parser returns something — an
abstract syntax tree — and recursive descent builds it beautifully: each function
returns the node for the sub-phrase it parsed, and the caller stitches those returns together. Because
parseExpr's loop puts the previous node on the left of each new
Run it. 2 + 3 * 4 gives 14, not 20, because
term greedily grabs the expr ever
sees the { op, left, right }) and the
very same skeleton yields an AST instead of a number.
Every industrial C, C++, C# and Rust front end you can name uses a hand-written recursive-descent
parser, not a generated table. GCC switched away from a Bison grammar to hand-written descent;
Clang, the Microsoft/Roslyn C# compiler, the Rust compiler and V8's JavaScript parser were recursive
descent from the start. Why abandon the elegant machinery? Three reasons. Error messages:
a human wrote the code, so a human can write "did you forget a semicolon after this expression?" exactly
where it belongs — generated parsers famously say "syntax error". Context: real
languages have grammar quirks (C's "is this a type or a variable?" ambiguity, C++'s
most-vexing-parse) that are trivial to resolve with an if in hand-written code and
agonising in a pure grammar. Control: you can hook in semantic actions, error recovery,
and incremental re-parsing wherever you like. The tidy table wins the textbook; the messy function wins
the shipping compiler.
Writing one function per precedence level (expr, term, factor, …)
is clear but tedious — a language with ten precedence levels wants ten near-identical functions. The
Pratt parser (a.k.a. precedence climbing, or top-down operator
precedence) collapses them into a single recursive function parameterised by a
"minimum binding power". Each operator carries a numeric precedence; the loop keeps folding while the
next operator binds at least as tightly as the current threshold, and recurses with a higher threshold to
climb into tighter sub-expressions.
This is how many production parsers (including parts of Clang and rustc) handle expression grammars: the statement-level constructs stay as readable hand-written functions, and the fiddly operator-precedence part is delegated to one compact Pratt loop. It is the same top-down idea — just data-driven on precedence instead of hard-coded into the call graph.
The single fatal mistake in recursive descent is a left-recursive production. Suppose
you write the grammar the "natural" mathematical way:
parseExpr's very first action is to call parseExpr — with the
cursor not moved one bit. No token is consumed, so the recursion never bottoms out: instant stack
overflow, before a single character is read. A predictive parser cannot have left recursion.
The cure is standard. Eliminate the left recursion by rewriting
while loop: the loop is the
de-left-recursed grammar, and as a bonus it gives you the correct left-associative tree, which a naive
right-recursive rewrite would not. Rule of thumb: whenever a nonterminal would recurse on itself with no
token consumed first, turn that self-reference into a loop.