The rule table

A grammar is a table. ABNF compiles to it, plugins build it programmatically, and you can write it directly as data. This page is the format.

Shape

The table, and the walk it produces

import { Tabnas } from '@tabnas/parser'const tn = new Tabnas()// The rule table itself: options, then rules, each with an open and a close// phase holding alternates tried in order.tn.grammar({  options: {    fixed: { token: { '#PL': '+' } },   // custom fixed tokens    rule: { start: 'val' },             // where parsing begins  },  rule: {    val: {      open: [{ p: 'add' }],      close: [{}],    },    add: {      open: [{ s: '#NR' }],      close: [{ s: '#PL', r: 'add' }, {}],    },  },})// `p` pushes (depth grows), `r` repeats (depth stays). The trace shows it:// `val` pushes `add` once, then every `+` repeats `add` at the same depth.const trace: string[] = []tn.sub({ rule: (r: any) => trace.push(`${r.name}~${r.state}@${r.d}`) })tn.parse('1+2+3')console.log(trace.join(' '))
package mainimport (	"fmt"	"strings"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	// The rule table itself: options, then rules, each with an open and a close	// phase holding alternates tried in order.	err := j.Grammar(&tabnas.GrammarSpec{		OptionsMap: map[string]any{			"fixed": map[string]any{"token": map[string]any{"#PL": "+"}}, // custom fixed tokens			"rule":  map[string]any{"start": "val"},                      // where parsing begins		},		Rule: map[string]*tabnas.GrammarRuleSpec{			"val": {				Open:  []*tabnas.GrammarAltSpec{{P: "add"}},				Close: []*tabnas.GrammarAltSpec{{}},			},			"add": {				Open:  []*tabnas.GrammarAltSpec{{S: "#NR"}},				Close: []*tabnas.GrammarAltSpec{{S: "#PL", R: "add"}, {}},			},		},	})	if err != nil {		panic(err)	}	// `p` pushes (depth grows), `r` repeats (depth stays). The trace shows it:	// `val` pushes `add` once, then every `+` repeats `add` at the same depth.	trace := []string{}	j.Sub(nil, func(r *tabnas.Rule, ctx *tabnas.Context) {		trace = append(trace, fmt.Sprintf("%s~%s@%d", r.Name, r.State, r.D))	})	if _, err := j.Parse("1+2+3"); err != nil {		panic(err)	}	fmt.Println(strings.Join(trace, " "))}

The grammar is a table: rules, each with an open and a close phase, each phase a list of alternates tried in order. Subscribing to rule events prints the machine walking it — val pushes add (depth 0 to 1), then each + repeats add at depth 1 rather than nesting.

That flatness is what lets an accumulator live in one place: every repetition shares the one val parent.

output val~o@0 add~o@1 add~c@1 add~o@1 add~c@1 add~o@1 add~c@1 val~c@0

Each rule has an open phase (on the way down) and a close phase (on the way back up). Each phase holds a list of alternates, tried in order. The first one whose token pattern matches wins.

Alternate fields

FieldMeaning
sMatch this token sequence. One token, or several for lookahead.
pPush a child rule. It nests: the child’s parent is this rule.
rRepeat a rule at the same stack depth. No nesting; the parent is unchanged.
aAction — a function, a @ref name, a $-builtin, or an array of them.
cCondition; the alternate only applies when it holds.
{}The empty alternate. Ends the phase.

p versus r is the distinction worth internalising. Push builds depth, so a+b+c nests three levels. Repeat stays flat, so every repetition shares one parent — which is what lets an accumulator be a single value in a single place.

Every phase needs a way out. If no alternate matches, that is a parse error. {} matches anything and does nothing, which is how a rule ends.

The rule instance

Actions receive r, the current rule instance:

PropertyWhat it is
r.nodeThe value this rule carries. What parse() returns for the start rule.
r.oTokens matched in the open phase. r.o[0].val is the first token’s value, r.o[0].src its source text.
r.parentThe enclosing rule instance.
r.childThe rule instance just closed beneath this one.

Tokens

Some tokens exist before you declare anything:

TokenMatches
#NRA number. val is a real number, not a string.
#TXBare text.
#STA quoted string.
#CA,
#CL:
#OB #CB{ }
#OS #CS[ ]
#SP #LNSpace, newline.
#ZZEnd of source.

Declare your own under options.fixed.token. Pick a name that isn’t taken — #CM is comment, not comma, and silently redefining it will cost you an afternoon.

Builtin actions

The engine ships $-suffixed action builtins, merged into the ref map when the grammar loads. Because they are referenced by name, a grammar using only these is pure JSON with no functions in it — serialisable, diffable, and safe to accept from somewhere else.

BuiltinEffect
@object$r.node = {}
@array$r.node = []
@key$Capture the matched key token.
@setval$Assign the child’s node as an object property.
@push$Append the child’s node to an array.
@value$Resolve the matched scalar token.
@reset$Clear the parent-seeded node.
@node$ @capture$ @bubble$Rebuild the { rule, src, kids } tree. Used by the ABNF compiler.

A grammar that is pure JSON

import { Tabnas } from '@tabnas/parser'// Actions referenced by name, so the whole grammar is JSON with no functions.const SPEC = `{  "options": { "rule": { "start": "val" } },  "rule": {    "val": {      "open":  [ { "s": "#NR", "a": "@value$" } ],      "close": [ {} ]    }  }}`const tn = new Tabnas()tn.grammar(JSON.parse(SPEC))for (const src of ['42', '-7', '3.5']) {  console.log(src.padEnd(4), '=>', tn.parse(src))}
package mainimport (	"encoding/json"	"fmt"	tabnas "github.com/tabnas/parser/go")// Actions referenced by name, so the whole grammar is JSON with no functions.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)	}	for _, src := range []string{"42", "-7", "3.5"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		fmt.Printf("%-4s => %v\n", src, out)	}}

@value$ is an engine builtin referenced by name, so the grammar is a JSON document with no code in it — here it is loaded from a string to prove the point. It resolves the matched token into r.node, which is what parse returns.

Go has no JSON.parse, so it registers a text parser backed by encoding/json and calls GrammarText.

output 42 => 42 · -7 => -7 · 3.5 => 3.5

Constraints

These are properties of the machine, not gaps to be filled later:

  • Deterministic dispatch. Alternates are tried in order and the first match wins, so two alternates that can’t be told apart from their leading tokens will resolve to whichever comes first.
  • No backtracking. One path through, or an error.
  • No ambiguity. One parse per input.
  • A hand-written grammar has no end-of-source check. The ABNF compiler adds a __start__ wrapper that consumes #ZZ; if you write the table yourself, trailing input can be silently ignored unless you handle it.

See also

Describes @tabnas/parser 0.8.10 · all pinned versions