Building with tabnas #
Written for an agent that has arrived here to build something. It covers the grammar format you should emit, the constraints the engine imposes, how to check your work, and where the per-package instructions live. A human wanting the argument for any of this should read why tabnas instead.
1 · What you are targeting #
A tabnas grammar is data: a table of rules, each with
an open and a close phase, each phase holding
a list of alternates. An alternate matches a short token
pattern and may run an action, push a child rule, or repeat the current
one. Parsing is a for-loop over tokens with a rule stack — no recursion
and no backtracking.
That is the whole machine. You are not writing a parser; you are filling in a table. It is deliberately dull, which is what makes it a target you can hit reliably.
2 · Install #
npm install @tabnas/parser @tabnas/abnf go get github.com/tabnas/parser/go Pin exact versions. Everything is pre-1.0, so a caret range will refuse the next minor release without telling you.
3 · Do not start from scratch #
This is the step most worth spending time on. Extension is the cheapest path and the one the engine is designed for: if something close to your format already parses, start there and add rules. JSONC is JSON plus comments; jsonic is JSONC with the quoting relaxed. Check the package list before writing anything.
- Parsing a JSON dialect? Start from jsonic and remove or add rules.
- Need operators and precedence? Add expr rather than hand-rolling a Pratt parser.
- Need
@name-style forms? Add directive. - Need free text between delimiters? Add hoover.
- Composing several documents into one parse? Add multisource.
aontu is the worked example: a whole configuration language assembled from five of these plugins, defining no parser of its own.
4 · The rule table #
This is the form to emit. It is plain data, so you can validate it, diff it, and print it before anything runs.
A rule table, loaded and exercised
output 1 accept · 1+2 accept · 1+2+3 accept · 1+ reject · +1 reject
| Field | On an alternate, means |
|---|---|
s | Match this token sequence — one token, or several for lookahead. |
p | Push a child rule — it becomes a child node. |
r | Repeat a rule at the same stack depth — no nesting, same parent. |
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. Without one, no match is a parse error. |
p versus r is the distinction that catches
people out. Push nests, so the child's parent is the pushing
rule. Repeat stays at the same depth, so every repetition shares one
parent — which is what makes an accumulator a single value in a single
place.
5 · Actions, ideally without code #
A grammar that only recognises input returns nothing. Actions build the
result. There are three ways to attach them, and you should prefer them
in this order:
Builtin actions — no functions at all #
The engine ships $-suffixed builtins, merged into the ref
map when the grammar loads. Referenced by name, they let you emit a
grammar that is pure JSON with no code in it — which is
the safest thing you can hand to someone else.
A grammar with no code in it
output 42 => 42 · 3.5 => 3.5 · -7 => -7
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.
Named refs — code, bound out of band #
Keeps the grammar declarative while the code lives in the host
program. Two kinds of name: alternate marks (open- or close-phase),
and rule-phase hooks (bo, ao,
bc, ac — before/after open and close).
Keep results on the node, not in a variable outside the parse, so
the grammar stays reusable.
A rule-phase hook and an alternate mark
output 1 => total 1, terms 1 · 1+2+3 => total 6, terms 3 · 12+3+45 => total 60, terms 3
Mark names are assigned by the compiler from each alternate's leading
discriminator. Do not guess them. Ask:
tabnas-abnf --marks -f grammar.abnf.
Inline functions #
Written straight onto the alternate as a. The most direct,
but the grammar is now code and can no longer be serialised or safely
shared.
Actions written onto the alternates
output 1 => 1 · 1+2+3 => 6 · 12+3+45 => 60
r is the rule instance, r.node the value it
carries, r.o the tokens matched in the open phase (so
r.o[0].val is the first token's value, already a number),
and r.parent / r.child the neighbouring rules.
6 · Constraints to design around #
These are the things that will bite. All of them are by design.
- Deterministic, no backtracking. Alternates are
tried in order and the first match wins. Two alternates that can't be
told apart from their leading tokens resolve to whichever comes
first — order them deliberately.
- Ambiguity is not supported. One parse or an error.
If you need every valid parse, this is the wrong engine.
- You write the lexer. tabnas separates lexing from
parsing and is about the parsing half. Built-ins like
NR
and fixed tokens cover a lot; beyond that, you assemble it.
- Left recursion is rewritten, not supported. The ABNF
compiler applies Paull's
algorithm, so
P = P a / b
becomes P = b *(a). The tree comes out flat rather than
left-nested, and a purely left-recursive rule is an error.
- A tail self-reference compiles to a repeat.
X = prefix [ sep X ] becomes a same-depth
r: repeat, so r.parent is the wrapping
rule for every repetition and r.parent.node is the
accumulator idiom — identical to a hand-written table. Other sugar
(( … ), *( … )) still desugars into
generated group rules, where parent may be synthetic;
check with tabnas-abnf --marks.
- Empty alternates matter. A phase with no matching
alternate is a parse error.
{} is how a rule ends.
7 · Verify your work #
A grammar that looks right and parses one happy-path input is not
evidence. Before reporting success:
- Parse several known-good inputs and assert the values, not just that it didn't throw.
- Parse known-bad inputs and confirm they're rejected. A grammar that accepts everything is a common failure.
- Run
tabnas-abnf --marks if an action didn't fire. - Use @tabnas/debug to describe the live grammar and render it back to ABNF — if the round-trip isn't what you meant, the grammar isn't either.
- Use @tabnas/railroad to draw it when the structure is hard to hold in your head.
- Try it in the playground — it takes ABNF or a rule table and shows both the tree and the value.
8 · Per-package instructions #
Every package repository ships an AGENTS.md with its
layout, conventions, test commands, and the non-obvious things to know
before changing it. Read the one for the package you're touching — start
with parser.
Engine
Syntax plugins
Command line
Work lands in TypeScript first, then Go to match; both run the same
fixtures, so a change that doesn't really work fails loudly. Agent-written
contributions are welcome and need no disclosure — see
community.
9 · Skills and MCP #
Everything on this page is also packaged for an agent to install
rather than read. Skills are five portable Agent
Skills — author a grammar, debug a parse, pin behaviour with fixtures,
build and upgrade plugins — and MCP is the server
that makes the commands they teach executable: parse, validate a
grammar before running it, explain a failure, run fixtures, read the
plugin catalogue, and compare a grammar change against the one it
replaces. The same seven operations are a tabnas
command-line tool, so a shell transcript and an agent transcript
describe the same run.
Run it locally. npx --yes @tabnas/mcp mcp
is the supported path: free, private, unlimited, and the same code as
everything else here. If your client cannot spawn a process, the same
seven tools are served over streamable HTTP at https://mcp.tabnas.dev/mcp — bounded by a body cap and a per-IP rate
limit, both reported by its /.well-known/mcp, and
covered by a privacy policy that is short
because the service keeps nothing.
10 · This site, machine-readable #
- llms.txt indexes the site
and llms-full.txt is
the full documentation text. Both are generated from the site's own
pages, so they cannot fall behind them.
- versions.json says
which package versions this documentation describes — worth reading
before trusting an example against whatever you have installed.
- /errors is every error code
the engine and its plugins can raise, one page per code at
/errors/<code>. A diagnostic's code is
therefore a URL you can follow.
Source for everything is under
github.com/tabnas.