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
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 Error("Execution timeout")
}
}
This uses bitwise AND (& 1023) instead of modulo for speed — a micro-optimization that matters in tight loops.
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