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.
outputoriginal 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.
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.
outputval 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.
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 altwith 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.
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 rrepeatsadd 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.
output6 · 60
Two one-line actions. It works because rrepeatsadd 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.
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.