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:

  1. The same global environment persists across REPL lines — you can define a function on one line and call it on the next.
  2. Errors are caught and printed, not propagated — the REPL doesn't die on a bad expression.
  3. 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.