Skip to content

Commit 14463d8

Browse files
feat(vm): upvalue capture for closures (by value, single level) (#94)
## What Adds **upvalue capture** to VM closures — the deferred follow-up from #93. Previously a lambda could only use its params + globals; referencing an enclosing local compiled to a `LoadGlobal` that failed at runtime. Now closures capture enclosing locals. - **Compiler**: the flat locals list becomes a **stack of scopes**. A lambda pushes a scope; the enclosing scope stays visible. Name resolution is now: `LoadLocal` (current scope) → `LoadUpvalue` (captured from the enclosing scope, recorded + deduped) → `LoadGlobal`. The lambda emits `MakeClosure(func_idx, upvalue_srcs)`. - **Value**: `VmClosure` now carries `{ func_idx, upvalues: Vec<Value> }` (was just an index); the 5 exhaustive `Value` matches updated. - **bytecode**: `MakeClosure(usize, Vec<usize>)` + new `LoadUpvalue(usize)`. - **machine**: `CallFrame` carries `upvalues`; `MakeClosure` captures the listed enclosing locals **by value**; `CallValue` installs the closure's upvalues into the new frame; `LoadUpvalue` reads them. ## Verification - **Full lib suite green: 188 passed, 0 failed** (+2 tests). - New tests: `(|x| x + n)(5)` → 15 (single capture); `(|x| x + a + b)(3)` → 123 (multiple). - `rustfmt` + `clippy` (CI mode) clean. ## Scope (documented limitations → follow-ups) - **By-value capture**: the closure snapshots captured values at creation; mutation *through* a captured variable isn't reflected back to the enclosing scope. (The interpreter captures by reference via a shared env; matching that needs reference cells.) - **Single-level nesting**: a lambda captures its immediate enclosing function's locals, not a grand-enclosing scope (no recursive upvalue threading yet). ## Remaining VM follow-ups By-reference / multi-level capture · interpreter↔VM parity harness · `.wbc` serialization · worker statements · consent grant-sources. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013wg3Mtq2QFhYi4XVw1Z6z7 --- _Generated by [Claude Code](https://claude.ai/code/session_013wg3Mtq2QFhYi4XVw1Z6z7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f659790 commit 14463d8

8 files changed

Lines changed: 147 additions & 37 deletions

File tree

src/interpreter/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1056,7 +1056,7 @@ impl Interpreter {
10561056
Value::Okay(inner) => format!("Okay({})", self.value_to_string(inner)),
10571057
Value::Oops(msg) => format!("Oops({})", msg),
10581058
Value::Channel(_) => "<channel>".to_string(),
1059-
Value::VmClosure(_) => "<vm-closure>".to_string(),
1059+
Value::VmClosure { .. } => "<vm-closure>".to_string(),
10601060
}
10611061
}
10621062
}

src/interpreter/value.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,12 @@ pub enum Value {
223223
Function(Closure),
224224
/// Go-style channel for concurrent communication
225225
Channel(ChannelHandle),
226-
/// A VM bytecode closure: an index into the compiled function table.
227-
/// Non-capturing for now (uses params + globals, not enclosing locals).
228-
VmClosure(usize),
226+
/// A VM bytecode closure: a function-table index plus captured upvalues
227+
/// (enclosing locals captured by value at closure-creation time).
228+
VmClosure {
229+
func_idx: usize,
230+
upvalues: Vec<Value>,
231+
},
229232
}
230233

231234
impl Value {
@@ -243,7 +246,7 @@ impl Value {
243246
Value::Oops(_) => false,
244247
Value::Function(_) => true,
245248
Value::Channel(ch) => !ch.is_closed(),
246-
Value::VmClosure(_) => true,
249+
Value::VmClosure { .. } => true,
247250
}
248251
}
249252

@@ -308,7 +311,7 @@ impl fmt::Display for Value {
308311
None => write!(f, "<chan {}>", status),
309312
}
310313
}
311-
Value::VmClosure(idx) => write!(f, "<vm-closure {}>", idx),
314+
Value::VmClosure { func_idx, .. } => write!(f, "<vm-closure {}>", func_idx),
312315
}
313316
}
314317
}

