Chapter 4 · Parsing in Practice: Expressions, Other Paradigms, Recovery & Syntax Trees¶
Part 1 · Front End · about 2–3 weeks · Previous: Ch 3 · Next: Ch 5
The problem¶
Chapters 2 and 3 answered "is this token sequence in \(L(G)\), and what is its tree?" for the grammar classes LL and LR. A production front end needs more: expressions with a dozen precedence levels parsed without a dozen functions; grammars that are not LL or LR (ambiguous, left-recursive, written for humans) parsed anyway; a parser that turns any text into a useful tree with every error reported once, and updates that tree in milliseconds after an edit; and a tree representation that later phases can walk, share and lower. The input is a token sequence (Chapter 1) plus a grammar or operator table; the output is a syntax tree (lossless or abstract) and a list of diagnostics. In pebblec this is parseModule(Lexer&, ASTContext&, DiagnosticEngine&) in pebble/lib/Parse, whose AST every later chapter reads; the chapter ends with how syntax is extended by macros before or during parsing.
What you will be able to do¶
- Parse expressions with Pratt parsing, precedence climbing, shunting-yard and a layered grammar, prove the four agree (Theorem 4.1.14), and trace binding powers by hand.
- Write a PEG, explain why ordered choice changes the language, make it linear with packrat memoization and handle left recursion by seed growing.
- Fill Earley charts and CYK tables by hand, explain Leo's optimization and SPPFs, and say which paradigm accepts a given grammar.
- Compare parser combinators (backtracking, committed choice, furthest failure, recovery) on real libraries: nom, Parsec, chumsky.
- Implement
pebblec's parser (recursive descent + Pratt) with multi-error recovery, error nodes, an error cap and a nesting limit, tested by golden ASTs, random precedence tests and round trips. - Explain resilient LL parsing, error nodes vs error productions, and incremental reparsing (tree-sitter, rust-analyzer), and build an item-level incremental reparser (★).
- Choose a syntax-tree design (class hierarchy with LLVM RTTI,
std::variant, arena/indices, red–green, CST → AST → HIR) for a given compiler, and explain token vs AST macros and hygiene.
Prerequisites: Ch 1 (tokens, lossless lexing), Ch 2 (grammars, FIRST/FOLLOW, recursive descent, panic-mode recovery), Ch 3 (LR items, GLR and the graph-structured stack, SPPF).
Notation¶
Shared notation follows the house notation (§1 sets and functions, §5 grammars and parsing, §8 complexity), Chapter 2's and Chapter 3's. In this chapter:
| Symbol | Meaning |
|---|---|
| \(T = (O, \mathrm{lev}, \mathrm{assoc}, \mathrm{Pre})\), \(O_p\), \(k\) | an operator table: infix operators, levels \(1..k\) (1 loosest), associativity per level, prefix operators at level \(k+1\); \(O_p\) the operators of level \(p\) (Definition 4.1.1) |
| \(E_1, \dots, E_{k+1}\) | the nonterminals of the layered grammar \(G_T\), one per level (Definition 4.1.2) |
| chunk, depth-0 operator | a maximal operand with its prefix operators, and an operator outside every parenthesis (Definition 4.1.3) |
| \((\mathrm{lbp}(o), \mathrm{rbp}(o))\), \(\lambda(m)\) | the left and right binding powers of an operator: \((2p, 2p+1)\) for left/non-associative level \(p\), \((2p+1, 2p)\) for right; \(\lambda(m) = \max(1, \lceil m/2 \rceil)\) the lowest level allowed at minimum power \(m\) (Definition 4.1.7) |
| \(G = (N, T, R, e_S)\), \(e_1 / e_2\), \(\&e\), \(!e\) | a PEG, ordered choice, and- and not-predicates (Definition 4.2.1) |
| \(\mathrm{match}(e, i)\), \(\mathsf{fail}\) | the PEG match function: end position or failure (Definition 4.2.2) |
| \([A \to \alpha \bullet \beta, j]\), \(S_0, \dots, S_n\) | an Earley item with origin \(j\); the Earley sets (Definition 4.3.1) |
| \(\tau(B, i)\) | the topmost item of Leo's deterministic reduction path (Definition 4.3.5) |
| \((X, i, j)\), \((A \to \alpha \bullet \beta, i, j)\) | SPPF symbol and intermediate nodes spanning \(t_{i+1} \cdots t_j\) (Definition 4.3.7) |
| \(V[i, j]\) | the CYK table cell: nonterminals deriving the substring of length \(j\) starting at \(i\) (Algorithm 4.4.3) |
| \(X ::= \alpha \cdot \beta\), \(\langle L, j \rangle\), \((L, u, i)\), \(\mathcal{U}_i\), \(\mathcal{P}\) | a grammar slot, a GSS node, a GLL descriptor, the descriptors created at \(i\), the popped set (Definition 4.4.4) |
| \(p : T^{*} \to \mathrm{List}(\tau \times T^{*})\), \(\mathbin{>\!\!>\!\!=}\) | a list-of-successes parser and sequencing (Definition 4.5.1) |
| \(\mathsf{Cok}, \mathsf{Cerr}, \mathsf{Eok}, \mathsf{Eerr}\) | Parsec's four replies: consumed/empty × ok/error (Definition 4.5.4) |
\(R\) (recovery set), ERROR |
the tokens an enclosing construct can use; an error node (Definitions 4.6.1–4.6.2) |
| \([a, b) \mapsto s\) | an edit replacing bytes \(a..b-1\) by \(s\) (Definition 4.6.5) |
| \(\kappa\), \(S_D\), \(\mathit{first}(D)\), \(\mathit{last}(D)\) | kind numbering of a class hierarchy and the kinds below class \(D\) (Definition 4.7.1) |
| \(w(g)\), \(r_i\), \((g, \text{parent}, o)\) | width of a green element, relative offset of child \(i\), a red node with absolute offset \(o\) (Definition 4.7.7) |
| \(\ell : L_1 \to L_2\), \([\![\cdot]\!]\) | a lowering between tree languages and their meaning (Definition 4.7.9) |
| \(\mu\), \((n, c)\) | an expansion mark; an identifier with name \(n\) and syntax context \(c\) (Definition 4.8.5) |
| \(n\), \(\lvert G \rvert\), \(d\) | input length (tokens or bytes, as stated), grammar size, tree depth |
Technique map¶
| Family | Techniques (origin) | Lesson |
|---|---|---|
| Expression parsing | precedence-layered grammar (ALGOL 60 report, Naur 1960); precedence climbing (Richards 1979; Clarke 1986); Pratt parsing / top-down operator precedence (Pratt 1973); shunting-yard (Dijkstra 1961) — and the proof that they agree | 4.1 |
| PEG and packrat | parsing expression grammars (Ford 2004; TDPL, Birman & Ullman 1973); packrat memoization (Ford 2002); left recursion by seed growing (Warth, Douglass & Millstein 2008) and bounded left recursion (Medeiros, Mascarenhas & Ierusalimschy 2014) | 4.2 |
| Earley parsing | Earley's algorithm (Earley 1970) with nullable handling (Aycock & Horspool 2002); Leo's right-recursion optimization (Leo 1991); SPPFs from Earley charts (Scott 2008) | 4.3 |
| CYK and GLL | Chomsky normal form and CYK (Kasami 1965; Younger 1967), Valiant's reduction (1975); GLL (Scott & Johnstone 2010) | 4.4 |
| Parser combinators | list of successes (Wadler 1985; Hutton & Meijer 1996/1998) and backtracking libraries (nom); committed choice and try (Parsec, Leijen & Meijer 2001); furthest-failure errors and recovery (Ford 2002; chumsky) |
4.5 |
| Resilient and incremental parsing | resilient LL with recovery sets (rust-analyzer; Kladov 2023); error nodes vs error productions (yacc, Johnson 1975); incremental LR with subtree reuse (Wagner & Graham 1998; tree-sitter, Brunsfeld 2018); block-level reparsing (rust-analyzer) | 4.6 |
| Syntax-tree design | class hierarchies with LLVM-style RTTI (Clang); sum types / std::variant (ML datatypes; the expression problem, Wadler 1998); arena and index-based trees (Hanson 1990; rustc HirId); lossless red–green trees (Roslyn; rowan; SwiftSyntax); lowering CST → AST → HIR (rustc) |
4.7 |
| Syntax extension | token-based macros (the C preprocessor); AST-based macros (macros by example, procedural and Swift macros); hygiene (Kohlbecker et al. 1986; Dybvig, Hieb & Bruggeman 1992; Flatt 2016) | 4.8 |
flowchart LR
subgraph EXPR[Expression parsing]
L[Layered grammar<br/>Naur 1960] -->|one loop per level| C[Precedence climbing<br/>Richards 1979, Clarke 1986]
C -->|levels to binding powers| P[Pratt<br/>1973]
C -->|explicit stacks| S[Shunting-yard<br/>Dijkstra 1961]
end
subgraph GEN[Beyond LL/LR]
RD[Backtracking RD] -->|ordered choice, predicates| PEG[PEG<br/>Ford 2004]
PEG -->|memoize| PK[Packrat<br/>Ford 2002]
PK -->|seed growing| LR[Left-recursive PEG<br/>Warth 2008]
E[Earley 1970] -->|right recursion| LEO[Leo 1991]
E -->|all parses| SP[SPPF<br/>Scott 2008]
CYK[CYK 1965-67] -->|matrix mult.| VAL[Valiant 1975]
GLR[GLR, Ch 3] -->|same GSS, top-down| GLL[GLL<br/>Scott & Johnstone 2010]
RD -->|as a library| COMB[Combinators<br/>Wadler 1985, Parsec 2001]
end
subgraph IDE[Resilience and incrementality]
PM[Panic mode, Ch 2] -->|recovery sets, error nodes| RES[Resilient LL<br/>rust-analyzer]
YE[yacc error productions<br/>Johnson 1975] -.->|alternative| RES
WG[Wagner & Graham 1998] -->|GLR + reuse| TS[tree-sitter]
RES -->|persistent trees| BR[Block reparsing]
end
subgraph TREES[Trees and extension]
H[Class hierarchy + RTTI] ---|expression problem| V[Sum types]
AR[Arenas and indices] --> H
AR --> V
RG[Red-green trees<br/>Roslyn, rowan] -->|lower| AST[AST] -->|desugar| HIR[HIR]
TOK[Token macros, cpp] -->|parse fragments| MBE[AST macros]
MBE -->|marks| HYG[Hygiene<br/>Kohlbecker 1986]
end
RES --> RG
BR --> RG
Who uses what¶
| System | Technique | Notes |
|---|---|---|
| Clang/LLVM 23 | precedence climbing (ParseRHSOfBinaryExpression); class hierarchy + LLVM RTTI in a bump arena; token-based preprocessor |
Lessons 4.1, 4.7, 4.8 |
| GCC 15 | shunting-yard-style operator stack for binary expressions (c_parser_binary_expression, cp_parser_binary_expression) |
Lesson 4.1 |
| rustc 1.94 | precedence climbing (parse_expr_assoc_with); sum-type AST; HirId indices; CST-free AST → HIR lowering; macros by example with mixed-site hygiene |
Lessons 4.1, 4.7, 4.8 |
| rust-analyzer | Pratt (expr_bp); resilient LL with recovery sets; red–green trees (rowan); block-level reparsing |
Lessons 4.1, 4.6, 4.7 |
| CPython 3.11+ | PEG with packrat memoization and left recursion (pegen) | Lesson 4.2 |
| tree-sitter 0.25 | GLR with incremental subtree reuse and error recovery | Lesson 4.6 |
| Lark 1.3 | Earley (SPPF, explicit ambiguity), CYK, LALR | Lessons 4.3, 4.4 |
| Swift 6 / swift-syntax | sequence folding of operators after parsing; lossless trees with a raw arena; recovery by precedence; macros on syntax trees | Lessons 4.1, 4.6, 4.7, 4.8 |
| Go 1.24, V8 13 | precedence climbing (parseBinaryExpr, ParseBinaryExpression) |
Lesson 4.1 |
| nom 8, Parsec 3.1, chumsky 0.10 | combinators: backtracking, committed choice, recovery | Lesson 4.5 |
| Roslyn (C#) | red–green trees, skipped-token trivia | Lessons 4.6, 4.7 |
pebblec |
recursive descent + Pratt, panic-mode recovery with sync sets and error nodes; class hierarchy in a bump arena | Exercises |
Comparison¶
The rows are the lessons' comparison tables (§8), verbatim, in lesson order.
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Precedence-layered grammar | Any finite table of infix levels (+ prefix above them), as an unambiguous CFG usable by every LL/LR/PEG tool | \(\Theta(k\,n)\) · up to \((k+1)\) calls per operand (16 calls for 6 operands in §3) | The grammar documents the language; errors per level ("expected term") | Low per level, but one rule and function per level | Language standards, generated parsers, CPython's PEG grammar |
| Precedence climbing | Same trees as the layered grammar (Theorem 4.1.14); ?: and context flags added by hand |
\(\Theta(n)\) · one call per operand | Good; errors where an operand is missing | Low: one loop and a level table | Clang, Go, rustc, V8 |
| Pratt parsing | Same, plus prefix/postfix/mixfix/call/index as per-token handlers | \(\Theta(n)\) · one call per operand (1.18 steps/token, 7.8 ms at 28 500 tokens in the lab) | Good; handlers give token-specific messages | Low: a table of (nud, led, powers) | rust-analyzer, JSLint, pebblec |
| Shunting-yard | Same trees; prefix/postfix via a two-state automaton | \(\Theta(n)\) · no recursion (1.18 steps/token, 5.8 ms at 28 500 tokens in the lab) | Weaker: errors are detected on stack states, far from their cause | Low for infix; fiddly for calls and prefix operators | GCC's C/C++ binary expressions, calculators, RPN conversion |
| Parsing expression grammars | Unambiguous by construction; recognizes some non-CFLs (\(a^m b^m c^m\), Theorem 4.2.5); ordered choice can silently shrink a CFG's language (Proposition 4.2.6) | Linear with packrat; exponential without on adversarial grammars | Furthest-failure messages; no conflicts to report | Low: the grammar is the parser | CPython, DSLs (pest, PEG.js, LPeg), scannerless parsers |
| Packrat memoization | Same language as the PEG | \(O(\lvert G \rvert\, n)\) · 4.46 calls+hits/token and 11.5 ms at 28 500 tokens in the lab | Same as PEG | Low: one table | CPython (selectively), the lab |
| Left recursion in PEGs (seed growing) | Adds direct (and, with cycle leaders, indirect) left recursion with left-nested trees | One extra round per left-recursive call | Same as PEG | Medium: the growing loop and the leader analysis | CPython's sum/term, Ohm, pegen |
| Earley (with Aycock–Horspool) | Every CFG, exact (Theorem 4.3.8); left recursion and ε included | \(O(n^3)\), \(O(n^2)\) unambiguous, linear on bounded-state grammars · 25.3 items/token, 157 ms at 28 500 tokens in the lab (≈20× Pratt) | Correct-prefix error position (first empty set); the chart lists what was expected | Medium: three operations plus tree extraction | Lark, Marpa, NLTK, grammar prototyping, the lab |
| Leo's completer | Same language; removes right-recursion blow-up | Linear on LR-regular grammars (all LR(k)) | Same; trees need path expansion | Medium–high (tree recovery) | Marpa; general parsers for grammars written with right recursion |
| SPPF construction | All parses of an ambiguous sentence in \(O(n^3)\) space | Proportional to the chart | Every tree, shared and packed | Medium (binarization) | Lark ambiguity="explicit", GLL/GLR back ends, grammar debugging |
| CYK | Every CFG (after CNF, Theorem 4.4.6), exact membership, all parses countable | \(\Theta(n^3 \lvert P \rvert)\) on every input (166 million span/split pairs at \(n = 1000\)) | No error position (only "not in \(V[1,n]\)"); trees over CNF helpers unless reverted | Low: three loops + CNF conversion | NLP (probabilistic CKY), teaching, Lark, complexity theory |
| GLL | Every CFG, left recursion and ambiguity included; SPPF of all parses | \(O(n^3)\); near-linear on near-deterministic grammars with FIRST/FOLLOW tests | Recursive-descent-shaped; errors at the furthest descriptor position | Medium–high: GSS, descriptors, SPPF | Grammar engineering (Iguana, Rascal), language composition, gll-pg |
| Backtracking combinators (list of successes; nom-style ordered choice) | All parses of any non-left-recursive CFG (list of successes); PEG semantics for single-result backtracking (Proposition 4.5.9) | Exponential worst case; linear when failures are shallow | Poor by default: leftover input instead of an error (nom box) unless errors are merged or cut is used |
Lowest: a library, grammar in the host language | nom for binary formats, prototypes, small DSLs |
Committed choice (Parsec + try) |
LL(1)-style predictive, plus local backtracking where try is written |
\(O(k\,n)\) without try (Theorem 4.5.10) |
Good: the error is at the first token no alternative consumes (column 9 and 6 in the Parsec box) | Low; try placement needs understanding |
Parsec/Megaparsec in Haskell compilers and tools |
| Furthest-failure reporting and recovery (chumsky) | Adds error merging and recovery to either family | \(O(1)\) per failure; recovery linear in skipped tokens | Best among combinators: several errors per run, partial ASTs with placeholders | Medium: recovery strategies per construct | chumsky-based compilers and language servers, packrat parsers |
| Resilient LL parsing | Any input → a tree with every token; well-formed parts intact | \(O(n\,D)\) · no measurable cost over a non-recovering parser | Errors at the offending token, no cascades (6 errors, 0 cascades on err-statements.pbl) |
Medium: recovery sets per list, discipline in every function | rust-analyzer, swift-syntax, pebblec |
| Error nodes vs error productions | Error nodes: exact skipped regions in the tree; error productions: grammar-level resynchronization | Both linear | Error nodes: precise and tool-friendly; productions: coarse ("skipped to ;") |
Nodes: in the parser; productions: in the grammar | Nodes: IDE parsers; productions: yacc/Bison grammars |
| Incremental LR with subtree reuse | Exact (Theorem 4.6.11) for LR and, in tree-sitter, GLR grammars | \(O(e + d)\) per edit · 62 ms vs 374 ms full on 20 000 functions | Keeps error nodes across edits; changed ranges for highlighting | High: stateful nodes, breakdown, lexer lookahead tracking | tree-sitter (GitHub, Neovim, Zed, Helix), Ensemble |
| Block-level reparsing | Exact when it does not fall back (Theorem 4.6.12) | \(O(b + d)\) · one block or item | Same as the underlying parser | Low–medium: persistent trees + a fallback | rust-analyzer, the lab's ★ |
| Class hierarchies with LLVM-style RTTI | Any node shape; open to new operations via visitors | \(O(1)\) kind tests · Clang nodes 16–32 B, 2 slabs for a small file | Exhaustiveness only via -Wswitch |
Medium: node list + classof per class (generated) | Clang, LLVM IR, Swift, pebblec |
Sum types with std::variant |
Closed set of kinds, checked exhaustive case analysis | \(O(1)\) visit · every node sized to the largest alternative (24 B example) |
Missing cases rejected at compile time (7 errors, library-deep) | Low in Rust/ML; medium in C++ | rustc AST/HIR, OCaml, GHC |
| Arena and index-based trees | Any shape; ids usable as keys in side tables | \(O(1)\) bump allocation (Prop. 4.7.13) · 4-byte links | Stale indices are logic errors, not crashes | Low | Clang/pebblec arenas, rustc HirId, the lab's Tree.h |
| Lossless red–green trees | Every byte kept; shareable across versions | \(O(1)\) navigation, \(O(d)\) edits · 1+1 shared, 1 + 1 not |
Error tokens stay in the tree (Lesson 4.6) | High: two layers, interning, typed views | Roslyn, rust-analyzer, SwiftSyntax |
| Lowering CST → AST → HIR | Each level fits one family of phases | \(O(n)\) per level | Diagnostics must map back through ids/spans | Medium per level | rustc, rust-analyzer, Swift, Kotlin |
| Token-based macros | Any token rewrite, no structure; DOUBLE(1 + 2) = 5 |
\(O(N \cdot d)\) · fast, before parsing | Errors point into expansions ("expanded from macro"); no grouping, no scoping | Low | C, C++, assembly, #include guards |
| AST-based macros | Rewrites of parsed fragments; double!(1 + 2) = 6 |
matching per rule, parsing per expansion | Errors in terms of fragments; "no rules expected this token" | Medium (by example) to high (procedural) | Rust, Scheme, Swift, Elixir |
| Hygiene | Prevents capture both ways (Theorem 4.8.10) | \(O(N)\) with lazy marks, \(O(N^2)\) eager | Correct programs by default (tmp=2 other=1) |
Medium: contexts on every identifier, resolution by context | Rust, Racket, Scheme; not C or Swift |
Comparison-lab results (reproduce with build/<preset>/bin/ch04-parsebench): at about 28 500 tokens of random Pebble expressions, Pratt 7.8 ms (1.18 steps/token), shunting-yard 5.8 ms (1.18), packrat 11.5 ms (4.46 calls + hits/token), Earley 157 ms (25.3 items/token); all four grow linearly on this grammar (Proposition 4.3.12 for Earley).
Route through this chapter¶
| Step | What | Techniques | How it is exercised |
|---|---|---|---|
| 1 | Lesson 4.1 | layered grammar, climbing, Pratt, shunting-yard | drills pratt-trace, shunting-yard; quiz; flashcards |
| 2 | Exercises E1–E6 | pebblec uses recursive descent + Pratt with panic-mode recovery (full implementation) |
./course test 4 (ch04.PebbleParser.*, lit goldens) |
| 3 | Lesson 4.2 | PEG, packrat, left recursion | drill packrat-memo; quiz |
| 4 | Lesson 4.3 | Earley, Leo, SPPF | drill earley-chart; quiz |
| 5 | Comparison lab labs/ch04-paradigms/ L1–L5 |
Pratt vs shunting-yard vs packrat vs Earley | lab tests + ch04-parsebench measurements |
| 6 | Lesson 4.4 | CYK, GLL (theory + drills + oracle) | drills cyk-table, paradigm-accepts; quiz |
| 7 | Lesson 4.5 | combinators (theory + real library runs) | quiz; flashcards |
| 8 | Lesson 4.6 | resilient LL, error nodes, incremental LR, block reparsing | exercises E5–E6; ★ lab L6 (item-level incremental reparser) |
| 9 | Lessons 4.7 and 4.8 | tree designs, lowering, macros, hygiene (theory + real compiler runs) | quiz; flashcards |
| 10 | Theory test | all | ./course quiz 4 (≥ 80 % to finish) |
Practice and check¶
./course drill pratt-trace --difficulty easy # warm up; --solution shows every step
./course drill shunting-yard
./course drill packrat-memo
./course drill earley-chart
./course drill cyk-table
./course drill paradigm-accepts
./course flash 4 # daily, a few minutes
./course quiz 4 # after the lessons
./course test 4 # after the exercises and the lab
./course status # done = quiz ≥ 80 % and tests pass
References¶
The chapter's annotated bibliography — papers, textbook sections, pinned source files, docs, talks — is in references.md. Start with: [Pra73] (Pratt's paper, short and still the best introduction to binding powers), [For04] (the PEG paper, with the formal semantics of Lesson 4.2), [Ear70] (Earley's algorithm), [Kla23] (resilient LL parsing as rust-analyzer does it), [WG98] (incremental LR parsing) and [KFFD86] (hygienic macro expansion).