Skip to content
Select theme

Control flow and functions

Step 6 gave the tree a memory: an environment where var, assignment and block scopes live. What it could not do is jump. fun had nowhere to store the function it declared, and return had no call to return to — both refused with messages that dated them. This step adds the missing machinery: a Flow enum that lets a statement finish early, a function value the environment can hold, and a call that pushes parameters, runs a body, and unwraps whatever return hands back. By the end, fact(5) computes 120. The if and while half of the brief needs no new code at all — both have been plain subtree walks since the evaluator step — and else if chains turn out to compose for free, because an else branch is a statement and an if is a statement.

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

Terminal window
git checkout interpreter-ch06
cargo run -q

Still one file, src/main.rs. The scanner and the parser are untouched — the grammar has carried fun, if, while and return since the tree step, which is half the point of building the tree first. The work sits below the tree: Value gains a variant, execute changes what it returns, the two arms that used to refuse become real, and a call function appears between the evaluator and the statement walker. At the end, the same command at the interpreter-ch07 tag runs recursion, returns and all three errors.

Last step, execute returned Result<(), RuntimeError>, and that type is exactly why return could not work. Err means this statement cannot run. return is the opposite — a statement that ran fine and decided to stop early — and an Ok that carries only () has no way to say so. So the Ok half learns to carry something:

enum Flow {
Normal,
Return(Value),
}

execute now returns Result<Flow, RuntimeError>. Every arm that used to end in Ok(()) ends in Ok(Flow::Normal); nothing else about those arms moves. The Return arm is the one that changes meaning:

Stmt::Return { value } => {
let value = match value {
Some(expr) => evaluate(expr, env)?,
None => Value::Nil,
};
Ok(Flow::Return(value))
}

The value is evaluated where the return sits, then handed upward. Nothing catches it on the way — not a while body, not a block. execute_scoped still pushes and pops its scope, and a Flow::Return passes straight through:

let mut result = Ok(Flow::Normal);
for statement in statements {
match execute(statement, env) {
Ok(Flow::Normal) => {}
other => {
result = other;
break;
}
}
}
env.scopes.pop();
result

That is the distinction the whole design rests on: a block is a scope, a call is a destination. Only one place in the program knows what a return is for — call, the function behind the Call arm. Every other statement just lets the Flow keep travelling, and the Err half of the result keeps its old job: a statement that genuinely cannot run.

A Flow::Return does land nowhere useful in one case: a return issued at the top level, outside any function. It reaches main’s driver, which is the bottom of the stack, and gets the same verdict the last step printed by hand:

Ok(Flow::Return(_)) => {
println!(" runtime error: return outside of a function");
break;
}

Defining a function is now just defining: the declaration becomes a value, and the environment stores it like any other. The value is the declaration the parser already built, held as runtime data:

struct FunctionValue {
name: String,
params: Vec<String>,
body: Vec<Stmt>,
}

It goes into Value behind an Rc:

Function(Rc<FunctionValue>),

Value derives Clone and PartialEq, and a clone of a whole function body — a tree of boxed Exprs — would be a deep copy every time a value carrying one moves. Rc makes that clone a pointer bump. The price is that FunctionValue needs the same derives as Value, and since it holds a Vec<Stmt>, the tree itself has to become Clone and PartialEq too: two derive attributes on Expr and Stmt, done.

The Display arm gives functions the printed form Python chose:

Value::Function(function) => write!(f, "<fun {}>", function.name),

and type_name learns "a function", so runtime errors about one read like the rest. The statement arm itself is short — the duplicate parameter check from last step, then a define:

env.define(
name.clone(),
Value::Function(Rc::new(FunctionValue {
name: name.clone(),
params: params.clone(),
body: body.clone(),
})),
);
Ok(Flow::Normal)

Note what is not here: any snapshot of the environment. Recursion does not need one. fun fact(n) { ... fact(n - 1) ... } defines fact into the global scope before its body ever runs, and by the time the body executes, the name is a plain lookup away.

The Call arm has been evaluating its callee and arguments since the evaluator step, then refusing. Now the refusal is only for the genuinely uncallable:

