The Problem
We have an AST. We need to run it.
// We have this tree:
CallExpression("+")
├── Literal(Integer(2))
└── Literal(Integer(3))
// We need this result:
5
But there’s a catch. What about (+ x 3)
? Where does x
come from? We need a place to store variables - an environment.
Runtime Values vs AST Nodes
The AST describes structure. Runtime values hold actual data.
// AST nodes (structure)
pub enum Form {
Literal(Literal),
CallExpression(String),
Symbol(String),
List,
// ...
}
// Runtime values (data)
pub enum RuntimeObject {
Primitive(Literal), // 42, "hello", true
List(Vec<RuntimeObject>), // [1, 2, 3]
Function(BuiltInFunction), // +, -, *, etc.
RuntimeFunction(Lambda), // user-defined functions
Record(HashMap<String, RuntimeObject>),
NoOp, // for def, defn
}
Why separate them? Because Symbol("x")
in the AST becomes Primitive(Integer(42))
at runtime - if x
is bound to 42.
The Evaluation Algorithm
Evaluation is a big pattern match on the AST node type:
pub fn eval(node: &Node<Form>, env: EnvPointer) -> Result<RuntimeObject> {
match node.owned_value() {
Form::Literal(lit) => Ok(lit.into()), // Literals evaluate to themselves
Form::Symbol(symbol) => env // Symbols look up their value
.lookup(&symbol)
.ok_or(anyhow!("{symbol} is not defined")),
Form::List => eval_list(node, env), // Lists evaluate their elements
Form::CallExpression(func) => match func.as_str() {
"if" => eval_if(node, env), // Special forms
"def" => def(node, env),
"defn" => defn(node, env),
_ => eval_call_expr(func, node, env), // Function calls
},
Form::Root => { // Multiple expressions
let results: Result<Vec<_>> = node.iter()
.map(|n| eval(n, env.clone()))
.collect();
results?.last().cloned()
.ok_or_else(|| anyhow!("Empty root"))
}
// ...
}
}
The key insight: evaluation is recursive. To evaluate a node, we often need to evaluate its children first.
Environments: Where Variables Live
An environment maps symbols to values:
pub struct Env {
vars: RefCell<HashMap<String, RuntimeObject>>,
parent: Option<Rc<Env>>,
}
Why RefCell
? Because we need to mutate the HashMap (adding variables) while multiple references exist.
Why parent
? For lexical scoping:
(def x 10) ; Global env: {x: 10}
(defn f [x] ; Function env: {x: ?} → Global env
(+ x 1)) ; Looks up + in parent
(f 5) ; Function env: {x: 5} → Global env
Variable lookup walks up the chain:
impl Env {
pub fn lookup(&self, symbol: &str) -> Option<RuntimeObject> {
if let Some(value) = self.vars.borrow().get(symbol) {
return Some(value.clone());
}
self.parent.as_ref()?.lookup(symbol)
}
}
Special Forms
Some expressions need custom evaluation rules:
if
- Conditional Evaluation
fn eval_if(node: &Node<Form>, env: EnvPointer) -> Result<RuntimeObject> {
let mut args = node.clone();
let condition = args.take_first()
.ok_or_else(|| anyhow!("if needs condition"))?;
let result = eval(&condition, env.clone())?;
let chosen_branch = match result {
RuntimeObject::Primitive(Literal::Bool(true)) => args.first(),
RuntimeObject::Primitive(Literal::Bool(false)) => args.last(),
_ => return Err(anyhow!("if condition must be boolean")),
}?;
eval(chosen_branch, env) // Only evaluate the chosen branch!
}
Key: We don’t evaluate both branches - only the one we need.
def
- Creating Bindings
fn def(node: &Node<Form>, env: EnvPointer) -> Result<RuntimeObject> {
let mut body = node.clone();
let symbol = body.take_first()
.ok_or_else(|| anyhow!("def needs symbol"))?;
let value_node = body.first()
.ok_or_else(|| anyhow!("def needs value"))?;
let value = eval(value_node, env.clone())?; // Evaluate the value
env.vars.borrow_mut()
.insert(symbol.to_string(), value); // Store in environment
Ok(RuntimeObject::NoOp)
}
defn
- Defining Functions
fn defn(node: &Node<Form>, env: EnvPointer) -> Result<RuntimeObject> {
// (defn name [args] body)
let mut parts = node.clone();
let name = parts.take_first()?;
let args = parts.take_first()?;
let body = parts.take_first()?;
// Create a lambda and bind it to the name
let lambda = build_lambda(args, body, env.clone())?;
env.vars.borrow_mut().insert(name.to_string(), lambda);
Ok(RuntimeObject::NoOp)
}
Function Calls
Regular function calls evaluate all arguments first:
fn eval_call_expr(name: String, form: &Node<Form>, env: EnvPointer)
-> Result<RuntimeObject> {
// Evaluate all arguments
let args = eval_children(form, env.clone())?;
// Built-in function?
if let Some(func) = built_in_functions::FUNCTIONS.get(name.as_str()) {
return func(&args);
}
// User-defined function?
if let Some(RuntimeObject::RuntimeFunction(lambda)) = env.lookup(&name) {
return lambda.eval(&args);
}
Err(anyhow!("{name} is not defined"))
}
fn eval_children(form: &Node<Form>, env: EnvPointer)
-> Result<Vec<RuntimeObject>> {
form.iter()
.map(|child| eval(child, env.clone()))
.collect()
}
Built-in functions are just Rust functions:
fn add(args: &[RuntimeObject]) -> Result<RuntimeObject> {
let sum: i32 = args.extract_numbers()?.iter().sum();
Ok(sum.into())
}
Closures and Lambda
Functions capture their defining environment:
pub struct Lambda {
args: Vec<String>,
body: Node<Form>,
env: Env, // Captured environment
}
impl Lambda {
pub fn eval(&self, arg_values: &[RuntimeObject]) -> Result<RuntimeObject> {
// Create new environment for this call
self.bind_arguments(arg_values);
// Evaluate body in the new environment
let results = eval_forest(self.body.clone(),
Rc::new(self.env.clone()))?;
results.last().cloned()
.ok_or_else(|| anyhow!("Empty function body"))
}
fn bind_arguments(&self, values: &[RuntimeObject]) {
self.env.vars.borrow_mut().clear();
self.env.vars.borrow_mut().extend(
self.args.iter().cloned()
.zip(values.iter().cloned())
);
}
}
This enables closures:
(defn make-adder [x]
(fn [y] (+ x y))) ; Captures x
(def add5 (make-adder 5))
(add5 3) ; Returns 8
Complete Example
Let’s trace through (defn square [x] (* x x)) (square 5)
:
1. Parse: Two top-level expressions
├── CallExpression("defn")
│ ├── Symbol("square")
│ ├── List → Symbol("x")
│ └── CallExpression("*") → Symbol("x"), Symbol("x")
└── CallExpression("square") → Literal(5)
2. Eval first expression (defn):
- Create Lambda with args ["x"] and body (* x x)
- Store in env: {square: Lambda}
- Return NoOp
3. Eval second expression (square 5):
- Look up "square" → Lambda
- Evaluate argument: 5
- Create new env: {x: 5} → parent env
- Eval body (* x x) in new env:
- Look up "*" → built-in multiply
- Eval args: x → 5, x → 5
- Call multiply([5, 5]) → 25
- Return 25
What We Built
We now have a working Lisp interpreter with:
- Tree-walking evaluation
- Lexical scoping with environments
- Special forms for control flow
- First-class functions with closures
- Built-in arithmetic and list operations
The interpreter is ~170 lines of Rust. The core evaluation logic is just pattern matching and recursion.
From text to tokens to tree to execution - we’ve built a complete programming language.