Worked Example: Arithmetic Parser
This page walks through the full lifecycle of a grammar — from .ebnf source
file to generated parser to four scaffold patterns — using a simple arithmetic
expression grammar as the running example.
All files live in examples/arith/
and are documented in the ArithExample API module.
Regenerate them any time the grammar changes with:
npm run generate:examplesThe grammar
The arithmetic grammar lives in src/grammars/arith.ebnf.
It handles +, -, *, / with correct precedence (via Expr → Term → Factor),
parenthesised sub-expressions, multi-digit integers, and optional whitespace.
(* A simple arithmetic expression grammar with operator precedence and whitespace.
Supports +, -, *, / with correct precedence via Expr -> Term -> Factor.
Parentheses for grouping; integers are one or more decimal digits. *)
Expr = wsp, Term, {wsp, ('+' | '-'), wsp, Term}, wsp;
Term = Factor, {wsp, ('*' | '/'), wsp, Factor};
Factor = '(', Expr, ')' | Number;
Number = Digit, {Digit};
Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
wsp = {' '};
The railroad diagrams for the three top-level rules:
Step 1 — generate the parser
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--tree-name ArithTree \
--output examples/arith/ArithParser.tschildNodes(node: ArithTree): ArithTree[] is always emitted alongside the parser
and types — no extra flag is needed.
The generated ArithParser has a
static parse(input: string): ExprNode entry point and one private method per
production rule. The exported ArithTree
union covers all six node kinds:
export type ArithTree =
| ExprNode
| TermNode
| FactorNode
| NumberNode
| DigitNode
| WspNodeEach node carries a kind field that equals the rule name, making it a
proper TypeScript discriminated union —
narrow on kind with switch or if and TypeScript tracks the exact shape.
Step 2 — choose a scaffold
Run the scaffolds once, then edit the output freely — they are not regenerated.
| Scaffold | Command | Best for |
|---|---|---|
| interpreter | --traversal interpreter | Recursive evaluation / interpretation |
| facade | --traversal interpreter --facade | Hiding the parser behind a clean domain API |
| pipeline | --traversal tree-walker --pipeline | Multi-stage parse → validate → transform |
| tree-walker | --traversal tree-walker | Tree traversal with per-node visitor dispatch |
| transformer | --transformer | Exhaustive conversion to a typed IR or output |
| json-transformer | --transformer json | Two-way translation to/from JSON |
Scaffold: interpreter
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--traversal interpreter \
--output examples/arith/arith-evaluator.tsEmits one eval{Rule}() function per grammar rule, an evaluate() entry point,
and wraps RDParserException in
a plain Error. Replace unknown with your concrete return types, then fill in
the function bodies.
// arith-evaluator.ts — edit freely; not regenerated
import {
ArithParser,
type ExprNode,
type TermNode,
type FactorNode,
type NumberNode,
type DigitNode,
type WspNode,
} from './ArithParser.js'
import { RDParserException } from '@configuredthings/rdp.js'
export function evaluate(input: string): unknown {
try {
return evalExpr(ArithParser.parse(input))
} catch (e) {
if (e instanceof RDParserException) throw new Error(`parse error: "${input}"`)
throw e
}
}
function evalExpr(node: ExprNode): unknown {
throw new Error('not implemented')
}
function evalTerm(node: TermNode): unknown {
throw new Error('not implemented')
}
// … one function per rule …Full file: arith-evaluator.ts
Scaffold: facade
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--traversal interpreter \
--facade \
--output examples/arith/arith-facade.tsEmits a domain ArithResult class with a static from(tree) factory, a typed
ArithError, a public parseArith() entry point, and private eval{Rule}()
functions. The parser is completely hidden — callers only see parseArith() and
ArithResult.
// arith-facade.ts — edit freely; not regenerated
import {
ArithParser,
type ExprNode,
type TermNode,
// … one type per rule …
} from './ArithParser.js'
import { RDParserException } from '@configuredthings/rdp.js'
export class ArithResult {
// TODO: define constructor fields
constructor() {}
static from(tree: ExprNode): ArithResult {
throw new Error('not implemented')
}
}
export class ArithError extends Error {
constructor(input: string) {
super(`invalid input: "${input}"`)
this.name = 'ArithError'
}
}
export function parseArith(input: string): ArithResult {
try {
return ArithResult.from(ArithParser.parse(input))
} catch (e) {
if (e instanceof RDParserException) throw new ArithError(input)
throw e
}
}
function evalExpr(node: ExprNode): unknown {
throw new Error('not implemented')
}
function evalTerm(node: TermNode): unknown {
throw new Error('not implemented')
}
// … one function per rule …Full file: arith-facade.ts
Scaffold: pipeline
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--traversal tree-walker \
--pipeline \
--output examples/arith/arith-pipeline.tsEmits three exported stage functions — parse() (throws on syntax error), validate()
(returns a { ok: true; tree } | { ok: false; errors } result type), and
transform() (stub) — plus a loadArith() combinator that chains them.
Use this when validation logic is substantial or needs to be testable independently.
// arith-pipeline.ts — edit freely; not regenerated
export function parse(input: string): ExprNode { … }
export function validate(
tree: ExprNode,
): { ok: true; tree: ExprNode } | { ok: false; errors: ValidationError[] } { … }
export function transform(tree: ExprNode): ArithResult { … }
export function loadArith(input: string): ArithResult { … }Full file: arith-pipeline.ts
Scaffold: tree-walker
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--tree-name ArithTree \
--traversal tree-walker \
--output examples/arith/arith-walker.tsEmits a walk(root, fn) utility using childNodes (always present) and a
commented Visitor stub covering every rule in the grammar:
// arith-walker.ts — edit freely; not regenerated
import { ArithParser, childNodes, type ArithTree } from './ArithParser.js'
import { visit, type Visitor } from '@configuredthings/rdp.js'
export function walk(root: ArithTree, fn: (node: ArithTree) => void): void {
fn(root)
for (const child of childNodes(root)) walk(child, fn)
}
// Add handlers for the node kinds you care about.
// Use Required<Visitor<ArithTree>> to enforce that every kind is handled.
//
// const visitor: Visitor<ArithTree> = {
// 'Expr': (node) => { /* ... */ },
// 'Term': (node) => { /* ... */ },
// 'Factor': (node) => { /* ... */ },
// 'Number': (node) => { /* ... */ },
// 'Digit': (node) => { /* ... */ },
// 'wsp': (node) => { /* ... */ },
// }
//
// walk(ArithParser.parse(input), (node) => visit(node, visitor))Full file: arith-walker.ts
Translation: Arith to RPN and JSON
The arithmetic grammar makes a good translation example because two different surface
forms — infix (2 + 3 * 4) and Reverse Polish Notation (2 3 4 * +) — represent
exactly the same semantic content. By lowering both to a shared IR (intermediate representation — a semantic data
structure independent of either surface syntax) first, translating between them
(and to JSON) becomes a matter of swapping emitters.
The shared IR
type IR =
| { kind: 'num'; value: number }
| { kind: 'binop'; op: '+' | '-' | '*' | '/'; left: IR; right: IR }The IR captures semantics, not syntax. It knows nothing about whitespace, parentheses, or operator precedence rules — those are artefacts of the surface format.
Arith → IR
The --transformer flag generates the starting point:
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--tree-name ArithTree \
--transformer \
--output examples/arith/arith-to-ir.tsFilling in the stubs (leaf nodes Digit and wsp are consumed by their parents and
should never be dispatched — the throws document that reaching them is a bug):
import { transform, type Transformer } from '@configuredthings/rdp.js'
import type { ArithTree, ExprNode, TermNode, FactorNode, NumberNode, DigitNode, WspNode } from './ArithParser.js'
type IR =
| { kind: 'num'; value: number }
| { kind: 'binop'; op: '+' | '-' | '*' | '/'; left: IR; right: IR }
export const arithToIR: Transformer<ArithTree, IR> = {
Expr(n: ExprNode): IR {
let result = transform(n.term, arithToIR)
for (const op of n.item2)
result = { kind: 'binop', op: op.item1 as IR['op'], left: result, right: transform(op.term, arithToIR) }
return result
},
Term(n: TermNode): IR {
let result = transform(n.factor, arithToIR)
for (const op of n.item1)
result = { kind: 'binop', op: op.item1 as IR['op'], left: result, right: transform(op.factor, arithToIR) }
return result
},
Factor(n: FactorNode): IR {
const inner = n.item0
// item0 is either a parenthesised Expr or a Number
return 'kind' in inner ? transform(inner, arithToIR) : transform(inner.expr, arithToIR)
},
Number(n: NumberNode): IR {
const digits = n.digit.item0 + n.item1.map((d) => d.item0).join('')
return { kind: 'num', value: parseInt(digits, 10) }
},
// Leaf nodes consumed by their parents — dispatching to them is a bug
Digit(_: DigitNode): IR { throw new Error('Digit consumed by Number, not dispatched') },
wsp(_: WspNode): IR { throw new Error('wsp consumed by Expr, not dispatched') },
}IR → RPN
export const irToRPN: Transformer<IR, string> = {
num(n) { return String(n.value) },
binop(n) {
return `${transform(n.left, irToRPN)} ${transform(n.right, irToRPN)} ${n.op}`
},
}IR → JSON
rdp-gen src/grammars/arith.ebnf \
--parser-name ArithParser \
--tree-name ArithTree \
--transformer json \
--output examples/arith/arith-json.tsThe json-transformer scaffold generates stubs for both directions. The irToJSONAST
direction, filled in:
import { transform, type Transformer, fromJSONAST, type JSONAST } from '@configuredthings/rdp.js'
const irToJSONAST: Transformer<IR, JSONAST> = {
num(n) {
return { kind: 'number', value: n.value }
},
binop(n) {
return {
kind: 'object',
entries: [
{ key: 'op', value: { kind: 'string', value: n.op } },
{ key: 'left', value: transform(n.left, irToJSONAST) },
{ key: 'right', value: transform(n.right, irToJSONAST) },
],
}
},
}Round-trip
import { ArithParser } from './ArithParser.js'
const toRPN = (input: string) =>
transform(transform(ArithParser.parse(input), arithToIR), irToRPN)
const toJSON = (input: string) =>
fromJSONAST(transform(transform(ArithParser.parse(input), arithToIR), irToJSONAST))
toRPN('2 + 3 * 4') // → "2 3 4 * +"
toJSON('2 + 3 * 4') // → '{"op":"+","left":2,"right":{"op":"*","left":3,"right":4}}'Grammar AST
The grammar AST is the intermediate
representation produced by EBNFParser.parse(arithEBNF). It is a
GrammarAST — an array of
ProductionRule objects, one per
rule in the grammar.
import { EBNFParser } from '@configuredthings/rdp.js/generator'
import { arithEBNF } from '@configuredthings/rdp.js/grammars'
const ast = EBNFParser.parse(arithEBNF)
// ast.rules → [{ name: 'Expr', … }, { name: 'Term', … }, …]The AST is what generateParser and generateScaffold receive internally — you
can inspect it to understand how rdp.js represents your grammar before code is emitted.