Your grammar file has JavaScript in it. Every rule ends in a { return ... } block, so the ABNF
you copied from the specification no longer compiles with any other tool. The Go port of your
parser needs a grammar of its own, and a linter that wants the tree instead of the value cannot
share the file either.
What you get
You will end up with one grammar in plain ABNF and a handler map keyed by the names the compiler assigned. A test fails when a handler is left pointing at nothing. This is for you if more than one program consumes your grammar.
Short answer
Keep the grammar free of code and bind behavior to rule names after it compiles. With
@tabnas/abnf, tn.abnf(grammar, { actions }) takes a map keyed by marks such as @val:o:NR and
@pair:ac, which tabnas-abnf --marks lists, and refuses a key that names no rule. Lark and
chevrotain bind methods by rule name and say nothing when a name matches nothing, so add a test
that every handler fires on a corpus.
You will need
Node 22 or later, and a grammar written in RFC 5234
ABNF.1 Verified 2026-09-25 against Node 22.22.2, @tabnas/abnf 0.4.15, @tabnas/parser
0.12.2, chevrotain 13.2.0, and peggy 5.1.0. The Lark transformer was run by hand against Lark 1.3.1 on
Python 3.11. The checks behind this page install no Python packages, so its output is not shown.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| chevrotain CST visitor | The grammar is already TypeScript and you want a tree first and values second | A visitor class per output, and a validator that reports a missing method and not a stale one | The grammar must be readable by people who do not write TypeScript |
| Lark Transformer | The parser is Python and the grammar is Lark’s own EBNF dialect | Methods named after rules and aliases, with a method that matches nothing ignored in silence | The same grammar must drive a parser outside Python |
| peggy inline actions | One output shape, one language, and the shortest path to a value | JavaScript inside the grammar, so no other tool compiles it and a second output is a second grammar | The grammar is ABNF from an RFC, or a Go port is on the roadmap |
@tabnas/abnf marks | Plain ABNF that another tool, or the Go port, must read unchanged | A naming contract you keep stable, and marks the compiler assigns that you list rather than guess | One parser, one language, and nobody else reads the grammar |
Voxgig maintains tabnas. It is one of four options here, not the recommendation.
Inline actions are the shortest path and they tie the grammar to one language and one output. The three name-bound rows untie it, and each charges a naming contract. chevrotain and Lark bind methods to rule names, tabnas binds functions to marks, and in all three a rename can leave a handler attached to nothing. What differs is who notices, which is the subject of the failure section.
Write the format once, in ABNF
A settings format, key = value, with numbers, quoted strings, and booleans. TX, NR, ST,
and VL are the engine’s built-in tokens for bare text, a number, a quoted string, and a literal
such as true. The notation itself is covered in the
ABNF guide.
doc = 1*pair
pair = key EQ val
key = TX
val = NR / ST / VL
EQ = "="
Nothing in that file is code, so any ABNF tool reads it. Ask the compiler which names it assigned, rather than guessing them from the rule names.
npx tabnas-abnf --marks -f settings.abnf
doc o:_gen1_plus_pair p:_gen1_plus_pair
doc c:_ (empty)
pair o:TX s:#TX #EQ
pair c:_ (empty)
key o:TX s:#TX
val o:NR s:#NR
val o:ST s:#ST
val o:VL s:#VL
A mark is <rule> <phase>:<name>, and the name is the token or rule that starts the alternate:
val o:NR is the alternate of val that opens on a number. 1*pair compiled to a generated rule,
_gen1_plus_pair, so doc opens by pushing a rule you did not write. The listing shows one more
thing worth reading twice. pair’s open alternate matches #TX #EQ itself, so the key rule is
listed and never entered, which the failure section returns to.
Bind the handlers to the names
The handler map lives in a JavaScript file and the grammar does not know it exists.
export const ACTIONS = {
// A value: the token the alternate matched, already typed by the lexer.
'@val:o:NR': (r) => { r.node.value = r.o0.val },
'@val:o:ST': (r) => { r.node.value = r.o0.val },
'@val:o:VL': (r) => { r.node.value = r.o0.val },
// After a pair closes: the key is its first open token, the value came
// from the child rule that just closed.
'@pair:ac': (r) => { r.node.value = [r.o0.src, r.child.node.value] },
// After the document closes: assemble the pairs from the tree it built.
'@doc:ac': (r) => {
r.node.value = Object.fromEntries(r.node.kids.map((k) => k.value))
},
}
// Binding happens here, after compilation. A ref naming a rule the grammar
// does not have is refused at this call, not at parse time.
export function makeTabnasParser(actions = ACTIONS, grammar = GRAMMAR) {
const tn = new Tabnas({ plugins: [abnf] })
tn.abnf(grammar, { actions })
return (src) => tn.parse(src).value
}
Three fields of the rule instance do the work. r.o0.val is the first matched token’s value as
the lexer typed it, so 8080 arrives as a number and "api" without its quotes. r.child is the
rule that closed beneath this one, which is why the pair assembles itself in its after-close hook
and not on the way down. r.node is the { rule, src, kids } tree node the compiler builds
anyway, and value is a property hung on it, so the document’s handler reads its pairs off
kids.
A second map, keyed by the same names, gives the same grammar a second output: a linter that records positions, or a tree walker that emits Go. The grammar file is untouched either way, and the same names bind in the Go port, which the actions guide shows beside the TypeScript.
Run one corpus through every engine
The same format on the other three engines, with the actions where each one keeps them. peggy puts them in the grammar.
pair
= k:key _ "=" _ v:val { return [k, v] }
key
= $([a-zA-Z_][a-zA-Z0-9_]*)
val
= num / str / bool
num
= digits:$[0-9]+ { return parseInt(digits, 10) }
chevrotain builds a concrete syntax tree from a grammar written as TypeScript, and a visitor class turns it into a value, one method per rule.
export class BuildValue extends BaseVisitor {
constructor() {
super()
this.validateVisitor()
}
doc(ctx) { return Object.fromEntries(ctx.pair.map((p) => this.visit(p))) }
pair(ctx) { return [ctx.Key[0].image, this.visit(ctx.val)] }
val(ctx) {
if (ctx.Num) return Number(ctx.Num[0].image)
if (ctx.Str) return ctx.Str[0].image.slice(1, -1)
return Boolean(ctx.True)
}
}
Lark’s Transformer is the Python form of the same idea, with the
alias syntax naming each alternative
of val so that a method can catch it.
class BuildValue(Transformer):
def doc(self, pairs):
return dict(pairs)
def pair(self, items):
return (str(items[0]), items[1])
def number(self, items):
return int(items[0])
def string(self, items):
return str(items[0])[1:-1]
def boolean(self, items):
return str(items[0]) == "true"
Three documents through the three engines this directory runs, and a diff of what they build.
node compare.mjs
peggy 5.1.0 | chevrotain 13.2.0 | @tabnas/abnf 0.4.15 | @tabnas/parser 0.12.2
"name = \"api\"\nport = 8080\ndebug = true"
peggy inline same {"name":"api","port":8080,"debug":true}
chevrotain visitor same {"name":"api","port":8080,"debug":true}
tabnas actions same {"name":"api","port":8080,"debug":true}
"retries = 3 timeout = 1500 region = \"eu-west-1\""
peggy inline same {"retries":3,"timeout":1500,"region":"eu-west-1"}
chevrotain visitor same {"retries":3,"timeout":1500,"region":"eu-west-1"}
tabnas actions same {"retries":3,"timeout":1500,"region":"eu-west-1"}
"verbose = false"
peggy inline same {"verbose":false}
chevrotain visitor same {"verbose":false}
tabnas actions same {"verbose":false}
all three build the same value for every document
Agreement on the corpus is not agreement on the format. The engine’s NR also reads 1.5 and
-1, and its VL reads null, where the peggy and chevrotain grammars stop with a syntax
error. Put the edge cases in the corpus if the engines must agree on them.
The Lark transformer, run by hand, builds the same three dictionaries. Its unmatched() helper
compares the transformer’s method names with the rule and alias names on parser.rules, because
Lark makes no such check itself.
Check it worked
Two tests carry the naming contract, with the Node test runner. The first asks whether every handler names a rule the grammar defines. The second asks the question that matters more: whether every handler ran at least once on the corpus.
test('every tabnas handler names a rule the grammar defines', () => {
const rules = new Set(parseAbnf(ABNF).productions.map((p) => p.name))
for (const ref of Object.keys(ACTIONS)) {
const rule = ref.slice(1).split(':')[0]
assert.ok(rules.has(rule), `${ref} names ${rule}, which is not a rule`)
}
})
test('every tabnas handler fires at least once on the corpus', () => {
const { wrapped, unfired } = counted(ACTIONS)
const parse = makeTabnasParser(wrapped)
for (const { src } of CORPUS) parse(src)
assert.deepEqual(unfired(), [])
})
node --test handlers.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 293.882705
counted wraps each handler in a function that increments a count and calls through, and the
same wrapper fits a visitor’s methods. The corpus has to reach every alternative for the second
test to mean anything, so keep a document with each value type in it.
When it goes wrong
Rename pair to entry in each grammar, leave the handlers as they were, and see who says so.
node rename.mjs
peggy, rule and reference renamed: {"name":"api","port":8080,"debug":true}
chevrotain, strict base visitor: Error: Errors Detected in CST Visitor <Stale>: Missing visitor method: <entry> on Stale CST Visitor.
chevrotain, base visitor with defaults: {}
tabnas, rule renamed: ActionError: abnf: action ref '@pair:ac' targets unknown rule 'pair'
tabnas, handler on key: bound without error, fired 0 times over 3 documents
peggy has nothing to detach. The action is inside the rule, so it moves with the rename, and a
reference left pointing at the old name is a GrammarError at generation time.
The answer from chevrotain depends on which base class the visitor extends. The strict one fails on the
missing entry method, which catches the rename. The one with defaults fills the gap with a
method that visits the children and returns nothing, so the stale visitor builds {} and no error
is raised. validateVisitor() reports missing methods only; the stale pair method passes it in
both cases.2
tabnas refuses the stale name when the map is bound, before any input is parsed. The last line
is the case it does not refuse. key is a rule in the grammar and in the mark listing, and the
parse never enters it, because pair consumes the key token itself. A handler on @key:o:TX
binds and never runs, which only the coverage test sees.
Lark says nothing in either case. A tree with no matching method is copied through
__default__,3 and a method with no matching rule is never called.
When not to do this
Do not move actions out of the grammar for a format only one program will ever read. The naming contract is a cost you pay on every rename, and it buys you nothing until a second consumer of the grammar exists.
Do not reach for tabnas to get name binding on a grammar that is already TypeScript. The chevrotain visitor gives the same separation in the language the grammar is written in, and the case for tabnas is the grammar that has to stay plain ABNF.
Do not trust a name check alone, tabnas included. The compiler refuses a mark that names no rule,
and it binds a mark on a rule the parse never enters without a word, as the key line of the
rename output shows. The test that every handler fires is the one that catches both.
Do not rename a rule without searching for its name as a string. The grammar and the handler map are two files, and only one of them is checked by the grammar compiler.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, @tabnas/abnf 0.4.15, @tabnas/parser 0.12.2,
chevrotain 13.2.0, and peggy 5.1.0. Every output block is what the command preceding it printed. The Lark
transformer was run by hand against Lark 1.3.1 on Python 3.11 and is not part of the checked
output.
Footnotes
-
ABNF was not always one document. The introduction to RFC 5234 says that in the early days of the Arpanet each specification carried its own definition of it. The email specifications, RFC 733 and then RFC 822, became the common citations. The document separates the definition out “to permit selective reference,” and the sentence after that opens with “Predictably” and promises modifications and enhancements. A grammar for grammars, extracted from a standard for mail headers, and amended on the way out. ↩︎ Back to text
-
The chevrotain documentation says
validateVisitor()can be used to detect missing or redundant visitor methods. In the 13.2.0 source, the function returns the result of the missing-method check and nothing else, and the error enumeration beside it still carries aREDUNDANT_METHODvalue that no code produces. The documentation describes the check as it was, and the enumeration keeps a chair for it. ↩︎ Back to text -
The Lark documentation says that if the transformer cannot find a method with the right name it calls
__default__, which by default creates a copy of the node. That covers a rule with no method. A method with no rule is the other half, and the documentation has nothing to say about it, because from the transformer’s side there is nothing to see. ↩︎ Back to text