07 — REPL, Tests, and Extensions
The final step wires the components into a usable tool and verifies the interpreter with automated tests.
The REPL loop
void repl(std::istream& in, std::ostream& out) {
auto global = std::make_shared<Environment>();
Interpreter interp(global);
std::string line;
while (true) {
out << "> ";
if (!std::getline(in, line)) break;
try {
Lexer lex(line);
Parser p(lex.scanAll());
auto stmts = p.parse();
for (auto& s : stmts) interp.execute(*s);
} catch (const std::exception& e) {
out << "error: " << e.what() << "\n";
}
}
}
The key points:
- The same
globalenvironment persists across REPL lines — you can define a function on one line and call it on the next. - Errors are caught and printed, not propagated — the REPL doesn't die on a bad expression.
- Each line is re-lexed and re-parsed; no incremental state.
File execution
int main(int argc, char** argv) {
if (argc == 1) { repl(std::cin, std::cout); return 0; }
std::ifstream f(argv[1]);
if (!f) { std::cerr << "cannot open " << argv[1] << "\n"; return 74; }
std::string src((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
Lexer lex(src);
Parser p(lex.scanAll());
auto stmts = p.parse();
Interpreter interp(std::make_shared<Environment>());
for (auto& s : stmts) interp.execute(*s);
return 0;
}
The test harness
cp-03 uses a hand-rolled test harness consistent with later labs. Each test runs a source string through the full pipeline and checks the output or thrown message:
static int g_checks = 0, g_passed = 0;
#define CHECK_EQ(a, b) do { ++g_checks; \
if ((a) == (b)) ++g_passed; \
else std::cerr << "FAIL " << __LINE__ << ": " << (a) << " != " << (b) << "\n"; \
} while(0)
// Example test
void test_closure() {
auto out = run("fn f(a) { fn g(b) { return a + b; } return g; } print f(3)(4);");
CHECK_EQ(out, "7\n");
}
run(src) lex-parses-interprets and returns the captured stdout. This
lets every test be written as a one-liner source program.
Extending MiniLang — next steps
These extensions each add one focused concept without rewriting the interpreter:
1. Native functions
Add a NativeValue variant to Value holding a std::function<Value(vector<Value>)>.
Register built-ins like clock(), sqrt(), len() in the global environment
before executing user code.
2. Arrays
Add VecValue = shared_ptr<vector<Value>>. Add arr[i] subscript as a
special CallExpr-like AST node, arr.push(x) as a method call.
3. Classes
Add class Foo { ... } syntax → a ClassDef node. Instances are
environments whose parent is the class's method map. this is a binding
in the method's call frame pointing to the instance environment.
4. for loops
Desugar into while at parse time:
for (let i = 0; i < n; i = i + 1) { body }
→
{ let i = 0; while (i < n) { body; i = i + 1; } }
No new interpreter support needed.
5. Continuation-based non-local flow
Replace ReturnSignal exception with a Continuation value that wraps
the rest of the execution as a callable — the basis for coroutines and
generators.
Each extension exercises a different compiler engineering concept. The interpreter's visitor-based architecture absorbs new node types without touching existing ones — which is the point of choosing Visitor over ad-hoc dispatch in step 02.