Skip to content
Select theme

A REPL for the interpreter

Step 8 left birch failing as well as it runs: every error of every kind points at the token that caused it. What the language still cannot do is be talked to. Every run is a batch — a script scanned, parsed and executed top to bottom by a driver whose whole job was feeding it the next demo program. This step replaces that driver. birch file.birch runs a file; birch with no argument starts the loop the project has been circling since step 1 — read a line, evaluate it, print the value, do it again:

>>> var x = 10;
>>> x * x;
100

The previous step is published and tagged, so start from its end state:

Terminal window
git checkout interpreter-ch08
cargo run -q

Still one file, src/main.rs. The scanner and the evaluator are untouched this step; the parser grows one field; everything else new lives at the bottom of the file, where the driver was. At the end, the same two commands at the interpreter-ch09 tag start a session or run a file — the list of demo programs that was main is gone.

The old main looped over a list of demo scripts: scan, parse, execute, print the error, next script. Both new modes want pieces of that loop, so it splits in two. Running statements is the same in both — a list of statements into an environment, first uncaught error printed, and everything defined before it stays defined:

fn run_statements(statements: &[Stmt], env: &mut Environment) {
for statement in statements {
match execute(statement, env) {
Ok(Flow::Normal) => {}
Ok(Flow::Return(_)) => {
println!(" runtime error: return outside of a function");
return;
}
Err(e) => {
println!(" runtime error: {}:{} {}", e.line, e.col, e.message);
return;
}
}
}
}

A return at the top level still lands here with no call to catch it — same verdict as last step. Running a source adds the scan and parse halves:

fn run(source: &str, env: &mut Environment) {
let (tokens, errors) = Scanner::new(source).scan_tokens();
if !errors.is_empty() {
for e in &errors {
println!(" scan error: {}:{} {}", e.line, e.col, e.message);
}
return;
}
match Parser::new(tokens).program() {
Ok(statements) => run_statements(&statements, env),
Err(e) => println!(" parse error: {}:{} {}", e.line, e.col, e.message),
}
}

That is the whole file mode, and it is the old driver with the demo list removed. main picks between it and the loop:

fn main() {
// `birch program.birch` runs a file; no argument, the loop
match std::env::args().nth(1) {
Some(path) => match std::fs::read_to_string(&path) {
Ok(source) => run(&source, &mut Environment::new()),
Err(e) => println!("cannot read {path}: {e}"),
},
None => repl(),
}
}

One environment per file, the way each demo script used to get one.

The first session would have started with silence. print! writes without a newline, and Rust’s stdout is line-buffered — the buffer lets go on a newline, and a prompt has none. Without an explicit flush the prompt sits in the buffer until the first result prints, and the reader stares at a terminal that looks like the program never started. So the loop prints its prompt and flushes in the same breath:

print!(">>> ");
io::stdout().flush().unwrap();

which is why Write joins the imports at the top of the file. The unwrap is honest: a failed flush means stdout is gone, and there is nothing left to do anyway.

The hard part of the loop is not reading or evaluating. It is knowing when the user is done. A declaration can span lines — a while with a five-line body is five lines of input — and a line-based rule (“keep reading while braces are unbalanced”) re-implements the grammar badly: braces live inside strings, and a stray } is an error the parser should report, not a hint to wait. birch already owns a component that knows exactly when input stops being enough: the parser. Every parse error names the token it stumbled on. When that token is the end of input, nothing was wrong — the input stopped short.

ParseError grows one field:

struct ParseError {
line: usize,
col: usize,
message: String,
incomplete: bool,
}

expect and prefix already branch on Eof to word their messages, so they set the field in the same breath. The one other parse error, invalid assignment target, is always complete — the = was found, the target is what is wrong — and says so.

The loop reads the field as a request for more:

// the parse ran off the end of a half-typed declaration:
// keep the buffer, print nothing, ask for the next line
Err(e) if e.incomplete => {}

Lines accumulate in a pending string, and each new line rescans and reparses the whole buffer from the top. Wasteful in the abstract, free in practice: a session’s buffer is a few lines, and scanning it twice is unmeasurable against everything else the interpreter does. The sessions the rule produces look like this — three prompts, then the closing brace finishes the whole while:

>>> var i = 1;
>>> while (i < 3) {
... i = i + 1;
... }
2
3
>>> i;
3

The 2 and 3 are the loop body’s assignments printing — an expression statement prints its value wherever it runs, the rule the batch runner has had since the evaluator step. Nothing about the REPL had to change it.

There is one trap, and it is worth meeting head-on. The REPL is declaration-oriented, not line-oriented: no ;, no declaration. Type a bare expression and the parser reports the end of input, the field says incomplete, and the loop waits:

>>> 1 + 2
...

Typing ; on the next line finishes the job — the buffer is one source, and the parser is happy to find the semicolon there:

... ;
3

The sharper edge is that buffered lines join into one program:

>>> 1 + 2
... 3 + 4;
parse error: 2:1 expected `;`, found `3`

REPLs that auto-print bare expressions — Python’s, for one — get there with rules about which incomplete input counts as finished. birch buys out of that by reusing the batch rule it already had: write a declaration, get a value.

The environment is created once, before the loop, and nothing resets it. That is the feature: var x = 10; on one line is visible ten lines later, which is what makes a session a session instead of a pile of disconnected one-liners. It also makes errors survivable — a runtime error reports and returns, the loop redraws its prompt, and everything defined before the failing line is still there:

>>> 1 / 0;
runtime error: 1:3 division by zero
>>> var saved = "still here";
>>> saved;
still here

The trade-off is real and stated plainly: statements before the failing one have run, and their effects stay. Rolling that back would need transactions the language has no notion of; admitting the partial state is the smaller mistake.

The way out is end of input. read_line reports 0 bytes, the loop prints the newline the prompt never got, and the session ends — Ctrl-D on a terminal, a closed pipe everywhere else. Ctrl-C kills the process; no handler, and this step has enough on its hands.

File mode first — the recursion demo from step 7, now in a real file:

Terminal window
$ cat fact.birch
fun fact(n) {
if (n < 2) { return 1; }
return n * fact(n - 1);
}
fact(5);
$ cargo run -q -- fact.birch
120

Then the session this step was building toward. The x defined in the opening lines is still alive at the bottom, three errors later, and the multi-line x + got the continuation prompt and finished on the next line:

$ cargo run -q
birch — Ctrl-D to exit
>>> 1 + 2;
3
>>> var x = 10;
>>> x * x;
100
>>> fun greet(name) { return "hi, " + name; }
>>> greet("birch");
hi, birch
>>> x +
... 2;
12
>>> oops;
runtime error: 1:1 undefined variable `oops`
>>> 1 / 0;
runtime error: 1:3 division by zero
>>> var y = ;
parse error: 1:9 expected an expression, found `;`
>>>

Each error printed its address and the session went on. var y = ; was a parse error, not a crash: the parser found ; where an expression belonged, said so, and the loop moved on.

birch at interpreter-ch09 is a language you can sit down with: a file mode for programs worth keeping, a session for poking at everything else, one environment across the whole conversation, and errors that point before they explain. What it does not do is run fast. Every call re-walks the tree, and every line the REPL accepts is rescanned and reparsed from scratch — fine for a session, wasteful for a hot loop. The next step, Compiling instead of interpreting, walks the tree once and emits bytecode, so the machine the evaluator became can run without looking at the tree again. Until then, the track overview has the full itinerary.