all build notes

[ math ]math-replsha f08972dbuild note

The grammar behind the REPL in the hero

A C++23 expression language as a precedence ladder, and the one higher-order parser that collapses six near-identical levels into one.

sourcemath-repl / DESIGN.md + grammarsha f08972drepo →case study →

The calculator running in the hero of this site is a ~1,586 line C++23 expression language: tokenizer, recursive-descent parser, tree-walking evaluator, compiled to WebAssembly at 183 KB gzipped. This is the grammar it parses and the one factoring decision that kept the parser from turning into a wall of near-identical functions.

Three stages, each testable alone

The interpreter is the textbook shape, kept deliberately small so each stage reads as a worked example. A hand-written lexer emits a typed token stream. A recursive-descent parser turns tokens into a std::variant-based AST owned by unique_ptr. A tree-walking evaluator folds the AST to a double against a mutable state. Errors are typed exceptions (ParseError, EvalError, CommandError) caught at the REPL loop, so one bad line never kills the session.

The math-repl pipeline: source text enters the tokenizer, becomes a token stream, is parsed by the recursive-descent parser into an AST, which the tree-walking evaluator folds against state into a result.
Source to result. Each arrow is a stage with its own test suite. Source: math-repl/docs/diagrams/pipeline-dark.svg, recolored to the math accent.

The grammar as a precedence ladder

The language is an expression grammar with assignment, ternaries, comparisons, the usual arithmetic, right-associative power, and user-defined functions with recursion. Written as a precedence ladder from loosest to tightest binding, it is:

assignment  := equality ( '=' assignment )?           // right assoc
ternary     := equality ( '?' expr ':' ternary )?     // right assoc
equality    := relational ( ('=='|'!=') relational )*
relational  := additive  ( ('<'|'>'|'<='|'>=') additive )*
additive    := term      ( ('+'|'-') term )*
term        := power      ( ('*'|'/') power )*
power       := unary      ( '^' power )?               // right assoc
unary       := ('-'|'+')? primary
primary     := number | ident | call | '(' expr ')'
the precedence ladder, loosest binding at the top

Associativity is where a naive parser gets subtle bugs. Power binds right, so 2^3^2 is 2(32)=29=5122^{(3^2)} = 2^9 = 512, not (23)2=64(2^3)^2 = 64. The parser encodes this by recursing into power itself on the right, while the left-associative levels loop instead of recurse:

abc  =  a(bc)a \mathbin{\wedge} b \mathbin{\wedge} c \;=\; a \mathbin{\wedge} (b \mathbin{\wedge} c)
caret assoc

abstract_parse: one helper for six levels

Six precedence levels with the same shape is six near-identical parse functions, which is six places for a bug to hide and six places to edit when the grammar changes. Instead I wrote one higher-order helper, abstract_parse, parameterized by its sub-parser, the set of operators it accepts, and its associativity. Every binary level is one call to it. A left-associative level is the fixed point of:

level  =  sub(op  sub)\text{level} \;=\; \text{sub}\,\big(\,\text{op}\;\text{sub}\,\big)^{*}
left fold

which the helper implements as a loop that folds operators left while the next token is in the operator set. A right-associative level recurses on itself instead. The whole ladder (power, term, additive, relational, equality, assignment) derives from that single function.

// left-assoc additive: term ( ('+'|'-') term )*
Expr parse_additive() {
  return abstract_parse(&Parser::parse_term, {Plus, Minus}, Assoc::Left);
}
// right-assoc power: unary ( '^' power )?
Expr parse_power() {
  return abstract_parse(&Parser::parse_unary, {Caret}, Assoc::Right);
}
src/expression.cpp: every precedence level is one call to abstract_parse

Function definitions are just an assignment with shape

The evaluator decides that f(x) = x*x is a definition rather than an evaluation by looking at the shape of an assignment: a Binary node with operator = whose left side is a function-call node becomes a definition. Anything else is ordinary evaluation. That dispatch turns the calculator into a small language. Recursion works for free, because user functions live in shared state and a fresh local scope is built per call:

fact(n)=n=0  ?  1  :  nfact(n1)fact(5)=120\operatorname{fact}(n) = n=0 \;?\; 1 \;:\; n\cdot\operatorname{fact}(n-1) \quad\Rightarrow\quad \operatorname{fact}(5)=120
fact
built-ins
25 functions plus 3 constants (π\pi, ee, τ\tau), resolved before user functions so a user cannot shadow them by accident.
tests
21 Catch2 cases across the tokenizer, parser, evaluator, and integration suites, on a 3-OS CI matrix, building with zero warnings under -Wall -Wextra -std=c++23.

The parse tree the hero draws

The AST node taxonomy is small: numbers, identifiers, unary and binary operators, function calls, and the ternary. The hero renders exactly these node shapes for whatever you type. The example below, x > 0 ? x : -x, is the same tree the live engine builds.

AST for the expression x > 0 ? x : -x: a ternary node at the root with a greater-than comparison as its condition, x as the true branch, and a unary negation of x as the false branch.
The AST for x > 0 ? x : -x. Operator nodes in the math accent, operands as leaves. Source: math-repl/docs/diagrams/ast-dark.svg, recolored to the site palette.

The full grammar and the AST node definitions are in DESIGN.md and include/repl/expression.hpp. The engine that runs the hero is this exact repl_core library compiled to WebAssembly. Source at github.com/ShreeChaturvedi/math-repl.