A grammar with plugins

Your first grammar built a language from nothing. This is the other way, and usually the cheaper one: start from a grammar that already parses something close, and add the pieces you need.

We’ll build a config format that takes arithmetic in its values — so width: 2+3*4 yields 14, not the string "2+3*4". Writing that from scratch means an expression parser with operator precedence. Composing it takes about six lines.

1 · Install

npm install @tabnas/jsonic @tabnas/expr

jsonic is a relaxed JSON — unquoted keys, implicit objects, comments, trailing commas. expr adds Pratt-parser expressions with a configurable precedence scale.

2 · Start from jsonic

Start from jsonic

import { Jsonic } from '@tabnas/jsonic'// jsonic is a relaxed JSON — unquoted keys, implicit objects, comments,// trailing commas. Already a usable config format.console.log(JSON.stringify(Jsonic('a:1, b:{c:2}, d:[3,4]')))// What it doesn't do is arithmetic: a value like 1+2 is just a string.console.log(JSON.stringify(Jsonic('x: 1+2')))
package mainimport (	"encoding/json"	"fmt"	jsonic "github.com/tabnas/jsonic/go")func show(src string) string {	out, err := jsonic.Parse(src)	if err != nil {		panic(err)	}	b, _ := json.Marshal(out)	return string(b)}func main() {	// jsonic is a relaxed JSON — unquoted keys, implicit objects, comments,	// trailing commas. Already a usable config format.	fmt.Println(show("a:1, b:{c:2}, d:[3,4]"))	// What it doesn't do is arithmetic: a value like 1+2 is just a string.	fmt.Println(show("x: 1+2"))}

The starting point: a relaxed JSON that already reads unquoted keys, nested objects and arrays. Composition beats writing a parser, so the question is always what already parses something close.

The second line is the gap this tutorial closes — 1+2 comes back as a string, because jsonic has no notion of arithmetic.

output {"a":1,"b":{"c":2},"d":[3,4]} · {"x":"1+2"}

That is already a usable config format. What it doesn’t do is arithmetic: x: 1+2 gives you a string.

3 · Add the expression plugin

Jsonic.make() derives a fresh instance so the base parser is left alone, and .use() layers a plugin onto it.

Layer on the expression plugin

import { Jsonic } from '@tabnas/jsonic'import { Expr } from '@tabnas/expr'// `make()` derives a fresh instance so the base parser is left alone, and// `use()` layers a plugin onto it.// Cast: jsonic and expr publish separate copies of the Plugin type.const cfg = Jsonic.make().use(Expr as any)// The first element of an expression is an operator node; the readable part is// its `src`. This abbreviates the tree to that.function simplify(n: any): any {  if (Array.isArray(n)) return [n[0].src, ...n.slice(1).map(simplify)]  if (n && 'object' === typeof n) {    return Object.fromEntries(Object.entries(n).map(([k, v]) => [k, simplify(v)]))  }  return n}// Precedence is already handled: 1+2*3 groups the multiplication first.console.log(JSON.stringify(simplify(cfg('x: 1+2*3'))))
package mainimport (	"encoding/json"	"fmt"	tabnasexpr "github.com/tabnas/expr/go"	jsonic "github.com/tabnas/jsonic/go")func main() {	// `Make()` derives a fresh instance so the base parser is left alone, and	// `Use()` layers a plugin onto it.	cfg := jsonic.Make()	if err := cfg.Use(tabnasexpr.Expr); err != nil {		panic(err)	}	out, err := cfg.Parse("x: 1+2*3")	if err != nil {		panic(err)	}	// The first element of an expression is an operator node; `Simplify`	// abbreviates each one to its source string.	b, _ := json.Marshal(tabnasexpr.Simplify(out))	// Precedence is already handled: 1+2*3 groups the multiplication first.	fmt.Println(string(b))}

Six lines buy a Pratt parser. Values are now expression trees and precedence is already handled — 1+2*3 groups the multiplication first without you saying so.

The tree is a LISP-style S-expression: operator first, then its terms. Shown abbreviated here, since the real first element is an operator node whose readable part is its src.

output {"x":["+",1,["*",2,3]]}

Values are now expression trees, and precedence is already handled — 1+2*3 groups the multiplication first, without you saying so. Parentheses work too:

Parentheses too

import { Jsonic } from '@tabnas/jsonic'import { Expr } from '@tabnas/expr'// Cast: jsonic and expr publish separate copies of the Plugin type.const cfg = Jsonic.make().use(Expr as any)function simplify(n: any): any {  if (Array.isArray(n)) return [n[0].src, ...n.slice(1).map(simplify)]  if (n && 'object' === typeof n) {    return Object.fromEntries(Object.entries(n).map(([k, v]) => [k, simplify(v)]))  }  return n}// Parentheses work too, and are a node of their own with a single term — so an// evaluator can recognise them and just descend.console.log(JSON.stringify(simplify(cfg('y: (1+2)*3'))))
package mainimport (	"encoding/json"	"fmt"	tabnasexpr "github.com/tabnas/expr/go"	jsonic "github.com/tabnas/jsonic/go")func main() {	cfg := jsonic.Make()	if err := cfg.Use(tabnasexpr.Expr); err != nil {		panic(err)	}	out, err := cfg.Parse("y: (1+2)*3")	if err != nil {		panic(err)	}	// Parentheses work too, and are a node of their own with a single term — so	// an evaluator can recognise them and just descend.	b, _ := json.Marshal(tabnasexpr.Simplify(out))	fmt.Println(string(b))}

Parentheses override precedence and survive into the tree as a node of their own, carrying exactly one term. That is what lets an evaluator spot them and simply descend, which is the whole of the paren case in a walker.

The shape is abbreviated to each operator's source text; the real first element is an operator node.

output {"y":["*",["(",["+",1,2]],3]}

(The first element is an operator node; the readable part is its src. The shapes above are abbreviated for clarity.)

4 · Evaluate

expr did the parsing, so evaluation is a short recursive walk:

A short recursive walk

import { Jsonic } from '@tabnas/jsonic'import { Expr } from '@tabnas/expr'// Cast: jsonic and expr publish separate copies of the Plugin type.const cfg = Jsonic.make().use(Expr as any)// `expr` did the parsing, so evaluation is a short recursive walk.const OPS: Record<string, (a: number, b: number) => number> = {  '+': (a, b) => a + b,  '-': (a, b) => a - b,  '*': (a, b) => a * b,  '/': (a, b) => a / b,}function evaluate(n: any): number {  if (!Array.isArray(n)) return n           // a plain value  const [op, ...terms] = n  if (op.paren) return evaluate(terms[0])   // ( … ) — a single term  return OPS[op.src](...(terms.map(evaluate) as [number, number]))}for (const src of ['v: 1+2*3', 'v: (1+2)*3', 'v: 10/4', 'v: 7-1-2']) {  console.log(src.padEnd(12), '=>', evaluate((cfg(src) as any).v))}
package mainimport (	"fmt"	tabnasexpr "github.com/tabnas/expr/go"	jsonic "github.com/tabnas/jsonic/go")// `expr` did the parsing, so evaluation is a short recursive walk.var OPS = map[string]func(a, b float64) float64{	"+": func(a, b float64) float64 { return a + b },	"-": func(a, b float64) float64 { return a - b },	"*": func(a, b float64) float64 { return a * b },	"/": func(a, b float64) float64 { return a / b },}func num(v any) float64 {	switch n := v.(type) {	case int:		return float64(n)	case float64:		return n	}	panic(fmt.Sprintf("not a number: %#v", v))}func evaluate(n any) float64 {	terms, isExpr := n.([]any)	if !isExpr {		return num(n) // a plain value	}	op := terms[0].(string)	if "(" == op {		return evaluate(terms[1]) // ( … ) — a single term	}	return OPS[op](evaluate(terms[1]), evaluate(terms[2]))}func main() {	cfg := jsonic.Make()	if err := cfg.Use(tabnasexpr.Expr); err != nil {		panic(err)	}	for _, src := range []string{"v: 1+2*3", "v: (1+2)*3", "v: 10/4", "v: 7-1-2"} {		out, err := cfg.Parse(src)		if err != nil {			panic(err)		}		conf := tabnasexpr.Simplify(out).(map[string]any)		fmt.Printf("%-12s => %v\n", src, evaluate(conf["v"]))	}}

The plugin did the parsing, so evaluation is ten lines: a plain value returns itself, a paren node has one term to descend into, and everything else looks its operator up and applies it. Precedence and associativity are already in the tree — 7-1-2 is 4, not 8.

TypeScript reads op.paren and op.src off the operator node; Go walks the same tree after Simplify has reduced each operator to its source string.

output v: 1+2*3 => 7 · v: (1+2)*3 => 9 · v: 10/4 => 2.5 · v: 7-1-2 => 4

And that’s the language:

And that's the language

import { Jsonic } from '@tabnas/jsonic'import { Expr } from '@tabnas/expr'// Cast: jsonic and expr publish separate copies of the Plugin type.const cfg = Jsonic.make().use(Expr as any)const OPS: Record<string, (a: number, b: number) => number> = {  '+': (a, b) => a + b,  '-': (a, b) => a - b,  '*': (a, b) => a * b,  '/': (a, b) => a / b,}function evaluate(n: any): number {  if (!Array.isArray(n)) return n  const [op, ...terms] = n  if (op.paren) return evaluate(terms[0])  return OPS[op.src](...(terms.map(evaluate) as [number, number]))}// And that's the language: a config format that takes arithmetic in its values.const conf: any = cfg('width: 2+3*4, height: (2+3)*4, ratio: 10/4')for (const key of ['width', 'height', 'ratio']) {  console.log(key.padEnd(7), evaluate(conf[key]))}
package mainimport (	"fmt"	tabnasexpr "github.com/tabnas/expr/go"	jsonic "github.com/tabnas/jsonic/go")var OPS = map[string]func(a, b float64) float64{	"+": func(a, b float64) float64 { return a + b },	"-": func(a, b float64) float64 { return a - b },	"*": func(a, b float64) float64 { return a * b },	"/": func(a, b float64) float64 { return a / b },}func num(v any) float64 {	switch n := v.(type) {	case int:		return float64(n)	case float64:		return n	}	panic(fmt.Sprintf("not a number: %#v", v))}func evaluate(n any) float64 {	terms, isExpr := n.([]any)	if !isExpr {		return num(n)	}	op := terms[0].(string)	if "(" == op {		return evaluate(terms[1])	}	return OPS[op](evaluate(terms[1]), evaluate(terms[2]))}func main() {	cfg := jsonic.Make()	if err := cfg.Use(tabnasexpr.Expr); err != nil {		panic(err)	}	// And that's the language: a config format that takes arithmetic in its	// values.	out, err := cfg.Parse("width: 2+3*4, height: (2+3)*4, ratio: 10/4")	if err != nil {		panic(err)	}	conf := tabnasexpr.Simplify(out).(map[string]any)	for _, key := range []string{"width", "height", "ratio"} {		fmt.Printf("%-7s %v\n", key, evaluate(conf[key]))	}}

The finished language: width: 2+3*4 yields 14, not the string "2+3*4". No parser was written — an existing one was picked, a plugin added the missing part, and ten lines of evaluation did the rest.

No grammar file, no generated code, and no fork of jsonic: the base instance is untouched, so other code using it is unaffected.

output width 14 · height 20 · ratio 2.5

What just happened

You didn’t write a parser. You picked one that was close, added a plugin for the part it was missing, and wrote ten lines of evaluation. No grammar file, no generated code, and no fork of jsonic — the base instance is untouched, so other code using it is unaffected.

This is the normal way to build with tabnas, and it composes further:

AddFor
directive@name and add<1,2> forms
hooverBlock strings with unquoted spaces
multisourceOne document pulling in others
pathKnowing where each value sits in the tree

aontu is the same trick at full size: a CUE-like configuration language built from five of these plugins and no parser of its own.

Next

Describes @tabnas/parser 0.8.10 · all pinned versions