Parse expressions with precedence

Add infix, prefix, suffix and ternary operators to a grammar, with a binding-power scale you control.

@tabnas/expr

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:

Operatorleftright
+ - prefix:4000000
* / %30000003100000
+ - infix20000002100000

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