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:examples

The 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:

Expr
wsp Term wsp "+" "-" wsp Term wsp
Term
Factor wsp "*" "/" wsp Factor
Factor
"(" Expr ")" Number

Step 1 — generate the parser

rdp-gen src/grammars/arith.ebnf \
  --parser-name ArithParser \
  --tree-name ArithTree \
  --output examples/arith/ArithParser.ts

childNodes(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
  | WspNode

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

ScaffoldCommandBest for
interpreter--traversal interpreterRecursive evaluation / interpretation
facade--traversal interpreter --facadeHiding the parser behind a clean domain API
pipeline--traversal tree-walker --pipelineMulti-stage parse → validate → transform
tree-walker--traversal tree-walkerTree traversal with per-node visitor dispatch
transformer--transformerExhaustive conversion to a typed IR or output
json-transformer--transformer jsonTwo-way translation to/from JSON

Scaffold: interpreter

rdp-gen src/grammars/arith.ebnf \
  --parser-name ArithParser \
  --traversal interpreter \
  --output examples/arith/arith-evaluator.ts

Emits 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.ts

Emits 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.ts

Emits 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.ts

Emits 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.ts

Filling 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.ts

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