Parse expressions with precedence
Add infix, prefix, suffix and ternary operators to a grammar, with a binding-power scale you control.
Precedence is the one part of parsing that rule tables are bad at. Expressing
1+2*3 as nested rules means one rule per precedence level, and adding a level
means rewriting the chain. @tabnas/expr does it with a
Pratt parser
instead: every operator carries two numbers, and precedence falls out of
comparing them.
Register it on a grammar that already defines values, and values become expressions:
import { Tabnas } from '@tabnas/parser'
import { jsonic } from '@tabnas/jsonic'
import { Expr } from '@tabnas/expr'
const tn = new Tabnas().use(jsonic).use(Expr)
tn.parse('1+2*3')
What comes back
An S-expression: an array whose first element is the operator and whose
remaining elements are the terms. The operator is an Op object — it carries
the source text, the binding powers, and which fixity matched — so printing a
tree usually means replacing it with op.src first:
// Replace each Op with its source text, for display.
const S = (x) =>
Array.isArray(x) && x.length
? [x[0].src || x[0].osrc || S(x[0]), ...x.slice(1).map(S)]
: x
S(tn.parse('1+2*3')) // => [ '+', 1, [ '*', 2, 3 ] ]
S(tn.parse('-1+2')) // => [ '+', [ '-', 1 ], 2 ]
S(tn.parse('2+3+4')) // => [ '+', [ '+', 2, 3 ], 4 ]
S(tn.parse('(1+2)*3')) // => [ '*', [ '(', [ '+', 1, 2 ] ], 3 ]
Two things worth noticing. 2+3+4 groups to the left, because addition’s
binding powers say so. And the parenthesis is kept in the tree as an
operator of its own rather than dissolved — the tree records that the source
was written with brackets, which matters if you are formatting it back out.
Expressions live wherever values live, so this needs nothing extra:
S(tn.parse('[1+2, 3*4]'))
// => [ [ '+', 1, 2 ], [ '*', 3, 4 ] ]
The binding-power scale
Each operator gets a left and a right number. When two operators compete
for a term, the larger binding power wins. The default table is spaced in
millions so there is room to insert between levels:
| Operator | left | right |
|---|---|---|
+ - prefix | — | 4000000 |
* / % | 3000000 | 3100000 |
+ - infix | 2000000 | 2100000 |
Associativity is the relationship between the two numbers on one operator.
left < right binds to the left, which is what you want for arithmetic.
left > right binds to the right, which is what you want for exponentiation
and assignment.
Adding operators
op is a map of definitions merged over the defaults, so you only name what
you are adding:
const tn = new Tabnas().use(jsonic).use(Expr, {
op: {
exponent: { infix: true, left: 5000000, right: 4900000, src: '**' },
lt: { infix: true, left: 1400000, right: 1500000, src: '<' },
and: { infix: true, left: 1000000, right: 1100000, src: '&&' },
factorial: { suffix: true, left: 6000000, src: '!' },
},
})
S(tn.parse('2**3**2')) // => [ '**', 2, [ '**', 3, 2 ] ]
S(tn.parse('1<2 && 3<4')) // => [ '&&', [ '<', 1, 2 ], [ '<', 3, 4 ] ]
S(tn.parse('3! + 1')) // => [ '+', [ '!', 3 ], 1 ]
** binds right (left > right), so 2**3**2 is 2**(3**2). && sits below
comparison, so comparisons bind tighter and become its operands. ! is a
suffix at the top of the scale, so it takes 3 before + sees it.
Setting an operator to null removes it. Be aware of what that means: the
symbol is no longer an operator, but it is still lexable text, so in a relaxed
grammar like jsonic 1 % 2 becomes the implicit list [1, '%', 2] rather than
a syntax error. Removing an operator is not the same as forbidding it.
Function-call and index syntax
A paren operator with a preval takes the value written immediately before
it as its first term. That is all a function call is:
const tn = new Tabnas().use(jsonic).use(Expr, {
op: {
call: { paren: true, osrc: '(', csrc: ')', preval: { active: true } },
index: { paren: true, osrc: '[', csrc: ']', preval: { required: true } },
},
})
S(tn.parse('max(1,2)')) // => [ '(', 'max', [ 1, 2 ] ]
S(tn.parse('f(1)')) // => [ '(', 'f', 1 ]
S(tn.parse('a[1]')) // => [ '[', 'a', 1 ]
active means the preceding value is used if present, so (1+2) still
groups. required means the operator only matches with one, which is what
keeps [ working as a list bracket everywhere else.
Ternaries are declared with a two-element src:
const tn = new Tabnas().use(jsonic).use(Expr, {
op: { ternary: { ternary: true, src: ['?', ':'], left: 1500000, right: 1400000 } },
})
S(tn.parse('a ? b : c')) // => [ '?', 'a', 'b', 'c' ]
Computing a value instead
Pass evaluate and the plugin reduces each node as it closes, so parse
returns the answer rather than the tree:
const math = (rule, ctx, op, terms) => {
if (op.paren) return terms[0]
if (op.prefix) return '-' === op.src ? -terms[0] : +terms[0]
switch (op.src) {
case '+': return terms[0] + terms[1]
case '-': return terms[0] - terms[1]
case '*': return terms[0] * terms[1]
case '/': return terms[0] / terms[1]
default: return NaN
}
}
const tn = new Tabnas().use(jsonic).use(Expr, { evaluate: math })
tn.parse('1+2*3') // => 7
tn.parse('-(1+2)*3') // => -9
tn.parse('a: 2*(3+4), b: 1') // => { a: 14, b: 1 }
Check op.prefix before switching on op.src: - is both a prefix and an
infix operator and they arrive at the same callback with a different number of
terms.
Evaluating during the parse is a choice, not the default. It is the right one for a configuration language, where the result is a value. It is the wrong one for anything that wants to inspect, rewrite or re-emit the source — keep the tree and walk it afterwards.
What this costs
The expression grammar is not a small addition. It brings its own rules,
counters and edge cases around implicit lists and maps, and it changes what
some inputs mean. On plain jsonic, a: 1-2 parses to the string '1-2';
install Expr and it is a subtraction. That is usually the point, but it is a
behaviour change to existing input, so install it on a derived instance
(base.make().use(Expr)) if other code depends on the original.
See also
- Handle recursion and repetition — what to do when the nesting isn’t operator precedence.
- Write a parameterised parser — the option
pattern
Exprfollows. - @tabnas/expr — the full operator table and the Pratt implementation.