The parser is where a flat stream of tokens becomes a tree. We map the grammar-class landscape — LL, LR, SLR, LALR(1), GLR, PEG — build parsers by hand and by generator, climb expressions with Pratt parsing, trace a shift-reduce automaton, and resolve the conflicts that every real grammar throws at you
Parsing is the heart of the front end. We start from context-free grammars, contrast the two great parsing families, and finish at the AST that the rest of the compiler consumes.
The lexer (Part 03) handed us a flat stream of tokens. The parser checks that those tokens form a valid sentence of the source language's grammar and, in the process, recovers the structure the flat stream lost — nesting, grouping, precedence.
Regular expressions (the lexer's tool) cannot count nesting — they can't match balanced brackets. Recursion in the language needs a context-free grammar and a stack.
The lexer/parser split mirrors the Chomsky hierarchy: regular languages for tokens, context-free languages for phrase structure. Anything beyond — declared-before-use, type agreement — is left to semantic analysis (Part 05).
A CFG is a 4-tuple G = (N, Σ, P, S): non-terminals N, terminals Σ, productions P, and a distinguished start symbol S. Each production rewrites one non-terminal into a string of terminals and non-terminals.
Expr → Expr "+" Term
| Expr "-" Term
| Term
Term → Term "*" Factor
| Term "/" Factor
| Factor
Factor → "(" Expr ")"
| number
| ident
The classic expression grammar. Expr is the start symbol; + - * / ( ) number ident are terminals.
Σ)The token classes from the lexer — the leaves of every tree. They never appear on a production's left-hand side.
N)Syntactic variables naming phrases: Expr, Term, Stmt. Defined by their productions.
P)Rewrite rules A → α. “Context-free” because the LHS is a single non-terminal, replaceable regardless of context.
S)The root non-terminal. The language L(G) is every terminal string derivable from S.
A derivation applies productions step by step, each rewriting one non-terminal, until only terminals remain. Each intermediate string is a sentential form; one with no non-terminals left is a sentence.
Always rewrite the leftmost non-terminal. For a + b * c:
Expr
⇒ Expr + Term (Expr→Expr+Term)
⇒ Term + Term (Expr→Term)
⇒ Factor + Term (Term→Factor)
⇒ a + Term (Factor→ident)
⇒ a + Term * Factor (Term→Term*Factor)
⇒ a + Factor * Factor
⇒ a + b * Factor
⇒ a + b * c
Always rewrite the rightmost non-terminal. Bottom-up (LR) parsers build a rightmost derivation in reverse — this is why they matter.
α ⇒ β is one step; α ⇒* β is zero-or-more steps. L(G) = { w ∈ Σ* | S ⇒* w }.
The parse tree (concrete syntax tree) records every grammar symbol used — including chains like Term→Factor and the parentheses. The AST (abstract syntax tree) keeps only the essential structure. Same input a + b * c:
Eleven nodes collapse to five. The AST drops single-child chains and punctuation; * sits below +, so precedence survives as shape.
A grammar is ambiguous if some sentence has more than one parse tree (equivalently, more than one leftmost derivation). Ambiguity means the structure — and therefore the meaning — is undefined.
E → E "+" E
| E "*" E
| number
For 1 + 2 * 3 this allows two trees: (1+2)*3 and 1+(2*3). The grammar never says which — it has no built-in precedence.
An ambiguous grammar can't drive a deterministic parser without a tie-breaking rule. Most generators report the ambiguity as a conflict.
S → "if" E "then" S "else" S
| "if" E "then" S
| other
In if a then if b then s1 else s2, does else bind to the inner or outer if? Two trees.
else matches the nearest if”Some ambiguity is inherent — no equivalent unambiguous grammar exists — but the cases compilers meet are almost always fixable.
The cure for the ambiguous expression grammar is to stratify it into one non-terminal per precedence level. Higher precedence sits lower in the grammar (closer to the leaves), so it binds tighter.
Expr → Expr "+" Term | Term // + is left-assoc,
Term → Term "*" Factor | Factor // lowest precedence
Factor → "-" Factor // unary minus
| Power
Power → Atom "^" Power | Atom // ^ is RIGHT-assoc
Atom → number | "(" Expr ")"
Left-recursion (Expr→Expr+Term) → left-associative. Right-recursion (Power→Atom^Power) → right-associative.
| Level | Operators | Assoc. |
|---|---|---|
| lowest | + - | left |
| ↓ | * / % | left |
| ↓ | unary - ! | right |
| highest | ^ (power) | right |
You can either bake precedence into the grammar's shape (as above) or keep a flat grammar and supply precedence declarations to the generator (bison's %left / %right), or climb it numerically (Pratt — slide 13).
Not every CFG can be parsed deterministically with bounded lookahead. The parsing classes form a containment hierarchy — each is the set of grammars a given method can handle. Bigger means more grammars accepted.
Note: LL and LR are incomparable in general for arbitrary k — the clean chain above is the practical k=1 picture. ANTLR's ALL(*) and GLR sit outside it, trading determinism for power.
Each class trades grammar generality against implementation simplicity and error quality. The dominant production choice is hand-written recursive descent (an LL flavour) or LALR(1) via a generator.
| Class | Direction | Derivation | Lookahead | Typical use |
|---|---|---|---|---|
| LL(k) | top-down | leftmost | k tokens, predictive | recursive descent; hand-written |
| LL(*) / ALL(*) | top-down | leftmost | adaptive, unbounded | ANTLR 4 |
| SLR(1) | bottom-up | rightmost (rev.) | FOLLOW-based | teaching; small tables |
| LALR(1) | bottom-up | rightmost (rev.) | merged LR(1) states | yacc, bison, menhir |
| LR(1) | bottom-up | rightmost (rev.) | full 1-token contexts | menhir (--canonical) |
| GLR | bottom-up | all parses | forks on conflict | tree-sitter, Bison %glr, Elkhound |
| PEG / packrat | top-down | ordered choice | unbounded, memoised | parser combinators, packrat |
Want the best diagnostics and full control → hand-written recursive descent. Want a grammar that's a near-direct transcription of the spec → an LALR(1)/LR generator. Need to parse a fuzzy or ambiguous real-world language (editors) → GLR (tree-sitter).
Moving down the list, you accept more grammars but pay in table size, build complexity, or error-message quality. GLR can be O(n³) worst case; LL/LR are linear O(n); PEG is linear but memo-hungry.
The most direct parser: one function per non-terminal. The call stack is the parse stack; the grammar reads straight off the code. This is how GCC, Clang and most hand-written parsers work.
// Expr → Term (('+'|'-') Term)*
Node *parse_expr(Parser *p) {
Node *lhs = parse_term(p);
while (peek(p) == '+' || peek(p) == '-') {
Tok op = advance(p); // consume operator
Node *rhs = parse_term(p);
lhs = mk_binary(op, lhs, rhs); // build AST node
}
return lhs;
}
// Factor → number | '(' Expr ')'
Node *parse_factor(Parser *p) {
if (peek(p) == '(') {
expect(p, '(');
Node *e = parse_expr(p);
expect(p, ')'); // error if missing
return e;
}
return mk_num(expect(p, NUMBER));
}
Direct left-recursion (Expr→Expr+Term) makes parse_expr call itself forever. We rewrite left-recursion into the iterative loop shown — slide 14.
When one lookahead token uniquely picks the production, no backtracking is needed — that's LL(1).
To decide which production to take from one lookahead, a predictive parser precomputes two sets. FIRST(α) = terminals that can begin a string derived from α. FOLLOW(A) = terminals that can appear immediately after A.
E → T E'
E' → "+" T E' | ε
T → F T'
T' → "*" F T' | ε
F → "(" E ")" | id
| X | FIRST | FOLLOW |
|---|---|---|
| E | ( id | $ ) |
| E' | + ε | $ ) |
| T | ( id | + $ ) |
| T' | * ε | + $ ) |
| F | ( id | * + $ ) |
a: FIRST = {a}A→X₁X₂…: add FIRST(X₁); if X₁ ⇒* ε, also FIRST(X₂)…$ in FOLLOW(start)A→αBβ: add FIRST(β)\{ε} to FOLLOW(B)For each A→α: put it under M[A,t] for every t∈FIRST(α); if ε∈FIRST(α), also for every t∈FOLLOW(A). Two entries in one cell = not LL(1).
For expressions, recursive descent needs a function per precedence level. Pratt parsing (Vaughan Pratt, 1973) collapses them into one loop driven by binding powers — the technique behind many real parsers (Go, Clang's expressions, rustc, Crafting Interpreters).
# binding power table: (left_bp, right_bp)
BP = {'+':(10,11), '-':(10,11), # left-assoc
'*':(20,21), '/':(20,21),
'^':(31,30)} # right-assoc
def expr(p, min_bp=0):
lhs = atom(p) # number / (expr) / unary
while True:
op = p.peek()
if op not in BP: break
lbp, rbp = BP[op]
if lbp < min_bp: break # stop: weaker than caller
p.next() # consume operator
rhs = expr(p, rbp) # recurse with op's right bp
lhs = ('binary', op, lhs, rhs)
return lhs
Each operator carries a left and right binding power. Asymmetry encodes associativity: (31,30) for ^ lets a later ^ bind, giving right-association.
“Precedence climbing” (Clarke) and “Pratt parsing” are essentially the same algorithm framed differently.
Top-down parsers can't handle left-recursion (infinite loop) or common prefixes (can't choose with one token). Two mechanical transforms make a grammar LL-friendly while preserving the language.
// before (left-recursive)
A → A α | β
// after (right-recursive + ε)
A → β A'
A' → α A' | ε
// concretely
Expr → Expr "+" Term | Term
⇓
Expr → Term Expr'
Expr' → "+" Term Expr' | ε
// before (common prefix — can't pick)
S → "if" E "then" S "else" S
| "if" E "then" S
// after (factor the shared prefix)
S → "if" E "then" S S'
S' → "else" S | ε
Hand-written recursive descent rarely applies these literally — it just writes the loop from slide 11, which is the eliminated form. The transforms matter most when feeding an LL generator.
Bottom-up parsers build the tree from the leaves up, tracing a rightmost derivation in reverse. They keep a stack and repeatedly choose between two moves: shift the next token onto the stack, or reduce a stack suffix matching a production's RHS back to its LHS.
Push the next input token onto the stack.
The stack top matches a production RHS (a handle); pop it, push the LHS non-terminal. This builds one AST node.
A handle is the substring whose reduction is the correct next step in the reverse rightmost derivation. The whole problem is: find the handle. The LR automaton answers it.
Accept when the stack is just S and input is $. Error when no shift or reduce is legal.
Trace for id * id with the slide-12 grammar:
| Stack | Input | Action |
|---|---|---|
| $ | id * id $ | shift |
| $ id | * id $ | reduce F→id |
| $ F | * id $ | reduce T→F |
| $ T | * id $ | shift |
| $ T * | id $ | shift |
| $ T * id | $ | reduce F→id |
| $ T * F | $ | reduce T→T*F |
| $ T | $ | reduce E→T |
| $ E | $ | accept |
How does the parser know when a handle is on the stack? It runs a DFA over grammar symbols. Each state is a set of LR(0) items — productions with a dot • marking how much has been seen. The dot at the end means “handle complete → reduce”.
A, add all A→•… itemsX, take closure → next stateStart from closure({S'→•S}) and apply goto until no new item sets appear. That set of states is the LR(0) automaton driving the parse stack.
The parser stack actually holds states, not symbols. The current state's items tell it whether to shift or which rule to reduce.
LR(0) alone often hits states with both a shift and a reduce item and no way to choose. The three flavours differ in how much lookahead context they attach to reduce decisions.
Reduce A→α only when the next token is in FOLLOW(A). Cheapest, but FOLLOW is too coarse — rejects many real grammars.
Each item carries an exact lookahead token: items are [A→α•β, a]. Most powerful deterministic LR, but state counts explode (thousands → tens of thousands).
Build LR(1), then merge states with identical cores (same items, different lookaheads). Nearly LR(1) power at LR(0) table size — the sweet spot.
DeRemer's 1969 LALR construction gives compact tables that handle almost every practical programming-language grammar. yacc (Johnson, 1975) made it the industry default; bison and menhir continue it.
State merging can introduce new reduce/reduce conflicts that pure LR(1) wouldn't have. Rare in practice, but it's why menhir offers a --canonical (full LR(1)) mode.
The automaton is compiled into two tables. ACTION[state, terminal] → shift s, reduce r, accept, or error. GOTO[state, non-terminal] → the state to enter after a reduction. The driver is a tiny, fixed loop.
// Table-driven LR driver (the heart of yacc output)
for (;;) {
State s = top(stack);
Token a = peek();
Action act = ACTION[s][a];
switch (act.kind) {
case SHIFT: // sN
push(act.state);
advance();
break;
case REDUCE: // rN: A → β
pop(|β| states);
State t = top(stack);
push(GOTO[t][A]);
emit_ast_node(A); // semantic action
break;
case ACCEPT: return root;
case ERROR: recover(); // see slide 21
}
}
A fragment of the table for the expression grammar:
| State | id | + | * | $ | E | T |
|---|---|---|---|---|---|---|
| 0 | s5 | 1 | 2 | |||
| 1 | s6 | acc | ||||
| 2 | r2 | s7 | r2 | |||
| 5 | r6 | r6 | r6 | |||
| 6 | s5 | 9 |
Each token causes a bounded number of moves → O(n) total. The tables are generated once at build time; parsing is just array lookups and a stack.
A conflict is a table cell with two actions — the grammar isn't in the class the generator targets. Every yacc/bison user meets these; resolving them is a craft.
The state could shift or reduce. The canonical case is the dangling else: after if E then S, on seeing else, shift (bind inner) or reduce (bind outer)?
state: S → if E then S • [• else …]
on 'else': shift → inner if (usual)
reduce → outer if
Generators default to shift, which happens to give the desired “nearest if” rule — benign, but warned about.
Two different productions could both reduce the same handle. Almost always a real grammar bug — never resolve it by ignoring; fix the grammar.
A → x • (reduce A→x)
B → x • (reduce B→x) ← which?
bison's %left '+', %left '*', %right '^', %nonassoc assign precedence & associativity to tokens, automatically resolving operator shift/reduce conflicts without rewriting the grammar.
You rarely write LR tables by hand. A generator reads a grammar with embedded semantic actions and emits the parser. The choice of tool fixes the grammar class, the error story, and the workflow.
| Tool | Algorithm | Language | Notable for |
|---|---|---|---|
| yacc / bison | LALR(1) (bison: GLR opt.) | C/C++ | the classic; powers many real compilers & tools |
| menhir | LALR(1) & full LR(1) | OCaml | excellent conflict explanations, modular grammars |
| ANTLR 4 | ALL(*) adaptive LL | Java/many targets | readable LL grammars, great tooling & IDE support |
| tree-sitter | GLR (incremental) | C / many | editor parsing: incremental, error-tolerant |
| PEG / packrat | ordered-choice PEG | many | combined lex+parse, linear w/ memoisation |
| parser combinators | recursive descent (lib) | Haskell, Rust… | parsers as composable values (parsec, nom) |
A Parsing Expression Grammar replaces ambiguous | with ordered choice / — the first alternative that matches wins, so PEGs are unambiguous by construction. Packrat parsing memoises every (rule, position) result → guaranteed linear time at the cost of memory. Downside: ordered choice can silently mask alternatives, and error reporting is weaker.
In functional languages, parsers are first-class functions combined with <|>, many, seq. They are recursive descent expressed compositionally — great ergonomics, but watch backtracking cost and left-recursion (still forbidden).
A compiler that stops at the first error is useless. Good parsers recover — report the error, then resynchronise to keep finding more — without an avalanche of bogus follow-on errors.
On error, discard tokens until a synchronising token (;, }, end) appears, then resume. Simple, robust, the workhorse of recursive descent.
Locally repair: insert a missing ;, delete a stray token, swap two. Lets the parser limp on as if the input were correct.
Add grammar rules for common mistakes (bison's error token) so the parser recognises and reports them precisely.
GCC moved from a bison grammar to a hand-written recursive-descent C/C++ parser (2004) chiefly for diagnostics. Clang and Roslyn (C#/VB) are hand-written recursive descent for the same reason — and to support IDEs with incremental, error-tolerant parsing.
The parser's real product is the AST. It's built on the fly by semantic actions — code attached to productions (or written inline in recursive descent) that constructs a node as each rule completes.
// A typical AST as a Rust enum (sum type)
enum Expr {
Num(f64),
Var(String),
Unary { op: UnOp, rhs: Box<Expr> },
Binary { op: BinOp, lhs: Box<Expr>, rhs: Box<Expr> },
Call { callee: String, args: Vec<Expr> },
}
enum Stmt {
Let { name: String, init: Expr },
If { cond: Expr, then: Box<Stmt>, els: Option<Box<Stmt>> },
Block(Vec<Stmt>),
Expr(Expr),
}
# bison semantic action ($$ = result, $1.. = children)
expr : expr '+' term { $$ = mk_bin(ADD, $1, $3); }
| term { $$ = $1; }
Keep the AST abstract: drop parentheses, semicolons, single-child chains. Some tools (rust-analyzer, tree-sitter) keep a lossless CST for refactoring/formatting and derive an AST view from it.
This tree is exactly what Part 05 walks to resolve names and check types — the bridge from syntax to meaning.
Both produce the same AST; they differ in how they find it and in their engineering trade-offs. The industry has largely converged on hand-written recursive descent + Pratt for serious compilers, generators for everything else.
| Top-Down (LL / recursive descent) | Bottom-Up (LR / LALR) | |
|---|---|---|
| Derivation | leftmost, predict from the top | rightmost-reverse, recognise from leaves |
| Grammar power | smaller class; no left-recursion | larger class; left-recursion is natural |
| Readability | code mirrors grammar — very readable | opaque tables; debug via the generator |
| Error messages | excellent, full context | harder; improving (menhir, ANTLR) |
| Who uses it | GCC, Clang, Roslyn, V8, Go, rustc | Ruby (parse.y), PHP, Bash, many DSLs |
Diagnostics & IDE support matter, the language is stable, you want full control, and the grammar fits LL with a Pratt expression sub-parser. (Almost every modern production compiler.)
The grammar is large/evolving, you want the spec to be the parser, left-recursion abounds, or you're prototyping a DSL. menhir & ANTLR give the best modern experience.
+ - * / ^ ()^ right-associatesTwo ways to find the same tree: predict it from the top (LL) or recognise it from the leaves (LR). The grammar decides which is possible; engineering decides which you pick.