AnyLearn
All lessons
Programmingadvanced

Parsing: Tokens to ASTs

The parser takes a flat token stream and recovers the hierarchical structure the programmer intended. Learn context-free grammars, recursive-descent parsing, operator precedence, the difference between a parse tree and an AST, and when LL versus LR parsers matter — including the classic dangling-else ambiguity.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

Why a flat token stream isn't enough

The lexer gives the parser a sequence like INT(1) PLUS INT(2) STAR INT(3). That sequence is ambiguous: does * bind tighter than +? The grammar must say so. Parsing recovers nesting — the hierarchy that tells you 1 + (2 * 3), not (1 + 2) * 3.

The right tool is a context-free grammar (CFG). CFGs are strictly more powerful than regular expressions: they can describe balanced parentheses and recursive structure, things no finite automaton can match. Every mainstream programming language is defined by a CFG (possibly with a few context-sensitive patches bolted on).

The output of parsing is a tree, not a flat structure. Getting from tokens to that tree is the parser's only job.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. Why a flat token stream isn't enough

The lexer gives the parser a sequence like INT(1) PLUS INT(2) STAR INT(3). That sequence is ambiguous: does * bind tighter than +? The grammar must say so. Parsing recovers nesting — the hierarchy that tells you 1 + (2 * 3), not (1 + 2) * 3.

The right tool is a context-free grammar (CFG). CFGs are strictly more powerful than regular expressions: they can describe balanced parentheses and recursive structure, things no finite automaton can match. Every mainstream programming language is defined by a CFG (possibly with a few context-sensitive patches bolted on).

The output of parsing is a tree, not a flat structure. Getting from tokens to that tree is the parser's only job.

2. Context-free grammars in BNF

A CFG is a set of production rules over terminals (token kinds) and nonterminals (syntactic categories).

expr   ::= expr PLUS  term
         | expr MINUS term
         | term
term   ::= term STAR  factor
         | term SLASH factor
         | factor
factor ::= INT
         | LPAREN expr RPAREN

This grammar encodes precedence structurally: term is nested deeper than expr, so multiplication binds tighter than addition. Left-recursion (expr ::= expr PLUS term) encodes left-associativity1 - 2 - 3 parses as (1 - 2) - 3.

BNF (Backus-Naur Form) is the standard notation. EBNF adds * and ? shorthands. Real language specs (C11, Java SE) are essentially large BNF documents.

3. Recursive-descent parsing

The most readable parser strategy: one function per nonterminal, each calling others recursively. The grammar above maps directly to code:

def parse_factor(tokens):
    tok = tokens.peek()
    if tok.kind == 'INT':
        tokens.consume()
        return IntNode(tok.value)
    elif tok.kind == 'LPAREN':
        tokens.consume()          # eat '('
        node = parse_expr(tokens)
        tokens.expect('RPAREN')   # eat ')'
        return node
    raise SyntaxError(f'unexpected {tok}')

def parse_term(tokens):
    left = parse_factor(tokens)
    while tokens.peek().kind in ('STAR', 'SLASH'):
        op = tokens.consume().kind
        right = parse_factor(tokens)
        left = BinOpNode(op, left, right)
    return left

Recursive descent is the approach used in Clang, GCC (for C), CPython, and most hand-written production parsers. It handles LL(1) grammars naturally and can handle LL(k) with a bit of lookahead.

4. Parse tree vs. abstract syntax tree

A parse tree (concrete syntax tree) mirrors the grammar exactly — every intermediate nonterminal becomes a node. For 1 + 2 * 3 it has nodes for expr, term, factor that carry no semantic payload. It's bulky.

An AST discards noise:

Parse treeAST
Has exprtermfactor wrapper nodesOnly BinOp(+, Int(1), BinOp(*, Int(2), Int(3)))
Contains every parenthesis, semicolon tokenParentheses gone — precedence is in the shape
One node per grammar symbolOne node per semantic concept
Size proportional to grammar sizeSize proportional to program complexity

The AST is what all later compiler phases (semantic analysis, type checking, IR generation) actually walk. The parse tree is typically never materialized — the parser builds AST nodes directly.

5. AST for 1 + 2 * 3

The AST captures precedence in its shape — multiplication is deeper, so it binds tighter.

flowchart TD
  A["BinOp(+)"]
  B["Int(1)"]
  C["BinOp(*)"]
  D["Int(2)"]
  E["Int(3)"]
  A --> B
  A --> C
  C --> D
  C --> E

6. LL vs. LR parsers

Two broad families of parsing algorithms:

PropertyLL (top-down)LR (bottom-up)
DirectionPredicts nonterminals left-to-rightReduces terminals into nonterminals
Stack contentsRemaining nonterminals to expandAlready-seen symbols (the handle)
Handles left-recursion?No — must rewrite grammarYes — natural
Grammar classLL(k), typically LL(1)LR(0), SLR, LALR(1), GLR
ToolsHand-written, ANTLRBison/Yacc, LALR generators
Lookaheadkk tokens peeked to choose production1 token to decide shift vs. reduce

Most production compilers use recursive descent (an LL variant) because it gives direct control over error messages. LALR(1) generators like Bison are common for language prototyping. Shift/reduce conflict: the parser sees a symbol on the stack and a lookahead token but can't decide whether to shift (push) or reduce (apply a rule).

7. Ambiguity and the dangling else

A grammar is ambiguous if a single input string has more than one parse tree. The canonical example is the dangling else:

if (a) if (b) x(); else y();

