How-to › Parse, validate and transform data

How to attach parse actions to grammar rules by name#

Keep the grammar as plain ABNF, bind behavior to the rule names the compiler assigns, and test that every handler still names a rule and fires after a rename.

Audience
Library maintainer
Level
intermediate
Topic
Write a grammar and parser
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
chevrotain CST visitorThe grammar is already TypeScript and you want a tree first and values secondA visitor class per output, and a validator that reports a missing method and not a stale oneThe grammar must be readable by people who do not write TypeScript
Lark TransformerThe parser is Python and the grammar is Lark’s own EBNF dialectMethods named after rules and aliases, with a method that matches nothing ignored in silenceThe same grammar must drive a parser outside Python
peggy inline actionsOne output shape, one language, and the shortest path to a valueJavaScript inside the grammar, so no other tool compiles it and a second output is a second grammarThe grammar is ABNF from an RFC, or a Go port is on the roadmap
@tabnas/abnf marksPlain ABNF that another tool, or the Go port, must read unchangedA naming contract you keep stable, and marks the compiler assigns that you list rather than guessOne 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.

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

  1. 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

  2. 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 a REDUNDANT_METHOD value that no code produces. The documentation describes the check as it was, and the enumeration keeps a chair for it. ↩︎ Back to text

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.