Chapter 4
The Interpreter
Running your code
The interpreter takes the AST from the parser and executes it. It walks through the tree, evaluating each node and producing results. This is called tree-walking interpretation.
The Evaluation Loop
The interpreter has one main job: given an AST node, compute its value. This is recursive — to evaluate a complex expression, you evaluate its sub-expressions first.
To evaluate 10 + 5:
- Evaluate left child (Literal 10) → returns 10
- Evaluate right child (Literal 5) → returns 5
- Apply operator (+) → returns 15
Value Types
Bloom values at runtime:
Environments and Scope
Variables live in environments. Each scope creates a new environment that links to its parent.
let x = 1 // Global environment: { x: 1 }
fn test() {
let y = 2 // Function environment: { y: 2 } → parent: global
if true {
let z = 3 // Block environment: { z: 3 } → parent: function
// Can see: z, y, x
}
// Can see: y, x (z is gone)
}
// Can see: x (y is gone)
x: 1
parent: none
y: 2
parent: Global
z: 3
parent: Function
When you reference a variable, the interpreter searches up the chain: current environment → parent → parent → ... until it finds the name or reaches the top.
Native Functions
Drawing functions like circle() are native functions — they're implemented in JavaScript, not Bloom. When the interpreter sees a call to circle, it:
- Looks up "circle" in the environment → finds a native function
- Evaluates all the arguments (x, y, radius)
- Calls the JavaScript implementation with those values
- The JS code draws on the Canvas
The Animation Loop
Bloom programs typically have setup() and draw() functions. Here's how the runtime manages them:
Each frame:
- Increment
framecounter - Update
mouseX,mouseY,keyPressed - Call
draw()function - Wait for next animation frame (~16.6ms for 60fps)
Executing a For Loop
Let's trace through this loop:
for i in 0..3 {
print(i)
}
Create new scope for loop body
Execute body: print(0) → outputs "0"
Execute body: print(1) → outputs "1"
Execute body: print(2) → outputs "2"
Discard loop scope, continue after loop
Closures
When you create a function, it captures its surrounding environment. This is a closure.
fn makeCounter() {
let count = 0
return fn() {
count = count + 1
return count
}
}
let counter = makeCounter()
print(counter()) // 1
print(counter()) // 2
print(counter()) // 3
The inner function "remembers" the count variable from when it was created, even after makeCounter() has returned. This works because:
- When the inner function is created, it stores a reference to its environment
- That environment contains
count - Even after
makeCounter()returns, the environment lives on - Each call to
counter()modifies that samecount
Closures fall directly out of the environment-chain design: a function value just holds onto the environment it was defined in. The bytecode VM reaches the same behavior by a very different route — it boxes captured variables into heap cells at compile time. That story gets its own chapter; see Chapter 6: Closures.
Control Flow Without Stack Traces
How does a break or continue deep inside nested ifs jump back out to the loop? The interpreter throws a small signal object and catches it at the loop body. return used to work the same way — but it no longer does, and the reason is performance (more below).
// These deliberately do NOT extend Error.
export class BreakException {}
export class ContinueException {}
// break/continue carry no data, so a single shared
// instance is reused instead of allocating each time.
export const BREAK_SIGNAL = new BreakException()
export const CONTINUE_SIGNAL = new ContinueException()
The crucial detail is that these classes do not extend Error. Constructing a real JavaScript Error captures a stack trace, which is surprisingly expensive when thrown in a tight loop.
return stopped throwing
return originally unwound the same way, via a thrown ReturnException. But a recursive program does a return on every call, and even a stack-trace-free throw/catch is costly at that volume — benchmarking showed it was roughly 44× slower than the alternative. So return now sets a returning flag plus a returnValue field; statement loops check the flag and stop early, and the function-call boundary consumes it and clears it. No throw at all. break and continue still use the shared signal objects above — they fire far less often, so the simpler exception model is fine for them.
Error Handling
When something goes wrong during execution, the interpreter throws a runtime error:
foo()^^^
Runtime errors stop execution immediately. Unlike syntax errors (caught during parsing), these happen when the code runs.
Timeout Protection
Infinite loops would freeze your browser. The interpreter checks periodically:
// Inside the interpreter loop:
if ((iterations & 1023) === 0) { // Every 1024 iterations
if (Date.now() - startTime > timeout) {
throw new TimeoutError(...)
}
}
This uses bitwise AND (& 1023) instead of modulo for speed — a micro-optimization that matters in tight loops. Note that TimeoutError and LoopLimitError do extend Error (they're real errors a user should see), unlike the control-flow signals above.
In the Source Code
The interpreter lives in src/lang/interpreter.ts. Key methods:
execute(stmt)— Execute a statementevaluate(expr)— Evaluate an expression to a valuevisitBinaryExpr()— Handle + - * / etc.visitCallExpr()— Handle function callsvisitForStmt()— Handle for loopsdefineNatives()— Register all 100+ native functions