04 — Recursive-Descent Statement Parsing

Expressions handle values and operators. Statements handle control flow, declarations, and side-effects. The two halves live in different parser methods and produce different AST node types.

Statement dispatch

The top-level parse method peeks at the current token and dispatches:

StmtPtr parseStmt() {
    switch (peek().kind) {
        case TokKind::Let:    return parseLet();
        case TokKind::Var:    return parseLet();
        case TokKind::If:     return parseIf();
        case TokKind::While:  return parseWhile();
        case TokKind::Return: return parseReturn();
        case TokKind::Print:  return parsePrint();
        case TokKind::Fn:     return parseFnDecl();
        case TokKind::LBrace: return parseBlock();
        default:              return parseExprStmt();
    }
}

Each branch consumes exactly the tokens it owns. All branches advance past any trailing ;.

Blocks create scope

parseBlock reads {, a list of statements, then }:

StmtPtr parseBlock() {
    int line = advance().line;   // consume {
    std::vector<StmtPtr> body;
    while (peek().kind != RBrace && peek().kind != Eof)
        body.push_back(parseStmt());
    expect(RBrace);
    return std::make_unique<BlockStmt>(move(body), line);
}

The interpreter will create a new Environment child for every BlockStmt, so blocks naturally scope variable declarations.

if with optional else

StmtPtr parseIf() {
    int line = advance().line;   // consume 'if'
    expect(LParen);
    auto cond = parseExpr(0);
    expect(RParen);
    auto then = parseBlock();    // always a block
    StmtPtr else_;
    if (match(Else)) else_ = peek().kind == If ? parseIf() : parseBlock();
    return std::make_unique<IfStmt>(move(cond), move(then), move(else_), line);
}

else if chains are implemented by letting else consume another if statement, producing a right-recursive tree. No special elif keyword.

while is simpler

StmtPtr parseWhile() {
    int line = advance().line;
    expect(LParen); auto cond = parseExpr(0); expect(RParen);
    auto body = parseBlock();
    return std::make_unique<WhileStmt>(move(cond), move(body), line);
}

Named function declaration → desugar

StmtPtr parseFnDecl() {
    int line = advance().line;   // consume 'fn'
    auto name = expect(Ident).lexeme;
    auto fn   = parseFnBody(line);  // parses (params) { body }
    // Desugar into: let name = fn(params) { body }
    return std::make_unique<LetStmt>(name, /*immutable=*/true, move(fn), line);
}

This is the key simplification: the interpreter's visitLet handles both variable declarations and function declarations uniformly. A function is just a value bound to a name.

Panic-mode error recovery

When the parser hits something unexpected, it throws or calls a sync function that skips tokens until it finds a synchronisation point:

void sync() {
    while (peek().kind != Eof) {
        if (previous().kind == Semicolon) return;
        switch (peek().kind) {
            case Fn: case Let: case Var: case If:
            case While: case Return: return;
            default: advance();
        }
    }
}

After sync, parsing resumes at the next statement boundary. In a REPL this means one bad expression doesn't lock up the session. In a file run it means a single error doesn't suppress everything downstream.

The expression-statement bridge

StmtPtr parseExprStmt() {
    int line = peek().line;
    auto e = parseExpr(0);
    expect(Semicolon);
    return std::make_unique<ExprStmt>(move(e), line);
}

Function calls at statement position (foo(42);) hit this path. The expression is evaluated for side effects; its value is discarded.