Which if does else belong to? The naive CFG rule

stmt ::= IF LPAREN expr RPAREN stmt
       | IF LPAREN expr RPAREN stmt ELSE stmt
       | ...

gives two parse trees. C and Java resolve it by the nearest-enclosing-if rule (the else binds to the inner if). Most parser generators resolve the shift/reduce conflict by preferring shift — which happens to implement nearest-enclosing. The grammar is still technically ambiguous; the conflict-resolution rule patches it.

Real-world fix: use explicit endif (Ada, Pascal) or braces (Rust requires {}), eliminating the ambiguity at the grammar level.

8. Operator precedence without grammar proliferation

Writing a separate nonterminal per precedence level is tedious for languages with 15 levels (C has exactly 15). The Pratt parser (top-down operator precedence) is an elegant alternative: each token carries a binding power integer, and a small loop drives the entire expression parser.

def parse_expr(tokens, min_bp=0):
    left = parse_atom(tokens)
    while True:
        op = tokens.peek()
        lbp = LEFT_BINDING_POWER.get(op.kind, -1)
        if lbp < min_bp:
            break
        tokens.consume()
        right = parse_expr(tokens, lbp + 1)  # +1 => left-assoc
        left = BinOpNode(op.kind, left, right)
    return left

Right-associativity (**, =): pass lbp instead of lbp + 1 as min_bp. Pratt parsers are used in Rust's parser and many modern language implementations precisely because they handle precedence tables directly, without grammar surgery.

9. Error recovery and synchronization

A parser that crashes on the first error is useless. Production parsers implement error recovery so they can report multiple errors per file.

Common strategies:

  • Panic mode: Discard tokens until a synchronization token is found (;, }, keyword). Simple, coarse.
  • Error productions: Add grammar rules like stmt ::= ERROR SEMI that explicitly match known malformed constructs.
  • Automatic recovery (ANTLR4): The parser inserts a missing token or deletes an unexpected one and continues — emitting an error message but pushing on.

Clang's recursive-descent parser uses synchronization sets: each parsing function knows which tokens could plausibly start a valid continuation. It skips everything else, re-establishes context, and tries again. Getting error recovery right is what separates a research parser from a production one.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Given the grammar `expr ::= expr PLUS term | term` and `term ::= INT`, what does the parse tree for `1 + 2 + 3` look like?
    • Right-associative: `1 + (2 + 3)` — the leftmost `expr` matches first.
    • Left-associative: `(1 + 2) + 3` — because the rule is left-recursive.
    • Ambiguous: the grammar yields both trees with equal priority.
    • A flat list: the parser cannot determine grouping without parentheses.
  2. What key information does an AST omit that a parse tree retains?
    • Operator precedence — the AST flattens all operators to the same level.
    • Concrete syntax details like parentheses and grammar wrapper nodes that carry no semantic content.
    • Source line numbers — ASTs never store position information.
    • The types of literal values — those are inferred later.
  3. An LL(1) parser fails on a grammar with left recursion. What is the standard fix?
    • Switch to a DFA-based scanner to handle the lookahead.
    • Eliminate left recursion by rewriting `A ::= A alpha | beta` as `A ::= beta A'` with `A' ::= alpha A' | ε`.
    • Use a shift/reduce table — left recursion is only a problem for recursive descent.
    • Increase lookahead from LL(1) to LL(2) — that always resolves left recursion.
  4. In a Pratt parser, what determines whether an operator is left- or right-associative?
    • Whether it appears on the left or right side of the grammar production.
    • The right-binding power passed as min_bp when recursing: `lbp + 1` gives left-associativity, `lbp` gives right-associativity.
    • Whether the parser shifts or reduces when it sees the operator on the stack.
    • The order of operators in the binding-power table.
  5. The dangling-else ambiguity in `if (a) if (b) x(); else y();` is resolved by most parsers by:
    • Reporting a syntax error — the programmer must add braces.
    • Preferring a shift over a reduce, which binds `else` to the nearest enclosing `if`.
    • Preferring a reduce over a shift, which binds `else` to the outermost `if`.
    • Using two tokens of lookahead to distinguish the two parse trees.

Related lessons

Programming
intermediate

Linting from first principles

What a linter actually is, what its rules enforce, how AST-based analysis works, the cost of auto-fix, the ecosystem (ESLint, Ruff, Clippy, golangci-lint), where to wire it in (LSP / pre-commit / CI), why false-positive rate is the real budget, and when a custom rule pays for itself.

8 steps·~12 min
Programming
advanced

Semantic Analysis and Type Checking

The parser only checks syntax — it happily accepts `x = y + z` even if none of those names exist. Semantic analysis adds meaning: it builds symbol tables, resolves names, enforces scoping rules, and runs the type checker that catches the errors a grammar cannot. Learn what happens between the raw AST and the typed AST the IR generator gets.

9 steps·~14 min·audio
Programming
advanced

Lexical Analysis: Source to Tokens

Before a compiler can understand your code it has to chop it into meaningful pieces. Learn how scanners work, how regular expressions become finite automata, why maximal munch is the rule, and what a real token stream looks like — the foundation every later compiler phase depends on.

9 steps·~14 min·audio
Programming
advanced

IR, Optimization, and Code Generation

The typed AST is high-level — too high for a CPU. Learn why compilers lower to an intermediate representation first, what SSA form buys you, how classic optimizations (constant folding, dead-code elimination, CSE) transform IR, and how instruction selection and register allocation finally produce machine code.

9 steps·~14 min·audio