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 — valpushesadd (depth 0 to 1), then each +repeatsadd at depth 1 rather than nesting.
That flatness is what lets an accumulator live in one place: every repetition shares the one val parent.
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.
Match this token sequence. One token, or several for lookahead.
p
Push a child rule. It nests: the child’s parent is this rule.
r
Repeat a rule at the same stack depth. No nesting; the parent is unchanged.
a
Action — a function, a @ref name, a $-builtin, or an array of them.
c
Condition; 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.
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.
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.
Builtin
Effect
@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.
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.