Step 5 — REPL, Tests, and CLI
Goal: wire lexer + parser + evaluator into a real tool, with a test suite and an interactive REPL.
The CLI Driver — main.cpp
int main(int argc, char** argv) {
if (argc == 1) return arith::repl();
// gather argv[1..] into one expression for unquoted use
std::ostringstream oss;
for (int i = 1; i < argc; ++i) {
if (i > 1) oss << ' ';
oss << argv[i];
}
return arith::evaluateOnce(oss.str());
}
Two modes:
- One-shot:
./eval "1 + 2 * 3"or./eval 1 + 2 + 3. - REPL:
./evalwith no args.
The REPL Loop
static int repl() {
std::cout << "arith repl (cp-02). type 'quit' or Ctrl-D to exit.\n";
std::string line;
for (;;) {
std::cout << "> " << std::flush;
if (!std::getline(std::cin, line)) { std::cout << "\n"; return 0; }
if (line == "quit" || line == "exit") return 0;
if (line.empty()) continue;
evaluateOnce(line);
}
}
Every iteration: read a line, send it through the whole pipeline, print. No persistent state between lines — variables and bindings come in cp-03.
Building It
cd src/cpp
cmake -B build # configure once
cmake --build build # build (incremental)
CMake breakdown:
arithlib— static library withlexer.cpp,parser.cpp,evaluator.cpp.eval— executable that linksarithlibandmain.cpp.test_eval— executable that linksarithliband the test file.
Separating library from main lets us reuse the same compiler internals from tests — a pattern we keep in every later lab.
The Test Suite — test_eval.cpp
Assert-based, no test-framework dependency. 19 tests across 5 categories:
// arithmetic
assert(APPROX(eval("1 + 2"), 3.0));
assert(APPROX(eval("10 - 4"), 6.0));
// precedence
assert(APPROX(eval("1 + 2 * 3"), 7.0));
// left-associativity
assert(APPROX(eval("10 - 3 - 2"), 5.0));
// parens
assert(APPROX(eval("(1 + 2) * 3"), 9.0));
// unary
assert(APPROX(eval("-(3 + 4)"), -7.0));
// floats / whitespace
assert(APPROX(eval("3.14 + 0.86"), 4.0));
// error cases
assert(throws(""));
assert(throws("1 +"));
assert(throws("1 / 0"));
assert(throws("(1 + 2"));
The macro APPROX(a, b) uses std::fabs((a) - (b)) < 1e-9 because comparing floats with == is fragile. throws(...) runs a snippet inside a try/catch and returns whether any std::exception was thrown.
Running The Tests
ctest --test-dir build
Or directly:
./build/tests/test_eval
# cp-02 tests: 19/19 PASS
Expected: 19/19 tests pass. If anything fails, the failing assert aborts immediately with a line number.
Why No gtest / catch2?
Pulling in a framework would mean a find_package (often a git submodule), a CMake config file, and 10 MB of headers. For 19 trivial tests, plain assert is shorter, faster to build, and removes a teaching distraction.
We'll graduate to a real framework around cp-08, when individual test cases benefit from structured fixtures and parameterization.
Manual Sanity Checks
The classic checklist:
./build/eval "1 + 2 * 3" # 7 (precedence)
./build/eval "(1 + 2) * 3" # 9 (parens override)
./build/eval "10 - 3 - 2" # 5 (left associativity)
./build/eval "3 * -4" # -12 (unary in factor)
./build/eval "((((42))))" # 42 (parens nest)
./build/eval "1 / 0" 2>&1 # division by zero
./build/eval "1 +" 2>&1 # parse error
If any of these don't match, suspect: parser precedence (Step 3), evaluator dispatch (Step 4), error propagation (parser → eval).
A Tour Of A Failure
Suppose you accidentally swapped left-associativity for right in parseExpr:
// WRONG:
ExprPtr right = parseExpr(); // recursion instead of loop
Then:
10 - 3 - 2→ tree:(10 - (3 - 2))=10 - 1 = 9(not 5!).- Test
assert(APPROX(eval("10 - 3 - 2"), 5.0));fails immediately.
This is exactly why we write tests for associativity — the bug is subtle and silent without them.
Outcomes
You now have:
- A working evaluator binary, both REPL and one-shot.
- A 19-test test suite proving correctness across precedence, associativity, parens, unary, floats, whitespace, and 4 error categories.
- A CMake project structured for reuse — the same
arithlibwill be linked from cp-03's expanded language.
Next
→ 06-extensions.md — optional extension exercises that deepen each concept.