---
title: "A grammar with plugins · Docs · tabnas"
description: "Build a configuration language by composing existing grammars instead of writing one."
source: "https://tabnas.dev/docs/grammar-with-plugins/"
---

# A grammar with plugins

[Your first grammar](https://tabnas.dev/docs/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

```bash
npm install @tabnas/jsonic @tabnas/expr
```

[jsonic](https://github.com/tabnas/jsonic) is a relaxed JSON: unquoted keys, implicit objects, comments, trailing commas. [expr](https://github.com/tabnas/expr) adds Pratt-parser expressions with a configurable precedence scale.

## 2 · Start from jsonic

**Start from jsonic**

TypeScript:

```ts
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')))
```

Go:

```go
package main

import (
	"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"))
}
```

Explain: 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**

TypeScript:

```ts
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'))))
```

Go:

```go
package main

import (
	"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))
}
```

Explain: 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**

TypeScript:

```ts
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'))))
```

Go:

```go
package main

import (
	"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))
}
```

Explain: 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**

TypeScript:

```ts
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))
}
```

Go:

```go
package main

import (
	"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"]))
	}
}
```

Explain: 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**

TypeScript:

```ts
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]))
}
```

Go:

```go
package main

import (
	"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]))
	}
}
```

Explain: 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:

| Add | For |
| --- | --- |
| [directive](https://github.com/tabnas/directive) | `@name` and `add<1,2>` forms |
| [hoover](https://github.com/tabnas/hoover) | Block strings with unquoted spaces |
| [multisource](https://github.com/tabnas/multisource) | One document pulling in others |
| [path](https://github.com/tabnas/path) | Knowing where each value sits in the tree |

[aontu](https://tabnas.dev/examples/) 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

-   [Extending a grammar](https://tabnas.dev/docs/extending/): adding and removing rules directly.
-   [The rule table](https://tabnas.dev/docs/rule-table/): what a plugin is actually doing.
-   [Packages](https://tabnas.dev/docs/packages/): everything available to compose.