let Value::Function(function) = callee else {
return Err(RuntimeError {
message: format!("{} cannot be called", type_name(&callee)),
});
};
if values.len() != function.params.len() {
return Err(RuntimeError {
message: format!(
"`{}` expects {} argument{}, got {}",
function.name,
function.params.len(),
if function.params.len() == 1 { "" } else { "s" },
values.len()
),
});
}
call(&function, values, env)

Arity is checked before the body runs — a call that cannot succeed should not get to fail halfway through. Then call does the work:

fn call(
function: &FunctionValue,
args: Vec<Value>,
env: &mut Environment,
) -> Result<Value, RuntimeError> {
env.scopes.push(HashMap::new());
for (param, value) in function.params.iter().zip(args) {
env.scopes.last_mut().unwrap().insert(param.clone(), value);
}
let mut result = Ok(Flow::Normal);
for stmt in &function.body {
match execute(stmt, env) {
Ok(Flow::Normal) => {}
other => {
result = other;
break;
}
}
}
env.scopes.pop();
match result {
Ok(Flow::Normal) => Ok(Value::Nil),
Ok(Flow::Return(value)) => Ok(value),
Err(e) => Err(e),
}
}

Three outcomes: a body that falls off the end returns nil, a Flow::Return finally becomes the value it was carrying, and a RuntimeError propagates with the scope already popped — the environment comes back the way it went in, whatever happened.

The honest caveat sits in one line: env.scopes.push(...) puts the parameters on the caller’s stack. birch functions are not closures. A body sees the caller’s variables, which is wrong in the general case — a function defined inside a block should remember that block’s scope after the block dies, and this one will not. The fix is environments that outlive their block, a heap of shared maps rather than a stack, and that is a redesign this small language does not need. What the caller’s-stack choice does mean in practice: recursion and mutual recursion work, because the function’s own name lives in a scope the caller can already see; free variables from a dead enclosing block do not.

One more limit worth naming: birch recursion is Rust recursion. Each birch call is a chain of Rust frames — evaluate, call, execute — so a runaway fun loop() { loop(); } reports nothing; it overflows the native stack and the process dies. Real interpreters cap call depth with one counter and one if; that is left where it belongs, in the next language this pattern builds.

This is the complete src/main.rs for this step. The scanner, the parser and the environment are unchanged; the new pieces are the Flow enum, the FunctionValue struct, the rewritten Call and Return arms, and call itself:

Terminal window
git checkout interpreter-ch07
cargo run -q
1 + 2 * 3;
"na" + "na";
!(1 == 2);
[10, 20, 30][1];
if (2 > 1) "big"; else "small";
7
nana
true
20
big
var score = 83;
if (score >= 90) "A";
else if (score >= 80) "B";
else "C";
B
var total = 0;
var i = 1;
while (i <= 5) {
total = total + i;
i = i + 1;
}
total;
1
2
3
3
6
4
10
5
15
6
15
fun add(a, b) { return a + b; }
add(2, 3);
5
fun fact(n) {
if (n < 2) { return 1; }
return n * fact(n - 1);
}
fact(5);
120
fun nothing() { return; }
nothing();
fun silent() { 1 + 1; }
silent();
nil
2
nil
fun double(n) { return n * 2; }
double(1, 2);
runtime error: `double` expects 1 argument, got 2
"nope"(1);
runtime error: a string cannot be called
return 1;
runtime error: return outside of a function

The two functions in the nothing/silent pair are worth a second look. nothing(); prints nil — a return; with no value carries the language’s empty value. silent(); prints twice: 2 from the body’s bare 1 + 1;, because an expression statement prints and that rule never goes on holiday inside a function, then nil from the call, because a body that falls off the end returns the same empty value.

The three errors at the bottom are the guards, not gaps. Wrong arity is rejected before the body runs, and the message is singular because double takes one parameter. A string cannot be called because the Call arm matches on the callee’s value, not its syntax. And a top-level return still ends the script: the Flow::Return travels all the way down and lands in main’s driver, which has no call to hand it to.

birch at interpreter-ch07 runs its whole grammar — every arm of both enums now does the thing it was named for. The language works; what it does not do yet is fail well. A runtime error is a bare message with no position, while the scanner has been recording line and column on every token since step 2 and nothing downstream has used them. The next step, Error reporting with line numbers, points at the token instead of the file. Until then, the track overview has the full itinerary.