Operator Precedence Parsing
Master a simple but powerful bottom-up technique used to parse expressions — built around precedence and associativity relations between operators (<·, =·, ·>).
What is Operator Precedence Parsing?
Operator-precedence parsing is a simple bottom-up technique that works on expression grammars. It does not need a full parsing table — only a table of relationships between pairs of terminals.
It is the technique most often used in handwritten calculator parsers and in pocket-calculator firmware.
What is an Operator Grammar?
A grammar is an operator grammar if it satisfies two rules:
- No production has ε on the right-hand side.
- No production has two adjacent non-terminals on the right-hand side.
For example, this is an operator grammar:
Every production has non-terminals separated by a terminal (+, *, (, )). Note: the grammar is ambiguous — but operator-precedence parsing doesn't care about ambiguity because it disambiguates using a precedence table.
This is not an operator grammar:
The production S → A B has two non-terminals next to each other — not allowed.
Why "Operator Precedence"?
Because the parser uses a table of precedence relations between operators (terminals) to decide whether to shift the next token or reduce the handle on the stack.
These three relations — <·, =·, ·> — are the entire decision-making mechanism.
The Three Precedence Relations
Let's break down the meaning of each precedence relation in detail.
a <· b — "a yields precedence to b"
When the parser has just seen a and the next token is b, and a <· b, the parser will shift b onto the stack and process b first.
a =· b — "a and b are at the same level"
These two terminals appear together in some production (typically matching delimiters like ( and )).
a ·> b — "a takes precedence over b"
The parser must reduce before processing b.
Important — These are NOT symmetric
Don't confuse these relations with mathematical <, =, >:
Each relation between any pair of terminals is independent. Some pairs may have no relation at all (error entries).
Example Precedence Table
For the grammar E → E + E | E * E | ( E ) | id:
Reading the table: pick the row for the topmost terminal on the stack, the column for the next input token. The cell is the relation.
The blank cells (—) are errors — those pairs cannot legally appear next to each other.
The Operator-Precedence Parsing Algorithm
Once we have the precedence table, parsing is straightforward.
The Algorithm
The parser does not need to know which production to apply — the operators between the popped tokens uniquely identify the handle.
Step-by-Step Trace — id + id * id
Grammar: E → E + E | E * E | ( E ) | id
(Using the table above. The topmost terminal on the stack is what we compare; non-terminals are invisible to the comparison.)
Notice how the parser automatically:
- Reduces
idtoEwhenever the lookahead has higher precedence. - Defers reducing
E + Euntil afterE * Efinishes — that's how precedence is enforced.
Computing Precedence Relations — LEADING and TRAILING
So far we have used a hand-written precedence table. For a general grammar, we compute the relations using two auxiliary sets, LEADING and TRAILING, for each non-terminal.
LEADING(A)
The set of terminals that can appear as the first terminal in some string derived from A.
TRAILING(A)
The set of terminals that can appear as the last terminal in some string derived from A. (Mirror of LEADING — scanned right to left.)
Computing the Three Relations
Once LEADING and TRAILING are known, the relations are derived directly from the productions:
Plus, for the start symbol:
$ <· every terminal in LEADING(start)every terminal in TRAILING(start) ·> $
Worked Example
Grammar: E → E + T | T, T → T * F | F, F → ( E ) | id
Step 1 — LEADING and TRAILING.
Step 2 — Apply the rules.
From E → E + T:
+ <· LEADING(T)→+ <· *,+ <· (,+ <· idTRAILING(E) ·> +→+ ·> +,* ·> +,) ·> +,id ·> +
From T → T * F:
* <· LEADING(F)→* <· (,* <· idTRAILING(T) ·> *→* ·> *,) ·> *,id ·> *
From F → ( E ):
( <· LEADING(E)→( <· +,( <· *,( <· (,( <· idTRAILING(E) ·> )→+ ·> ),* ·> ),) ·> ),id ·> )( =· )
After collecting all of these, we get exactly the precedence table shown in the previous section.
Advantages and Limitations
Operator-precedence parsing is one of the simplest parsing techniques in any compiler course — but it is also one of the most limited.
Advantages
| Advantage | Detail |
|---|---|
| Simple to implement | Just a precedence table and a stack — fits on one page |
| Fast | Each token is shifted and reduced O(1) times — O(n) overall |
| Small tables | Only |
| No grammar transformation | The grammar can be left-recursive or ambiguous — table sidesteps this |
| Natural for calculators | Pocket calculators and small expression interpreters use this directly |
Limitations
| Limitation | Detail |
|---|---|
| Only works on operator grammars | No ε productions, no adjacent non-terminals |
| Reductions don't say WHICH production | Cannot easily build an AST that distinguishes + and - nodes if both reduce to E (workaround: peek at popped operator) |
| Cannot detect every error | A blank cell catches some errors, but ambiguous grammars may accept wrong inputs |
| Not used in real compilers | LR/LALR replaced it for serious work — but it lives on in hand-written expression parsers |
When to Use
- Building a calculator or expression interpreter by hand — operator precedence is the easiest and most practical choice.
- Teaching parsing — its mechanics make the concepts of shift, reduce, and precedence concrete.
- A small DSL with only expressions — quick to implement, easy to extend.
Modern Variants
The same idea lives on under different names:
- Pratt parsing (top-down operator precedence) — a top-down variant invented by Vaughan Pratt. Used in TypeScript's parser, ESLint, and many modern hand-written compilers.
- Shunting-yard algorithm (Dijkstra) — Dijkstra's classic algorithm that converts infix expressions into Reverse Polish Notation using exactly these precedence relations.
If you have ever written a Pratt parser, you have written an operator-precedence parser — just dressed up as recursive descent.