Parsing Expressions with Operators
Expressions with operators (1 + 2 * 3, -x ** 2, f(a)[i].name, c ? a : b) are where a grammar needs
precedence and associativity. PEGO gives you three ways to write them:
| Technique | You write | Typical size |
|---|---|---|
Pratt expression (pratt { ... }) |
A table: operands, then one level per precedence level, each with its operators |
One rule |
| Left-recursive rules | One rule per level, each of the form level = level OP next / next |
One rule per level |
| Precedence chain | One rule per level, each a repetition next (OP next)* folded with foldl |
One rule per level |
This guide builds the same calculator in all three styles, compares the trees and error messages they produce, and
helps you choose. It relies on Trees and actions for captures, types and foldl; the normative
text is in Pratt Expressions and
Left recursion, and the reasoning behind the design in
design record 005 and design record 003.
To follow along, put each grammar in the file named on its first line and run the commands shown (pego is the tool
from go install github.com/ornew/pego/cmd/pego@latest, or go run ./cmd/pego in a checkout). The Pratt and the
left-recursive calculator are the files calc.pego and calc_lr.pego in
examples/calculator (without their header comments); the chain variant is written out
below.
1. A Pratt calculator
// calc.pego
package calc
type Number terminal
type Binary struct {
Left Expr
Op Match
Right Expr
}
type Unary struct {
Op Match
X Expr
}
type Expr = Number | Binary | Unary
def main: Expr = e:expr ws $$ -> $e
def expr: Expr = pratt {
skip ws
operand number
operand "(" e:expr ws ")" -> $e
level { infix left "+" / "-" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { infix left "*" / "/" / "%" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { prefix "-" / "+" -> new Unary{Op: $op, X: $rhs} }
level { infix right "^" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
}
def number: Number = (?0-9)+ ("." (?0-9)+)?
def ws = (? \t\r\n)*
pego parse -g calc.pego -f sexpr -i '1 + 2 * 3'
pego parse -g calc.pego -f sexpr -i '1 - 2 - 3'
pego parse -g calc.pego -f sexpr -i '2 ^ 3 ^ 2'
pego parse -g calc.pego -f sexpr -i '-2 ^ 2'
pego parse -g calc.pego -f sexpr -i '2 ^ -1'
pego parse -g calc.pego -f sexpr -i '(1 + 2) * 3'
(Binary Left=Number"1"@number Op="+" Right=(Binary Left=Number"2"@number Op="*" Right=Number"3"@number))
(Binary Left=(Binary Left=Number"1"@number Op="-" Right=Number"2"@number) Op="-" Right=Number"3"@number)
(Binary Left=Number"2"@number Op="^" Right=(Binary Left=Number"3"@number Op="^" Right=Number"2"@number))
(Unary Op="-" X=(Binary Left=Number"2"@number Op="^" Right=Number"2"@number))
(Binary Left=Number"2"@number Op="^" Right=(Unary Op="-" X=Number"1"@number))
(Binary Left=(Binary Left=Number"1"@number Op="+" Right=Number"2"@number) Op="*" Right=Number"3"@number)
Here is how to read the pratt block.
skip wsis inserted, with its result discarded, before every operand and every operator. It is how a grammar without a scanner says "tokens may be separated by whitespace". Without askip, nothing is inserted.operanditems describe what an expression can start with when no prefix operator applies: numbers and parenthesized expressions here. Severaloperanditems are tried in order, like an ordered choice. An action belongs to its own item, so alternatives that need different actions are separate items (operand "(" e:expr ws ")" -> $eunwraps the parentheses). An operand cannot start by calling its own rule: that would be left recursion, and it is a compile error. Constructs that take an expression on their left (calls, indexing) are operators.level { ... }declares one binding level. Levels are listed from the loosest to the tightest, so inserting a level is a one-line change and nothing else has to be renumbered. All operators of a level share a precedence, and a level may mix prefix, infix and postfix operators.infix left | right | nonegives the associativity:1 - 2 - 3is(1 - 2) - 3,2 ^ 3 ^ 2is2 ^ (3 ^ 2), andnoneoperators do not chain (section 5).- An operator part (
"+" / "-") is an ordinary parsing expression. It may be several tokens, call other rules, or contain captures (section 4). - Actions see
$lhs,$rhs(the operands) and$op(the text the operator part matched, as aMatchnode). In a rule with a declared type,$lhsand$rhshave that type, which is what lets theBinaryfields beExpr.
The unary minus is declared in a level tighter than * but looser than ^: -2 ^ 2 is -(2 ^ 2), while 2 ^ -1
still parses, because a prefix operator at the start of an operand position is not restricted by the levels.
If the input ends where an operand is required, the error lists what could have followed, without the whitespace that
skip would have consumed (compare the next section):
pego parse -g calc.pego -f sexpr -i '1 +'
pego: 1:4: syntax error: expected "(", "+", "-", (?0-9)
2. The same language with left recursion
A PEG rule may call itself in leftmost position, directly or through other rules. PEGO detects such cycles and parses them by growing a seed: it first matches the non-recursive alternatives, then repeatedly tries again with the result so far as the recursive call, as long as the match gets longer. The result is exactly what the grammar says in the textbook notation, in linear time and without any rewriting:
// calc_lr.pego
package calc
type Number terminal
type Binary struct {
Left Expr
Op Match
Right Expr
}
type Unary struct {
Op Match
X Expr
}
type Expr = Number | Binary | Unary
def main: Expr = e:expr ws $$ -> $e
def expr: Expr = add / term
def add: Expr = l:expr ws op:@("+" / "-") r:term -> new Binary{Left: $l, Op: $op, Right: $r}
def term: Expr = mul / unary
def mul: Expr = l:term ws op:@("*" / "/" / "%") r:unary -> new Binary{Left: $l, Op: $op, Right: $r}
def unary: Expr = neg / power
def neg: Expr = ws op:@("-" / "+") x:unary -> new Unary{Op: $op, X: $x}
// Exponentiation is right-associative, and its right operand may carry a unary operator (2^-1).
def power: Expr = pow / primary
def pow: Expr = l:primary ws op:"^" r:unary -> new Binary{Left: $l, Op: $op, Right: $r}
def primary: Expr = ws p:(number / group) -> $p
def group: Expr = "(" e:expr ws ")" -> $e
def number: Number = (?0-9)+ ("." (?0-9)+)?
def ws = (? \t\r\n)*
pego parse -g calc_lr.pego -f sexpr -i '1 + 2 * 3'
pego parse -g calc_lr.pego -f sexpr -i '1 - 2 - 3'
pego parse -g calc_lr.pego -f sexpr -i '2 ^ 3 ^ 2'
pego parse -g calc_lr.pego -f sexpr -i '-2 ^ 2'
pego parse -g calc_lr.pego -f sexpr -i '2 ^ -1'
pego parse -g calc_lr.pego -f sexpr -i '(1 + 2) * 3'
(Binary Left=Number"1"@number Op="+" Right=(Binary Left=Number"2"@number Op="*" Right=Number"3"@number))
(Binary Left=(Binary Left=Number"1"@number Op="-" Right=Number"2"@number) Op="-" Right=Number"3"@number)
(Binary Left=Number"2"@number Op="^" Right=(Binary Left=Number"3"@number Op="^" Right=Number"2"@number))
(Unary Op="-" X=(Binary Left=Number"2"@number Op="^" Right=Number"2"@number))
(Binary Left=Number"2"@number Op="^" Right=(Unary Op="-" X=Number"1"@number))
(Binary Left=(Binary Left=Number"1"@number Op="+" Right=Number"2"@number) Op="*" Right=Number"3"@number)
The trees are identical, because both grammars build the same typed AST through actions. What differs is how the grammar is organized.
- One rule per level, linked in a chain.
exprisadd / term,termismul / unary, and so on. A new level means a new pair of rules and an edit to the rule above it. - Left associativity is the shape of the recursion:
add = expr "+" termmakes the left operand as long as possible. Right associativity is recursion on the right: inpow = primary "^" unarythe right operand is aunary, which can lead to anotherpow. Nothing in the text says "right associative"; it is a consequence of where the recursion is. - Whitespace is part of the rules: every rule that starts a token or follows an operand has to say
ws, and the main rule has to consume the trailingws. - Every level is a rule you can call.
termparses a product without sums, which is useful if other parts of your grammar need exactly that (a Pratt expression offers the same with level-restricted calls).
pego parse -g calc_lr.pego -f sexpr -i '1 +'
pego: 1:4: syntax error: expected "(", "+", "-", (? \t\r\n), (?0-9)
Note the extra (? \t\r\n) in the expected list: a rule-based grammar reports its whitespace, while skip is hidden
from error messages.
Left recursion pitfalls
The recursive alternative must come first. Growing starts from the non-recursive match and then tries the alternatives in order with the recursive call bound to the result so far. If the base case comes first, it matches again with the same length, nothing grows, and the rest of the input is left over:
// lr-order.pego
def good = expr $$
def expr = expr "+" term / term
def term = @(?0-9)+
def bad = expr2 $$
def expr2 = term / expr2 "+" term
pego parse -g lr-order.pego -s good -f sexpr -i '1+2+3'
pego parse -g lr-order.pego -s bad -f sexpr -i '1+2+3'
(Seq (Seq (Seq "1"@term "+" "2"@term)@expr "+" "3"@term)@expr)@good
pego: 1:2: syntax error: expected (?0-9), end of input
The error ("expected a digit or the end of input at column 2") is accurate but gives no hint about the order of alternatives; when a left-recursive rule stops short, look at the order first.
Ordered choice picks the first operator that matches, not the longest. In op:("<" / "<=") the < wins on 1<=2
and =2 is left over; write "<=" / "<" (or put !"=" after "<"). This is the usual PEG rule, and you have to
follow it in every operator list. A Pratt expression does not have the problem (section 8).
// lr-ops.pego
def rel = l:num op:("<" / "<=") r:num
def rel2 = l:num op:("<=" / "<") r:num
def num = @(?0-9)+
pego parse -g lr-ops.pego -s rel -f sexpr -i '1<=2'
pego parse -g lr-ops.pego -s rel2 -f sexpr -i '1<=2'
pego: 1:3: syntax error: expected (?0-9)
(Seq "1"@num "<=" "2"@num l="1"@num op="<=" r="2"@num)@rel2
3. The precedence chain
The oldest way to write an expression grammar needs neither left recursion nor Pratt. Each level matches an operand of
the next level, followed by any number of operator-operand pairs, and foldl builds the left-leaning tree
(Recipe 1 of the trees guide):
// chain.pego
type Number terminal
type Binary struct { Left Expr, Op Match, Right Expr }
type Unary struct { Op Match, X Expr }
type Expr = Number | Binary | Unary
def main: Expr = e:expr ws $$ -> $e
def expr: Expr = l:term rest:(ws op:@("+" / "-") r:term)*
-> foldl($l, $rest, (acc, i) => new Binary{Left: $acc, Op: $i.op, Right: $i.r})
def term: Expr = l:unary rest:(ws op:@("*" / "/" / "%") r:unary)*
-> foldl($l, $rest, (acc, i) => new Binary{Left: $acc, Op: $i.op, Right: $i.r})
def unary: Expr = neg / power
def neg: Expr = ws op:@("-" / "+") x:unary -> new Unary{Op: $op, X: $x}
// right-associative: the right operand is parsed by the rule that includes the operator itself
def power: Expr = pow / primary
def pow: Expr = l:primary ws op:"^" r:unary -> new Binary{Left: $l, Op: $op, Right: $r}
def primary: Expr = ws p:(number / group) -> $p
def group: Expr = "(" e:expr ws ")" -> $e
def number: Number = (?0-9)+ ("." (?0-9)+)?
def ws = (? \t\r\n)*
pego parse -g chain.pego -f sexpr -i '1 - 2 - 3'
pego parse -g chain.pego -f sexpr -i '2 ^ 3 ^ 2'
pego parse -g chain.pego -f sexpr -i '-2 ^ 2'
(Binary Left=(Binary Left=Number"1"@number Op="-" Right=Number"2"@number) Op="-" Right=Number"3"@number)
(Binary Left=Number"2"@number Op="^" Right=(Binary Left=Number"3"@number Op="^" Right=Number"2"@number))
(Unary Op="-" X=(Binary Left=Number"2"@number Op="^" Right=Number"2"@number))
Left-associative levels use the repetition and foldl; right-associative ones recurse on the right. It works in any
PEG tool, and the only machinery it uses is ordered choice and repetition. Its costs are the same as for rules: one rule
per level, whitespace handled by hand, and one rule call per level for every operand, even a bare number.
Non-associative operators. Replace the repetition with {0,1}: it yields a list with at most one element, so
foldl handles it unchanged, and a second operator is simply not consumed:
// chain-cmp.pego
type Num terminal
type Bin struct { L E, Op Match, R E }
type E = Num | Bin
def main: E = e:cmp ws $$ -> $e
// at most one comparison: "1 < 2 < 3" is a syntax error
def cmp: E = l:add rest:(ws op:@("<=" / ">=" / "<" / ">" / "==" / "!=") r:add){0,1}
-> foldl($l, $rest, (acc, i) => new Bin{L: $acc, Op: $i.op, R: $i.r})
def add: E = l:atom rest:(ws op:@("+" / "-") r:atom)*
-> foldl($l, $rest, (acc, i) => new Bin{L: $acc, Op: $i.op, R: $i.r})
def atom: E = ws n:num -> $n
def num: Num = (?0-9)+
def ws = " "*
pego parse -g chain-cmp.pego -f sexpr -i '1 + 2 <= 3'
pego parse -g chain-cmp.pego -f sexpr -i '1 < 2 < 3'
(Bin L=(Bin L=Num"1"@num Op="+" R=Num"2"@num) Op="<=" R=Num"3"@num)
pego: 1:7: syntax error: expected " ", "+", "-", end of input
4. Level-restricted calls
Some constructs contain an expression that must not use the loosest operators. The classic example is the argument list
of a call: in f(a, b) the comma separates arguments, so the arguments must be parsed without the comma operator,
while (a, b) in parentheses is a comma expression. A Pratt expression handles this with a named level and a call
with that level, expr(assignment): it parses only operators of the named level and tighter ones. A call without a
level (expr) parses all levels. The rule name and ( must be adjacent.
The grammar below is the one from the specification: it has every kind of operator.
// js.pego
type Num terminal
type Ident terminal
type Comma struct { L Expr, R Expr }
type Assign struct { L Expr, R Expr }
type Cond struct { Cond Expr, Then Expr, Else Expr }
type Bin struct { L Expr, Op Match, R Expr }
type Unary struct { Op Match, X Expr }
type Call struct { Fn Expr, Args []Expr }
type Index struct { X Expr, Index Expr }
type Member struct { X Expr, Name Ident }
type Expr = Num | Ident | Comma | Assign | Cond | Bin | Unary | Call | Index | Member
def expr: Expr = pratt {
skip ws
operand number
operand ident
operand "(" e:expr ws ")" -> $e
level { infix left "," -> new Comma{L: $lhs, R: $rhs} }
level assignment { infix right "=" -> new Assign{L: $lhs, R: $rhs} }
level { infix right "?" then:expr ws ":" -> new Cond{Cond: $lhs, Then: $then, Else: $rhs} }
level { infix none "==" / "!=" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { infix left "+" / "-" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { infix left "*" / "/" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { prefix "-" / "!" -> new Unary{Op: $op, X: $rhs} }
level { infix right "**" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level {
postfix "(" xs:args? ws ")" -> new Call{Fn: $lhs, Args: concat($xs)}
postfix "[" i:expr ws "]" -> new Index{X: $lhs, Index: $i}
postfix "." name:ident -> new Member{X: $lhs, Name: $name}
}
}
def args = first:expr(assignment) rest:(-ws "," x:expr(assignment))*
-> concat(list($first), map($rest, (r) => $r.x))
def number: Num = (?0-9)+
def ident: Ident = (?a-z)+
def ws = (? \t\r\n)*
pego parse -g js.pego -s expr -f sexpr -i 'x, y'
pego parse -g js.pego -s expr -f sexpr -i 'f(a = 1, b)(2)[3].x'
pego parse -g js.pego -s expr -f sexpr -i 'a = b = c'
pego parse -g js.pego -s expr -f sexpr -i 'a ? b : c ? d : e'
pego parse -g js.pego -s expr -f sexpr -i '-a ** b'
pego parse -g js.pego -s expr -f sexpr -i '2 ** -1'
(Comma L=Ident"x"@ident R=Ident"y"@ident)
(Member Name=Ident"x"@ident X=(Index Index=Num"3"@number X=(Call Args=[Num"2"@number] Fn=(Call Args=[(Assign L=Ident"a"@ident R=Num"1"@number) Ident"b"@ident] Fn=Ident"f"@ident))))
(Assign L=Ident"a"@ident R=(Assign L=Ident"b"@ident R=Ident"c"@ident))
(Cond Cond=Ident"a"@ident Else=(Cond Cond=Ident"c"@ident Else=Ident"e"@ident Then=Ident"d"@ident) Then=Ident"b"@ident)
(Unary Op="-" X=(Bin L=Ident"a"@ident Op="**" R=Ident"b"@ident))
(Bin L=Num"2"@number Op="**" R=(Unary Op="-" X=Num"1"@number))
Things to notice:
f(a = 1, b)has two arguments:argscallsexpr(assignment), which includes the=level and everything tighter but not,. Outside a call,x, yis aCommanode.- The postfix operators
(...),[...]and.nameshare one level, sof(a)(2)[3].xchains left to right. Their operator parts are arbitrary parsing expressions:"(" xs:args? ws ")"parses an argument list with the rest of the grammar, and the whole matched part is$op(here the actions use the captures instead). "?" then:expr ws ":"is a mixfix operator: the middle operand is parsed by a fullexprcall, and the right operand by the Pratt loop, which makesa ? b : c ? d : enest to the right.-a ** bis-(a ** b)because**is in a tighter level than the prefix-, and2 ** -1parses anyway.skipis not inserted inside an operator part, so"?" then:expr ws ":"spells out the whitespace before":". See pitfalls.
5. Operator forms and associativity
| Declaration | Form | Notes |
|---|---|---|
prefix P |
P rhs |
The operand is parsed by tighter levels only |
postfix P |
lhs P |
Applied repeatedly: a!! is (a!)! |
infix left P |
lhs P rhs |
a - b - c is (a - b) - c |
infix right P |
lhs P rhs |
a ** b ** c is a ** (b ** c); the right operand may contain the same level |
infix none P |
lhs P rhs |
a == b == c does not chain: the expression stops after a == b |
infix left _ |
lhs rhs |
An empty operator part is juxtaposition: function application, concatenation |
Non-associative operators. After a == b, an == of the same level is not consumed, so the enclosing rule sees
unexpected input. In the grammar of the previous section:
pego parse -g js.pego -s expr -f sexpr -i 'a == b == c'
pego: 1:8: syntax error: expected "(", "*", "**", "+", ",", "-", ".", "/", "?", "["
The reported position is the second ==, and the list of expected tokens contains neither == nor !=, which is the
accurate message for a chain: the expression stops after a == b, and the start rule then reports the leftover input.
Juxtaposition. An infix operator may match the empty string, which is how application is written. It is a normal level, so you choose how tightly it binds:
// apply.pego
type Id terminal
type Num terminal
type Bin struct { L E, Op Match, R E }
type App struct { Fn E, Arg E }
type Neg struct { X E }
type E = Id | Num | Bin | App | Neg
def e: E = pratt {
skip ws
operand num
operand id
operand "(" x:e ws ")" -> $x
level { infix left "+" / "-" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { infix left _ -> new App{Fn: $lhs, Arg: $rhs} }
level { prefix "-" -> new Neg{X: $rhs} }
}
def num: Num = (?0-9)+
def id: Id = (?a-z)+
def ws = " "*
pego parse -g apply.pego -s e -f sexpr -i 'f x y'
pego parse -g apply.pego -s e -f sexpr -i 'f x + g y'
pego parse -g apply.pego -s e -f sexpr -i 'f -1'
pego parse -g apply.pego -s e -f sexpr -i 'f (-1)'
(App Arg=Id"y"@id Fn=(App Arg=Id"x"@id Fn=Id"f"@id))
(Bin L=(App Arg=Id"x"@id Fn=Id"f"@id) Op="+" R=(App Arg=Id"y"@id Fn=Id"g"@id))
(Bin L=Id"f"@id Op="-" R=Num"1"@num)
(App Arg=(Neg X=Num"1"@num) Fn=Id"f"@id)
The last two lines show the catch of juxtaposition: f -1 is a subtraction, because at that position both - and
the empty operator match and the longer one wins (see pitfalls). The argument has to be parenthesized.
Prefix and postfix in the same level. The right operand of a prefix operator contains only tighter levels, so a
postfix operator of the same level is applied to the whole prefix expression: -3! is (-3)!. Put the postfix operator
in a tighter level if you want -(3!).
// same-level.pego
type Num terminal
type Neg struct { X E }
type Fact struct { X E }
type E = Num | Neg | Fact
def same: E = pratt {
operand num
level {
prefix "-" -> new Neg{X: $rhs}
postfix "!" -> new Fact{X: $lhs}
}
}
def tighter: E = pratt {
operand num
level { prefix "-" -> new Neg{X: $rhs} }
level { postfix "!" -> new Fact{X: $lhs} }
}
def num: Num = (?0-9)+
pego parse -g same-level.pego -s same -f sexpr -i '-3!'
pego parse -g same-level.pego -s tighter -f sexpr -i '-3!'
(Fact X=(Neg X=Num"3"@num))
(Neg X=(Fact X=Num"3"@num))
6. The tree of a Pratt expression
Typed rules and the Operator node
When an operator has no action, it produces a node of the reserved type Operator. Its children are [lhs, op, rhs]
for infix, [op, rhs] for prefix and [lhs, op] for postfix operators, its label is the rule, and its operator field
is the index of the operator in declaration order across all levels. Operands without an action appear unchanged:
// raw.pego
def e = pratt {
operand @(?0-9)+
level { infix left "+" }
level { infix left "*" }
level { prefix "-" }
level { postfix "!" }
}
pego parse -g raw.pego -s e -f sexpr -i '-1+2*3!'
pego parse -g raw.pego -s e -f sexpr -i '1+2+3'
(Operator (Operator "-" "1"@e operator=2)@e "+" (Operator "2"@e "*" (Operator "3"@e "!" operator=3)@e operator=1)@e operator=0)@e
(Operator (Operator "1"@e "+" "2"@e operator=0)@e "+" "3"@e operator=0)@e
Here + is operator 0, * is 1, - is 2 and ! is 3; the input is (-1) + (2 * (3!)). The tree is as deep as the
expression, not as deep as the number of levels. Compare the tree of a rule chain without actions, where even a bare
number passes through every level:
// chain-cst.pego
def expr = term (("+" / "-") term)*
def term = factor (("*" / "/") factor)*
def factor = @(?0-9)+
pego parse -g chain-cst.pego -s expr -f sexpr -i '7'
pego parse -g raw.pego -s e -f sexpr -i '7'
(Seq (Seq "7"@factor [])@term [])@expr
"7"@e
The default Pratt tree is a good starting point (nothing to write), but note the consequence for typed rules: if the
rule has a declared type that does not include Operator, every operator needs an action:
// raw-typed.pego
type Num terminal
def t: Num = pratt {
operand num
level { infix left "+" }
}
def num: Num = (?0-9)+
pego parse -g raw-typed.pego -s t -i '1+2'
pego: raw-typed.pego:3:1: operators of t without an action produce Operator, which is not assignable to Num
$n and operators
Operator actions cannot use $1, $2, ...; use the captures in the operator part or $lhs, $rhs, $op. Operand
actions can use $n:
// operand-n.pego
type Num terminal
type Paren struct { X E }
type E = Num | Paren
def e: E = pratt {
operand num
operand "(" e ")" -> new Paren{X: $2}
}
def num: Num = (?0-9)+
pego parse -g operand-n.pego -s e -f sexpr -i '((1))'
(Paren X=(Paren X=Num"1"@num))
7. Choosing between the three
| Pratt expression | Left-recursive rules | Precedence chain | |
|---|---|---|---|
| Adding a level | One level line |
Two rules and an edit to a neighbour | A rule and an edit to a neighbour |
| Associativity | Declared (left, right, none) |
Shape of the recursion | Repetition + foldl, or recursion |
| Prefix, postfix, mixfix, ternary, calls | Operators in a level | Extra alternatives per level | Extra alternatives per level |
| Operator selection | Longest match, then declaration order | First match in the ordered choice | First match in the ordered choice |
| Whitespace | skip, hidden from error messages |
By hand in every rule | By hand in every rule |
| Tree depth without actions | Proportional to the expression | One node per level for every operand | One node per level for every operand |
| Rules callable for a sub-level | expr(level) |
Each level is a rule | Each level is a rule |
| Speed (20,000-term expression) | 9.76 ms | 25.6 ms | not in the benchmarks |
| Needs left recursion support | No | Yes | No |
The speed row is from benchmarks.md (closure backend, 133 KB input): the Pratt calculator is about 2.6 times as fast as the left-recursive one, as you would expect from a loop that does not make one rule call per level for every operand and does not grow seeds.
A rule of thumb:
- Use a Pratt expression for any language with more than two or three precedence levels, for prefix, postfix,
ternary or call/index operators, or when you expect to add operators later. It is the default for expression
languages, and what the larger grammars (
examples/minilang,parsers/golang,parsers/python) use. - Use left-recursive rules when the recursion is not an operator table: member chains (
a.b.c), a handful of levels in an otherwise ordinary grammar, constructs such aslist = list "," item / item, or a grammar ported from a notation that already uses left recursion. They also work with Pratt expressions in the same grammar. - Use the chain when you need plain PEG that other tools can read, or for a one- or two-level expression where a fold is the shortest thing to write.
The three can be mixed freely. A Pratt expression's operands and operator parts are normal parsing expressions, so they can call any rule; and the rules of a statement grammar call the rule that holds the Pratt expression like any other.
8. Pitfalls
The word operand (and level, skip, prefix, postfix, infix, pratt) is a keyword. A rule called
operand can be defined, but every reference to it is a syntax error (expected an expression, found 'operand');
pick another name such as atom. The complete list is in
Lexical Structure.
An operand cannot call its own rule at the start. It would be left recursion inside the loop. The compiler says so:
// operand-lr.pego
def e = pratt {
operand e "!"
operand (?0-9)+
level { infix left "+" }
}
pego parse -g operand-lr.pego -s e -i '1'
pego: operand-lr.pego:2:9: operand of e calls e at its start (left recursion); use infix or postfix instead
Write ! as a postfix operator instead.
skip is not inserted inside an operator part or an operand, and trailing whitespace is not consumed. In
infix right "?" t:expr ":" the ":" must directly follow the end of t; write ws before it. Otherwise the operator
part fails, the operator is not applied, and the error is reported at the farthest failure (below, the colon at column 7,
which no operator can continue). After the last token the Pratt expression stops, so the rule that uses it must
consume trailing whitespace (the main rules of the calculators say ws $$).
// skip.pego
type Num terminal
type Id terminal
type Cond struct { C E, T E, F E }
type E = Num | Id | Cond
def loose: E = pratt {
skip ws
operand num
operand id
level { infix right "?" t:loose ":" -> new Cond{C: $lhs, T: $t, F: $rhs} }
}
def tight: E = pratt {
skip ws
operand num
operand id
level { infix right "?" t:tight ws ":" -> new Cond{C: $lhs, T: $t, F: $rhs} }
}
def num: Num = (?0-9)+
def id: Id = (?a-z)+
def ws = " "*
pego parse -g skip.pego -s loose -f sexpr -i 'a ? b : c'
pego parse -g skip.pego -s loose -f sexpr -i 'a ? b: c'
pego parse -g skip.pego -s tight -f sexpr -i 'a ? b : c'
pego: 1:7: syntax error: expected "?"
(Cond C=Id"a"@id F=Id"c"@id T=Id"b"@id)
(Cond C=Id"a"@id F=Id"c"@id T=Id"b"@id)
Operators are chosen by longest match, before precedence is considered. At each position the parser picks, among the operator parts that match, the longest one; only then does it check whether the operator can be applied at the current level. If it cannot, the parser does not fall back to a shorter operator. This is the behavior of a tokenizer's maximal munch, and it has two consequences.
First, the order of the operators does not matter for correctness. "<" and "<=" can be in different levels, in any
order, and 1 <= 2 parses as expected (in rule-based grammars "<" / "<=" is the bug shown in section 2).
Second, a ++b is a postfix ++ followed by an unexpected b, and not a + (+b), even though both are
syntactically possible:
// munch.pego
type Num terminal
type Bin struct { L E, Op Match, R E }
type Un struct { Op Match, X E }
type Post struct { X E, Op Match }
type E = Num | Bin | Un | Post
def main: E = e:e $$ -> $e
def e: E = pratt {
skip ws
operand num
level { infix left "+" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { prefix "+" -> new Un{Op: $op, X: $rhs} }
level { postfix "++" -> new Post{X: $lhs, Op: $op} }
}
def num: Num = (?0-9)+
def ws = " "*
pego parse -g munch.pego -f sexpr -i '1 + +2'
pego parse -g munch.pego -f sexpr -i '1 ++2'
pego parse -g munch.pego -f sexpr -i '1 ++'
(Bin L=Num"1"@num Op="+" R=(Un Op="+" X=Num"2"@num))
pego: 1:5: syntax error: expected "+", "++", end of input
(Post Op="++" X=Num"1"@num)
As in C, write 1 + +2 with a space between the operators.
Word operators need a word boundary. "and" also matches the start of android. Check the boundary in the
operator part with a negative lookahead, and exclude the keywords from identifiers:
// words.pego
type Id terminal
type Bin struct { L E, Op Match, R E }
type Not struct { X E }
type E = Id | Bin | Not
def e: E = pratt {
skip ws
operand id
level { infix left "or" !idchar -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { infix left "and" !idchar -> new Bin{L: $lhs, Op: $op, R: $rhs} }
level { prefix "not" !idchar -> new Not{X: $rhs} }
}
def id: Id = !kw (?a-z)+
def kw = ("or" / "and" / "not") !idchar
def idchar = (?a-z)
def ws = " "*
pego parse -g words.pego -s e -f sexpr -i 'a or b and not c'
pego parse -g words.pego -s e -f sexpr -i 'notable or a'
(Bin L=Id"a"@id Op="or" R=(Bin L=Id"b"@id Op="and" R=(Not X=Id"c"@id)))
(Bin L=Id"notable"@id Op="or" R=Id"a"@id)
A matched operator whose right operand fails is not applied, unless you cut. The parser backs up to before the
operator and ends the expression there, leaving the operator for the enclosing rule. This is usually right (the outer
rule reports the error at the farthest failure), but if the same text can also continue differently, the expression
quietly ends early. A cut (--) in the operator part commits to the operator:
// cut.pego
type Num terminal
type Bin struct { L E, Op Match, R E }
type E = Num | Bin
def plain: E = pratt {
skip ws
operand num
level { infix left "+" -> new Bin{L: $lhs, Op: $op, R: $rhs} }
}
def committed: E = pratt {
skip ws
operand num
level { infix left "+" -- -> new Bin{L: $lhs, Op: $op, R: $rhs} }
}
def m1 = plain ws "+" ws "x" $$
def m2 = committed ws "+" ws "x" $$
def num: Num = (?0-9)+
def ws = " "*
pego parse -g cut.pego -s m1 -f sexpr -i '1 + x'
pego parse -g cut.pego -s m2 -f sexpr -i '1 + x'
(Seq Num"1"@num [" "]@ws "+" [" "]@ws "x")@m1
pego: 1:5: syntax error: expected (?0-9)
Without the cut, 1 + x is the number 1 followed by the literal text + x; with it, the + is an operator and the
missing operand is an error.
Left recursion needs the base case last. See section 2. If a left-recursive rule matches less than you expect, check the order of its alternatives first.
9. A larger example: minilang
examples/minilang is a small language where statements are ordinary PEG rules
and expressions are one Pratt expression, nested in each other. The pieces that matter for this guide:
def expr: Expr = e:operation #error(message="expected an expression") -> $e
def operation: Expr = pratt {
skip ws
operand number
operand string
operand bool
operand ident
operand "(" e:operation ws ")" -> $e
operand "[" xs:args? ws "]" -> new ArrayLit{Elems: concat($xs)}
level { infix right "?" t:operation ws ":" -> new Cond{Cond: $lhs, Then: $t, Else: $rhs} }
level { infix left "||" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { infix left "&&" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { infix none "==" / "!=" / "<=" / ">=" / "<" / ">" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { infix left "+" / "-" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { infix left "*" / "/" / "%" -> new Binary{Left: $lhs, Op: $op, Right: $rhs} }
level { prefix "-" / "!" -> new Unary{Op: $op, X: $rhs} }
level {
postfix "(" xs:args? ws ")" -> new Call{Fn: $lhs, Args: concat($xs)}
postfix "[" i:operation ws "]" -> new Index{X: $lhs, Index: $i}
postfix "." ws n:name -> new Member{X: $lhs, Name: $n}
}
}
def args = first:expr rest:(-ws -"," x:expr)* -> concat(list($first), map($rest, (r) => $r.x))
(The grammar is abridged here; the types and the lexical rules are in the file.)
- The Pratt rule is
operation; the rule that statements call isexpr, a thin wrapper that attaches an#errormessage to the whole expression. Inside the Pratt expression, nested expressions calloperationdirectly, so the wrapper's message applies only where a whole expression is expected. - The language has no comma operator, so call arguments are plain
exprcalls and no level name is needed. - Statements such as
letandwhileare PEG rules that callexpr. Together with the#recoverattribute onstmt, a bad expression only loses its own statement. See Errors and recovery.
Parsing a few statements with the real grammar:
pego parse -g examples/minilang/minilang.pego -f sexpr -i 'let x = a + b * c;'
pego parse -g examples/minilang/minilang.pego -f sexpr -i 'f(1)(2)[3].x;'
pego parse -g examples/minilang/minilang.pego -f sexpr -i 'let z = -f(x) * 2;'
(Program Body=[(Let Name=Ident"x"@ident Value=(Binary Left=Ident"a"@ident Op="+" Right=(Binary Left=Ident"b"@ident Op="*" Right=Ident"c"@ident)))])
(Program Body=[(ExprStmt X=(Member Name=Ident"x"@ident X=(Index Index=Number"3"@number X=(Call Args=[Number"2"@number] Fn=(Call Args=[Number"1"@number] Fn=Ident"f"@ident)))))])
(Program Body=[(Let Name=Ident"z"@ident Value=(Binary Left=(Unary Op="-" X=(Call Args=[Ident"x"@ident] Fn=Ident"f"@ident)) Op="*" Right=Number"2"@number))])