[ math ]math-replsha f08972dmeasured-in-repo
A small expression language you can read end to end.
I wrote a C++23 REPL in three stages: a hand-written tokenizer, a recursive-descent parser, and a tree-walk evaluator. It does variables, user-defined functions, recursion, and ternaries, in 1,907 lines you can follow from input to result. The same core is compiled to WebAssembly and running on this page.
- [ math-repl ], noun
- pillar
- math ●
- lang
- C++23
- license
- MIT
- size
- ~1,907 LOC
- baseline
- none (honest)
- runtime
- WASM · 183 KB gz
wasm build, gzipped
the real C++23 evaluator, executing in your tab
lines of C++23
tokenizer · parser · evaluator · state, each testable
3-OS CI matrix
builds with -Wall -Wextra and zero warnings
The headline is the literal artifact: the engine compiled to WebAssembly and running below. Every supporting number on this page is cited to sha f08972d.
01Problem
A calculator that goes past arithmetic, and still reads like a worked example.
I wanted an interactive calculator that does real work: variables, user-defined functions with recursion, ternaries, and reuse of the last result. The harder constraint was pedagogical. The interpreter had to stay small enough that each stage reads and reasons end to end, so the code itself works as an explanation of how an expression language is built.
That ruled out reaching for a parser generator or a big expression library. A generated parser would hide the part I most wanted to be legible: the grammar, and how precedence falls out of it.
02Approach
Three stages, cleanly separated so each is testable on its own.
The shape is a textbook three-stage interpreter. A hand-written lexer emits a typed token stream. A recursive-descent parser turns tokens into a std::variant AST owned by unique_ptr. A tree-walking evaluator folds the AST to a double against a mutable state. Errors are typed exceptions caught at the REPL loop, so one bad line never kills the session.
- i
Tokenizer.
A hand-written scanner. It tracks a single decimal point, supports leading-dot numbers like
.25, and does two-char operator lookahead for==,!=,<=,>=with a helpful diagnostic on a lone!.src/token.cpp - ii
Parser.
Recursive descent with precedence climbing. One higher-order helper, abstract_parse, derives the whole precedence ladder from a sub-parser, an operator set, and an associativity flag. It is the cleanest part of the codebase.
src/expression.cpp:170 - iii
Evaluator.
A tree-walk over the variant AST. Built-ins resolve before user functions, arity is checked, and each call gets a fresh local scope, so recursion works for free against shared state.
src/evaluator.cpp:87
03Architecture
A library core and a thin host, so the engine runs in a terminal and a browser.
The code splits into a repl_core static library, which holds token, expression, evaluator, and state, and a thin repl executable that owns the loop, command dispatch, comment stripping, script loading, and optional line editing. That split is what let me compile the same core to WebAssembly and run it in a browser, not just a terminal.
loading engine · tree parses locally
Type an expression. Watch the parser build its tree.
The recursive-descent parser turns what you type into an abstract syntax tree, drawn here in 3D as it assembles. The same C++23 engine, compiled to WebAssembly, evaluates it in your tab and stamps the result below.
Operator nodes (violet) carry the grammar; leaves are operands. Hover a node to trace its subtree.
x > 0 ? x : -x, laid out by the same tidy-tree routine the live REPL uses. Operator nodes carry the math accent; operands stay neutral.One core, two front ends.
The reusable engine carries the lexer, the parser and its AST, the tree-walk, and the mutable state. The thin host adds only the loop, the command and script dispatch, line editing, and the typed errors it catches. About 1,907 lines hold both.
04Tradeoffs · road not taken
The shortcuts I took on purpose, and what each one cost.
Three decisions trade something away, and all three stay in the repo as written rather than papered over.
Double everywhere, with exact == comparisons
README · DESIGN.mdSimple and honest, and called out in the README and DESIGN. The cost is no integer or rational types and surprising float-equality semantics. Booleans ride the same doubles: a comparison yields 0.0 or 1.0, and a ternary reads its condition as not equal to zero.
Road not taken: a tagged numeric type.
One abstract_parse helper, not a class hierarchy
src/expression.cpp:170The precedence chain is a ladder of small parse_* functions unified by one higher-order helper that takes the sub-parser, the operator set, and the associativity. It trades a little indirection for deleting the repetitive precedence-level boilerplate.
Road not taken: a Pratt parser or a generated grammar.
Re-tokenize function args by splitting on top-level commas
src/expression.cpp:69parse_fn_args splits on commas at paren depth zero rather than threading the token stream through. A pragmatic shortcut that keeps argument parsing short and readable, at the cost of a second small pass over the argument text.
Road not taken: one unified streaming parse.
05Evidence · vs named baseline
No benchmark, because there is nothing honest to benchmark against.
A calculator REPL has no competing tool or prior number to race, so I did not invent one. The only comparisons available would be against a generic prompted LLM or a standard-library expression evaluator, and neither is a claim I make in the repo. What I can show instead is verifiable: the engine runs live below, the implementation is small and counted, and the build is clean on three operating systems.
~1,586
lines across src/*.cpp and include/repl/*.hpp
wc -l · src/ + include/repl/
21
Catch2 test cases, about 67 REQUIRE / CHECK assertions
tests/*_test.cpp
25
built-in functions, plus 3 constants (pi, e, tau)
src/state.cpp:47-89
3
OS CI matrix: Linux, macOS, Windows, zero warnings
.github/workflows/ci.yml:13
Inventing a speedup here would be noise. Recording the absence of a fair baseline is the honest call, so every figure above traces to the pinned commit instead.
[ math ]playground
Type an expression. The C++ engine answers.
This console runs the exact repl_core compiled to WebAssembly, with the same persistent state as the terminal build. Define a function, recurse, reuse the last result with _, or trip an error and watch it get caught. The full editor with the live parse tree lives in the playground.
transcript · math-repl
fact(n) = n < 2 ? 1 : n * fact(n - 1)
Defined fact(n)
fact(5)
120
x = 2^10
1024
_ / 4
256
1 / 0
ERR[eval]: Division by zero
Read the whole thing.
The tokenizer, the precedence-climbing parser, the tree-walk evaluator, and the state registry are all in the repo, pinned at the commit every number on this page is measured from. Cross-platform CI runs the Catch2 suite on Linux, macOS, and Windows.