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

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'))
package mainimport (	"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)}

@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 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

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)}
package mainimport (	"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)))	}}

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:

tabnas-abnf --marks -f grammar.abnf
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:

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

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'))
package mainimport (	"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)))	}}

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 wantUse
A grammar that survives JSON round-trippingBuiltins
A grammar an agent wrote, that you want to check before runningBuiltins
ABNF that stays valid RFC 5234Named refs
Full control of the rule tableInline 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.

Describes @tabnas/parser 0.8.10 · all pinned versions