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

Source: https://voxgig.com/howto/attach-parse-actions-by-rule-name

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://www.rfc-editor.org/rfc/rfc5234.html)
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](https://chevrotain.io/docs/guide/concrete_syntax_tree.html) | 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](https://lark-parser.readthedocs.io/en/stable/visitors.html) | 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](https://peggyjs.org/documentation.html#grammar-syntax-and-semantics) | 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](https://tabnas.dev/docs/actions/) | 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](https://tabnas.dev/docs/abnf-grammars/).

```text title="settings.abnf"
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.

```bash
npx tabnas-abnf --marks -f settings.abnf
```

```text output
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.

```ts title="tabnas-actions.mjs"
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](https://tabnas.dev/docs/actions/) 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.

```text title="settings.peggy"
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.

```ts title="chevrotain-visitor.mjs"
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](https://lark-parser.readthedocs.io/en/stable/grammar.html) naming each alternative
of `val` so that a method can catch it.

```python title="lark_actions.py"
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.

```bash
node compare.mjs
```

```text output
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](https://nodejs.org/api/test.html). 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.

```ts title="handlers.test.mjs"
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(), [])
})
```

```bash
node --test handlers.test.mjs
```

```text output
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.

```bash
node rename.mjs
```

```text output
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

- [Remove left recursion from a PEG grammar](/howto/remove-left-recursion-from-a-peg-grammar)

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

[^1]: ABNF was not always one document. The introduction to
[RFC 5234](https://www.rfc-editor.org/rfc/rfc5234.html) 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.

[^2]: The [chevrotain documentation](https://chevrotain.io/docs/guide/concrete_syntax_tree.html)
says `validateVisitor()` can be used to detect missing or redundant visitor methods. In the
[13.2.0 source](https://unpkg.com/chevrotain@13.2.0/lib/src/parse/cst/cst_visitor.js), 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.

[^3]: The [Lark documentation](https://lark-parser.readthedocs.io/en/stable/visitors.html) 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.