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.