[ 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
- 1.a tokenizer, parser, and evaluator small enough to read each stage end to end.
- 2.variables, recursive user functions, and ternaries, with errors caught at the loop.
- 3.the same C++ core compiled to WebAssembly, running in this tab.
- pillar
- math ●
- lang
- C++23
- license
- MIT
- size
- ~1,907 LOC
- baseline
- none
- runtime
- WASM · 183 KB gz
wasm build, gzipped
the real C++23 evaluator, executing in your tab
lines of C++23, whole tree
tokenizer · parser · evaluator · state, each testable
3-OS CI matrix
builds with -Wall -Wextra and zero warnings
The engine, compiled to WebAssembly, runs 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.
src/token.cpp - ii
Parser.
src/expression.cpp:170 - iii
Evaluator.
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.
Double everywhere, with exact == comparisons
README · DESIGN.mdRoad not taken: a tagged numeric type.
One abstract_parse helper, not a class hierarchy
src/expression.cpp:170Road not taken: a Pratt parser or a generated grammar.
Re-tokenize function args by splitting on top-level commas
src/expression.cpp:69Road not taken: one unified streaming parse.
05Evidence · vs named baseline
No benchmark. A calculator REPL has nothing to race.
A calculator REPL has no competing tool, so I did not invent a baseline. Comparing against a prompted LLM or a standard library evaluator would measure something different. The engine runs live below, the implementation is counted, and the build passes 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
26
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
There is no fair baseline to race, so every figure traces to the pinned commit.
[ math ]math-repl
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 runs below.
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
Full editor: type an expression, watch the live parse tree, keep session state.
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.