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.
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.
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.)
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.
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.
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: