Builtin actions

An action is normally a function. Functions are the reason a grammar stops being data: it can no longer be serialised, diffed, stored, or accepted from somewhere you don’t trust.

The engine ships a set of actions referenced by name. A grammar using only those is pure JSON with no code in it — and still builds a real value. This tutorial builds a small JSON-shaped parser that way, with not one function in the grammar.

The naming rule

A ref is a string starting with @. A trailing $ marks it as an engine builtin:

A builtin, and a reserved namespace

import { Tabnas } from '@tabnas/parser'// A ref is a string starting with `@`; a trailing `$` marks an engine builtin.const tn = new Tabnas()tn.grammar({  options: { rule: { start: 'val' } },  rule: {    val: {      open: [{ s: '#NR', a: '@value$' }],      close: [{}],    },  },})console.log('42 =>', tn.parse('42'))// The `$` namespace is reserved, so a grammar cannot shadow a builtin with a// ref of its own.let refused = falsetry {  new Tabnas().grammar({    ref: { '@my$thing': (r: any) => { r.node = 1 } },    options: { rule: { start: 'val' } },    rule: { val: { open: [{ s: '#NR', a: '@my$thing' }], close: [{}] } },  } as any)} catch {  refused = true}console.log("user ref containing '$' refused:", refused)
package mainimport (	"fmt"	tabnas "github.com/tabnas/parser/go")func main() {	// A ref is a string starting with `@`; a trailing `$` marks an engine builtin.	j := tabnas.Make()	err := j.Grammar(&tabnas.GrammarSpec{		OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}},		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open:  []*tabnas.GrammarAltSpec{{S: "#NR", A: "@value$"}},				Close: []*tabnas.GrammarAltSpec{{}},			},		},	})	if err != nil {		panic(err)	}	out, err := j.Parse("42")	if err != nil {		panic(err)	}	fmt.Println("42 =>", out)	// The `$` namespace is reserved, so a grammar cannot shadow a builtin with a	// ref of its own.	shadow := tabnas.Make().Grammar(&tabnas.GrammarSpec{		Ref: map[tabnas.FuncRef]any{			"@my$thing": tabnas.AltAction(func(r *tabnas.Rule, ctx *tabnas.Context) { r.Node = 1 }),		},		OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}},		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open:  []*tabnas.GrammarAltSpec{{S: "#NR", A: "@my$thing"}},				Close: []*tabnas.GrammarAltSpec{{}},			},		},	})	fmt.Println("user ref containing '$' refused:", shadow != nil)}

A ref is a string beginning with @, and a trailing $ marks it as an engine builtin — so '@value$' is an action the engine supplies rather than one you wrote.

The $ namespace is reserved: installing a grammar whose own ref map contains a $ is an error, so a builtin can never be shadowed by something a grammar brought with it.

output 42 => 42 · user ref containing '$' refused: true

The $ namespace is reservedgrammar() refuses a user ref containing $, so a builtin can never be shadowed by something a grammar brought with it.

The value builders

Seven of them. This is the whole set you need for JSON-shaped data:

BuiltinWhat it does
@object$put a fresh empty object in r.node
@array$put a fresh empty array in r.node
@value$resolve the matched scalar token into r.node
@key$capture the matched key token into r.u.key
@setval$r.node[r.u.key] = r.child.node
@push$append r.child.node to the array in r.node
@reset$clear r.node back to “no value”

@key$ and @setval$ are a pair: the first stores the key on the way in, the second uses it on the way out, once the child has produced a value.

A whole grammar, as data

Five rules, seven builtins, no functions

import { Tabnas } from '@tabnas/parser'// A JSON-shaped parser with not one function in it: every action is a builtin// named by string. Search this object for the word `function` — there isn't one.const spec = {  options: { rule: { start: 'val' } },  rule: {    val: {      open: [        { s: '#OB', p: 'map', b: 1, a: '@object$' },        { s: '#OS', p: 'list', b: 1, a: '@array$' },        { s: '#VAL', a: '@value$' },      ],      close: [{}],    },    map: {      open: [{ s: ['#OB', '#CB'], b: 1 }, { s: '#OB', p: 'pair' }],      close: [{ s: '#CB' }],    },    pair: {      open: [{ s: ['#TX', '#CL'], p: 'val', a: '@key$' }],      close: [        { s: '#CA', r: 'pair', a: '@setval$' },        { s: '#CB', b: 1, a: '@setval$' },      ],    },    list: {      open: [{ s: ['#OS', '#CS'], b: 1 }, { s: '#OS', p: 'elem' }],      close: [{ s: '#CS' }],    },    elem: {      open: [{ p: 'val' }],      close: [        { s: '#CA', r: 'elem', a: '@push$' },        { s: '#CS', b: 1, a: '@push$' },      ],    },  },}const tn = new Tabnas()// Cast: the declarative GrammarSpec type wants '@…' literal types, which a// separately declared object widens to plain strings.tn.grammar(spec as any)for (const src of ['42', '"hi"', '{a:1}', '{a:1,b:2}', '{}', '[]']) {  console.log(src.padEnd(9), '=>', JSON.stringify(tn.parse(src)))}
package mainimport (	"encoding/json"	"fmt"	tabnas "github.com/tabnas/parser/go")func main() {	// A JSON-shaped parser with not one function in it: every action is a	// builtin named by string. Search this value for a func literal — there	// isn't one.	spec := &tabnas.GrammarSpec{		OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}},		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open: []*tabnas.GrammarAltSpec{					{S: "#OB", P: "map", B: 1, A: "@object$"},					{S: "#OS", P: "list", B: 1, A: "@array$"},					{S: "#VAL", A: "@value$"},				},				Close: []*tabnas.GrammarAltSpec{{}},			},			"map": {				Open:  []*tabnas.GrammarAltSpec{{S: []string{"#OB", "#CB"}, B: 1}, {S: "#OB", P: "pair"}},				Close: []*tabnas.GrammarAltSpec{{S: "#CB"}},			},			"pair": {				Open: []*tabnas.GrammarAltSpec{{S: []string{"#TX", "#CL"}, P: "val", A: "@key$"}},				Close: []*tabnas.GrammarAltSpec{					{S: "#CA", R: "pair", A: "@setval$"},					{S: "#CB", B: 1, A: "@setval$"},				},			},			"list": {				Open:  []*tabnas.GrammarAltSpec{{S: []string{"#OS", "#CS"}, B: 1}, {S: "#OS", P: "elem"}},				Close: []*tabnas.GrammarAltSpec{{S: "#CS"}},			},			"elem": {				Open: []*tabnas.GrammarAltSpec{{P: "val"}},				Close: []*tabnas.GrammarAltSpec{					{S: "#CA", R: "elem", A: "@push$"},					{S: "#CS", B: 1, A: "@push$"},				},			},		},	}	j := tabnas.Make()	if err := j.Grammar(spec); err != nil {		panic(err)	}	for _, src := range []string{"42", `"hi"`, "{a:1}", "{a:1,b:2}", "{}", "[]"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		b, _ := json.Marshal(out)		fmt.Printf("%-9s => %s\n", src, string(b))	}}

Five rules and seven builtin names build real JSON-shaped values with no code in the grammar at all. The trick is the seeded node: a pushed rule inherits its parent's r.node, so @setval$ writing a property from inside pair is writing into the very object val returns.

@key$ stores the key on r.u, the non-propagating bag, so a nested pair cannot clobber an outer one's key.

TypeScript writes the spec as an object literal; Go writes the same table as a GrammarSpec struct, since Go has no untyped object literal.

output 42 => 42 · "hi" => "hi" · {a:1} => {"a":1} · {a:1,b:2} => {"a":1,"b":2} · {} => {} · [] => []

Search that object for the word function. There isn’t one.

Why it works: the seeded node

The part that looks like magic is @setval$ writing into r.node from inside pair, and the result appearing in the map.

A pushed rule’s node is seeded from its parent. @object$ runs on val and puts an object in val.node; val pushes map, which inherits it; map pushes pair, which inherits it too. All three names refer to the same object, so @setval$ writing a property from pair is writing into the object val will return.

Once that clicks, the rest of the table reads straightforwardly:

  • @key$ stores the key on r.u — the non-propagating bag, so a nested pair cannot clobber an outer one’s key.
  • @setval$ runs in the close phase, because r.child.node does not exist until the child has closed.
  • @push$ skips a child with no value, so a trailing comma adds nothing.

Configuring a builtin

Config rides on the alternate’s k, keyed by the builtin’s name:

Config on `k`, keyed by builtin

import { Tabnas } from '@tabnas/parser'// Config for a builtin rides on the alternate's `k`, keyed by the builtin name.// `from` is which open token to read (default 0), `slot` is the `r.u` key to// store under (default 'key').function build(keyConfig?: Record<string, any>) {  const tn = new Tabnas()  tn.grammar({    options: {      fixed: { token: { '#EQ': '=' } },      rule: { start: 'pair' },    },    rule: {      pair: {        open: [{          s: ['#TX', '#EQ'],          p: 'val',          a: ['@object$', '@key$'],          ...(keyConfig ? { k: keyConfig } : {}),        }],        close: [{ a: '@setval$' }],      },      val: {        open: [{ s: '#NR', a: '@value$' }],        close: [{}],      },    },  })  return tn}// Spelling the defaults out changes nothing — which is why a grammar that// relies on them never mentions them.console.log('config omitted:  ', JSON.stringify(build().parse('port = 8080')))console.log('defaults spelled:', JSON.stringify(  build({ key$: { from: 0, slot: 'key' } }).parse('port = 8080')))
package mainimport (	"encoding/json"	"fmt"	tabnas "github.com/tabnas/parser/go")// Config for a builtin rides on the alternate's `K`, keyed by the builtin name.// `from` is which open token to read (default 0), `slot` is the `r.U` key to// store under (default "key").func build(keyConfig map[string]any) *tabnas.Tabnas {	j := tabnas.Make()	err := j.Grammar(&tabnas.GrammarSpec{		OptionsMap: map[string]any{			"fixed": map[string]any{"token": map[string]any{"#EQ": "="}},			"rule":  map[string]any{"start": "pair"},		},		Rule: map[string]*tabnas.GrammarRuleSpec{			"pair": {				Open: []*tabnas.GrammarAltSpec{{					S: []string{"#TX", "#EQ"},					P: "val",					A: []any{"@object$", "@key$"},					K: keyConfig,				}},				Close: []*tabnas.GrammarAltSpec{{A: "@setval$"}},			},			"val": {				Open:  []*tabnas.GrammarAltSpec{{S: "#NR", A: "@value$"}},				Close: []*tabnas.GrammarAltSpec{{}},			},		},	})	if err != nil {		panic(err)	}	return j}func render(j *tabnas.Tabnas) string {	out, err := j.Parse("port = 8080")	if err != nil {		panic(err)	}	b, _ := json.Marshal(out)	return string(b)}func main() {	// Spelling the defaults out changes nothing — which is why a grammar that	// relies on them never mentions them.	fmt.Println("config omitted:  ", render(build(nil)))	fmt.Println("defaults spelled:", render(build(map[string]any{		"key$": map[string]any{"from": 0, "slot": "key"},	})))}

A builtin's configuration rides on the alternate's k, keyed by the builtin's name: k: { key$: { from: 0, slot: 'key' } }. from picks which open token to read and slot names the r.u key to store it under.

Both values here are the defaults, so spelling them out changes nothing — which is why a grammar that relies on them never mentions them.

output config omitted: {"port":8080} · defaults spelled: {"port":8080}

from is which open token to read (default 0), slot is the r.u key to store under (default 'key'). Both defaults are what the grammar above relies on, which is why it doesn’t mention them.

Several actions on one alternate

a takes an array, run in order:

Run in order

import { Tabnas } from '@tabnas/parser'// `a` takes an array, run in order. `['@reset$', '@object$']` is the idiom for// a rule that must not inherit its parent's node: clear first, then build.function build(actions: string[]) {  const tn = new Tabnas()  tn.grammar({    options: { rule: { start: 'val' } },    rule: {      val: {        open: [          { s: '#OB', p: 'map', b: 1, a: actions as any },          { s: '#VAL', a: '@value$' },        ],        close: [{}],      },      map: {        open: [{ s: ['#OB', '#CB'], b: 1 }, { s: '#OB', p: 'pair' }],        close: [{ s: '#CB' }],      },      pair: {        open: [{ s: ['#TX', '#CL'], p: 'val', a: '@key$' }],        close: [          { s: '#CA', r: 'pair', a: '@setval$' },          { s: '#CB', b: 1, a: '@setval$' },        ],      },    },  })  return tn}// Order matters: reset after building throws the object away again.for (const actions of [['@reset$', '@object$'], ['@object$', '@reset$']]) {  const out = build(actions).parse('{a:1}')  console.log(actions.join(','), '=>', undefined === out ? '(no value)' : JSON.stringify(out))}
package mainimport (	"encoding/json"	"fmt"	"strings"	tabnas "github.com/tabnas/parser/go")// `A` takes a list, run in order. `["@reset$", "@object$"]` is the idiom for a// rule that must not inherit its parent's node: clear first, then build.func build(actions []any) *tabnas.Tabnas {	j := tabnas.Make()	err := j.Grammar(&tabnas.GrammarSpec{		OptionsMap: map[string]any{"rule": map[string]any{"start": "val"}},		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open: []*tabnas.GrammarAltSpec{					{S: "#OB", P: "map", B: 1, A: actions},					{S: "#VAL", A: "@value$"},				},				Close: []*tabnas.GrammarAltSpec{{}},			},			"map": {				Open:  []*tabnas.GrammarAltSpec{{S: []string{"#OB", "#CB"}, B: 1}, {S: "#OB", P: "pair"}},				Close: []*tabnas.GrammarAltSpec{{S: "#CB"}},			},			"pair": {				Open: []*tabnas.GrammarAltSpec{{S: []string{"#TX", "#CL"}, P: "val", A: "@key$"}},				Close: []*tabnas.GrammarAltSpec{					{S: "#CA", R: "pair", A: "@setval$"},					{S: "#CB", B: 1, A: "@setval$"},				},			},		},	})	if err != nil {		panic(err)	}	return j}func main() {	// Order matters: reset after building throws the object away again.	for _, actions := range [][]any{{"@reset$", "@object$"}, {"@object$", "@reset$"}} {		out, err := build(actions).Parse("{a:1}")		if err != nil {			panic(err)		}		names := []string{}		for _, a := range actions {			names = append(names, a.(string))		}		shown := "(no value)"		if nil != out && !tabnas.IsUndefined(out) {			b, _ := json.Marshal(out)			shown = string(b)		}		fmt.Println(strings.Join(names, ","), "=>", shown)	}}

An alternate's a accepts a list of actions, run in the order given. ['@reset$', '@object$'] is the idiom for a rule that must not inherit its parent's node: clear the seeded value first, then build a fresh one.

Reversing the pair proves the order is real — resetting after building discards the object again and the parse yields nothing.

output @reset$,@object$ => {"a":1} · @object$,@reset$ => (no value)

That is the idiom for a rule that must not inherit its parent’s node: clear first, then build.

The catch: install mutates the spec

grammar() resolves ref strings in place. After installing, the a fields hold functions, not strings:

typeof spec.rule.val.open[0].a       // => 'function'
JSON.stringify(spec).includes('@object$')   // => false

So serialise before you install, or install a copy:

Serialise first, then install

import { Tabnas } from '@tabnas/parser'const spec = {  options: {    fixed: { token: { '#EQ': '=' } },    rule: { start: 'pair' },  },  rule: {    pair: {      open: [{ s: '#TX #EQ', p: 'val', a: ['@object$', '@key$'] }],      close: [{ a: '@setval$' }],    },    val: {      open: [{ s: '#NR', a: '@value$' }],      close: [{}],    },  },}// Serialise BEFORE installing. `grammar()` resolves the '@…$' ref strings in// place, so afterwards those fields hold functions and the spec no longer// round-trips — the copy would install cleanly and lose every action.const wire = JSON.stringify(spec)const tn = new Tabnas()// Cast: the declarative GrammarSpec type wants '@…' literal types, which a// separately declared object widens to plain strings.tn.grammar(spec as any)console.log('here      =>', JSON.stringify(tn.parse('port = 8080')))// Later, or elsewhere, or in another process.const elsewhere = new Tabnas()elsewhere.grammar(JSON.parse(wire))console.log('elsewhere =>', JSON.stringify(elsewhere.parse('port = 8080')))
package mainimport (	"encoding/json"	"fmt"	tabnas "github.com/tabnas/parser/go")// The same grammar, kept as text — the form it would live in on disk or on the// wire.const WIRE = `{  "options": {    "fixed": { "token": { "#EQ": "=" } },    "rule":  { "start": "pair" }  },  "rule": {    "pair": {      "open":  [ { "s": "#TX #EQ", "p": "val", "a": ["@object$", "@key$"] } ],      "close": [ { "a": "@setval$" } ]    },    "val": {      "open":  [ { "s": "#NR", "a": "@value$" } ],      "close": [ {} ]    }  }}`func parse(text string) string {	j := tabnas.Make()	if err := j.GrammarText(text); err != nil {		panic(err)	}	out, err := j.Parse("port = 8080")	if err != nil {		panic(err)	}	b, _ := json.Marshal(out)	return string(b)}func main() {	// Go has no built-in JSON.parse, so hand the engine one to read text with.	tabnas.RegisterTextParser(func(src string) (any, error) {		var out any		err := json.Unmarshal([]byte(src), &out)		return out, err	})	fmt.Println("here      =>", parse(WIRE))	// Later, or elsewhere, or in another process — the same text, nothing else.	fmt.Println("elsewhere =>", parse(WIRE))}

A builtin-only grammar is data, so it can be stored and installed again somewhere else and parse identically. Take the serialised copy before installing.

That ordering matters in TypeScript, where grammar() resolves the @…$ ref strings in place: serialise afterwards and you get a spec that still installs, still parses, and silently returns nothing. Go's GrammarText reads the text and leaves your value alone, so the trap does not arise there.

output here => {"port":8080} · elsewhere => {"port":8080}

Getting this the wrong way round produces a spec that silently loses every action — the grammar still installs, still parses, and returns undefined.

The other families

Two more sets exist, and you are unlikely to write either by hand:

  • Tree builders@node$, @capture$, @bubble$, @fold$ build the { rule, src, kids } AST. This is what @tabnas/abnf emits, and it is why an ABNF grammar can compile to pure data too.
  • Probe dispatch@probeInit$, @probeDecide$, @probePhase0$/1$/2$ implement the optional-prefix disambiguation the ABNF compiler needs.

Both are versioned by BUILTIN_SCHEMA_VERSION. A serialized grammar can declare the schema it was compiled against as v, and the engine refuses one that needs a newer version than it implements — so an old engine fails loudly rather than mis-parsing.

When to use code instead

Builtins cover building values. They do not cover computing them. The moment you need arithmetic, validation, or a call into your own code, write a function — and accept that the grammar is now code.

The useful middle ground is named refs: the grammar keeps ref names, and the functions are supplied separately at install time. The grammar text stays declarative; only the binding is code.

Next

Describes @tabnas/parser 0.8.10 · all pinned versions