An extensible parsing engine, and a compile target for agents

tabnas runs grammars as tables of rules and token alternates. Extend an existing grammar with plugins, compile one from ABNF, or define its rule table directly. Agents can use the same data format and validate it before parsing.

ABNF in, a parser out

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'const tn = new Tabnas({ plugins: [abnf] })tn.abnf(`  val = add  add = NR [ PL add ]  PL  = "+"`, {  actions: {    '@val:o:add': (r: any) => { r.node.value = 0 },    '@add:o:NR': (r: any) => { r.parent.node.value += r.o[0].val },  },})console.log(tn.parse('1+2+3').value)console.log(tn.parse('12+3+45').value)
package mainimport (	"fmt"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	_, err := abnf.Install(j, `  val = add  add = NR [ PL add ]  PL  = "+"`, nil, abnf.ActionsMap{		"@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)		}},	})	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("%v\n", int(out.(map[string]any)["value"].(float64)))	}}

The grammar is plain RFC 5234 ABNF: nothing tabnas-specific is added to it. val = add makes val the entry rule, add = NR [ PL add ] reads a number then optionally another + … chain, and PL = "+" names the operator token.

Behaviour binds out of band, through names the compiler already assigns: @val:o:add is the val rule's open-phase alternate that pushes add, and @add:o:NR is add's open-phase alternate matching a number token. Run tabnas-abnf --marks to list a grammar's marks rather than guessing them.

Because the tail self-reference [ PL add ] compiles to a close-phase repeat, r.parent is val for every repetition, so the running total accumulates on val's node, which is what parse returns.

The two versions differ in one detail: Go's number tokens carry float64, where TypeScript carries a JS number, so the Go version asserts float64 and converts once at the end.

output 6 · 60

Valid RFC 5234: nothing added to it. Behaviour binds out of band, through names the compiler already assigns. See it end to end ↓

MIT licensed · github.com/tabnas

Why tabnas

Adding syntax to an existing language requires a way to change its grammar. In tabnas, plugins add or modify rules and token alternates while using the same parsing engine.

tabnas started as an attempt to write a more forgiving JSON parser and turned into an extensible one by accident. The engine is a for-loop over tokens with a rule stack, and the grammar is the lookup table it walks. So adding a language feature means adding rules and token alternates to a table. That is the whole extension mechanism: JSON is a handful of rules, JSONC adds comments to them, jsonic relaxes the quoting, YAML keeps going. Every grammar is a plugin over another plugin.

The rule table is also a compile target. The ABNF plugin compiles grammar notation into that table. An agent can emit a declarative grammar directly, then check its structure and test sample inputs.

Start with a grammar that parses a related format, then add the rules your language needs.

Read the whole story: how the algorithm works, and where it came from

The technical TLDR

You write a for-loop over parsing tokens. There's no recursion: a stack keeps track of rule depth. Each rule represents a semantic element of your language, and you move to the next rule using a lookup table of token alternates. Rules are hit twice, once on descent and once on ascent, so they have two states, open and close, and two token lookup tables.

Core features

Extend, don't fork

Every grammar is a plugin over another. Add rules and token alternates to a language that already parses, instead of taking ownership of someone else's parser.

A target agents can hit

A declarative grammar contains rules, token alternates, and action references. Validate its structure and test its behaviour before using it. The skills and MCP server support this workflow; the agent guide has the commands.

A target compilers can hit

@tabnas/abnf compiles RFC 5234 ABNF into a working grammar, left recursion included, so an RFC's own notation is runnable.

Three levels

ABNF when a human is writing, declarative JSON when an agent is, and a programmatic API for parameterised grammars: the level expr and directive are built at.

Nothing is generated

The grammar is walked at runtime, so it can be inspected while it runs: debug describes a live grammar and prints it back as ABNF, railroad draws it.

Runs where you do

The engine is implemented in TypeScript and in Go, both driven by the same grammars and checked against the same fixtures.

A grammar, end to end

Addition, four times: the grammar as a person writes it, the same grammar as data, and the same two actions attached two ways: by reference to the ABNF, and inline on the rule table. Steps 3 and 4 compute the same total with the same logic, because the ABNF compiles to the table.

1 · ABNF: as a person writes it

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'// Plain RFC 5234 ABNF — this is the whole grammar.const GRAMMAR = `  val = add  add = NR [ PL add ]  PL  = "+"`const tn = new Tabnas({ plugins: [abnf] })tn.abnf(GRAMMAR)// Compiled, it recognises an addition chain and refuses anything else.for (const src of ['1+2+3', '12+3+45', '1+*']) {  let ok = true  try { tn.parse(src) } catch { ok = false }  console.log(src.padEnd(8), ok ? 'accepted' : 'rejected')}
package mainimport (	"fmt"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")// Plain RFC 5234 ABNF — this is the whole grammar.const GRAMMAR = `  val = add  add = NR [ PL add ]  PL  = "+"`func main() {	j := tabnas.Make()	if _, err := abnf.Install(j, GRAMMAR, nil, nil); err != nil {		panic(err)	}	// Compiled, it recognises an addition chain and refuses anything else.	for _, src := range []string{"1+2+3", "12+3+45", "1+*"} {		state := "accepted"		if _, err := j.Parse(src); err != nil {			state = "rejected"		}		fmt.Printf("%-8s %s\n", src, state)	}}

NR is the built-in number token, [ … ] is optional, and add refers to itself to take a whole chain, so val wraps an addition of any length.

Bare ABNF is not a program, so it is wrapped here in the smallest thing that runs it: compile the grammar, then parse. With no actions attached it only recognises, which is why the output records acceptance rather than a value.

output 1+2+3 accepted · 12+3+45 accepted · 1+* rejected

NR is the built-in number token, [ … ] is optional, and add refers to itself to take a whole chain. val wraps it: that's where the total will live.

2 · The same grammar, as data

import { Tabnas } from '@tabnas/parser'// The same grammar as step 1, but as data — the shape an agent emits.const GRAMMAR = `{  "options": {    "fixed": { "token": { "#PL": "+" } },    "rule":  { "start": "val" }  },  "rule": {    "val": {      "open":  [ { "p": "add" } ],      "close": [ {} ]    },    "add": {      "open":  [ { "s": "#NR" } ],      "close": [ { "s": "#PL", "r": "add" }, {} ]    }  }}`const tn = new Tabnas()tn.grammar(JSON.parse(GRAMMAR))// It recognises an addition chain and refuses anything else — but, having no// actions, it produces nothing.for (const src of ['1+2+3', '12+3+45', '1+*']) {  let ok = true  try { tn.parse(src) } catch { ok = false }  console.log(src.padEnd(8), ok ? 'accepted' : 'rejected')}
package mainimport (	"encoding/json"	"fmt"	tabnas "github.com/tabnas/parser/go")// The same grammar as step 1, but as data — the shape an agent emits.const GRAMMAR = `{  "options": {    "fixed": { "token": { "#PL": "+" } },    "rule":  { "start": "val" }  },  "rule": {    "val": {      "open":  [ { "p": "add" } ],      "close": [ {} ]    },    "add": {      "open":  [ { "s": "#NR" } ],      "close": [ { "s": "#PL", "r": "add" }, {} ]    }  }}`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(GRAMMAR); err != nil {		panic(err)	}	// It recognises an addition chain and refuses anything else — but, having	// no actions, it produces nothing.	for _, src := range []string{"1+2+3", "12+3+45", "1+*"} {		state := "accepted"		if _, err := j.Parse(src); err != nil {			state = "rejected"		}		fmt.Printf("%-8s %s\n", src, state)	}}

p pushes a rule, s matches tokens, r repeats the rule at the same depth, and {} is the alternate that ends it. This is the compiled form of the ABNF in step 1, and it recognises exactly the same inputs, but with no actions it produces nothing, which is why the output only records acceptance.

TypeScript reads the literal with the built-in JSON.parse; Go has no equivalent, so it registers a text parser once and then hands the engine the same string.

output 1+2+3 accepted · 12+3+45 accepted · 1+* rejected

p pushes a rule, s matches tokens, r repeats the rule at the same depth, and {} is the alternate that ends it. This recognises 1+2+3 and rejects 1+*, but produces nothing. It's the shape an agent emits.

3 · Actions by reference: the @ref form

import { Tabnas } from '@tabnas/parser'import { abnf } from '@tabnas/abnf'const tn = new Tabnas({ plugins: [abnf] })tn.abnf(`  val = add  add = NR [ PL add ]  PL  = "+"`, {  actions: {    '@val:o:add': (r: any) => { r.node.value = 0 },    '@add:o:NR': (r: any) => { r.parent.node.value += r.o[0].val },  },})console.log(tn.parse('1+2+3').value)console.log(tn.parse('12+3+45').value)
package mainimport (	"fmt"	abnf "github.com/tabnas/abnf/go"	tabnas "github.com/tabnas/parser/go")func main() {	j := tabnas.Make()	_, err := abnf.Install(j, `  val = add  add = NR [ PL add ]  PL  = "+"`, nil, abnf.ActionsMap{		"@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)		}},	})	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("%v\n", int(out.(map[string]any)["value"].(float64)))	}}

The grammar is plain RFC 5234 ABNF: nothing tabnas-specific is added to it. val = add makes val the entry rule, add = NR [ PL add ] reads a number then optionally another + … chain, and PL = "+" names the operator token.

Behaviour binds out of band, through names the compiler already assigns: @val:o:add is the val rule's open-phase alternate that pushes add, and @add:o:NR is add's open-phase alternate matching a number token. Run tabnas-abnf --marks to list a grammar's marks rather than guessing them.

Because the tail self-reference [ PL add ] compiles to a close-phase repeat, r.parent is val for every repetition, so the running total accumulates on val's node, which is what parse returns.

The two versions differ in one detail: Go's number tokens carry float64, where TypeScript carries a JS number, so the Go version asserts float64 and converts once at the end.

output 6 · 60

The grammar is exactly the ABNF from step 1: valid RFC 5234, nothing added to it. Behaviour binds out of band, through names the compiler already assigns: '@val:o:add' is the val rule's alternate that pushes add; '@add:o:NR' is the add rule's alternate on an NR token. Ask the compiler which marks a grammar has with tabnas-abnf --marks rather than guessing.

These are the same two actions, on the same two rules, as step 4 below, because the compiler emits the same shape. The tail self-reference [ PL add ] compiles to the r: 'add' close-phase repeat that step 4 declares by hand, so r.parent is val for every repetition, and the total lands on val's node, where parse returns it.

This is what keeps the grammar a compile target: the text a person or an agent writes never has to carry code, so it stays printable, drawable, and checkable.

4 · Actions inline: carried on the parse

import { Tabnas } from '@tabnas/parser'const tn = new Tabnas()tn.grammar({  options: {    fixed: { token: { '#PL': '+' } },    rule: { start: 'val' },  },  // Cast: the declarative GrammarSpec type only sanctions '@ref' strings for  // `a`, but the rule table underneath takes a function directly.  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,})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)	}	for _, src := range []string{"1+2+3", "12+3+45"} {		out, err := j.Parse(src)		if err != nil {			panic(err)		}		fmt.Printf("%v\n", int(out.(float64)))	}}

Same grammar, same machine as step 3: the actions are just written straight onto the alternates as a. Because r repeats add at the same stack depth rather than nesting it, every add shares one parent, so the accumulator is a single number that rides on the parse and parse returns it directly.

The two versions differ in how the table is typed: Go's GrammarSpec takes the action function as-is, where TypeScript's declarative type only sanctions '@ref' strings, so the rule table needs a cast. Go's number tokens also carry float64 rather than a JS number.

output 6 · 60

Same grammar, same machine: the actions are just written straight onto the alternates as a. The total now rides on the parse instead of an outer variable, so parse returns it directly and concurrent parses can't collide.

Because r repeats add at the same stack depth rather than nesting it, every add shares one parent, so the accumulator is a single number and each action is a single expression. r.node is the value a rule carries; r.o the tokens matched in the open phase, so r.o[0].val is the number just read.

The quickstart walks through the @ref form, and the playground runs a grammar in the browser.

Install npm install @tabnas/parser @tabnas/abnf or go get github.com/tabnas/parser/go

Packages

A small core engine, tools for writing and testing grammars and for connecting agents, and 18 languages built on top: most of them extensions of another one in the list. The releases page has all 31, their versions, and which registries each one is on.

@tabnas/parser

The engine: a pluggable, rule-based parsing machine and a uniform syntax tree.

@tabnas/abnf

Compile RFC 5234 ABNF straight into a working grammar.

@tabnas/bnf

The shared BNF-family compiler behind abnf, ebnf and gbnf.

@tabnas/debug

Inspect a live grammar: describe it, render it back as ABNF.

@tabnas/railroad

Render railroad (syntax) diagrams from a grammar.

@tabnas/support

Shared .tsv fixture loaders and the error-code census helpers: the machinery behind every repo's two-runtime specs.

@tabnas/mcp

The MCP server and the unified tabnas CLI: the same seven operations from one core, listed in the MCP registry as dev.tabnas/mcp.

Languages: jsonjsoncjson5jsonicyamltomlinicsvxmlmarkdowncsscprotozonfeedchesssemvergbnf all 31 packages

The project

Contributing

This project is AI-friendly: contributions written with an agent are welcome, and every package repository ships an AGENTS.md to point one in the right direction. Work lands in TypeScript first, then Go to match. See community.