---
title: "Builtin actions · Docs · tabnas"
description: "Build a value with `$`-suffixed action names instead of code, and keep the grammar as pure data."
source: "https://tabnas.dev/docs/builtin-actions/"
---

# 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**

TypeScript:

```ts
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 = false
try {
  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)
```

Go:

```go
package main

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

Explain: 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 **reserved**: `grammar()` 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:

| Builtin | What 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**

TypeScript:

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

Go:

```go
package main

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

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

TypeScript:

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

Go:

```go
package main

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

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

TypeScript:

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

Go:

```go
package main

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

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

```ts
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**

TypeScript:

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

Go:

```go
package main

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

Explain: 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](https://tabnas.dev/docs/actions/#named-refs--code-bound-out-of-band): 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

-   [The rule instance](https://tabnas.dev/docs/rule-instance/): what `r.node`, `r.u` and `r.child` are.
-   [Attaching actions](https://tabnas.dev/docs/actions/): the three ways, and when each is right.
-   [The rule table](https://tabnas.dev/docs/rule-table/): every alternate field.
