What a Parser Actually Does
A parser turns a flat token list into a tree whose shape is dictated entirely by the grammar. It decides what is applied to what — and it is not allowed to decide whether any of it means anything.
What is a parser responsible for, and what is it not allowed to decide?
Input: a flat sequence of tokens, each with a kind, its text and a source span — an ordering, with no nesting of any kind. Output: a syntax tree in which every node's children are exactly the phrases the grammar says belong to it. The tree exists to answer the one question a list cannot express: what is applied to what. Not what it means, not whether it is well-typed, not whether the names exist.
The parser is entitled to assume the lexer has already settled every character-level question: that 1.5e-3 is one number token and not four, that // began a comment that is now gone, that string escapes are resolved. It is entitled to assume nothing else. In particular it may not assume a name has been declared, that two operands have compatible types, or that a function is called with the right number of arguments — those depend on information no grammar can carry, and a parser that rejects on them will reject legal programs in any language with forward references or overloading.
Key points
- A token list has order but no containment; every question later phases ask is a containment question, which is why parsing is a phase.
- Precedence and associativity live in the grammar, not in the parser. A parser recovers the tree the grammar already implies.
- A parser accepts programs that are complete nonsense, and it is correct to do so —
"hello" - trueparses fine. - The reason it must not check meaning is forward references: at the call site the callee frequently has not been seen yet.
- The tree carries spans as well as structure; without them, every diagnostic downstream loses its position.
- C and C++ deliberately break the separation with the lexer hack; almost every language designed since avoids needing to.
A list is not a structure
The lexer has already done real work. It decided that 12 is one number rather than two digits, that >= is one operator rather than two, that the run of spaces between them is not a token at all. What it produced is still a list: a total order over classified lexemes, and nothing more.
A list can answer "what is the third thing" and "how many things are there". It cannot answer "which of these operands does that operator take", because that is a claim about *containment*, and a list has no containment. Every question the rest of the compiler wants to ask — is this expression a call, what is the body of that loop, which branch does this else belong to — is a containment question. That is why parsing exists as a phase.
1 + 2 * 31 + 2 * 3Read it asFive tokens, one order, zero grouping. (1 + 2) * 3 and 1 + (2 * 3) produce token streams that differ only by two parenthesis tokens; strip those and the streams are identical while the answers are 9 and 7. The list is genuinely ambiguous. Something has to resolve it, and that something is the grammar — not the parser's taste.
The grammar decides; the parser obeys
Two grammars over the same tokens produce two different trees, and both parsers are correct. The one below stratifies expressions into expr, term and factor, and that layering — not any code in the parser — is what makes * bind tighter than +. Flatten the two levels into a single expr -> expr op expr rule and the same input has two derivations, which is what [[ambiguous-grammars]] is about.
This is the most useful thing to internalise about the phase: precedence and associativity are properties of the language, written into its grammar, and a parser is a procedure for recovering the tree the grammar already implies. When a compiler gets a + b * c wrong, the bug is almost never a typo in the parser — it is a precedence level in the wrong place, and it produces no error at all. See [[operator-precedence]] and [[associativity]].
| expr | ::= | expr '+' term | term | Left-recursive, so `+` groups left: `a + b + c` is `(a + b) + c`. |
| term | ::= | term '*' factor | factor | One level deeper than `expr`, which is the entire mechanism of precedence. |
| factor | ::= | NUMBER | '(' expr ')' |
- 1.exprapplying start symbol
- 2.expr '+' termapplying expr -> expr '+' term
- 3.term '+' termapplying expr -> term
- 4.factor '+' termapplying term -> factor
- 5.1 '+' termapplying factor -> NUMBER
- 6.1 '+' term '*' factorapplying term -> term '*' factor
- 7.1 '+' factor '*' factorapplying term -> factor
- 8.1 '+' 2 '*' factorapplying factor -> NUMBER
- 9.1 '+' 2 '*' 3applying factor -> NUMBER
The red flag: a parser does not understand your program
T * x; is a declaration if T names a type and a multiplication expression if it names a variable, so a C parser must consult a symbol table *while parsing* — the "lexer hack". C++ adds template-argument-versus-less-than and the most vexing parse on top. Languages designed after that experience (Go, Rust, Java) keep the grammar decidable without a symbol table, which is why their parsers can be pure and their IDE tooling can parse a file in isolation.The confident wrong statement, and it is worth demolishing explicitly, is "a parser understands whether the program makes semantic sense." It does not, it must not, and a parser that tried would be wrong about real programs.
Every one of these parses cleanly and is nonsense: undefinedVariable + 1. "hello" - true. f(1, 2, 3) where f takes one argument. return at the top level of a file. x = x where x is a constant. In each case the token sequence matches the grammar exactly, so the parser must accept it and hand on a tree. Rejection happens later, in name resolution and type checking, because only those phases have the table of declarations and the typing rules that the question requires — see [[semantic-analysis]] and [[type-checking]].
The separation is not fastidiousness, it is what makes forward references possible at all. In C++, Java, Rust and Go a function may call a function declared two hundred lines below it. When the parser reaches the call, the callee does not exist in any table yet. If the parser were checking, it would have to reject. Because it only builds structure, resolution can run afterwards over a complete tree and see every declaration in the file at once — which is exactly what [[declaration-order]] is about.
f(x) + 1| Question | Answered by | Why not the parser |
|---|---|---|
| Which tokens form one name? | Lexer | Already settled before the parser sees anything. |
Is f(x) a call, and is it the left operand of +? | Parser | This is the parser's entire job — pure structure. |
Does f exist, and which f is it? | Name resolution | Requires a symbol table over all scopes, which does not exist until the tree does. |
Does f accept one argument? | Type checking | Requires the resolved declaration's signature. |
Is + defined for the type f returns? | Type checking | Requires a type on every subexpression, computed bottom-up over the tree. |
Will f(x) overflow at runtime? | Nobody, statically | Needs the values, which no static phase has. |
What the tree buys the phases downstream
The output is not merely "structured input". It is a data structure with a shape that every later pass is written against: resolution walks it attaching symbols, type checking walks it attaching types, lowering walks it emitting IR. Because containment is now explicit, each of those is an ordinary tree traversal rather than a bespoke scan — see [[ast-traversal]] and [[visitor-pattern]].
The tree also carries spans forward. Each node records the source range of the tokens it was built from, so that a type error discovered four phases later can still point at the exact characters that caused it. A parser that builds a tree without spans produces a compiler that can never say more than "type error somewhere in this file" — which is why [[source-locations]] and [[spans-and-ranges]] sit in the very next module.
How it works
The steps, in the order the compiler takes them.
- The parser holds a position in the token list and a notion of what it is currently trying to recognise.
- At each step it inspects one or more tokens of lookahead and decides which grammar production applies.
- Applying a production consumes the tokens the production names and creates a node whose children are the sub-phrases the production names.
- Each node records a span covering the tokens it consumed, computed as the union of its children's spans plus its own punctuation.
- When the token at the current position matches no applicable production, the parser reports a diagnostic naming what it expected, then attempts to recover rather than stopping — see
[[error-recovery]]. - Parsing succeeds when the start symbol has been recognised and the only remaining token is end-of-file.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- The grammar puts a precedence level in the wrong place, so
a + b * cbuilds as(a + b) * c. Nothing errors, everything compiles, and the program returns wrong numbers forever. - Associativity is wrong for a non-associative operator, so
8 - 3 - 2evaluates as8 - (3 - 2)= 7 instead of 3. Passing tests that only use+and*never notice. - The parser also checks declarations, and a call to a function defined later in the file is reported as an undefined name. The user "fixes" it by reordering the file.
- Spans are attached to the wrong token — commonly the token *after* the failure — and every error message in the compiler points one token to the right for the rest of the product's life.
- The parser throws on the first error, so a file with two typos requires two compile-edit cycles to find both, and an IDE has no tree to offer completions from.
- An ambiguous grammar is fed to a generator, the tool silently resolves the conflict by a default rule, and the language's
elsebinds to the wrongifin one construct that nobody tests.
When it helps
- Locating a defect: a wrong result with no diagnostic points at the grammar; a diagnostic that names the wrong thing points at spans; a rejected legal program usually points at a phase doing another phase's job.
- Designing a language feature: if the feature can be expressed as a grammar production it costs a parser change; if it needs to know what a name refers to, it is a resolution or typing feature and much more expensive.
- Reading an unfamiliar compiler: knowing that the parser cannot know anything about meaning tells you immediately which directory a given error message cannot have come from.
When it hurts
- Insisting on the pure separation in a language that does not have it. A C or C++ frontend genuinely needs the symbol table during parsing, and pretending otherwise produces a parser that cannot parse C.
- Treating "it parsed" as "it is valid". Configuration and data formats fail this way constantly: the YAML parses, the schema is violated, and nothing checks the second half — see
[[parse-dont-validate]]in Backend Engineering for the discipline that fixes it.
What it costs
Every one of these is paid by something.
- Keeping the parser free of semantic knowledge buys forward references, order-independent declarations and a frontend that can parse one file without the rest of the project — and pays for it in a grammar that must be decidable without a symbol table, which rules out some syntax a language designer might want.
- Recording a span on every node buys every diagnostic, jump-to-definition and refactoring the tooling will ever offer, and pays two machine words per node in memory plus the discipline of maintaining spans through every later transformation.
- A stratified grammar (one nonterminal per precedence level) makes precedence unambiguous and readable, and pays a nonterminal and a function call per level — fifteen levels of C precedence means fifteen frames on the stack for every leaf expression.
What else you could do
What a different compiler or language does instead, and when that is better.
- Skip the tree entirely: a single-pass compiler emits code directly from the parser as it recognises each construct, as early Pascal and Turbo Pascal implementations did. Extremely fast and memory-light, and it forecloses every optimization and every IDE feature, because there is no representation left to analyse.
- Parse to a lossless concrete syntax tree instead of an AST, keeping whitespace and comments as trivia. Formatters, refactoring tools and IDEs need this; optimizers do not — see
[[concrete-syntax-tree]]. - Skip the separate lexer and parse characters directly, as PEG and parser-combinator libraries typically do. It removes one phase and one interface, and it costs the maximal-munch guarantees the lexer was providing — see
[[lexical-analysis]]. - For data rather than programs, a schema-driven parser derives structure from a declared schema rather than a hand-written grammar; that is the right choice when the format changes more often than the code.
See it for yourself
The flag, dump or tool that shows you this directly.
clang -Xclang -ast-dump -fsyntax-only file.cprints the tree the C or C++ parser built, before any code generation.python -c "import ast,sys; print(ast.dump(ast.parse(open(sys.argv[1]).read()), indent=2))" file.pyprints CPython's AST for a file.node --experimental-vm-modulesis not needed for this: any JavaScript environment plus a parser such as Acorn or Espree will print an ESTree tree; the online AST Explorer does it for dozens of languages at once.- To prove the parser checks no meaning, feed it deliberate nonsense that satisfies the grammar —
1 + undefinedName— and observe that the parse succeeds and the error arrives from a later phase with a different message prefix. - Our own explorer at
/compilers/pipelineshows the token panel and the tree panel side by side for whatever you type, with spans linked between them.
Plausible wrong readings
Stated the way a confident engineer states them.
- "A parser understands whether the program makes semantic sense." It does not.
"hello" - trueparses without complaint; the objection comes from the type checker several phases later, and building the objection into the parser would break forward references. - "The parser decides that
*binds tighter than+." The grammar decides. The parser is a procedure that recovers the tree the grammar specifies, and a different grammar over the same tokens legitimately yields a different tree. - "If it parses, the syntax is right." If it parses, the syntax matched *this* grammar. Whether the grammar matches the language specification is a separate question, and mismatches there are how two compilers for the same language disagree.
- "Parsing is the hard part of a compiler." Parsing is the best-understood part, with fifty years of theory and generated solutions. The hard parts are semantics, legality and diagnostics.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A parser takes the flat list of tokens the lexer produced and works out which tokens belong together, producing a tree. The tree says what is applied to what: that * takes 2 and 3, and that + takes 1 and the result. It does not say whether any of the names exist or whether the operation makes sense — those are later questions, asked by phases that have information the parser does not.
practical
When something is wrong, use the phase boundary as a filter. Wrong answer with no error message: suspect the grammar's precedence or associativity, and dump the tree to confirm. Error message pointing at the wrong characters: suspect spans, not the grammar. A legal program rejected with a name error: suspect a phase doing another phase's work. And when you add syntax to a language, ask first whether the new construct is decidable from tokens alone — if recognising it requires knowing what a name refers to, you are not adding a grammar rule, you are adding a resolution rule, and the cost is an order of magnitude different.
advanced
The interesting design pressure is that the ideal separation and real language design pull against each other. Every syntax that reads nicely to humans risks needing context: T * x in C, a < b > c in a language with generics, an identifier that may be a macro. A language designer trades expressive syntax against a decidable grammar, and the bill arrives in the tooling — a grammar that needs a symbol table cannot be parsed incrementally per keystroke without also maintaining the symbol table incrementally, which is a substantially harder engineering problem and one reason C++ IDE support lagged Java's for two decades.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Give me a program that parses successfully and is complete nonsense. Which phase rejects it, and what does that phase know that the parser did not?
- Why can a parser not check that a called function exists?
- Where does the rule that
*binds tighter than+physically live in a compiler?
Connections
- Testing & Reliability Engineering — Property-based testing of a round tripThe strongest parser test is a property: parse then print then parse again must yield the same tree. The technique is general and owned there; applying it to a grammar is
[[compiler-testing]]here.