---
title: "tabnas: An extensible parsing engine, and a compile target for agents"
description: "tabnas runs extensible grammars as rule tables. Build on existing plugins, compile ABNF, or define a grammar directly with data and actions."
source: "https://tabnas.dev/"
---

# 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**

TypeScript:

```ts
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)
```

Go:

```go
package main

import (
	"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)))
	}
}
```

Explain: 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 ↓](#a-grammar-end-to-end)

MIT licensed · [github.com/tabnas](https://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](https://tabnas.dev/why/)

> 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](https://tabnas.dev/skills/) and [MCP server](https://tabnas.dev/mcp/) support this workflow; [the agent guide](https://tabnas.dev/agents/) has the commands.

### A target compilers can hit

[@tabnas/abnf](https://github.com/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](https://github.com/tabnas/expr) and [directive](https://github.com/tabnas/directive) are built at.

### Nothing is generated

The grammar is walked at runtime, so it can be inspected while it runs: [debug](https://github.com/tabnas/debug) describes a live grammar and prints it back as ABNF, [railroad](https://github.com/tabnas/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

TypeScript:

```ts
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')
}
```

Go:

```go
package main

import (
	"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)
	}
}
```

Explain: `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

TypeScript:

```ts
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')
}
```

Go:

```go
package main

import (
	"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)
	}
}
```

Explain: `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

TypeScript:

```ts
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)
```

Go:

```go
package main

import (
	"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)))
	}
}
```

Explain: 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

TypeScript:

```ts
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'))
```

Go:

```go
package main

import (
	"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)))
	}
}
```

Explain: 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](https://tabnas.dev/docs/quickstart/) walks through the `@ref` form, and the [playground](https://tabnas.dev/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](https://tabnas.dev/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.

[source](https://github.com/tabnas/parser) [npm](https://www.npmjs.com/package/@tabnas/parser) [go](https://pkg.go.dev/github.com/tabnas/parser/go)

### @tabnas/abnf

Compile RFC 5234 ABNF straight into a working grammar.

[source](https://github.com/tabnas/abnf) [npm](https://www.npmjs.com/package/@tabnas/abnf) [go](https://pkg.go.dev/github.com/tabnas/abnf/go)

### @tabnas/bnf

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

[source](https://github.com/tabnas/bnf) [npm](https://www.npmjs.com/package/@tabnas/bnf) [go](https://pkg.go.dev/github.com/tabnas/bnf/go)

### @tabnas/debug

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

[source](https://github.com/tabnas/debug) [npm](https://www.npmjs.com/package/@tabnas/debug) [go](https://pkg.go.dev/github.com/tabnas/debug/go)

### @tabnas/railroad

Render railroad (syntax) diagrams from a grammar.

[source](https://github.com/tabnas/railroad) [npm](https://www.npmjs.com/package/@tabnas/railroad) [go](https://pkg.go.dev/github.com/tabnas/railroad/go)

### @tabnas/support

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

[source](https://github.com/tabnas/support) [npm](https://www.npmjs.com/package/@tabnas/support) [go](https://pkg.go.dev/github.com/tabnas/support/go)

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

[source](https://github.com/tabnas/mcp) [npm](https://www.npmjs.com/package/@tabnas/mcp)

Languages: [json](https://github.com/tabnas/json)[jsonc](https://github.com/tabnas/jsonc)[json5](https://github.com/tabnas/json5)[jsonic](https://github.com/tabnas/jsonic)[yaml](https://github.com/tabnas/yaml)[toml](https://github.com/tabnas/toml)[ini](https://github.com/tabnas/ini)[csv](https://github.com/tabnas/csv)[xml](https://github.com/tabnas/xml)[markdown](https://github.com/tabnas/markdown)[css](https://github.com/tabnas/css)[c](https://github.com/tabnas/c)[proto](https://github.com/tabnas/proto)[zon](https://github.com/tabnas/zon)[feed](https://github.com/tabnas/feed)[chess](https://github.com/tabnas/chess)[semver](https://github.com/tabnas/semver)[gbnf](https://github.com/tabnas/gbnf) [all 31 packages](https://tabnas.dev/releases/)

## The project

### Who writes it

_tabnas_ is written by [Richard Rodger](https://richardrodger.com) ([@rjrodger](https://github.com/rjrodger)), who also wrote [Seneca](https://senecajs.org) and jsonic, which is where this came from.

### 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](https://tabnas.dev/community/).

### Licence and funding

MIT, across every repository in the [_tabnas_ org](https://github.com/tabnas). Development is sponsored by [Voxgig](https://voxgig.com): see [sponsors](https://tabnas.dev/sponsors/).
