---
title: "Attaching actions · Docs · tabnas"
description: "Turn a parse into a value, with builtins, named refs, or inline functions."
source: "https://tabnas.dev/docs/actions/"
---

# Attaching actions

A grammar that only recognises input returns nothing useful. Actions build the result. There are three ways to attach them; prefer them in this order.

## Builtins: no code at all

The engine ships `$`\-suffixed action builtins. Referenced by name, they let a grammar stay pure data:

**No code in the grammar**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

// Not one function in it, so it survives a trip through JSON.
const SPEC = `{
  "options": { "rule": { "start": "val" } },
  "rule": {
    "val": {
      "open":  [ { "s": "#NR", "a": "@value$" } ],
      "close": [ {} ]
    }
  }
}`

const tn = new Tabnas()
tn.grammar(JSON.parse(SPEC))
console.log('original  42 =>', tn.parse('42'))

// Somewhere else entirely — a second process, a config store, a network hop.
const elsewhere = new Tabnas()
elsewhere.grammar(JSON.parse(SPEC))
console.log('via JSON  42 =>', elsewhere.parse('42'))
```

Go:

```go
package main

import (
	"encoding/json"
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

// Not one function in it, so it survives a trip through JSON.
const SPEC = `{
  "options": { "rule": { "start": "val" } },
  "rule": {
    "val": {
      "open":  [ { "s": "#NR", "a": "@value$" } ],
      "close": [ {} ]
    }
  }
}`

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

	j := tabnas.Make()
	if err := j.GrammarText(SPEC); err != nil {
		panic(err)
	}
	out, err := j.Parse("42")
	if err != nil {
		panic(err)
	}
	fmt.Println("original  42 =>", out)

	// Somewhere else entirely — a second process, a config store, a network hop.
	elsewhere := tabnas.Make()
	if err := elsewhere.GrammarText(SPEC); err != nil {
		panic(err)
	}
	out, err = elsewhere.Parse("42")
	if err != nil {
		panic(err)
	}
	fmt.Println("via JSON  42 =>", out)
}
```

Explain: `@value$` is an action referenced by name, so the grammar contains no code and stays a JSON document. That is the whole point of the builtins: the same text installs anywhere and parses the same way. Prefer this form when a grammar has to be stored, diffed, or accepted from somewhere you do not trust.

Output: `original 42 => 42 · via JSON 42 => 42`

Nothing in that grammar is a function, so it round-trips through JSON. See [the rule table](https://tabnas.dev/docs/rule-table/#builtin-actions) for the full set.

## Named refs: code, bound out of band

The grammar text stays untouched; behaviour binds through names the compiler assigns. This is the form to use with ABNF, because it keeps the ABNF valid RFC 5234.

**Marks and rule-phase hooks**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'
import { abnf } from '@tabnas/abnf'

const tn = new Tabnas({ plugins: [abnf] })

// The ABNF text stays valid RFC 5234; the behaviour binds through names the
// compiler assigns.
tn.abnf(`
  val = add
  add = NR [ PL add ]
  PL  = "+"
`, {
  actions: {
    // Alternate marks: @<rule>:<phase>:<mark>.
    '@val:o:add': (r: any) => { r.node.value = 0 },
    '@add:o:NR': (r: any) => { r.parent.node.value += r.o[0].val },

    // A rule-phase hook: @<rule>:ac is after-close.
    '@val:ac': (r: any) => { console.log('val closed with', r.node.value) },
  },
})

for (const src of ['1+2+3', '12+3+45']) {
  console.log(src.padEnd(7), '=>', (tn.parse(src) as any).value)
}
```

Go:

```go
package main

import (
	"fmt"

	abnf "github.com/tabnas/abnf/go"
	tabnas "github.com/tabnas/parser/go"
)

func main() {
	j := tabnas.Make()

	// The ABNF text stays valid RFC 5234; the behaviour binds through names the
	// compiler assigns.
	_, err := abnf.Install(j, `
  val = add
  add = NR [ PL add ]
  PL  = "+"
`, nil, abnf.ActionsMap{
		// Alternate marks: @<rule>:<phase>:<mark>.
		"@val:o:add": {func(r *tabnas.Rule, ctx *tabnas.Context) {
			r.Node.(map[string]any)["value"] = float64(0)
		}},
		"@add:o:NR": {func(r *tabnas.Rule, ctx *tabnas.Context) {
			node := r.Parent.Node.(map[string]any)
			node["value"] = node["value"].(float64) + r.O[0].Val.(float64)
		}},

		// A rule-phase hook: @<rule>:ac is after-close.
		"@val:ac": {func(r *tabnas.Rule, ctx *tabnas.Context) {
			fmt.Println("val closed with", int(r.Node.(map[string]any)["value"].(float64)))
		}},
	})
	if err != nil {
		panic(err)
	}

	for _, src := range []string{"1+2+3", "12+3+45"} {
		out, err := j.Parse(src)
		if err != nil {
			panic(err)
		}
		fmt.Printf("%-7s => %v\n", src, int(out.(map[string]any)["value"].(float64)))
	}
}
```

Explain: Both kinds of name are here: alternate marks (`@val:o:add`, `@add:o:NR`) fire when that alternate matches, and a rule-phase hook (`@val:ac`) fires after the rule closes. The ABNF text is untouched and still valid RFC 5234. Marks come from each alternate's leading discriminator, which the compiler assigns: ask `tabnas-abnf --marks` for them rather than guessing.

