---
title: "The rule table · Docs · tabnas"
description: "The grammar format the engine walks: rules, phases, alternates, and the fields on each."
source: "https://tabnas.dev/docs/rule-table/"
---

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

TypeScript:

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

Go:

```go
package main

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

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

| Field | Meaning |
| --- | --- |
| `s` | 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.

## The rule instance

Actions receive `r`, the current rule instance:

| Property | What it is |
| --- | --- |
| `r.node` | The value this rule carries. What `parse()` returns for the start rule. |
| `r.o` | Tokens matched in the open phase. `r.o[0].val` is the first token’s value, `r.o[0].src` its source text. |
| `r.parent` | The enclosing rule instance. |
| `r.child` | The rule instance just closed beneath this one. |

## Tokens

Some tokens exist before you declare anything:

| Token | Matches |
| --- | --- |
| `#NR` | A number. `val` is a real number, not a string. |
| `#TX` | Bare text. |
| `#ST` | A quoted string. |
| `#CA` | `,` |
| `#CL` | `:` |
| `#OB` `#CB` | `{` `}` |
| `#OS` `#CS` | `[` `]` |
| `#SP` `#LN` | Space, newline. |
| `#ZZ` | End 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.

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

TypeScript:

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

Go:

```go
package main

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

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

-   [ABNF grammars](https://tabnas.dev/docs/abnf-grammars/): the notation that compiles to this.
-   [Attaching actions](https://tabnas.dev/docs/actions/): the three ways to bind behaviour.
-   [How it works](https://tabnas.dev/docs/how-it-works/): why the machine has this shape.