src/repl.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ impl Repl {
8585
crate::interpreter::Value::Float(f) => println!("{}", f),
8686
crate::interpreter::Value::String(s) => println!("\"{}\"", s),
8787
crate::interpreter::Value::Bool(b) => println!("{}", b),
88-
crate::interpreter::Value::VmClosure(idx) => println!("<vm-closure {}>", idx),
88+
crate::interpreter::Value::VmClosure { func_idx, .. } => {
89+
println!("<vm-closure {}>", func_idx)
90+
}
8991
crate::interpreter::Value::Array(arr) => {
9092
print!("[");
9193
for (i, val) in arr.iter().enumerate() {

src/stdlib/json.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ fn value_to_json_string(value: &Value) -> Result<String, StdlibError> {
147147
Value::Okay(_) | Value::Oops(_) => Err(StdlibError::RuntimeError(
148148
"Cannot stringify Result to JSON".to_string(),
149149
)),
150-
Value::VmClosure(_) => Err(StdlibError::RuntimeError(
150+
Value::VmClosure { .. } => Err(StdlibError::RuntimeError(
151151
"Cannot stringify closure to JSON".to_string(),
152152
)),
153153
}

src/vm/bytecode.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,13 @@ pub enum OpCode {
6969
Call(usize),
7070
/// Return from function
7171
Return,
72-
/// Create a closure: push a `VmClosure` for the given function index
73-
MakeClosure(usize),
72+
/// Create a closure: push a `VmClosure` for the given function index,
73+
/// capturing the listed enclosing locals (by stack index, by value).
74+
MakeClosure(usize, Vec<usize>),
7475
/// Call a closure value sitting just below its `usize` arguments on the stack
7576
CallValue(usize),
77+
/// Load captured upvalue `usize` of the executing closure onto the stack
78+
LoadUpvalue(usize),
7679

7780
// Array/Record operations
7881
/// Create an array from N elements on stack

src/vm/compiler.rs

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,31 @@ struct LoopCtx {
4040
break_jumps: Vec<usize>,
4141
}
4242

43+
/// A lexical scope (a function or lambda body): its locals plus the upvalues it
44+
/// captures from the immediately enclosing scope (recorded as enclosing-local
45+
/// indices, captured by value).
46+
struct Scope {
47+
locals: Vec<Local>,
48+
upvalues: Vec<usize>,
49+
}
50+
51+
impl Scope {
52+
fn new() -> Self {
53+
Self {
54+
locals: Vec::new(),
55+
upvalues: Vec::new(),
56+
}
57+
}
58+
}
59+
4360
/// Compiler state
4461
pub struct BytecodeCompiler {
4562
/// Current function being compiled
4663
current_function: Option<CompiledFunction>,
47-
/// Local variables in current scope
48-
locals: Vec<Local>,
49-
/// Current scope depth
64+
/// Stack of lexical scopes; the top is the current scope. Lambdas push a
65+
/// scope so they can capture upvalues from the enclosing scope.
66+
scopes: Vec<Scope>,
67+
/// Current scope depth (used only for lambda naming).
5068
scope_depth: usize,
5169
/// Function names to indices
5270
function_indices: HashMap<String, usize>,
@@ -60,7 +78,7 @@ impl BytecodeCompiler {
6078
pub fn new() -> Self {
6179
Self {
6280
current_function: None,
63-
locals: Vec::new(),
81+
scopes: vec![Scope::new()],
6482
scope_depth: 0,
6583
function_indices: HashMap::new(),
6684
program: CompiledProgram::new(),
@@ -128,7 +146,7 @@ impl BytecodeCompiler {
128146
compiled_func: &mut CompiledFunction,
129147
) -> Result<(), CompileError> {
130148
self.current_function = Some(compiled_func.clone());
131-
self.locals.clear();
149+
self.scopes = vec![Scope::new()];
132150
self.scope_depth = 0;
133151

134152
// Add parameters as locals
@@ -528,6 +546,8 @@ impl BytecodeCompiler {
528546
Expr::Identifier(name) => {
529547
if let Some(local_idx) = self.resolve_local(name) {
530548
func.emit(OpCode::LoadLocal(local_idx));
549+
} else if let Some(upvalue_idx) = self.resolve_upvalue(name) {
550+
func.emit(OpCode::LoadUpvalue(upvalue_idx));
531551
} else {
532552
func.emit(OpCode::LoadGlobal(name.clone()));
533553
}
@@ -681,9 +701,10 @@ impl BytecodeCompiler {
681701
let lambda_name = format!("<lambda_{}>", self.scope_depth);
682702
let mut lambda_func = CompiledFunction::new(lambda_name, lambda.params.len());
683703

684-
// Enter new scope for lambda
704+
// Enter a new scope; the enclosing scope stays accessible for
705+
// upvalue capture.
685706
self.scope_depth += 1;
686-
let old_locals = std::mem::take(&mut self.locals);
707+
self.scopes.push(Scope::new());
687708

688709
// Add parameters as locals
689710
for param in &lambda.params {
@@ -708,15 +729,14 @@ impl BytecodeCompiler {
708729
}
709730
}
710731

711-
// Restore scope
732+
// Pop the lambda scope, recovering the upvalues it captured.
733+
let lambda_scope = self.scopes.pop().expect("lambda scope");
712734
self.scope_depth -= 1;
713-
self.locals = old_locals;
714735

715-
// Add lambda function to program
736+
// Add the lambda to the program and create a closure that
737+
// captures the recorded enclosing locals by value.
716738
let func_idx = self.program.add_function(lambda_func);
717-
718-
// Create closure from function
719-
func.emit(OpCode::MakeClosure(func_idx));
739+
func.emit(OpCode::MakeClosure(func_idx, lambda_scope.upvalues));
720740
Ok(())
721741
}
722742

@@ -758,25 +778,55 @@ impl BytecodeCompiler {
758778
}
759779
}
760780

761-
/// Add a local variable
781+
/// Add a local variable to the current scope.
762782
fn add_local(&mut self, name: String) -> usize {
763-
let idx = self.locals.len();
764-
self.locals.push(Local {
765-
name,
766-
depth: self.scope_depth,
767-
});
783+
let depth = self.scope_depth;
784+
let scope = self.scopes.last_mut().expect("at least one scope");
785+
let idx = scope.locals.len();
786+
scope.locals.push(Local { name, depth });
768787
idx
769788
}
770789

771-
/// Resolve a local variable by name
790+
/// Resolve a local variable by name in the current scope.
772791
fn resolve_local(&self, name: &str) -> Option<usize> {
773-
for (idx, local) in self.locals.iter().enumerate().rev() {
792+
let scope = self.scopes.last()?;
793+
for (idx, local) in scope.locals.iter().enumerate().rev() {
774794
if local.name == name {
775795
return Some(idx);
776796
}
777797
}
778798
None
779799
}
800+
801+
/// Resolve `name` as an upvalue captured from the immediately enclosing
802+
/// scope. Registers the capture (deduplicated) in the current scope and
803+
/// returns its upvalue index. Single-level capture only.
804+
fn resolve_upvalue(&mut self, name: &str) -> Option<usize> {
805+
let n = self.scopes.len();
806+
if n < 2 {
807+
return None;
808+
}
809+
// Find `name` among the immediately enclosing scope's locals.
810+
let enclosing_local = {
811+
let enclosing = &self.scopes[n - 2];
812+
let mut found = None;
813+
for (idx, local) in enclosing.locals.iter().enumerate().rev() {
814+
if local.name == name {
815+
found = Some(idx);
816+
break;
817+
}
818+
}
819+
found?
820+
};
821+
// Register (or reuse) an upvalue capturing that enclosing local.
822+
let scope = self.scopes.last_mut().unwrap();
823+
if let Some(uidx) = scope.upvalues.iter().position(|&s| s == enclosing_local) {
824+
return Some(uidx);
825+
}
826+
let uidx = scope.upvalues.len();
827+
scope.upvalues.push(enclosing_local);
828+
Some(uidx)
829+
}
780830
}
781831

782832
impl Default for BytecodeCompiler {

src/vm/machine.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ struct CallFrame {
5151
ip: usize,
5252
/// Base pointer for locals (points into value stack)
5353
bp: usize,
54+
/// Captured upvalues, if this frame is executing a closure (else empty)
55+
upvalues: Vec<Value>,
5456
}
5557

5658
/// The virtual machine
@@ -90,6 +92,7 @@ impl VirtualMachine {
9092
function_idx: entry_idx,
9193
ip: 0,
9294
bp: 0,
95+
upvalues: Vec::new(),
9396
});
9497

9598
// Execute
@@ -312,6 +315,7 @@ impl VirtualMachine {
312315
function_idx: callee_idx,
313316
ip: 0,
314317
bp,
318+
upvalues: Vec::new(),
315319
});
316320
}
317321

@@ -469,23 +473,46 @@ impl VirtualMachine {
469473
self.push(Value::Bool(granted));
470474
}
471475

472-
OpCode::MakeClosure(func_idx) => {
473-
// Non-capturing closure: just the bytecode function index.
474-
self.push(Value::VmClosure(func_idx));
476+
OpCode::MakeClosure(func_idx, upvalue_srcs) => {
477+
// Capture the listed enclosing locals by value from the
478+
// current frame's locals (stack[bp + src]).
479+
let bp = self.frames.last().ok_or(VMError::InvalidIP)?.bp;
480+
let mut upvalues = Vec::with_capacity(upvalue_srcs.len());
481+
for src in &upvalue_srcs {
482+
let v = self
483+
.stack
484+
.get(bp + *src)
485+
.ok_or(VMError::InvalidLocalIndex(*src))?
486+
.clone();
487+
upvalues.push(v);
488+
}
489+
self.push(Value::VmClosure { func_idx, upvalues });
490+
}
491+
492+
OpCode::LoadUpvalue(idx) => {
493+
let value = self
494+
.frames
495+
.last()
496+
.ok_or(VMError::InvalidIP)?
497+
.upvalues
498+
.get(idx)
499+
.ok_or(VMError::InvalidLocalIndex(idx))?
500+
.clone();
501+
self.push(value);
475502
}
476503

477504
OpCode::CallValue(argc) => {
478505
// Stack: [callee_closure, arg0, .., arg{argc-1}]. Remove the
479506
// closure from below the args, then enter its frame with the
480-
// args as locals 0..argc.
507+
// args as locals 0..argc and the closure's captured upvalues.
481508
let cpos = self
482509
.stack
483510
.len()
484511
.checked_sub(argc + 1)
485512
.ok_or(VMError::StackUnderflow)?;
486513
let callee = self.stack.remove(cpos);
487-
let func_idx = match callee {
488-
Value::VmClosure(idx) => idx,
514+
let (func_idx, upvalues) = match callee {
515+
Value::VmClosure { func_idx, upvalues } => (func_idx, upvalues),
489516
other => {
490517
return Err(VMError::TypeError(format!(
491518
"cannot call non-closure value: {:?}",
@@ -500,6 +527,7 @@ impl VirtualMachine {
500527
function_idx: func_idx,
501528
ip: 0,
502529
bp: cpos,
530+
upvalues,
503531
});
504532
}
505533
}

src/vm/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,28 @@ mod tests {
311311
"#;
312312
assert_eq!(run_vm(source).unwrap(), Value::Int(42));
313313
}
314+
315+
#[test]
316+
fn test_vm_closure_captures_upvalue() {
317+
// The lambda captures the enclosing local `n` (= 10) by value.
318+
let source = r#"
319+
to main() {
320+
remember n = 10;
321+
give back (|x| x + n)(5);
322+
}
323+
"#;
324+
assert_eq!(run_vm(source).unwrap(), Value::Int(15));
325+
}
326+
327+
#[test]
328+
fn test_vm_closure_captures_multiple() {
329+
let source = r#"
330+
to main() {
331+
remember a = 100;
332+
remember b = 20;
333+
give back (|x| x + a + b)(3);
334+
}
335+
"#;
336+
assert_eq!(run_vm(source).unwrap(), Value::Int(123));
337+
}
314338
}

0 commit comments

Comments
 (0)