Output: `val closed with 6 · 1+2+3 => 6 · val closed with 60 · 12+3+45 => 60`

`r.parent` is `val` for **every** repetition, because the compiler turns the tail self-reference `[ PL add ]` into a same-depth repeat: the same shape as the inline example below. The total lives in one place, on `val`’s node, where `parse` returns it; the instance carries no state between calls.

Two kinds of name:

-   **Alternate marks**: `@<rule>:<phase>:<mark>`, where the mark comes from the alternate’s leading discriminator. Open-phase marks fire as tokens are matched; close-phase marks (like the repeat’s own `@add:c:PL`) fire on the way back up.
-   **Rule-phase hooks**: `@<rule>:bo`, `:ao`, `:bc`, `:ac` for before/after open and close.

### Finding the mark names

Marks are assigned by the compiler. Don’t guess:

```bash
tabnas-abnf --marks -f grammar.abnf
```

```plaintext
val  o:add  p:add
val  c:_  (empty)
add  o:NR  s:#NR
add  c:PL  s:#PL
add  c:_  (empty)
```

If you attach an action to a mark that doesn’t exist, the compiler says so rather than failing silently:

```plaintext
AbnfActionError: abnf: action ref '@item:o:ALPHA' matches no open alt
with mark 'ALPHA' in rule 'item'
```

That error is usually caused by desugaring: `( … )` and `*( … )` compile to generated group rules, so their marks belong to rules you didn’t write. (A tail self-reference like `[ PL add ]` is the exception: it compiles to a repeat on the rule itself, which is why `add` owns the `c:PL` mark above.) The listing is the authority.

## Inline functions: the most direct

Written straight onto the alternate as `a`:

**Functions on the alternate**

TypeScript:

```ts
import { Tabnas } from '@tabnas/parser'

const tn = new Tabnas()

tn.grammar({
  options: {
    fixed: { token: { '#PL': '+' } },
    rule: { start: 'val' },
  },
  rule: {
    val: {
      // Start the accumulator at zero.
      open: [{ p: 'add', a: (r: any) => { r.node = 0 } }],
      close: [{}],
    },
    add: {
      // Add each number to it.
      open: [{ s: '#NR', a: (r: any) => { r.parent.node += r.o[0].val } }],
      close: [{ s: '#PL', r: 'add' }, {}],
    },
  } as any,
})

// The total rides on the parse, so `parse()` returns it and two parses of
// different inputs cannot collide.
console.log(tn.parse('1+2+3'))
console.log(tn.parse('12+3+45'))
```

Go:

```go
package main

import (
	"fmt"

	tabnas "github.com/tabnas/parser/go"
)

func main() {
	j := tabnas.Make()

	err := j.Grammar(&tabnas.GrammarSpec{
		OptionsMap: map[string]any{
			"fixed": map[string]any{"token": map[string]any{"#PL": "+"}},
			"rule":  map[string]any{"start": "val"},
		},
		Rule: map[string]*tabnas.GrammarRuleSpec{
			"val": {
				// Start the accumulator at zero.
				Open: []*tabnas.GrammarAltSpec{{P: "add", A: func(r *tabnas.Rule, ctx *tabnas.Context) {
					r.Node = float64(0)
				}}},
				Close: []*tabnas.GrammarAltSpec{{}},
			},
			"add": {
				// Add each number to it.
				Open: []*tabnas.GrammarAltSpec{{S: "#NR", A: func(r *tabnas.Rule, ctx *tabnas.Context) {
					r.Parent.Node = r.Parent.Node.(float64) + r.O[0].Val.(float64)
				}}},
				Close: []*tabnas.GrammarAltSpec{{S: "#PL", R: "add"}, {}},
			},
		},
	})
	if err != nil {
		panic(err)
	}

	// The total rides on the parse, so `Parse()` returns it and two parses of
	// different inputs cannot collide.
	for _, src := range []string{"1+2+3", "12+3+45"} {
		out, err := j.Parse(src)
		if err != nil {
			panic(err)
		}
		fmt.Println(int(out.(float64)))
	}
}
```

Explain: Actions written straight onto the alternate as `a`. Two one-line functions suffice because `r` **repeats** `add` at the same stack depth rather than pushing it, so every `add` shares one parent and the total lives in one place. The cost is that the grammar is now code: it can no longer be serialised, printed back as ABNF, or safely accepted from anywhere you do not trust.

Output: `6 · 60`

Two one-line actions. It works because `r` **repeats** `add` at the same stack depth rather than pushing it, so every `add` shares one parent and the total lives in one place. The result rides on the parse rather than an outer variable, so `parse()` returns it and concurrent parses can’t collide.

The cost: the grammar is now code. It can’t be serialised, printed back as ABNF, or safely accepted from anywhere you don’t trust.

## Which to use

| If you want | Use |
| --- | --- |
| A grammar that survives JSON round-tripping | Builtins |
| A grammar an agent wrote, that you want to check before running | Builtins |
| ABNF that stays valid RFC 5234 | Named refs |
| Full control of the rule table | Inline functions |

## A note on `r.parent`

For a tail self-reference (`add = NR [ PL add ]`) the compiler emits a same-depth repeat, so `r.parent` is the wrapping rule for every repetition and accumulating onto `r.parent.node` is the intended idiom: in ABNF and hand-written grammars alike.

Other sugar is different: `( … )` and `*( … )` still compile to generated group rules, so inside those a rule’s `parent` may be a rule you didn’t write. When in doubt, `tabnas-abnf --marks` shows the compiled rule set.
