Skip to main content

sui_bytecode/
vm.rs

1//! Bytecode VM execution engine.
2//!
3//! A stack-based interpreter that executes compiled [`Chunk`]s. The VM
4//! maintains a NaN-boxed value stack (8 bytes per entry), a call stack
5//! for function invocations, and dispatches instructions via a `match` loop.
6//!
7//! # NaN-boxing
8//!
9//! The value stack uses [`NanBox`] instead of [`VMValue`]. Scalars (null,
10//! bool, int, float) are stored inline as 8-byte values without heap
11//! allocation. Complex types (strings, lists, attrsets, closures, builtins,
12//! thunks) use an `Rc<HeapObject>` pointer encoded in the NaN payload bits.
13//!
14//! The constant pool (in `Chunk`) still uses `VMValue`; values are converted
15//! to `NanBox` when pushed onto the stack and converted back only at the
16//! external API boundary (`execute` returns `VMValue`).
17use std::cell::{Cell, RefCell};
18use std::collections::{BTreeMap, HashMap};
19use std::path::PathBuf;
20use std::rc::Rc;
21use std::sync::atomic::{AtomicU64, Ordering};
22/// Counts how many files fell back to the tree-walker during VM import.
23static VM_FALLBACK_COUNT: AtomicU64 = AtomicU64::new(0);
24/// Return the number of files that fell back to tree-walker evaluation.
25pub fn vm_fallback_count() -> u64 {
26    VM_FALLBACK_COUNT.load(Ordering::Relaxed)
27}
28use crate::builtins::BuiltinRegistry;
29use crate::chunk::Chunk;
30use crate::compiler::Compiler;
31use crate::error::VMError;
32use crate::intern::{Interner, Symbol};
33use crate::nanbox::NanBox;
34use crate::opcode::OpCode;
35use crate::value::{HigherOrderBuiltin, HigherOrderOp, ThunkState, VMThunk, VMValue};
36/// Maximum call depth before we report a stack overflow.
37const MAX_CALL_DEPTH: usize = 1024;
38/// Maximum depth for thunk-in-thunk chain unwrapping.
39/// Catches `let x = x; in x` cycles while allowing normal fixpoints.
40const MAX_THUNK_CHAIN_DEPTH: u32 = 2000;
41/// A tiny bytecode chunk: `GetUpvalue 0; GetUpvalue 1; Call; Return`.
42/// Used to create deferred-application thunks where upvalue 0 is a
43/// function and upvalue 1 is its argument. Cached to avoid repeated
44/// allocation.
45fn deferred_apply_chunk() -> Rc<Chunk> {
46    thread_local! {
47        static CHUNK: Rc<Chunk> = {
48            let mut c = Chunk::new();
49            // GetUpvalue 0 — push the function (upvalue index 0, little-endian u16)
50            c.write_op(OpCode::GetUpvalue, 0);
51            c.write_byte(0, 0); // lo byte of index 0
52            c.write_byte(0, 0); // hi byte of index 0
53            // GetUpvalue 1 — push the argument (upvalue index 1, little-endian u16)
54            c.write_op(OpCode::GetUpvalue, 0);
55            c.write_byte(1, 0); // lo byte of index 1
56            c.write_byte(0, 0); // hi byte of index 1
57            // Call
58            c.write_op(OpCode::Call, 0);
59            // Return
60            c.write_op(OpCode::Return, 0);
61            Rc::new(c)
62        };
63    }
64    CHUNK.with(|c| c.clone())
65}
66// ── Flake resolver callback ─────────────────────────────────
67/// Signature for an external flake resolver.
68///
69/// When set, the VM delegates `builtins.getFlake` to this callback
70/// instead of using its own limited input resolution.  The callback
71/// receives the raw flake reference string (e.g. `"path:/foo/bar"`)
72/// and returns a `StringKeyedValue` attrset representing the fully
73/// resolved flake outputs.
74///
75/// `sui-eval` sets this to the tree-walker's `evaluate_flake` which
76/// handles all input types (GitHub, path, indirect) and produces
77/// correct results for `(getFlake ref).inputs.nixpkgs`.
78pub type FlakeResolverFn = dyn Fn(&str) -> Result<crate::value::StringKeyedValue, String>;
79thread_local! {
80    static FLAKE_RESOLVER: RefCell<Option<Box<FlakeResolverFn>>> = const { RefCell::new(None) };
81}
82/// Install a flake resolver callback for the current thread.
83///
84/// Returns an RAII guard that restores the previous resolver on drop.
85/// This ensures the resolver is always properly cleaned up even when
86/// evaluation errors occur.
87pub fn set_flake_resolver(
88    resolver: Box<FlakeResolverFn>,
89) -> FlakeResolverGuard {
90    let prev = FLAKE_RESOLVER.with(|r| r.borrow_mut().replace(resolver));
91    FlakeResolverGuard { _prev: prev }
92}
93/// RAII guard that restores the previous flake resolver on drop.
94pub struct FlakeResolverGuard {
95    _prev: Option<Box<FlakeResolverFn>>,
96}
97impl Drop for FlakeResolverGuard {
98    fn drop(&mut self) {
99        let prev = self._prev.take();
100        FLAKE_RESOLVER.with(|r| *r.borrow_mut() = prev);
101    }
102}
103/// A call frame on the VM's call stack.
104#[derive(Clone)]
105struct CallFrame {
106    /// The chunk being executed.
107    chunk: Rc<Chunk>,
108    /// Instruction pointer within the chunk.
109    ip: usize,
110    /// Base index in the value stack for this frame's locals.
111    stack_base: usize,
112    /// Upvalues captured by this frame's closure (NaN-boxed).
113    upvalues: Vec<NanBox>,
114}
115/// The bytecode virtual machine.
116///
117/// Uses NaN-boxed values on the value stack: each entry is exactly 8 bytes,
118/// making the stack cache-friendly. Scalars (null, bool, int, float) are
119/// stored inline without heap allocation. Complex types use heap pointers
120/// encoded in the NaN payload bits.
121pub struct VM<'a> {
122    /// NaN-boxed value stack (8 bytes per entry).
123    stack: Vec<NanBox>,
124    /// Call stack.
125    frames: Vec<CallFrame>,
126    /// Shared interner for attribute key operations.
127    interner: &'a mut Interner,
128    /// With-scope stack (dynamic variable scoping, NaN-boxed).
129    with_stack: Vec<NanBox>,
130    /// Registry of built-in functions.
131    builtins: BuiltinRegistry,
132    /// Import cache: canonical path -> evaluated result.
133    import_cache: Rc<RefCell<HashMap<String, VMValue>>>,
134    /// Compile cache: canonical path -> compiled bytecode.
135    /// Avoids re-parsing and re-compiling files that are imported
136    /// multiple times (e.g. via scopedImport or recursive imports).
137    compile_cache: HashMap<PathBuf, Rc<Chunk>>,
138}
139impl<'a> VM<'a> {
140    /// Create a new VM and execute a chunk, returning the result.
141    pub fn execute(chunk: Chunk, interner: &'a mut Interner) -> Result<VMValue, VMError> {
142        let mut vm = Self {
143            stack: Vec::with_capacity(256),
144            frames: Vec::with_capacity(64),
145            interner,
146            with_stack: Vec::new(),
147            builtins: BuiltinRegistry::new(),
148            import_cache: Rc::new(RefCell::new(HashMap::new())),
149            compile_cache: HashMap::new(),
150        };
151        vm.frames.push(CallFrame {
152            chunk: Rc::new(chunk),
153            ip: 0,
154            stack_base: 0,
155            upvalues: Vec::new(),
156        });
157        let result = vm.run()?;
158        // Force the top-level result so we never return a thunk.
159        let result = vm.force_value(result)?;
160        // Deep-force: recursively force thunks inside attrsets and lists
161        // so the caller never sees unforced thunks.
162        let result = vm.deep_force(result)?;
163        Ok(result.to_vmvalue())
164    }
165    /// Main execution loop -- delegates to `run_until(0)`.
166    fn run(&mut self) -> Result<NanBox, VMError> {
167        self.run_until(0)
168    }
169    /// Execute until the frame stack drops to `stop_depth`.
170    ///
171    /// When the `Return` opcode pops a frame and the stack depth equals
172    /// `stop_depth`, the loop exits and returns the result. This lets
173    /// `import_file` and `force_value` run sub-programs without a separate VM.
174    fn run_until(&mut self, stop_depth: usize) -> Result<NanBox, VMError> {
175        let mut op_count: u64 = 0;
176        loop {
177            op_count += 1;
178            if std::env::var("SUI_VM_TRACE").is_ok() && op_count % 1_000_000 == 0 {
179                eprintln!(
180                    "[sui-vm] {}M ops, depth {}, chunk: {}",
181                    op_count / 1_000_000,
182                    self.frames.len(),
183                    self.current_chunk_name(),
184                );
185            }
186            let op_byte = self.read_byte()?;
187            let op = OpCode::from_byte(op_byte).ok_or(VMError::InvalidOpcode(op_byte))?;
188            match op {
189                // Arithmetic
190                OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div | OpCode::Negate => {
191                    self.dispatch_arithmetic(op)?;
192                }
193                // Comparison
194                OpCode::Equal | OpCode::NotEqual | OpCode::Less | OpCode::Greater |
195                OpCode::LessEqual | OpCode::GreaterEqual => {
196                    self.dispatch_comparison(op)?;
197                }
198                // Logic
199                OpCode::Not | OpCode::And | OpCode::Or | OpCode::Implication => {
200                    self.dispatch_logic(op)?;
201                }
202                // Constants
203                OpCode::Constant | OpCode::Null | OpCode::True | OpCode::False => {
204                    self.dispatch_constant(op)?;
205                }
206                // Variables
207                OpCode::GetLocal | OpCode::SetLocal | OpCode::GetUpvalue | OpCode::SetUpvalue => {
208                    self.dispatch_variable(op)?;
209                }
210                // Attrsets
211                OpCode::MakeAttrs | OpCode::GetAttr | OpCode::HasAttr | OpCode::UpdateAttrs |
212                OpCode::SelectOrDefault | OpCode::DynGetAttr | OpCode::DynHasAttr |
213                OpCode::DynSelectOrDefault => {
214                    self.dispatch_attrset(op)?;
215                }
216                // Lists
217                OpCode::MakeList | OpCode::Concat => {
218                    self.dispatch_list(op)?;
219                }
220                // Control flow
221                OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue | OpCode::Assert | OpCode::Throw => {
222                    self.dispatch_control(op)?;
223                }
224                // Functions
225                OpCode::MakeClosure | OpCode::Call | OpCode::TailCall => {
226                    self.dispatch_function(op)?;
227                }
228                OpCode::Return => {
229                    let result = self.pop()?;
230                    let frame = self.frames.pop().ok_or(VMError::Internal(
231                        "return with empty call stack".to_string(),
232                    ))?;
233                    if self.frames.len() <= stop_depth {
234                        return Ok(result);
235                    }
236                    self.stack.truncate(frame.stack_base);
237                    self.push(result);
238                }
239                // Thunks
240                OpCode::MakeThunk | OpCode::MakeLazyThunk | OpCode::Force |
241                OpCode::PatchThunkUpvalues => {
242                    self.dispatch_thunk(op)?;
243                }
244                // Scope
245                OpCode::PushWith | OpCode::PopWith | OpCode::LookupWith |
246                OpCode::PushBuiltins => {
247                    self.dispatch_scope(op)?;
248                }
249                // Import + CallBuiltin
250                OpCode::Import | OpCode::CallBuiltin => {
251                    self.dispatch_import(op)?;
252                }
253                // Super-instructions
254                OpCode::GetLocalAttr | OpCode::GetLocalCall => {
255                    self.dispatch_super(op)?;
256                }
257                // Stack / String
258                OpCode::Pop | OpCode::Dup | OpCode::Interpolate => {
259                    self.dispatch_stack(op)?;
260                }
261            }
262        }
263    }
264    // ── Dispatch handler groups ──────────────────────────────────
265    fn dispatch_constant(&mut self, op: OpCode) -> Result<(), VMError> {
266        match op {
267            OpCode::Constant => {
268                let idx = self.read_u16()?;
269                let value = &self.current_chunk().constants[idx as usize];
270                let boxed = NanBox::from_vmvalue(value);
271                self.push(boxed);
272            }
273            OpCode::Null => self.push(NanBox::null()),
274            OpCode::True => self.push(NanBox::bool(true)),
275            OpCode::False => self.push(NanBox::bool(false)),
276            _ => unreachable!(),
277        }
278        Ok(())
279    }
280    fn dispatch_arithmetic(&mut self, op: OpCode) -> Result<(), VMError> {
281        match op {
282            OpCode::Add => {
283                let b = self.pop_forced()?;
284                let a = self.pop_forced()?;
285                self.push(self.add(&a, &b)?);
286            }
287            OpCode::Sub => {
288                let b = self.pop_forced()?;
289                let a = self.pop_forced()?;
290                self.push(self.num_op(&a, &b, |x, y| x - y, |x, y| x - y, "subtraction")?);
291            }
292            OpCode::Mul => {
293                let b = self.pop_forced()?;
294                let a = self.pop_forced()?;
295                self.push(self.num_op(&a, &b, |x, y| x * y, |x, y| x * y, "multiplication")?);
296            }
297            OpCode::Div => {
298                let b = self.pop_forced()?;
299                let a = self.pop_forced()?;
300                if a.is_int() && b.as_int() == Some(0) {
301                    return Err(VMError::DivisionByZero);
302                }
303                self.push(self.num_op(&a, &b, |x, y| x / y, |x, y| x / y, "division")?);
304            }
305            OpCode::Negate => {
306                let val = self.pop_forced()?;
307                if let Some(n) = val.as_int() {
308                    self.push(NanBox::int(-n));
309                } else if let Some(f) = val.as_float() {
310                    self.push(NanBox::float(-f));
311                } else {
312                    return Err(VMError::TypeError {
313                        expected: "int or float",
314                        got: val.type_name(),
315                        context: "negation".to_string(),
316                    });
317                }
318            }
319            _ => unreachable!(),
320        }
321        Ok(())
322    }
323    fn dispatch_logic(&mut self, op: OpCode) -> Result<(), VMError> {
324        match op {
325            OpCode::Not => {
326                let val = self.pop_forced()?;
327                let b = val.is_truthy()?;
328                self.push(NanBox::bool(!b));
329            }
330            OpCode::And => {
331                let b = self.pop_forced()?;
332                let a = self.pop_forced()?;
333                self.push(NanBox::bool(a.is_truthy()? && b.is_truthy()?));
334            }
335            OpCode::Or => {
336                let b = self.pop_forced()?;
337                let a = self.pop_forced()?;
338                self.push(NanBox::bool(a.is_truthy()? || b.is_truthy()?));
339            }
340            OpCode::Implication => {
341                let b = self.pop_forced()?;
342                let a = self.pop_forced()?;
343                self.push(NanBox::bool(!a.is_truthy()? || b.is_truthy()?));
344            }
345            _ => unreachable!(),
346        }
347        Ok(())
348    }
349    fn dispatch_comparison(&mut self, op: OpCode) -> Result<(), VMError> {
350        match op {
351            OpCode::Equal => {
352                let b = self.pop_forced()?;
353                let a = self.pop_forced()?;
354                let eq = self.deep_eq(&a, &b)?;
355                self.push(NanBox::bool(eq));
356            }
357            OpCode::NotEqual => {
358                let b = self.pop_forced()?;
359                let a = self.pop_forced()?;
360                let eq = self.deep_eq(&a, &b)?;
361                self.push(NanBox::bool(!eq));
362            }
363            OpCode::Less => {
364                let b = self.pop_forced()?;
365                let a = self.pop_forced()?;
366                self.push(NanBox::bool(self.compare(&a, &b)? == std::cmp::Ordering::Less));
367            }
368            OpCode::Greater => {
369                let b = self.pop_forced()?;
370                let a = self.pop_forced()?;
371                self.push(NanBox::bool(self.compare(&a, &b)? == std::cmp::Ordering::Greater));
372            }
373            OpCode::LessEqual => {
374                let b = self.pop_forced()?;
375                let a = self.pop_forced()?;
376                self.push(NanBox::bool(self.compare(&a, &b)? != std::cmp::Ordering::Greater));
377            }
378            OpCode::GreaterEqual => {
379                let b = self.pop_forced()?;
380                let a = self.pop_forced()?;
381                self.push(NanBox::bool(self.compare(&a, &b)? != std::cmp::Ordering::Less));
382            }
383            _ => unreachable!(),
384        }
385        Ok(())
386    }
387    fn dispatch_variable(&mut self, op: OpCode) -> Result<(), VMError> {
388        match op {
389            OpCode::GetLocal => {
390                let slot = self.read_u16()? as usize;
391                let abs_slot = self.current_frame().stack_base + slot;
392                if abs_slot >= self.stack.len() {
393                    let frame = self.current_frame();
394                    let chunk = &frame.chunk;
395                    let failing_ip = frame.ip.saturating_sub(3);
396                    let frame_info: Vec<String> = self.frames.iter().enumerate()
397                        .map(|(i, f)| format!("frame[{i}]: base={}, ip={}", f.stack_base, f.ip))
398                        .collect();
399                    let bytecode_context = Self::disassemble_around(chunk, failing_ip, 10);
400                    return Err(VMError::Internal(format!(
401                        "GetLocal: slot {slot} (abs {abs_slot}) out of bounds \
402                         (stack len {}, base {}, depth {})\n  \
403                         {}\n  bytecode around ip={failing_ip}:\n{}",
404                        self.stack.len(),
405                        self.current_frame().stack_base,
406                        self.frames.len(),
407                        frame_info.join("\n  "),
408                        bytecode_context,
409                    )));
410                }
411                let value = self.stack[abs_slot].clone();
412                self.push(value);
413            }
414            OpCode::SetLocal => {
415                let slot = self.read_u16()? as usize;
416                let abs_slot = self.current_frame().stack_base + slot;
417                if abs_slot >= self.stack.len() {
418                    return Err(VMError::Internal(format!(
419                        "SetLocal: slot {slot} (abs {abs_slot}) out of bounds \
420                         (stack len {}, base {})",
421                        self.stack.len(),
422                        self.current_frame().stack_base,
423                    )));
424                }
425                let value = self.peek()?.clone();
426                self.stack[abs_slot] = value;
427            }
428            OpCode::GetUpvalue => {
429                let idx = self.read_u16()? as usize;
430                let upvalues = &self.current_frame().upvalues;
431                if idx >= upvalues.len() {
432                    // Upvalue index out of bounds — compiler bug or missing
433                    // upvalue patching. Push null as fallback to avoid panic.
434                    eprintln!(
435                        "[sui-vm] GetUpvalue: index {} out of bounds (len {})",
436                        idx, upvalues.len()
437                    );
438                    self.push(NanBox::null());
439                } else {
440                    let value = upvalues[idx].clone();
441                    self.push(value);
442                }
443            }
444            OpCode::SetUpvalue => {
445                let idx = self.read_u16()? as usize;
446                let value = self.peek()?.clone();
447                self.current_frame_mut().upvalues[idx] = value;
448            }
449            _ => unreachable!(),
450        }
451        Ok(())
452    }
453    fn dispatch_scope(&mut self, op: OpCode) -> Result<(), VMError> {
454        match op {
455            OpCode::PushWith => {
456                let scope = self.pop_forced()?;
457                self.with_stack.push(scope);
458            }
459            OpCode::PopWith => {
460                self.with_stack.pop().ok_or_else(|| {
461                    VMError::Internal("PopWith: empty with-stack".to_string())
462                })?;
463            }
464            OpCode::LookupWith => {
465                let name_idx = self.read_u16()?;
466                let name_string = match &self.current_chunk().constants[name_idx as usize] {
467                    VMValue::String(s) => s.clone(),
468                    _ => {
469                        return Err(VMError::Internal(
470                            "LookupWith: constant not a string".to_string(),
471                        ));
472                    }
473                };
474                let sym = self.interner.intern(&name_string);
475                let mut found = None;
476                for scope in self.with_stack.iter().rev() {
477                    if let Some(attrs) = scope.as_attrs() {
478                        if let Some(val) = attrs.get(&sym) {
479                            found = Some(val.clone());
480                            break;
481                        }
482                    }
483                }
484                match found {
485                    Some(val) => self.push(val),
486                    None => {
487                        return Err(VMError::UndefinedVariable(name_string));
488                    }
489                }
490            }
491            OpCode::PushBuiltins => {
492                let builtins_val = self.builtins.make_builtins_attrset(self.interner);
493                self.push(NanBox::from_vmvalue(&builtins_val));
494            }
495            _ => unreachable!(),
496        }
497        Ok(())
498    }
499    fn dispatch_attrset(&mut self, op: OpCode) -> Result<(), VMError> {
500        match op {
501            OpCode::MakeAttrs => {
502                let count = self.read_u16()? as usize;
503                let mut attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
504                for _ in 0..count {
505                    let key = self.pop()?;
506                    let value = self.pop()?;
507                    let key_sym = if let Some(s) = key.as_string() {
508                        self.interner.intern(s)
509                    } else {
510                        return Err(VMError::TypeError {
511                            expected: "string",
512                            got: key.type_name(),
513                            context: "attrset key".to_string(),
514                        });
515                    };
516                    attrs.insert(key_sym, value);
517                }
518                self.push(NanBox::attrs(attrs));
519            }
520            OpCode::GetAttr => {
521                let key_idx = self.read_u16()?;
522                let key_sym = self.resolve_key_constant(key_idx)?;
523                let attrset = self.pop_forced()?;
524                if let Some(attrs) = attrset.as_attrs() {
525                    if let Some(val) = attrs.get(&key_sym) {
526                        let forced = if val.is_thunk() {
527                            self.force_value(val.clone())?
528                        } else {
529                            val.clone()
530                        };
531                        self.push(forced);
532                    } else {
533                        let key_str = self.interner.resolve(key_sym).to_string();
534                        return Err(VMError::AttrNotFound(key_str));
535                    }
536                } else {
537                    let key_str = self.interner.resolve(key_sym).to_string();
538                    return Err(VMError::TypeError {
539                        expected: "set",
540                        got: attrset.type_name(),
541                        context: format!("attribute selection '.{key_str}'"),
542                    });
543                }
544            }
545            OpCode::HasAttr => {
546                let key_idx = self.read_u16()?;
547                let key_sym = self.resolve_key_constant(key_idx)?;
548                let attrset = self.pop_forced()?;
549                let result = if let Some(attrs) = attrset.as_attrs() {
550                    attrs.contains_key(&key_sym)
551                } else {
552                    false
553                };
554                self.push(NanBox::bool(result));
555            }
556            OpCode::UpdateAttrs => {
557                let b = self.pop_forced()?;
558                let a = self.pop_forced()?;
559                let b_vmval = b.to_vmvalue();
560                let a_vmval = a.to_vmvalue();
561                match (a_vmval, b_vmval) {
562                    (VMValue::Attrs(mut left), VMValue::Attrs(right)) => {
563                        for (k, v) in right {
564                            left.insert(k, v);
565                        }
566                        self.push(NanBox::from_vmvalue(&VMValue::Attrs(left)));
567                    }
568                    (VMValue::Attrs(_), other) => {
569                        return Err(VMError::TypeError {
570                            expected: "set",
571                            got: other.type_name(),
572                            context: "// (right)".to_string(),
573                        });
574                    }
575                    (other, _) => {
576                        return Err(VMError::TypeError {
577                            expected: "set",
578                            got: other.type_name(),
579                            context: "// (left)".to_string(),
580                        });
581                    }
582                }
583            }
584            OpCode::SelectOrDefault => {
585                let key_idx = self.read_u16()?;
586                let key_sym = self.resolve_key_constant(key_idx)?;
587                let default = self.pop()?;
588                let attrset = self.pop_forced()?;
589                if let Some(attrs) = attrset.as_attrs() {
590                    if let Some(val) = attrs.get(&key_sym) {
591                        let forced = if val.is_thunk() {
592                            self.force_value(val.clone())?
593                        } else {
594                            val.clone()
595                        };
596                        self.push(forced);
597                    } else {
598                        self.push(default);
599                    }
600                } else {
601                    self.push(default);
602                }
603            }
604            OpCode::DynGetAttr => {
605                let key_val = self.pop_forced()?;
606                let attrset = self.pop_forced()?;
607                let key_str = key_val
608                    .as_string()
609                    .ok_or_else(|| VMError::TypeError {
610                        expected: "string",
611                        got: key_val.type_name(),
612                        context: "dynamic attribute key".to_string(),
613                    })?
614                    .to_string();
615                let key_sym = self.interner.intern(&key_str);
616                if let Some(attrs) = attrset.as_attrs() {
617                    if let Some(val) = attrs.get(&key_sym) {
618                        let forced = if val.is_thunk() {
619                            self.force_value(val.clone())?
620                        } else {
621                            val.clone()
622                        };
623                        self.push(forced);
624                    } else {
625                        return Err(VMError::AttrNotFound(key_str));
626                    }
627                } else {
628                    return Err(VMError::TypeError {
629                        expected: "set",
630                        got: attrset.type_name(),
631                        context: format!("dynamic select .${{{key_str}}}"),
632                    });
633                }
634            }
635            OpCode::DynHasAttr => {
636                let key_val = self.pop_forced()?;
637                let attrset = self.pop_forced()?;
638                let key_str = key_val
639                    .as_string()
640                    .ok_or_else(|| VMError::TypeError {
641                        expected: "string",
642                        got: key_val.type_name(),
643                        context: "dynamic hasattr key".to_string(),
644                    })?
645                    .to_string();
646                let key_sym = self.interner.intern(&key_str);
647                let result = attrset.as_attrs().map_or(false, |attrs| attrs.contains_key(&key_sym));
648                self.push(NanBox::bool(result));
649            }
650            OpCode::DynSelectOrDefault => {
651                let default = self.pop()?;
652                let key_val = self.pop_forced()?;
653                let attrset = self.pop_forced()?;
654                let key_str = key_val
655                    .as_string()
656                    .ok_or_else(|| VMError::TypeError {
657                        expected: "string",
658                        got: key_val.type_name(),
659                        context: "dynamic select-or-default key".to_string(),
660                    })?
661                    .to_string();
662                let key_sym = self.interner.intern(&key_str);
663                if let Some(attrs) = attrset.as_attrs() {
664                    if let Some(val) = attrs.get(&key_sym) {
665                        let forced = if val.is_thunk() {
666                            self.force_value(val.clone())?
667                        } else {
668                            val.clone()
669                        };
670                        self.push(forced);
671                    } else {
672                        self.push(default);
673                    }
674                } else {
675                    self.push(default);
676                }
677            }
678            _ => unreachable!(),
679        }
680        Ok(())
681    }
682    fn dispatch_list(&mut self, op: OpCode) -> Result<(), VMError> {
683        match op {
684            OpCode::MakeList => {
685                let count = self.read_u16()? as usize;
686                let start = self.stack.len() - count;
687                let items: Vec<NanBox> = self.stack.drain(start..).collect();
688                self.push(NanBox::list(items));
689            }
690            OpCode::Concat => {
691                let b = self.pop_forced()?;
692                let a = self.pop_forced()?;
693                let a_vmval = a.to_vmvalue();
694                let b_vmval = b.to_vmvalue();
695                match (a_vmval, b_vmval) {
696                    (VMValue::List(mut left), VMValue::List(right)) => {
697                        left.extend(right);
698                        self.push(NanBox::from_vmvalue(&VMValue::List(left)));
699                    }
700                    (VMValue::List(_), other) => {
701                        return Err(VMError::TypeError {
702                            expected: "list",
703                            got: other.type_name(),
704                            context: "++ (right)".to_string(),
705                        });
706                    }
707                    (other, _) => {
708                        return Err(VMError::TypeError {
709                            expected: "list",
710                            got: other.type_name(),
711                            context: "++ (left)".to_string(),
712                        });
713                    }
714                }
715            }
716            _ => unreachable!(),
717        }
718        Ok(())
719    }
720    fn dispatch_control(&mut self, op: OpCode) -> Result<(), VMError> {
721        match op {
722            OpCode::Jump => {
723                let target = self.read_u16()? as usize;
724                self.current_frame_mut().ip = target;
725            }
726            OpCode::JumpIfFalse => {
727                let target = self.read_u16()? as usize;
728                let cond = self.pop_forced()?;
729                match cond.is_truthy() {
730                    Ok(false) => { self.current_frame_mut().ip = target; }
731                    Ok(true) => {}
732                    Err(e) => {
733                        // Diagnostic for debugging (remove once fixed)
734                        if std::env::var("SUI_VM_TRACE").is_ok() {
735                            let keys_preview = if let Some(attrs) = cond.as_attrs() {
736                                let keys: Vec<_> = attrs.keys().take(5)
737                                    .map(|k| self.interner.resolve(*k).to_string())
738                                    .collect();
739                                format!("{{{}}}", keys.join(", "))
740                            } else {
741                                cond.type_name().to_string()
742                            };
743                            eprintln!(
744                                "[sui-vm] condition type error: got {} ({}) at depth {}, chunk: {}",
745                                cond.type_name(), keys_preview,
746                                self.frames.len(), self.current_chunk_name(),
747                            );
748                        }
749                        return Err(e);
750                    }
751                }
752            }
753            OpCode::JumpIfTrue => {
754                let target = self.read_u16()? as usize;
755                let cond = self.pop_forced()?;
756                match cond.is_truthy() {
757                    Ok(true) => { self.current_frame_mut().ip = target; }
758                    Ok(false) => {}
759                    Err(e) => {
760                        // Diagnostic for debugging (remove once fixed)
761                        if std::env::var("SUI_VM_TRACE").is_ok() {
762                            let keys_preview = if let Some(attrs) = cond.as_attrs() {
763                                let keys: Vec<_> = attrs.keys().take(5)
764                                    .map(|k| self.interner.resolve(*k).to_string())
765                                    .collect();
766                                format!("{{{}}}", keys.join(", "))
767                            } else {
768                                cond.type_name().to_string()
769                            };
770                            eprintln!(
771                                "[sui-vm] condition type error: got {} ({}) at depth {}, chunk: {}",
772                                cond.type_name(), keys_preview,
773                                self.frames.len(), self.current_chunk_name(),
774                            );
775                        }
776                        return Err(e);
777                    }
778                }
779            }
780            OpCode::Assert => {
781                let cond = self.pop_forced()?;
782                if !cond.is_truthy()? {
783                    return Err(VMError::AssertionFailed);
784                }
785            }
786            OpCode::Throw => {
787                let msg = self.pop_forced()?;
788                let msg_str = match msg.to_vmvalue() {
789                    VMValue::String(s) => s,
790                    other => format!("{other:?}"),
791                };
792                return Err(VMError::Throw(msg_str));
793            }
794            _ => unreachable!(),
795        }
796        Ok(())
797    }
798    fn dispatch_function(&mut self, op: OpCode) -> Result<(), VMError> {
799        match op {
800            OpCode::MakeClosure => {
801                let idx = self.read_u16()?;
802                let upvalue_count = self.read_u16()? as usize;
803                let closure_template = self.current_chunk().constants[idx as usize].clone();
804                if let VMValue::Closure(mut closure) = closure_template {
805                    let mut upvalues = Vec::with_capacity(upvalue_count);
806                    for _ in 0..upvalue_count {
807                        let is_local = self.read_byte()? != 0;
808                        let uv_index = self.read_u16()? as usize;
809                        if is_local {
810                            let abs_slot = self.current_frame().stack_base + uv_index;
811                            upvalues.push(self.stack[abs_slot].clone());
812                        } else {
813                            let val = self.current_frame().upvalues[uv_index].clone();
814                            upvalues.push(val);
815                        }
816                    }
817                    closure.upvalues = upvalues;
818                    self.push(NanBox::closure(closure));
819                } else {
820                    return Err(VMError::Internal(
821                        "MakeClosure: constant is not a closure".to_string(),
822                    ));
823                }
824            }
825            OpCode::Call => {
826                let arg = self.pop()?;
827                let func = self.pop_forced()?;
828                if let Some(closure) = func.as_closure() {
829                    let is_tail = self.peek_next_is_return();
830                    let chunk = closure.chunk.clone();
831                    let upvalues = closure.upvalues.clone();
832                    if is_tail && self.frames.len() > 1 {
833                        let base = self.current_frame().stack_base;
834                        self.stack.truncate(base);
835                        self.push(arg);
836                        let frame = self.current_frame_mut();
837                        frame.chunk = chunk;
838                        frame.ip = 0;
839                        frame.upvalues = upvalues;
840                    } else {
841                        if self.frames.len() >= MAX_CALL_DEPTH {
842                            return Err(VMError::StackOverflow);
843                        }
844                        let stack_base = self.stack.len();
845                        self.push(arg);
846                        self.frames.push(CallFrame {
847                            chunk,
848                            ip: 0,
849                            stack_base,
850                            upvalues,
851                        });
852                    }
853                } else if func.is_higher_order_builtin() {
854                    let hob = func.as_higher_order_builtin().unwrap().clone();
855                    let forced_arg = self.force_value(arg)?;
856                    let result = self.call_higher_order_builtin(&hob, forced_arg)?;
857                    self.push(result);
858                } else if let Some(builtin) = func.as_builtin() {
859                    // tryEval MUST receive unforced arg to catch errors during forcing
860                    if builtin.name == "tryEval" {
861                        if let Some(result) = self.try_vm_builtin("tryEval", &arg)? {
862                            self.push(result);
863                        }
864                    } else {
865                        let forced_arg = self.force_value(arg)?;
866                        if let Some(result) = self.try_vm_builtin(builtin.name, &forced_arg)? {
867                            self.push(result);
868                        } else {
869                            let mut arg_vmval = forced_arg.to_vmvalue();
870                            arg_vmval = self.shallow_force_list(arg_vmval)?;
871                            let builtin_func = builtin.func.clone();
872                            let result = self.call_builtin_with_scoped_import_dispatch(
873                                builtin_func, arg_vmval,
874                            )?;
875                            self.push(result);
876                        }
877                    }
878                } else {
879                    return Err(VMError::NotCallable(func.type_name().to_string()));
880                }
881            }
882            OpCode::TailCall => {
883                // Compiler-determined tail call: always reuse the current frame
884                // for closures (no runtime peek needed). For builtins, fall back
885                // to a regular call since they don't use bytecode frames.
886                let arg = self.pop()?;
887                let func = self.pop_forced()?;
888                if let Some(closure) = func.as_closure() {
889                    let chunk = closure.chunk.clone();
890                    let upvalues = closure.upvalues.clone();
891                    if self.frames.len() > 1 {
892                        // Tail-call optimization: reuse current frame.
893                        let base = self.current_frame().stack_base;
894                        self.stack.truncate(base);
895                        self.push(arg);
896                        let frame = self.current_frame_mut();
897                        frame.chunk = chunk;
898                        frame.ip = 0;
899                        frame.upvalues = upvalues;
900                    } else {
901                        // Top-level frame: cannot reuse, push new frame.
902                        if self.frames.len() >= MAX_CALL_DEPTH {
903                            return Err(VMError::StackOverflow);
904                        }
905                        let stack_base = self.stack.len();
906                        self.push(arg);
907                        self.frames.push(CallFrame {
908                            chunk,
909                            ip: 0,
910                            stack_base,
911                            upvalues,
912                        });
913                    }
914                } else if func.is_higher_order_builtin() {
915                    let hob = func.as_higher_order_builtin().unwrap().clone();
916                    let forced_arg = self.force_value(arg)?;
917                    let result = self.call_higher_order_builtin(&hob, forced_arg)?;
918                    self.push(result);
919                } else if let Some(builtin) = func.as_builtin() {
920                    if builtin.name == "tryEval" {
921                        if let Some(result) = self.try_vm_builtin("tryEval", &arg)? {
922                            self.push(result);
923                        }
924                    } else {
925                        let forced_arg = self.force_value(arg)?;
926                        if let Some(result) = self.try_vm_builtin(builtin.name, &forced_arg)? {
927                            self.push(result);
928                        } else {
929                            let mut arg_vmval = forced_arg.to_vmvalue();
930                            arg_vmval = self.shallow_force_list(arg_vmval)?;
931                            let builtin_func = builtin.func.clone();
932                            let result = self.call_builtin_with_scoped_import_dispatch(
933                                builtin_func, arg_vmval,
934                            )?;
935                            self.push(result);
936                        }
937                    }
938                } else {
939                    return Err(VMError::NotCallable(func.type_name().to_string()));
940                }
941            }
942            _ => unreachable!(),
943        }
944        Ok(())
945    }
946    fn dispatch_thunk(&mut self, op: OpCode) -> Result<(), VMError> {
947        match op {
948            OpCode::MakeThunk => {
949                let chunk_idx = self.read_u16()?;
950                let upvalue_count = self.read_u16()? as usize;
951                let thunk_chunk =
952                    match &self.current_chunk().constants[chunk_idx as usize] {
953                        VMValue::Closure(c) => c.chunk.clone(),
954                        _ => {
955                            return Err(VMError::Internal(
956                                "MakeThunk: constant is not a closure".to_string(),
957                            ))
958                        }
959                    };
960                let mut upvalues = Vec::with_capacity(upvalue_count);
961                for _ in 0..upvalue_count {
962                    let is_local = self.read_byte()? != 0;
963                    let uv_index = self.read_u16()? as usize;
964                    if is_local {
965                        let abs_slot = self.current_frame().stack_base + uv_index;
966                        upvalues.push(self.stack[abs_slot].clone());
967                    } else {
968                        let val = self.current_frame().upvalues[uv_index].clone();
969                        upvalues.push(val);
970                    }
971                }
972                let thunk = crate::value::VMThunk::new(thunk_chunk, upvalues);
973                self.push(NanBox::thunk(thunk));
974            }
975            OpCode::Force => {
976                let val = self.pop()?;
977                let forced = self.force_value(val)?;
978                self.push(forced);
979            }
980            OpCode::PatchThunkUpvalues => {
981                let patch_slot = self.read_u16()? as usize;
982                let patch_uv_count = self.read_u16()? as usize;
983                let patch_abs = self.current_frame().stack_base + patch_slot;
984                let mut patch_uvs: Vec<NanBox> = Vec::with_capacity(patch_uv_count);
985                for _ in 0..patch_uv_count {
986                    let il = self.read_byte()? != 0;
987                    let ui = self.read_u16()? as usize;
988                    if il {
989                        let a = self.current_frame().stack_base + ui;
990                        if a >= self.stack.len() {
991                            // Slot not yet allocated — skip this upvalue patch.
992                            patch_uvs.push(NanBox::null());
993                            continue;
994                        }
995                        patch_uvs.push(self.stack[a].clone());
996                    } else {
997                        if ui >= self.current_frame().upvalues.len() {
998                            patch_uvs.push(NanBox::null());
999                            continue;
1000                        }
1001                        patch_uvs.push(self.current_frame().upvalues[ui].clone());
1002                    }
1003                }
1004                if patch_abs < self.stack.len() {
1005                    let patch_nb = self.stack[patch_abs].clone();
1006                    let patch_vm = patch_nb.to_vmvalue();
1007                    if let VMValue::Thunk(ref t) = patch_vm {
1008                        let s = t.state.take();
1009                        if let Some(ThunkState::Pending { chunk: c, .. }) = s {
1010                            t.state.set(Some(ThunkState::Pending { chunk: c, upvalues: patch_uvs }));
1011                        } else {
1012                            t.state.set(s);
1013                        }
1014                    }
1015                }
1016            }
1017            OpCode::MakeLazyThunk => {
1018                let src_idx = self.read_u16()? as usize;
1019                let offset = self.read_u32()? as usize;
1020                let length = self.read_u32()? as usize;
1021                let dir_idx = self.read_u16()? as usize;
1022                let upvalue_count = self.read_u16()? as usize;
1023                let source_text = match &self.current_chunk().constants[src_idx] {
1024                    VMValue::String(s) => Rc::new(s.clone()),
1025                    _ => return Err(VMError::Internal(
1026                        "MakeLazyThunk: source constant not a string".to_string(),
1027                    )),
1028                };
1029                let base_dir_str = match &self.current_chunk().constants[dir_idx] {
1030                    VMValue::String(s) => s.clone(),
1031                    _ => return Err(VMError::Internal(
1032                        "MakeLazyThunk: base_dir constant not a string".to_string(),
1033                    )),
1034                };
1035                let base_dir = PathBuf::from(base_dir_str);
1036                let mut upvalues = Vec::with_capacity(upvalue_count);
1037                for _ in 0..upvalue_count {
1038                    let is_local = self.read_byte()? != 0;
1039                    let uv_index = self.read_u16()? as usize;
1040                    if is_local {
1041                        let abs_slot = self.current_frame().stack_base + uv_index;
1042                        upvalues.push(self.stack[abs_slot].clone());
1043                    } else {
1044                        let val = self.current_frame().upvalues[uv_index].clone();
1045                        upvalues.push(val);
1046                    }
1047                }
1048                let thunk = crate::value::VMThunk {
1049                    state: Rc::new(std::cell::Cell::new(Some(ThunkState::LazySource {
1050                        source: source_text,
1051                        offset,
1052                        length,
1053                        base_dir,
1054                        upvalues,
1055                    }))),
1056                };
1057                self.push(NanBox::thunk(thunk));
1058            }
1059            _ => unreachable!(),
1060        }
1061        Ok(())
1062    }
1063    fn dispatch_import(&mut self, op: OpCode) -> Result<(), VMError> {
1064        match op {
1065            OpCode::Import => {
1066                let path_val = self.pop()?;
1067                let path_val = self.force_value(path_val)?; // Force thunks before type check
1068                let path = if let Some(p) = path_val.as_path() {
1069                    p.to_string()
1070                } else if let Some(s) = path_val.as_string() {
1071                    s.to_string()
1072                } else {
1073                    return Err(VMError::TypeError {
1074                        expected: "path or string",
1075                        got: path_val.type_name(),
1076                        context: "import".to_string(),
1077                    });
1078                };
1079                let result = self.import_file(&path)?;
1080                self.push(result);
1081            }
1082            OpCode::CallBuiltin => {
1083                let builtin_idx = self.read_u16()?;
1084                let arg_count = self.read_u16()? as usize;
1085                let start = self.stack.len() - arg_count;
1086                let raw_args: Vec<NanBox> = self.stack.drain(start..).collect();
1087                let mut args = Vec::with_capacity(raw_args.len());
1088                for raw in raw_args {
1089                    let forced = self.force_value(raw)?;
1090                    let mut vm_val = forced.to_vmvalue();
1091                    vm_val = self.shallow_force_list(vm_val)?;
1092                    args.push(vm_val);
1093                }
1094                let result = self.builtins.call(builtin_idx, args)?;
1095                self.push(NanBox::from_vmvalue(&result));
1096            }
1097            _ => unreachable!(),
1098        }
1099        Ok(())
1100    }
1101    fn dispatch_super(&mut self, op: OpCode) -> Result<(), VMError> {
1102        match op {
1103            OpCode::GetLocalAttr => {
1104                let slot = self.read_u16()? as usize;
1105                let key_idx = self.read_u16()?;
1106                let key_sym = self.resolve_key_constant(key_idx)?;
1107                let abs_slot = self.current_frame().stack_base + slot;
1108                let local = self.stack[abs_slot].clone();
1109                let local = self.force_value(local)?;
1110                if let Some(attrs) = local.as_attrs() {
1111                    if let Some(val) = attrs.get(&key_sym) {
1112                        let forced = if val.is_thunk() {
1113                            self.force_value(val.clone())?
1114                        } else {
1115                            val.clone()
1116                        };
1117                        self.push(forced);
1118                    } else {
1119                        let key_str = self.interner.resolve(key_sym).to_string();
1120                        return Err(VMError::AttrNotFound(key_str));
1121                    }
1122                } else {
1123                    let key_str = self.interner.resolve(key_sym).to_string();
1124                    return Err(VMError::TypeError {
1125                        expected: "set",
1126                        got: local.type_name(),
1127                        context: format!("attribute selection '.{key_str}'"),
1128                    });
1129                }
1130            }
1131            OpCode::GetLocalCall => {
1132                let slot = self.read_u16()? as usize;
1133                let abs_slot = self.current_frame().stack_base + slot;
1134                let func = self.stack[abs_slot].clone();
1135                let func = self.force_value(func)?;
1136                let arg = self.pop()?;
1137                if let Some(closure) = func.as_closure() {
1138                    if self.frames.len() >= MAX_CALL_DEPTH {
1139                        return Err(VMError::StackOverflow);
1140                    }
1141                    let upvalues = closure.upvalues.clone();
1142                    let chunk = closure.chunk.clone();
1143                    let stack_base = self.stack.len();
1144                    self.push(arg);
1145                    self.frames.push(CallFrame {
1146                        chunk,
1147                        ip: 0,
1148                        stack_base,
1149                        upvalues,
1150                    });
1151                } else if func.is_higher_order_builtin() {
1152                    let hob = func.as_higher_order_builtin().unwrap().clone();
1153                    // Force the arg before passing to HOBs — matches
1154                    // the regular OpCode::Call handler's behavior.
1155                    let forced_arg = self.force_value(arg)?;
1156                    let result = self.call_higher_order_builtin(&hob, forced_arg)?;
1157                    self.push(result);
1158                } else if let Some(builtin) = func.as_builtin() {
1159                    // Force the arg before passing to builtins — matches
1160                    // the regular OpCode::Call handler's behavior.
1161                    let forced_arg = self.force_value(arg)?;
1162                    if let Some(result) = self.try_vm_builtin(builtin.name, &forced_arg)? {
1163                        self.push(result);
1164                    } else {
1165                        // Shallow-force container elements one level.
1166                        // Can't deep_force here — nixpkgs has massive nested
1167                        // structures that cause stack overflow. The force-aware
1168                        // helpers (as_list, force_as_string) handle remaining
1169                        // thunks on demand in builtin closures.
1170                        let mut arg_vmval = forced_arg.to_vmvalue();
1171                        // Force list elements only (not attrsets — too expensive).
1172                        arg_vmval = self.shallow_force_list(arg_vmval)?;
1173                        let builtin_func = builtin.func.clone();
1174                        let result = self.call_builtin_with_scoped_import_dispatch(
1175                            builtin_func, arg_vmval,
1176                        )?;
1177                        self.push(result);
1178                    }
1179                } else {
1180                    return Err(VMError::NotCallable(func.type_name().to_string()));
1181                }
1182            }
1183            _ => unreachable!(),
1184        }
1185        Ok(())
1186    }
1187    fn dispatch_stack(&mut self, op: OpCode) -> Result<(), VMError> {
1188        match op {
1189            OpCode::Pop => {
1190                self.pop()?;
1191            }
1192            OpCode::Dup => {
1193                let top = self.stack.last().ok_or(VMError::StackUnderflow)?.clone();
1194                self.push(top);
1195            }
1196            OpCode::Interpolate => {
1197                let count = self.read_u16()? as usize;
1198                let start = self.stack.len() - count;
1199                // Drain interpolation parts off the stack, force thunks.
1200                let mut parts: Vec<NanBox> = self.stack.drain(start..).collect();
1201                for part in &mut parts {
1202                    if part.is_thunk() {
1203                        *part = self.force_value(part.clone())?;
1204                    }
1205                }
1206                let mut result = String::new();
1207                for v in &parts {
1208                    if let Some(s) = v.as_string() {
1209                        result.push_str(s);
1210                    } else if let Some(n) = v.as_int() {
1211                        result.push_str(&n.to_string());
1212                    } else if let Some(f) = v.as_float() {
1213                        result.push_str(&format!("{f}"));
1214                    } else if let Some(p) = v.as_path() {
1215                        result.push_str(p);
1216                    } else if let Some(attrs) = v.as_attrs() {
1217                        // Attrset interpolation: check __toString then outPath.
1218                        let to_str_sym = sui_intern::intern("__toString");
1219                        if let Some(to_str_fn) = attrs.get(&to_str_sym) {
1220                            let func_nb = self.force_value(to_str_fn.clone())?;
1221                            let call_result = self.call_callable(&func_nb, v.clone())?;
1222                            let forced = self.force_value(call_result)?;
1223                            if let Some(s) = forced.as_string() {
1224                                result.push_str(s);
1225                            } else {
1226                                return Err(VMError::TypeError {
1227                                    expected: "string",
1228                                    got: forced.type_name(),
1229                                    context: "__toString result in string interpolation".to_string(),
1230                                });
1231                            }
1232                        } else {
1233                            let out_path_sym = sui_intern::intern("outPath");
1234                            if let Some(out_path) = attrs.get(&out_path_sym) {
1235                                let forced = self.force_value(out_path.clone())?;
1236                                if let Some(s) = forced.as_string() {
1237                                    result.push_str(s);
1238                                } else if let Some(p) = forced.as_path() {
1239                                    result.push_str(p);
1240                                } else {
1241                                    return Err(VMError::TypeError {
1242                                        expected: "string or path",
1243                                        got: forced.type_name(),
1244                                        context: "outPath in string interpolation".to_string(),
1245                                    });
1246                                }
1247                            } else {
1248                                return Err(VMError::TypeError {
1249                                    expected: "string, int, float, or path",
1250                                    got: "set (no __toString or outPath)",
1251                                    context: "string interpolation".to_string(),
1252                                });
1253                            }
1254                        }
1255                    } else if v.is_bool() {
1256                        let b = v.as_bool().unwrap();
1257                        return Err(VMError::TypeError {
1258                            expected: "string, int, float, or path",
1259                            got: if b { "bool (true)" } else { "bool (false)" },
1260                            context: "string interpolation".to_string(),
1261                        });
1262                    } else {
1263                        return Err(VMError::TypeError {
1264                            expected: "string, int, float, or path",
1265                            got: v.type_name(),
1266                            context: "string interpolation".to_string(),
1267                        });
1268                    }
1269                }
1270                // Stack was drained above; push the result.
1271                self.push(NanBox::string(result));
1272            }
1273            _ => unreachable!(),
1274        }
1275        Ok(())
1276    }
1277    // -- Deep equality (forces thunks during comparison) ----------------
1278    /// Deep equality comparison that forces thunks in both operands.
1279    ///
1280    /// Nix `==` semantics require that values are forced before comparison.
1281    /// This includes values nested inside attrsets and lists. Without this,
1282    /// attrsets whose values are still thunked would compare as unequal
1283    /// even if their forced values are identical.
1284    fn deep_eq(&mut self, a: &NanBox, b: &NanBox) -> Result<bool, VMError> {
1285        // Force both values if they are thunks.
1286        let a = if a.is_thunk() { self.force_value(a.clone())? } else { a.clone() };
1287        let b = if b.is_thunk() { self.force_value(b.clone())? } else { b.clone() };
1288        // Scalars and strings: use NanBox::PartialEq (no thunks possible inside).
1289        if a.is_null() || a.is_bool() || a.is_int() || a.is_float() {
1290            return Ok(a == b);
1291        }
1292        if a.is_string() || a.is_path() {
1293            return Ok(a == b);
1294        }
1295        // List comparison: force each element pair.
1296        if let (Some(a_items), Some(b_items)) = (a.as_list(), b.as_list()) {
1297            if a_items.len() != b_items.len() {
1298                return Ok(false);
1299            }
1300            for (ai, bi) in a_items.iter().zip(b_items.iter()) {
1301                if !self.deep_eq(ai, bi)? {
1302                    return Ok(false);
1303                }
1304            }
1305            return Ok(true);
1306        }
1307        // Attrs comparison: force each value pair.
1308        if let (Some(a_attrs), Some(b_attrs)) = (a.as_attrs(), b.as_attrs()) {
1309            if a_attrs.len() != b_attrs.len() {
1310                return Ok(false);
1311            }
1312            // Check that keys match and values are deeply equal.
1313            let a_entries: Vec<_> = a_attrs.iter().collect();
1314            let b_entries: Vec<_> = b_attrs.iter().collect();
1315            for ((ak, av), (bk, bv)) in a_entries.iter().zip(b_entries.iter()) {
1316                if ak != bk {
1317                    return Ok(false);
1318                }
1319                if !self.deep_eq(av, bv)? {
1320                    return Ok(false);
1321                }
1322            }
1323            return Ok(true);
1324        }
1325        // Functions are never equal.
1326        if a.is_closure() || a.is_builtin() || a.is_higher_order_builtin() {
1327            return Ok(false);
1328        }
1329        // Fallback: use NanBox::PartialEq.
1330        Ok(a == b)
1331    }
1332    // -- Stack helpers --------------------------------------------------
1333    fn push(&mut self, value: NanBox) {
1334        self.stack.push(value);
1335    }
1336    fn pop(&mut self) -> Result<NanBox, VMError> {
1337        self.stack.pop().ok_or(VMError::StackUnderflow)
1338    }
1339    /// Pop a value from the stack, forcing it if it is a thunk.
1340    /// Use this when the operation needs a concrete (non-thunk) value.
1341    fn pop_forced(&mut self) -> Result<NanBox, VMError> {
1342        let val = self.pop()?;
1343        self.force_value(val)
1344    }
1345    fn peek(&self) -> Result<&NanBox, VMError> {
1346        self.stack.last().ok_or(VMError::StackUnderflow)
1347    }
1348    // -- Frame helpers --------------------------------------------------
1349    fn current_frame(&self) -> &CallFrame {
1350        self.frames.last().expect("no active frame")
1351    }
1352    fn current_frame_mut(&mut self) -> &mut CallFrame {
1353        self.frames.last_mut().expect("no active frame")
1354    }
1355    fn current_chunk(&self) -> &Chunk {
1356        &self.current_frame().chunk
1357    }
1358    fn current_chunk_name(&self) -> String {
1359        self.current_chunk()
1360            .source_file
1361            .clone()
1362            .unwrap_or_else(|| "<inline>".to_string())
1363    }
1364    fn read_byte(&mut self) -> Result<u8, VMError> {
1365        let frame = self.current_frame();
1366        if frame.ip >= frame.chunk.code.len() {
1367            return Err(VMError::Internal("unexpected end of bytecode".to_string()));
1368        }
1369        let byte = frame.chunk.code[frame.ip];
1370        self.current_frame_mut().ip += 1;
1371        Ok(byte)
1372    }
1373    fn read_u16(&mut self) -> Result<u16, VMError> {
1374        let lo = self.read_byte()?;
1375        let hi = self.read_byte()?;
1376        Ok(u16::from_le_bytes([lo, hi]))
1377    }
1378    fn read_u32(&mut self) -> Result<u32, VMError> {
1379        let b0 = self.read_byte()?;
1380        let b1 = self.read_byte()?;
1381        let b2 = self.read_byte()?;
1382        let b3 = self.read_byte()?;
1383        Ok(u32::from_le_bytes([b0, b1, b2, b3]))
1384    }
1385    /// Peek ahead: check if the next instruction in the current frame
1386    /// is a `Return` opcode (used for tail-call optimization).
1387    fn peek_next_is_return(&self) -> bool {
1388        let frame = self.current_frame();
1389        if frame.ip < frame.chunk.code.len() {
1390            frame.chunk.code[frame.ip] == OpCode::Return as u8
1391        } else {
1392            false
1393        }
1394    }
1395    // -- Interning helpers ----------------------------------------------
1396    /// Resolve a constant pool string to a `Symbol`.
1397    fn resolve_key_constant(&mut self, idx: u16) -> Result<Symbol, VMError> {
1398        let idx_usize = idx as usize;
1399        let chunk = self.current_frame().chunk.clone();
1400        if let Some(Some(sym)) = chunk.key_symbols.get(idx_usize) {
1401            return Ok(*sym);
1402        }
1403        let key_string = match &chunk.constants[idx_usize] {
1404            VMValue::String(s) => s.clone(),
1405            _ => return Err(VMError::Internal("attr key constant not a string".to_string())),
1406        };
1407        Ok(self.interner.intern(&key_string))
1408    }
1409    // -- Arithmetic helpers (NanBox) ------------------------------------
1410    fn add(&self, a: &NanBox, b: &NanBox) -> Result<NanBox, VMError> {
1411        // Fast paths for inline scalars.
1412        if let (Some(x), Some(y)) = (a.as_int(), b.as_int()) {
1413            return Ok(NanBox::int(x + y));
1414        }
1415        if let (Some(x), Some(y)) = (a.as_float(), b.as_float()) {
1416            return Ok(NanBox::float(x + y));
1417        }
1418        if let (Some(x), Some(y)) = (a.as_int(), b.as_float()) {
1419            return Ok(NanBox::float(x as f64 + y));
1420        }
1421        if let (Some(x), Some(y)) = (a.as_float(), b.as_int()) {
1422            return Ok(NanBox::float(x + y as f64));
1423        }
1424        // String/path concat (heap path).
1425        if let (Some(x), Some(y)) = (a.as_string(), b.as_string()) {
1426            return Ok(NanBox::string(format!("{x}{y}")));
1427        }
1428        if let (Some(x), Some(y)) = (a.as_path(), b.as_string()) {
1429            return Ok(NanBox::path(format!("{x}{y}")));
1430        }
1431        if let (Some(x), Some(y)) = (a.as_path(), b.as_path()) {
1432            return Ok(NanBox::path(format!("{x}/{y}")));
1433        }
1434        Err(VMError::TypeError {
1435            expected: "numbers or strings",
1436            got: a.type_name(),
1437            context: format!("addition ({} + {})", a.type_name(), b.type_name()),
1438        })
1439    }
1440    fn num_op(
1441        &self,
1442        a: &NanBox,
1443        b: &NanBox,
1444        int_op: impl Fn(i64, i64) -> i64,
1445        float_op: impl Fn(f64, f64) -> f64,
1446        context: &str,
1447    ) -> Result<NanBox, VMError> {
1448        if let (Some(x), Some(y)) = (a.as_int(), b.as_int()) {
1449            return Ok(NanBox::int(int_op(x, y)));
1450        }
1451        if let (Some(x), Some(y)) = (a.as_float(), b.as_float()) {
1452            return Ok(NanBox::float(float_op(x, y)));
1453        }
1454        if let (Some(x), Some(y)) = (a.as_int(), b.as_float()) {
1455            return Ok(NanBox::float(float_op(x as f64, y)));
1456        }
1457        if let (Some(x), Some(y)) = (a.as_float(), b.as_int()) {
1458            return Ok(NanBox::float(float_op(x, y as f64)));
1459        }
1460        Err(VMError::TypeError {
1461            expected: "numbers",
1462            got: a.type_name(),
1463            context: context.to_string(),
1464        })
1465    }
1466    fn compare(&self, a: &NanBox, b: &NanBox) -> Result<std::cmp::Ordering, VMError> {
1467        if let (Some(x), Some(y)) = (a.as_int(), b.as_int()) {
1468            return Ok(x.cmp(&y));
1469        }
1470        if let (Some(x), Some(y)) = (a.as_float(), b.as_float()) {
1471            return Ok(x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal));
1472        }
1473        if let (Some(x), Some(y)) = (a.as_int(), b.as_float()) {
1474            return Ok((x as f64)
1475                .partial_cmp(&y)
1476                .unwrap_or(std::cmp::Ordering::Equal));
1477        }
1478        if let (Some(x), Some(y)) = (a.as_float(), b.as_int()) {
1479            return Ok(x
1480                .partial_cmp(&(y as f64))
1481                .unwrap_or(std::cmp::Ordering::Equal));
1482        }
1483        if let (Some(x), Some(y)) = (a.as_string(), b.as_string()) {
1484            return Ok(x.cmp(y));
1485        }
1486        Err(VMError::TypeError {
1487            expected: "comparable types",
1488            got: a.type_name(),
1489            context: "comparison".to_string(),
1490        })
1491    }
1492    // -- Thunk forcing --------------------------------------------------
1493    /// Force a value: if it is a thunk, evaluate it (with memoization
1494    /// and blackhole detection). If it is already a concrete value,
1495    /// return it unchanged.
1496    /// Recursively convert a `serde_json::Value` to a `VMValue`, using
1497    /// the live interner for object keys. Mirrors the shape used by
1498    /// `builtins.fromJSON`. Lives on the VM so it can intern keys.
1499    fn json_value_to_vm(&mut self, v: &serde_json::Value) -> VMValue {
1500        use std::collections::BTreeMap;
1501        match v {
1502            serde_json::Value::Null => VMValue::Null,
1503            serde_json::Value::Bool(b) => VMValue::Bool(*b),
1504            serde_json::Value::Number(n) => {
1505                if let Some(i) = n.as_i64() {
1506                    VMValue::Int(i)
1507                } else {
1508                    VMValue::Float(n.as_f64().unwrap_or(0.0))
1509                }
1510            }
1511            serde_json::Value::String(s) => VMValue::String(s.clone()),
1512            serde_json::Value::Array(arr) => {
1513                VMValue::List(arr.iter().map(|v| self.json_value_to_vm(v)).collect())
1514            }
1515            serde_json::Value::Object(map) => {
1516                let mut attrs: BTreeMap<Symbol, VMValue> = BTreeMap::new();
1517                for (k, val) in map {
1518                    let sym = self.interner.intern(k);
1519                    attrs.insert(sym, self.json_value_to_vm(val));
1520                }
1521                VMValue::Attrs(attrs)
1522            }
1523        }
1524    }
1525
1526    /// Wrapper for callers that want a NanBox directly.
1527    fn json_value_to_nanbox(&mut self, v: &serde_json::Value) -> NanBox {
1528        NanBox::from_vmvalue(&self.json_value_to_vm(v))
1529    }
1530
1531    fn force_value(&mut self, val: NanBox) -> Result<NanBox, VMError> {
1532        if !val.is_thunk() {
1533            return Ok(val);
1534        }
1535        // Convert to VMValue to access ThunkState machinery.
1536        let vmval = val.to_vmvalue();
1537        match vmval {
1538            VMValue::Thunk(ref thunk) => {
1539                let state = thunk.state.take();
1540                match state {
1541                    Some(ThunkState::Done(boxed)) => {
1542                        thunk.state.set(Some(ThunkState::Done(boxed.clone())));
1543                        Ok(NanBox::from_vmvalue(&*boxed))
1544                    }
1545                    Some(ThunkState::Evaluating) => {
1546                        // Re-entrant access to a thunk currently being evaluated.
1547                        // This is the fixpoint pattern (e.g., nixpkgs lib.fix).
1548                        //
1549                        // The VM can't store partial results mid-execution like
1550                        // the tree-walker. Return an empty attrset as a fixpoint
1551                        // placeholder. This allows the outer evaluation to proceed:
1552                        // - GetAttr on the placeholder → AttrNotFound (non-fatal
1553                        //   for optional/defaulted accesses)
1554                        // - The outer evaluation stores the REAL result as Done,
1555                        //   so subsequent accesses get the correct value.
1556                        //
1557                        // This matches how CppNix's fixpoint works: the first
1558                        // pass through f(x) constructs the attrset skeleton, and
1559                        // individual attribute accesses are lazy.
1560                        thunk.state.set(Some(ThunkState::Evaluating));
1561                        if std::env::var("SUI_VM_TRACE").is_ok() {
1562                            eprintln!(
1563                                "[sui-vm] fixpoint re-access at depth {}, returning placeholder",
1564                                self.frames.len(),
1565                            );
1566                        }
1567                        Ok(NanBox::attrs(BTreeMap::new()))
1568                    }
1569                    Some(ThunkState::Pending { chunk, upvalues }) => {
1570                        thunk.state.set(Some(ThunkState::Evaluating));
1571                        if self.frames.len() >= MAX_CALL_DEPTH {
1572                            thunk.state.set(Some(ThunkState::Pending {
1573                                chunk,
1574                                upvalues,
1575                            }));
1576                            return Err(VMError::StackOverflow);
1577                        }
1578                        let return_depth = self.frames.len();
1579                        let stack_base = self.stack.len();
1580                        // Upvalues are already NanBoxes (the frame representation);
1581                        // clone (Rc refcount bumps) for the frame and keep the
1582                        // original for the error-restore path.
1583                        let frame_upvalues: Vec<NanBox> = upvalues.clone();
1584                        let upvalues_for_restore = upvalues;
1585                        self.frames.push(CallFrame {
1586                            chunk: chunk.clone(),
1587                            ip: 0,
1588                            stack_base,
1589                            upvalues: frame_upvalues,
1590                        });
1591                        let result = self.run_until(return_depth);
1592                        // Restore the stack to its state before thunk evaluation.
1593                        // The Return handler's early exit (at stop_depth) skips
1594                        // truncation, so internal function calls may leave values.
1595                        self.stack.truncate(stack_base);
1596                        match result {
1597                            Ok(value) => {
1598                                // Store partial result IMMEDIATELY — enables
1599                                // fixpoint re-access. Any re-entrant force_value
1600                                // on this thunk (e.g., nixpkgs `fix`) will find
1601                                // Done instead of Evaluating, preventing false
1602                                // blackhole detection. Matches tree-walker
1603                                // approach (sui-eval value.rs lines 522-554).
1604                                let partial_vmval = value.to_vmvalue();
1605                                thunk.state.set(Some(ThunkState::Done(
1606                                    Box::new(partial_vmval),
1607                                )));
1608                                // Depth-limited thunk-chain unwrap.
1609                                let mut forced = value;
1610                                let mut depth = 0u32;
1611                                while forced.is_thunk() {
1612                                    depth += 1;
1613                                    if depth > MAX_THUNK_CHAIN_DEPTH {
1614                                        if std::env::var("SUI_VM_TRACE").is_ok() {
1615                                            eprintln!("[sui-vm] thunk chain depth {} exceeded at chunk: {}", depth, self.current_chunk_name());
1616                                        }
1617                                        return Err(VMError::InfiniteRecursion);
1618                                    }
1619                                    forced = self.force_value(forced)?;
1620                                }
1621                                // Update with fully-unwrapped value.
1622                                let forced_vmval = forced.to_vmvalue();
1623                                thunk.state.set(Some(ThunkState::Done(
1624                                    Box::new(forced_vmval),
1625                                )));
1626                                Ok(forced)
1627                            }
1628                            Err(e) => {
1629                                thunk.state.set(Some(ThunkState::Pending {
1630                                    chunk,
1631                                    upvalues: upvalues_for_restore,
1632                                }));
1633                                Err(e)
1634                            }
1635                        }
1636                    }
1637                    Some(ThunkState::LazySource { source, offset, length, base_dir, upvalues }) => {
1638                        thunk.state.set(Some(ThunkState::Evaluating));
1639                        // Compile the expression span on demand.
1640                        let expr_text = &source[offset..offset + length];
1641                        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
1642                        let compiled = Compiler::compile_expression(
1643                            expr_text,
1644                            &base_dir,
1645                            shared_interner.clone(),
1646                        ).map_err(|e| {
1647                            // Restore interner on compile failure.
1648                            *self.interner = match Rc::try_unwrap(shared_interner.clone()) {
1649                                Ok(cell) => cell.into_inner(),
1650                                Err(rc) => rc.borrow().clone(),
1651                            };
1652                            thunk.state.set(Some(ThunkState::LazySource {
1653                                source: source.clone(),
1654                                offset,
1655                                length,
1656                                base_dir: base_dir.clone(),
1657                                upvalues: upvalues.clone(),
1658                            }));
1659                            VMError::ImportError(format!("lazy thunk compile: {e}"))
1660                        })?;
1661                        *self.interner = match Rc::try_unwrap(shared_interner) {
1662                            Ok(cell) => cell.into_inner(),
1663                            Err(rc) => rc.borrow().clone(),
1664                        };
1665                        let chunk = Rc::new(compiled);
1666                        if self.frames.len() >= MAX_CALL_DEPTH {
1667                            thunk.state.set(Some(ThunkState::LazySource {
1668                                source, offset, length, base_dir, upvalues,
1669                            }));
1670                            return Err(VMError::StackOverflow);
1671                        }
1672                        let return_depth = self.frames.len();
1673                        let stack_base = self.stack.len();
1674                        // Upvalues are already NanBoxes; clone (Rc bumps) for the
1675                        // frame, keeping the original for the error-restore path.
1676                        let frame_upvalues: Vec<NanBox> = upvalues.clone();
1677                        self.frames.push(CallFrame {
1678                            chunk: chunk.clone(),
1679                            ip: 0,
1680                            stack_base,
1681                            upvalues: frame_upvalues,
1682                        });
1683                        let result = self.run_until(return_depth);
1684                        self.stack.truncate(stack_base);
1685                        match result {
1686                            Ok(value) => {
1687                                // Store partial result IMMEDIATELY for fixpoints.
1688                                let partial_vmval = value.to_vmvalue();
1689                                thunk.state.set(Some(ThunkState::Done(
1690                                    Box::new(partial_vmval),
1691                                )));
1692                                let mut forced = value;
1693                                let mut depth = 0u32;
1694                                while forced.is_thunk() {
1695                                    depth += 1;
1696                                    if depth > MAX_THUNK_CHAIN_DEPTH {
1697                                        if std::env::var("SUI_VM_TRACE").is_ok() {
1698                                            eprintln!("[sui-vm] thunk chain depth {} exceeded at chunk: {}", depth, self.current_chunk_name());
1699                                        }
1700                                        return Err(VMError::InfiniteRecursion);
1701                                    }
1702                                    forced = self.force_value(forced)?;
1703                                }
1704                                let forced_vmval = forced.to_vmvalue();
1705                                thunk.state.set(Some(ThunkState::Done(
1706                                    Box::new(forced_vmval),
1707                                )));
1708                                Ok(forced)
1709                            }
1710                            Err(e) => {
1711                                thunk.state.set(Some(ThunkState::Pending {
1712                                    chunk,
1713                                    upvalues,
1714                                }));
1715                                Err(e)
1716                            }
1717                        }
1718                    }
1719                    Some(ThunkState::NativeCallback(cb)) => {
1720                        thunk.state.set(Some(ThunkState::Evaluating));
1721                        match cb() {
1722                            Ok(sk_val) => {
1723                                let nb = self.string_keyed_to_nanbox(&sk_val);
1724                                // Store partial result IMMEDIATELY for fixpoints.
1725                                let partial_vmval = nb.to_vmvalue();
1726                                thunk.state.set(Some(ThunkState::Done(
1727                                    Box::new(partial_vmval),
1728                                )));
1729                                let mut forced = nb;
1730                                let mut depth = 0u32;
1731                                while forced.is_thunk() {
1732                                    depth += 1;
1733                                    if depth > MAX_THUNK_CHAIN_DEPTH {
1734                                        if std::env::var("SUI_VM_TRACE").is_ok() {
1735                                            eprintln!("[sui-vm] thunk chain depth {} exceeded at chunk: {}", depth, self.current_chunk_name());
1736                                        }
1737                                        return Err(VMError::InfiniteRecursion);
1738                                    }
1739                                    forced = self.force_value(forced)?;
1740                                }
1741                                let forced_vmval = forced.to_vmvalue();
1742                                thunk.state.set(Some(ThunkState::Done(
1743                                    Box::new(forced_vmval),
1744                                )));
1745                                Ok(forced)
1746                            }
1747                            Err(e) => {
1748                                // On error, restore the callback for retry.
1749                                thunk.state.set(Some(ThunkState::NativeCallback(cb)));
1750                                Err(VMError::Throw(format!("native thunk: {e}")))
1751                            }
1752                        }
1753                    }
1754                    None => Err(VMError::Internal("thunk state is None".to_string())),
1755                }
1756            }
1757            _ => Ok(NanBox::from_vmvalue(&vmval)),
1758        }
1759    }
1760    /// Shallow-force container elements: if `val` is a List, force each
1761    /// Force list elements one level. Builtins that iterate over list
1762    /// elements (calling `as_string`, `as_int`, etc.) need concrete values.
1763    /// Attrsets are NOT force — they can be enormous (nixpkgs has 80K+
1764    /// attrs) and builtins access individual attrs lazily via GetAttr.
1765    fn shallow_force_list(&mut self, val: VMValue) -> Result<VMValue, VMError> {
1766        match val {
1767            VMValue::List(items) => {
1768                let mut forced_items = Vec::with_capacity(items.len());
1769                for item in items {
1770                    let nb = NanBox::from_vmvalue(&item);
1771                    if nb.is_thunk() {
1772                        let forced = self.force_value(nb)?;
1773                        forced_items.push(forced.to_vmvalue());
1774                    } else {
1775                        forced_items.push(item);
1776                    }
1777                }
1778                Ok(VMValue::List(forced_items))
1779            }
1780            // Attrsets: do NOT force values — too expensive for large sets.
1781            // Force-aware helpers (force_vmvalue, force_as_string) handle
1782            // individual thunked values on demand.
1783            other => Ok(other),
1784        }
1785    }
1786    /// Deep-force a value: recursively force thunks inside attrsets and lists.
1787    /// Used at the VM boundary so callers never receive unforced thunks.
1788    fn deep_force(&mut self, val: NanBox) -> Result<NanBox, VMError> {
1789        let forced = self.force_value(val)?;
1790        if let Some(attrs) = forced.as_attrs() {
1791            let mut new_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
1792            for (k, v) in attrs {
1793                let forced_v = self.deep_force(v.clone())?;
1794                new_attrs.insert(*k, forced_v);
1795            }
1796            Ok(NanBox::attrs(new_attrs))
1797        } else if forced.is_list() {
1798            let vmval = forced.to_vmvalue();
1799            if let VMValue::List(items) = vmval {
1800                let mut new_items = Vec::with_capacity(items.len());
1801                for item in &items {
1802                    let item_nb = NanBox::from_vmvalue(item);
1803                    let forced_item = self.deep_force(item_nb)?;
1804                    new_items.push(forced_item);
1805                }
1806                Ok(NanBox::list(new_items))
1807            } else {
1808                Ok(forced)
1809            }
1810        } else {
1811            Ok(forced)
1812        }
1813    }
1814    // -- VM-level builtin dispatch (builtins needing interner access) ------
1815    /// Try to handle a builtin call at the VM level (for builtins that need
1816    /// interner access, like derivation, attrNames, etc.).
1817    /// Returns `Some(result)` if handled, `None` to fall through to the
1818    /// standard builtin dispatch.
1819    fn try_vm_builtin(
1820        &mut self,
1821        name: &str,
1822        arg: &NanBox,
1823    ) -> Result<Option<NanBox>, VMError> {
1824        match name {
1825            "tryEval" => {
1826                
1827                // tryEval forces its argument and catches throws/errors.
1828                // Success: { success = true; value = <forced>; }
1829                // Failure: { success = false; value = false; }
1830                let success_sym = self.interner.intern("success");
1831                let value_sym = self.interner.intern("value");
1832                match self.force_value(arg.clone()) {
1833                    Ok(forced) => {
1834                        let mut attrs = BTreeMap::new();
1835                        attrs.insert(success_sym, NanBox::bool(true));
1836                        attrs.insert(value_sym, forced);
1837                        Ok(Some(NanBox::attrs(attrs)))
1838                    }
1839                    Err(_) => {
1840                        let mut attrs = BTreeMap::new();
1841                        attrs.insert(success_sym, NanBox::bool(false));
1842                        attrs.insert(value_sym, NanBox::bool(false));
1843                        Ok(Some(NanBox::attrs(attrs)))
1844                    }
1845                }
1846            }
1847            "derivation" | "derivationStrict" => {
1848                let forced = self.force_value(arg.clone())?;
1849                let result = self.vm_build_derivation(forced)?;
1850                Ok(Some(result))
1851            }
1852            "import" => {
1853                // `import` used as a function value (not the special Apply form).
1854                let forced = self.force_value(arg.clone())?;
1855                let path = if let Some(p) = forced.as_path() {
1856                    p.to_string()
1857                } else if let Some(s) = forced.as_string() {
1858                    s.to_string()
1859                } else {
1860                    return Err(VMError::TypeError {
1861                        expected: "path or string",
1862                        got: forced.type_name(),
1863                        context: "import".to_string(),
1864                    });
1865                };
1866                let result = self.import_file(&path)?;
1867                Ok(Some(result))
1868            }
1869            "attrNames" => {
1870                let forced = self.force_value(arg.clone())?;
1871                if let Some(attrs) = forced.as_attrs() {
1872                    // Nix sorts attrNames alphabetically.
1873                    let mut name_strs: Vec<String> = attrs
1874                        .keys()
1875                        .map(|k| self.interner.resolve(*k).to_string())
1876                        .collect();
1877                    name_strs.sort();
1878                    let names: Vec<NanBox> = name_strs
1879                        .into_iter()
1880                        .map(NanBox::string)
1881                        .collect();
1882                    Ok(Some(NanBox::list(names)))
1883                } else {
1884                    Err(VMError::TypeError {
1885                        expected: "set",
1886                        got: forced.type_name(),
1887                        context: "attrNames".to_string(),
1888                    })
1889                }
1890            }
1891            "attrValues" => {
1892                // Parallel to attrNames: sort the Symbol keys by their
1893                // resolved string names (CppNix semantics), then emit
1894                // values in that order. Fixes the bug where real
1895                // nixpkgs `mapAttrsToList` returned values in
1896                // intern-order instead of lex-order.
1897                let forced = self.force_value(arg.clone())?;
1898                if let Some(attrs) = forced.as_attrs() {
1899                    let mut pairs: Vec<(String, &NanBox)> = attrs
1900                        .iter()
1901                        .map(|(k, v)| (self.interner.resolve(*k).to_string(), v))
1902                        .collect();
1903                    pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
1904                    let values: Vec<NanBox> =
1905                        pairs.into_iter().map(|(_, v)| v.clone()).collect();
1906                    Ok(Some(NanBox::list(values)))
1907                } else {
1908                    Err(VMError::TypeError {
1909                        expected: "set",
1910                        got: forced.type_name(),
1911                        context: "attrValues".to_string(),
1912                    })
1913                }
1914            }
1915            "functionArgs" => {
1916                // The registered builtin entry created a FRESH interner
1917                // locally, interned the parameter names into it, and
1918                // returned `VMValue::Attrs` keyed on those Symbols —
1919                // which were then resolved against the VM's REAL
1920                // interner during printing/conversion, producing
1921                // nonsense keys (`functionArgs = false` showing up as
1922                // an attribute!) plus inverted booleans.
1923                // Route through VM dispatch so we intern against
1924                // `self.interner`.
1925                let forced = self.force_value(arg.clone())?;
1926                let vmval = forced.to_vmvalue();
1927                match vmval {
1928                    VMValue::Closure(closure) => {
1929                        let mut result = std::collections::BTreeMap::new();
1930                        for (name, has_default) in &closure.formals {
1931                            let sym = self.interner.intern(name);
1932                            result.insert(sym, VMValue::Bool(*has_default));
1933                        }
1934                        Ok(Some(NanBox::from_vmvalue(&VMValue::Attrs(result))))
1935                    }
1936                    VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
1937                        Ok(Some(NanBox::from_vmvalue(&VMValue::Attrs(
1938                            std::collections::BTreeMap::new(),
1939                        ))))
1940                    }
1941                    other => Err(VMError::TypeError {
1942                        expected: "lambda",
1943                        got: other.type_name(),
1944                        context: "functionArgs".to_string(),
1945                    }),
1946                }
1947            }
1948            "fromJSON" => {
1949                // JSON objects need the interner to intern keys as
1950                // Symbols. The registered builtin returned `null` for
1951                // Object variants because `json_to_vm_value` has no
1952                // interner access — that silently broke every
1953                // `fromJSON "{...}"` call. Route through VM dispatch
1954                // so we can intern properly. Primitives, arrays, and
1955                // nested structures all handled here too, so the
1956                // registry path is effectively dead for fromJSON
1957                // post this change.
1958                let forced = self.force_value(arg.clone())?;
1959                let s = match forced.as_string() {
1960                    Some(s) => s.to_string(),
1961                    None => {
1962                        return Err(VMError::TypeError {
1963                            expected: "string",
1964                            got: forced.type_name(),
1965                            context: "fromJSON".to_string(),
1966                        });
1967                    }
1968                };
1969                let parsed: serde_json::Value = serde_json::from_str(&s)
1970                    .map_err(|e| VMError::Throw(format!("fromJSON: {e}")))?;
1971                Ok(Some(self.json_value_to_nanbox(&parsed)))
1972            }
1973            "listToAttrs" => {
1974                let forced = self.force_value(arg.clone())?;
1975                let vmval = forced.to_vmvalue();
1976                let list = match &vmval {
1977                    VMValue::List(l) => l,
1978                    other => {
1979                        return Err(VMError::TypeError {
1980                            expected: "list",
1981                            got: other.type_name(),
1982                            context: "listToAttrs".to_string(),
1983                        });
1984                    }
1985                };
1986                let name_sym = self.interner.intern("name");
1987                let value_sym = self.interner.intern("value");
1988                let mut result: BTreeMap<Symbol, NanBox> = BTreeMap::new();
1989                for item in list {
1990                    if let VMValue::Attrs(a) = item {
1991                        let name_val = a.get(&name_sym).ok_or_else(|| {
1992                            VMError::Throw(
1993                                "listToAttrs: element missing 'name'".to_string(),
1994                            )
1995                        })?;
1996                        let value_val = a.get(&value_sym).ok_or_else(|| {
1997                            VMError::Throw(
1998                                "listToAttrs: element missing 'value'".to_string(),
1999                            )
2000                        })?;
2001                        let key_str = match name_val {
2002                            VMValue::String(s) => s.clone(),
2003                            _ => {
2004                                return Err(VMError::TypeError {
2005                                    expected: "string",
2006                                    got: name_val.type_name(),
2007                                    context: "listToAttrs name".to_string(),
2008                                });
2009                            }
2010                        };
2011                        let key_sym = self.interner.intern(&key_str);
2012                        // Nix `listToAttrs` first-wins duplicate semantics:
2013                        // a repeated `name` keeps the FIRST occurrence (later
2014                        // duplicates ignored), matching cppnix + the tree-walker.
2015                        // BTreeMap::insert is last-wins, so guard with entry().
2016                        result
2017                            .entry(key_sym)
2018                            .or_insert_with(|| NanBox::from_vmvalue(value_val));
2019                    } else {
2020                        return Err(VMError::TypeError {
2021                            expected: "set",
2022                            got: item.type_name(),
2023                            context: "listToAttrs element".to_string(),
2024                        });
2025                    }
2026                }
2027                Ok(Some(NanBox::attrs(result)))
2028            }
2029            "removeAttrs" => {
2030                // removeAttrs is curried: first call takes the set, returns partial
2031                let forced = self.force_value(arg.clone())?;
2032                if let Some(attrs) = forced.as_attrs() {
2033                    // Convert to VMValue for the closure (closures can't capture NanBox BTreeMaps)
2034                    let attrs_vm: BTreeMap<Symbol, VMValue> = attrs
2035                        .iter()
2036                        .map(|(k, v)| (*k, v.to_vmvalue()))
2037                        .collect();
2038                    let interner_names: Vec<(Symbol, String)> = attrs
2039                        .keys()
2040                        .map(|k| (*k, self.interner.resolve(*k).to_string()))
2041                        .collect();
2042                    let result = VMValue::Builtin(crate::value::VMBuiltin {
2043                        name: "removeAttrs<partial>",
2044                        func: Rc::new(move |args2| {
2045                            let to_remove = match &args2[0] {
2046                                VMValue::List(l) => l,
2047                                other => {
2048                                    return Err(VMError::TypeError {
2049                                        expected: "list",
2050                                        got: other.type_name(),
2051                                        context: "removeAttrs".to_string(),
2052                                    });
2053                                }
2054                            };
2055                            let remove_names: std::collections::HashSet<String> = to_remove
2056                                .iter()
2057                                .filter_map(|v| {
2058                                    if let VMValue::String(s) = v {
2059                                        Some(s.clone())
2060                                    } else {
2061                                        None
2062                                    }
2063                                })
2064                                .collect();
2065                            let mut result = BTreeMap::new();
2066                            for &(sym, ref name) in &interner_names {
2067                                if !remove_names.contains(name) {
2068                                    if let Some(v) = attrs_vm.get(&sym) {
2069                                        result.insert(sym, v.clone());
2070                                    }
2071                                }
2072                            }
2073                            Ok(VMValue::Attrs(result))
2074                        }),
2075                        arity: 1,
2076                    });
2077                    Ok(Some(NanBox::from_vmvalue(&result)))
2078                } else {
2079                    Err(VMError::TypeError {
2080                        expected: "set",
2081                        got: forced.type_name(),
2082                        context: "removeAttrs".to_string(),
2083                    })
2084                }
2085            }
2086            "hasAttr" => {
2087                // hasAttr is curried: first call takes name string, returns partial
2088                let forced = self.force_value(arg.clone())?;
2089                let name_str = match forced.to_vmvalue() {
2090                    VMValue::String(s) => s,
2091                    other => {
2092                        return Err(VMError::TypeError {
2093                            expected: "string",
2094                            got: other.type_name(),
2095                            context: "hasAttr".to_string(),
2096                        });
2097                    }
2098                };
2099                let sym = self.interner.intern(&name_str);
2100                Ok(Some(NanBox::from_vmvalue(&VMValue::Builtin(
2101                    crate::value::VMBuiltin {
2102                        name: "hasAttr<partial>",
2103                        func: Rc::new(move |args2| {
2104                            let attrs = match &args2[0] {
2105                                VMValue::Attrs(a) => a,
2106                                other => {
2107                                    return Err(VMError::TypeError {
2108                                        expected: "set",
2109                                        got: other.type_name(),
2110                                        context: "hasAttr".to_string(),
2111                                    });
2112                                }
2113                            };
2114                            Ok(VMValue::Bool(attrs.contains_key(&sym)))
2115                        }),
2116                        arity: 1,
2117                    },
2118                ))))
2119            }
2120            "getAttr" => {
2121                let forced = self.force_value(arg.clone())?;
2122                let name_str = match forced.to_vmvalue() {
2123                    VMValue::String(s) => s,
2124                    other => {
2125                        return Err(VMError::TypeError {
2126                            expected: "string",
2127                            got: other.type_name(),
2128                            context: "getAttr".to_string(),
2129                        });
2130                    }
2131                };
2132                let sym = self.interner.intern(&name_str);
2133                let name_for_err = name_str.clone();
2134                Ok(Some(NanBox::from_vmvalue(&VMValue::Builtin(
2135                    crate::value::VMBuiltin {
2136                        name: "getAttr<partial>",
2137                        func: Rc::new(move |args2| {
2138                            let attrs = match &args2[0] {
2139                                VMValue::Attrs(a) => a,
2140                                other => {
2141                                    return Err(VMError::TypeError {
2142                                        expected: "set",
2143                                        got: other.type_name(),
2144                                        context: "getAttr".to_string(),
2145                                    });
2146                                }
2147                            };
2148                            attrs.get(&sym).cloned().ok_or_else(|| {
2149                                VMError::AttrNotFound(name_for_err.clone())
2150                            })
2151                        }),
2152                        arity: 1,
2153                    },
2154                ))))
2155            }
2156            "getFlake" => {
2157                let forced = self.force_value(arg.clone())?;
2158                let flake_ref = match forced.to_vmvalue() {
2159                    VMValue::String(s) => s,
2160                    other => {
2161                        return Err(VMError::TypeError {
2162                            expected: "string",
2163                            got: other.type_name(),
2164                            context: "getFlake".to_string(),
2165                        });
2166                    }
2167                };
2168                let result = self.vm_get_flake(&flake_ref)?;
2169                Ok(Some(result))
2170            }
2171            "scopedImport" => {
2172                // scopedImport is curried: first call takes scope, returns partial
2173                let forced = self.force_value(arg.clone())?;
2174                let scope_vmval = forced.to_vmvalue();
2175                match scope_vmval {
2176                    VMValue::Attrs(_) => {}
2177                    ref other => {
2178                        return Err(VMError::TypeError {
2179                            expected: "set",
2180                            got: other.type_name(),
2181                            context: "scopedImport".to_string(),
2182                        });
2183                    }
2184                }
2185                // Build a string-keyed scope for wrapping
2186                let scope_str = if let Some(attrs) = forced.as_attrs() {
2187                    let mut parts = String::from("{");
2188                    for (k, v) in attrs {
2189                        let key = self.interner.resolve(*k).to_string();
2190                        let val_vm = v.to_vmvalue();
2191                        let rhs = match &val_vm {
2192                            VMValue::Int(n) => n.to_string(),
2193                            VMValue::Float(f) => format!("{f}"),
2194                            VMValue::Bool(true) => "true".to_string(),
2195                            VMValue::Bool(false) => "false".to_string(),
2196                            VMValue::Null => "null".to_string(),
2197                            VMValue::String(s) => {
2198                                let escaped = s
2199                                    .replace('\\', "\\\\")
2200                                    .replace('"', "\\\"")
2201                                    .replace('$', "\\$");
2202                                format!("\"{escaped}\"")
2203                            }
2204                            VMValue::Path(p) => format!("\"{p}\""),
2205                            _ => {
2206                                return Err(VMError::Throw(format!(
2207                                    "scopedImport: cannot render scope value of type {}",
2208                                    val_vm.type_name()
2209                                )));
2210                            }
2211                        };
2212                        parts.push_str(&format!(" {key} = {rhs};"));
2213                    }
2214                    parts.push_str(" }");
2215                    parts
2216                } else {
2217                    "{}".to_string()
2218                };
2219                // Return a partial that takes the path
2220                let result = VMValue::Builtin(crate::value::VMBuiltin {
2221                    name: "scopedImport<partial>",
2222                    func: Rc::new(move |args2| {
2223                        let path = match &args2[0] {
2224                            VMValue::String(s) => s.clone(),
2225                            VMValue::Path(p) => p.clone(),
2226                            other => {
2227                                return Err(VMError::TypeError {
2228                                    expected: "path or string",
2229                                    got: other.type_name(),
2230                                    context: "scopedImport".to_string(),
2231                                });
2232                            }
2233                        };
2234                        // The actual import needs VM context. Store a placeholder
2235                        // that the VM will intercept.
2236                        Err(VMError::Throw(format!(
2237                            "__scopedImport_dispatch__:{}:{}",
2238                            scope_str, path
2239                        )))
2240                    }),
2241                    arity: 1,
2242                });
2243                Ok(Some(NanBox::from_vmvalue(&result)))
2244            }
2245            "scopedImport<partial>" => {
2246                // Intercept the partial application's result
2247                let forced = self.force_value(arg.clone())?;
2248                let path = match forced.to_vmvalue() {
2249                    VMValue::String(s) => s,
2250                    VMValue::Path(p) => p,
2251                    other => {
2252                        return Err(VMError::TypeError {
2253                            expected: "path or string",
2254                            got: other.type_name(),
2255                            context: "scopedImport".to_string(),
2256                        });
2257                    }
2258                };
2259                // This won't actually be called via try_vm_builtin because the
2260                // partial closure captures the scope. The __scopedImport_dispatch__
2261                // error is caught and processed by the VM. For now, fall through.
2262                let _ = path;
2263                Ok(None)
2264            }
2265            "catAttrs" => {
2266                let forced = self.force_value(arg.clone())?;
2267                let name_str = match forced.to_vmvalue() {
2268                    VMValue::String(s) => s,
2269                    other => {
2270                        return Err(VMError::TypeError {
2271                            expected: "string",
2272                            got: other.type_name(),
2273                            context: "catAttrs".to_string(),
2274                        });
2275                    }
2276                };
2277                let sym = self.interner.intern(&name_str);
2278                Ok(Some(NanBox::from_vmvalue(&VMValue::Builtin(
2279                    crate::value::VMBuiltin {
2280                        name: "catAttrs<partial>",
2281                        func: Rc::new(move |args2| {
2282                            let list = match &args2[0] {
2283                                VMValue::List(l) => l,
2284                                other => {
2285                                    return Err(VMError::TypeError {
2286                                        expected: "list",
2287                                        got: other.type_name(),
2288                                        context: "catAttrs".to_string(),
2289                                    });
2290                                }
2291                            };
2292                            let mut result = Vec::new();
2293                            for item in list {
2294                                if let VMValue::Attrs(a) = item {
2295                                    if let Some(v) = a.get(&sym) {
2296                                        result.push(v.clone());
2297                                    }
2298                                }
2299                            }
2300                            Ok(VMValue::List(result))
2301                        }),
2302                        arity: 1,
2303                    },
2304                ))))
2305            }
2306            // ── Bridge-dispatched builtins ─────────────────────────
2307            //
2308            // These builtins need tree-walker state (regex cache, TOML
2309            // parser, genericClosure closure-calling, etc.)
2310            // and are delegated to the builtin bridge.
2311            "readDir" | "parseDrvName" | "fromTOML" | "genericClosure"
2312            | "zipAttrsWith" | "getContext" | "toXML"
2313            | "convertHash" | "path" | "filterSource" | "parseFlakeRef"
2314            | "flakeRefToString" | "toFile" | "currentTime" | "hashFile"
2315            | "findFile" => {
2316                // Deep-force: bridge builtins need fully concrete values
2317                // because to_string_keyed converts unforced thunks to Lambda.
2318                let shallow = self.force_value(arg.clone())?;
2319                let forced = self.deep_force(shallow)?;
2320                let vmval = forced.to_vmvalue();
2321                let sk = vmval.to_string_keyed(self.interner);
2322                match crate::bridge::call_builtin_bridge(name, vec![sk]) {
2323                    Ok(Some(result)) => {
2324                        let vm_result = crate::builtins::string_keyed_to_vmvalue(
2325                            &result,
2326                            self.interner,
2327                        );
2328                        Ok(Some(NanBox::from_vmvalue(&vm_result)))
2329                    }
2330                    Ok(None) => {
2331                        // No bridge set — fall through to registry stub
2332                        // which will produce the appropriate error.
2333                        Ok(None)
2334                    }
2335                    Err(e) => Err(VMError::Internal(format!("bridge error in '{name}': {e}"))),
2336                }
2337            }
2338            // match and split are curried: first call takes pattern,
2339            // returns partial that takes the string.
2340            "match" | "split" => {
2341                let forced = self.force_value(arg.clone())?;
2342                let pattern = match forced.to_vmvalue() {
2343                    VMValue::String(s) => s,
2344                    other => {
2345                        return Err(VMError::TypeError {
2346                            expected: "string",
2347                            got: other.type_name(),
2348                            context: name.to_string(),
2349                        });
2350                    }
2351                };
2352                let builtin_name = name.to_string();
2353                Ok(Some(NanBox::from_vmvalue(&VMValue::Builtin(
2354                    crate::value::VMBuiltin {
2355                        name: if name == "match" {
2356                            "match<partial>"
2357                        } else {
2358                            "split<partial>"
2359                        },
2360                        func: Rc::new(move |args2| {
2361                            let input = match &args2[0] {
2362                                VMValue::String(s) => s.clone(),
2363                                other => {
2364                                    return Err(VMError::TypeError {
2365                                        expected: "string",
2366                                        got: other.type_name(),
2367                                        context: builtin_name.clone(),
2368                                    });
2369                                }
2370                            };
2371                            // Delegate to bridge with both args
2372                            let sk_args = vec![
2373                                crate::value::StringKeyedValue::String(pattern.clone()),
2374                                crate::value::StringKeyedValue::String(input),
2375                            ];
2376                            match crate::bridge::call_builtin_bridge(&builtin_name, sk_args) {
2377                                Ok(Some(result)) => {
2378                                    let mut tmp = crate::intern::Interner::new();
2379                                    Ok(crate::builtins::string_keyed_to_vmvalue(&result, &mut tmp))
2380                                }
2381                                Ok(None) => Err(VMError::Throw(format!(
2382                                    "{builtin_name}: requires bridge but no bridge is set"
2383                                ))),
2384                                Err(e) => Err(VMError::Internal(format!("bridge error in '{builtin_name}': {e}"))),
2385                            }
2386                        }),
2387                        arity: 1,
2388                    },
2389                ))))
2390            }
2391            _ => Ok(None),
2392        }
2393    }
2394    /// Coerce an already-forced [`VMValue`] to a derivation-env string the way
2395    /// CppNix (and the tree-walker's `coerce_to_string_copy_to_store`) does.
2396    ///
2397    /// Returns `None` for values with no meaningful string form (closures,
2398    /// builtins, un-`outPath`'d attrsets) — the caller skips those env entries
2399    /// rather than erroring, matching the tree-walker's `_opt` coercion.
2400    ///
2401    /// Mirrors `sui-eval/src/value.rs::coerce_to_string_impl` for every value
2402    /// type the VM can represent:
2403    ///   - `Float` → `%f` (6 decimals), NOT Rust's shortest form.
2404    ///   - `List` → items coerced + space-joined.
2405    ///   - `Attrs` → `outPath` (or `__toString`) coerced; else `None`.
2406    ///
2407    /// NOTE (parity tier): the VM does NOT track string context (VMValue::String
2408    /// carries no context — deferred to Phase 2), so this coercion cannot
2409    /// populate inputDrvs/inputSrcs edges the way the tree-walker does. For
2410    /// context-free derivation shapes the env bytes match; context-bearing
2411    /// shapes still diverge until the VM's Phase-2 context work lands. The
2412    /// differential test names exactly which shapes reach parity here.
2413    fn coerce_drv_env_value(&mut self, v: &VMValue) -> Option<String> {
2414        match v {
2415            VMValue::String(s) => Some(s.clone()),
2416            VMValue::Path(p) => Some(p.clone()),
2417            VMValue::Int(n) => Some(n.to_string()),
2418            // CppNix uses C printf "%f" → always 6 decimals (`1.5` → "1.500000").
2419            // Rust's `{}` strips trailing zeros; match the tree-walker's `{f:.6}`.
2420            VMValue::Float(f) => Some(format!("{f:.6}")),
2421            VMValue::Bool(true) => Some("1".to_string()),
2422            VMValue::Bool(false) => Some(String::new()),
2423            VMValue::Null => Some(String::new()),
2424            VMValue::List(items) => {
2425                let mut parts = Vec::with_capacity(items.len());
2426                for item in items {
2427                    // Force each item then coerce (tree-walker forces list items).
2428                    let forced = self
2429                        .force_value(NanBox::from_vmvalue(item))
2430                        .ok()?
2431                        .to_vmvalue();
2432                    parts.push(self.coerce_drv_env_value(&forced)?);
2433                }
2434                Some(parts.join(" "))
2435            }
2436            VMValue::Attrs(map) => {
2437                // CppNix: an attrset coerces via `__toString` then `outPath`;
2438                // otherwise it has no string form (tree-walker errors, but the
2439                // env loop uses the `_opt` variant → skip).
2440                let to_string_sym = self.interner.intern("__toString");
2441                if map.contains_key(&to_string_sym) {
2442                    // A `__toString`-bearing attrset requires applying the
2443                    // function; that goes through the tree-walker seam the VM
2444                    // does not have here. Leave to the tree-walker (skip) rather
2445                    // than emit a wrong value — honest under-approximation.
2446                    return None;
2447                }
2448                let out_path_sym = self.interner.intern("outPath");
2449                let out_path = map.get(&out_path_sym)?;
2450                let forced = self
2451                    .force_value(NanBox::from_vmvalue(out_path))
2452                    .ok()?
2453                    .to_vmvalue();
2454                self.coerce_drv_env_value(&forced)
2455            }
2456            _ => None,
2457        }
2458    }
2459    /// Build a derivation from a VM attrset (with interner access).
2460    fn vm_build_derivation(&mut self, arg: NanBox) -> Result<NanBox, VMError> {
2461        use sui_compat::derivation::{Derivation, DerivationOutput};
2462        let attrs = match arg.as_attrs() {
2463            Some(a) => a.clone(),
2464            None => {
2465                return Err(VMError::TypeError {
2466                    expected: "set",
2467                    got: arg.type_name(),
2468                    context: "derivation".to_string(),
2469                });
2470            }
2471        };
2472        // Helper: resolve a symbol key and get string value.
2473        let get_str = |attrs: &BTreeMap<Symbol, NanBox>,
2474                       interner: &mut Interner,
2475                       key: &str|
2476         -> Result<String, VMError> {
2477            let sym = interner.intern(key);
2478            let val = attrs.get(&sym).ok_or_else(|| {
2479                VMError::AttrNotFound(key.to_string())
2480            })?;
2481            match val.to_vmvalue() {
2482                VMValue::String(s) => Ok(s),
2483                other => Err(VMError::TypeError {
2484                    expected: "string",
2485                    got: other.type_name(),
2486                    context: format!("derivation attr '{key}'"),
2487                }),
2488            }
2489        };
2490        let get_str_opt = |attrs: &BTreeMap<Symbol, NanBox>,
2491                           interner: &mut Interner,
2492                           key: &str|
2493         -> Result<Option<String>, VMError> {
2494            let sym = interner.intern(key);
2495            match attrs.get(&sym) {
2496                None => Ok(None),
2497                Some(val) => match val.to_vmvalue() {
2498                    VMValue::String(s) => Ok(Some(s)),
2499                    other => Err(VMError::TypeError {
2500                        expected: "string",
2501                        got: other.type_name(),
2502                        context: format!("derivation attr '{key}'"),
2503                    }),
2504                },
2505            }
2506        };
2507        let name = get_str(&attrs, self.interner, "name")?;
2508        let system = get_str(&attrs, self.interner, "system")?;
2509        let builder = get_str(&attrs, self.interner, "builder")?;
2510        // Optional `args` list of strings.
2511        // IMPORTANT: List items come as NanBox entries that are often
2512        // still thunks — they MUST be forced before coercion, else
2513        // every string arg vanishes. Previous code pattern-matched
2514        // directly on `VMValue::Thunk(_)` → `_ => push("")`, which
2515        // emitted empty strings and caused every derivation with
2516        // computed args to have args=[] in its ATerm. That made the
2517        // .drv path diverge from CppNix on any non-trivial derivation.
2518        let args_sym = self.interner.intern("args");
2519        let args_list: Vec<String> = if let Some(a) = attrs.get(&args_sym) {
2520            let forced_a = self.force_value(a.clone())?;
2521            let vmval = forced_a.to_vmvalue();
2522            match vmval {
2523                VMValue::List(l) => {
2524                    let mut out = Vec::with_capacity(l.len());
2525                    for item in &l {
2526                        // Each item may still be a thunk — force it.
2527                        let forced = self.force_value(NanBox::from_vmvalue(item))?;
2528                        match forced.to_vmvalue() {
2529                            VMValue::String(s) => out.push(s.clone()),
2530                            VMValue::Int(n) => out.push(n.to_string()),
2531                            VMValue::Float(f) => out.push(format!("{f:.6}")),
2532                            VMValue::Bool(true) => out.push("1".to_string()),
2533                            VMValue::Bool(false) => out.push(String::new()),
2534                            VMValue::Null => out.push(String::new()),
2535                            VMValue::Path(p) => out.push(p.clone()),
2536                            _ => out.push(String::new()),
2537                        }
2538                    }
2539                    out
2540                }
2541                _ => Vec::new(),
2542            }
2543        } else {
2544            Vec::new()
2545        };
2546        // Optional `outputs` list.
2547        // IMPORTANT (parity fix): list items arrive as NanBox entries that are
2548        // frequently still thunks. The previous reader pattern-matched directly
2549        // on `VMValue::String(s)` and SKIPPED thunks — so every multi-output
2550        // derivation (glibc/openssl/systemd/gcc/most of stdenv) silently
2551        // collapsed to a single `out`-only drv, diverging the .drv path from
2552        // both nix and the tree-walker. Force each item exactly like the `args`
2553        // reader above so declared outputs survive.
2554        let outputs_sym = self.interner.intern("outputs");
2555        let outputs: Vec<String> = if let Some(o) = attrs.get(&outputs_sym) {
2556            let forced_o = self.force_value(o.clone())?;
2557            match forced_o.to_vmvalue() {
2558                VMValue::List(l) => {
2559                    let mut out = Vec::with_capacity(l.len());
2560                    for item in &l {
2561                        let forced = self.force_value(NanBox::from_vmvalue(item))?;
2562                        if let VMValue::String(s) = forced.to_vmvalue() {
2563                            out.push(s);
2564                        }
2565                    }
2566                    if out.is_empty() {
2567                        vec!["out".to_string()]
2568                    } else {
2569                        out
2570                    }
2571                }
2572                _ => vec!["out".to_string()],
2573            }
2574        } else {
2575            vec!["out".to_string()]
2576        };
2577        // `__ignoreNulls = true` (CppNix): attrs whose value is null are dropped
2578        // from the env, and `__ignoreNulls` itself is consumed (never emitted).
2579        // Every stdenv mkDerivation sets this, so without it the VM env carried
2580        // extra `__ignoreNulls` + any null attr, diverging the modulo hash from
2581        // both nix and the tree-walker (derivation.rs ~224).
2582        let ignore_nulls_sym = self.interner.intern("__ignoreNulls");
2583        let ignore_nulls = attrs
2584            .get(&ignore_nulls_sym)
2585            .map(|v| self.force_value(v.clone()))
2586            .transpose()?
2587            .map(|v| matches!(v.to_vmvalue(), VMValue::Bool(true)))
2588            .unwrap_or(false);
2589
2590        // Build env vars from non-special attributes.
2591        // Excluded from env: `name`/`system`/`builder` (re-inserted below from
2592        // the coerced locals), `args` (structural, not an env var), and the
2593        // control flags CppNix consumes rather than emits (`__ignoreNulls`,
2594        // `__impure`, `__contentAddressed`). NOT excluded — matching the
2595        // tree-walker (derivation.rs ~286): `outputs` (coerced to "out dev …")
2596        // and `__structuredAttrs` (coerced to "" for a non-structured drv);
2597        // CppNix emits both, and dropping either diverges the modulo hash.
2598        let special = [
2599            "name", "system", "builder", "args",
2600            "__ignoreNulls", "__impure", "__contentAddressed",
2601        ];
2602        let special_syms: Vec<Symbol> = special
2603            .iter()
2604            .map(|s| self.interner.intern(s))
2605            .collect();
2606        let mut env_vars: BTreeMap<String, String> = BTreeMap::new();
2607        // Collect the (sym, key_str) pairs first to avoid borrowing `attrs`
2608        // across the `&mut self` force calls in the loop below.
2609        let env_keys: Vec<(Symbol, String)> = attrs
2610            .iter()
2611            .filter(|(k, _)| !special_syms.contains(k))
2612            .map(|(k, _)| (*k, self.interner.resolve(*k).to_string()))
2613            .collect();
2614        for (k, key_str) in env_keys {
2615            let Some(v) = attrs.get(&k) else { continue };
2616            // Force the value BEFORE coercion: nearly every real env attr is a
2617            // thunk (the previous `v.to_vmvalue()` + `_ => continue` dropped
2618            // every thunk-valued env var — i.e. almost all of them). This
2619            // mirrors the tree-walker's force-then-coerce in construct_derivation.
2620            let forced = self.force_value(v.clone())?;
2621            let fv = forced.to_vmvalue();
2622            // `__ignoreNulls` drops null-valued attrs entirely.
2623            if ignore_nulls && matches!(fv, VMValue::Null) {
2624                continue;
2625            }
2626            // Coerce with the SAME semantics as the tree-walker's
2627            // `coerce_to_string_copy_to_store` for the value types the VM can
2628            // represent (lists space-join, attrs use outPath, floats use %f).
2629            // A value with no meaningful string form is skipped (matches the
2630            // tree-walker's `coerce_..._opt` returning None), not errored.
2631            match self.coerce_drv_env_value(&fv) {
2632                Some(s) => {
2633                    env_vars.insert(key_str, s);
2634                }
2635                None => continue,
2636            }
2637        }
2638        env_vars.insert("name".to_string(), name.clone());
2639        env_vars.insert("system".to_string(), system.clone());
2640        env_vars.insert("builder".to_string(), builder.clone());
2641        // Detect fixed-output derivation.
2642        let output_hash_sym = self.interner.intern("outputHash");
2643        let is_fod = attrs.contains_key(&output_hash_sym);
2644        let mut drv = Derivation {
2645            outputs: BTreeMap::new(),
2646            input_derivations: BTreeMap::new(),
2647            input_sources: Vec::new(),
2648            system,
2649            builder,
2650            args: args_list,
2651            env: env_vars,
2652        };
2653        let (drv_path, out_paths, mut drv) = if is_fod {
2654            let raw_output_hash = get_str(&attrs, self.interner, "outputHash")?;
2655            let raw_algo = get_str_opt(&attrs, self.interner, "outputHashAlgo")?
2656                .unwrap_or_default();
2657            let output_hash_mode = get_str_opt(&attrs, self.interner, "outputHashMode")?
2658                .unwrap_or_else(|| "flat".to_string());
2659            let is_recursive =
2660                output_hash_mode == "recursive" || output_hash_mode == "nar";
2661            // Empty outputHashAlgo: infer from SRI prefix (cppnix
2662            // semantics), else default to sha256.
2663            let output_hash_algo = if raw_algo.is_empty() {
2664                ["sha256", "sha512", "sha1", "md5"].iter()
2665                    .find(|a| raw_output_hash.starts_with(&format!("{a}-")))
2666                    .map(|s| (*s).to_string())
2667                    .unwrap_or_else(|| "sha256".to_string())
2668            } else {
2669                raw_algo
2670            };
2671            // Normalize hex/nix-base32/SRI → lowercase hex before
2672            // building the fixed:out:<algo>:<hex>: fingerprint.  See
2673            // sui-compat::hash::NixHash::parse_any for the contract.
2674            let algo = sui_compat::hash::HashAlgorithm::from_nix_str(&output_hash_algo)
2675                .map_err(|e| VMError::Internal(format!(
2676                    "derivation: invalid outputHashAlgo {output_hash_algo:?}: {e}",
2677                )))?;
2678            let parsed = sui_compat::hash::NixHash::parse_any(algo, &raw_output_hash)
2679                .map_err(|e| VMError::Internal(format!(
2680                    "derivation: invalid outputHash {raw_output_hash:?}: {e}",
2681                )))?;
2682            let output_hash_hex = parsed.to_hex();
2683            let out_path = sui_compat::store_path::compute_fixed_output_hash(
2684                &output_hash_algo,
2685                &output_hash_hex,
2686                is_recursive,
2687                &name,
2688            );
2689            drv.outputs.insert(
2690                "out".to_string(),
2691                DerivationOutput {
2692                    path: out_path.clone(),
2693                    hash_algo: if is_recursive {
2694                        format!("r:{output_hash_algo}")
2695                    } else {
2696                        output_hash_algo.clone()
2697                    },
2698                    hash: output_hash_hex,
2699                },
2700            );
2701            // CppNix hashes the FOD with `env["out"] = <out-path>` present (the
2702            // input-addressed spec's FillOutputs phase sets it; this hand-rolled
2703            // fixed-output branch skipped it) — without it the FOD drvPath
2704            // diverges from nix + the tree-walker while its outPath already
2705            // matches (derivation.rs ~437).
2706            drv.env.insert("out".to_string(), out_path.clone());
2707
2708            let drv_content = drv.serialize();
2709            // Fold the .drv's references (inputDrvs + inputSrcs) into the store
2710            // path — CppNix's makeTextPath does this for EVERY derivation,
2711            // including fixed-output ones. A fetchurl FOD consumes curl /
2712            // mirrors-list / stdenv as inputDrvs, so without the refs its .drv
2713            // path diverges from nix. (A bare FOD with no inputs has an empty
2714            // ref set, so the simple FOD case matched even while this hid.)
2715            // NOTE: the VM does not yet collect string context, so
2716            // `input_derivations`/`input_sources` are empty here — this fold is
2717            // a no-op today but matches the tree-walker's construction so the
2718            // path stays correct once VM context lands (derivation.rs ~446).
2719            let drv_refs: Vec<String> = drv.input_derivations.keys().cloned()
2720                .chain(drv.input_sources.iter().cloned())
2721                .collect();
2722            let drv_path = sui_compat::store_path::compute_drv_path_with_refs(
2723                drv_content.as_bytes(), &name, &drv_refs);
2724
2725            // CppNix `hashDerivationModulo` for a FIXED-OUTPUT derivation is the
2726            // special sha256("fixed:out:<methodAlgo>:<hashHex>:<outPath>"), NOT
2727            // the input-addressed ATerm hash. Cache it against this FOD's drv
2728            // path so every input-addressed derivation that consumes this FOD
2729            // substitutes the correct modulo hash — without it the consumer's
2730            // output path (and everything transitively above it) diverges from
2731            // nix + the tree-walker (derivation.rs ~452).
2732            let out_output = drv.outputs.get("out");
2733            let method_algo = out_output
2734                .map(|o| o.hash_algo.clone())
2735                .unwrap_or_default();
2736            let output_hash_hex = out_output
2737                .map(|o| o.hash.clone())
2738                .unwrap_or_default();
2739            let modulo_preimage =
2740                format!("fixed:out:{method_algo}:{output_hash_hex}:{out_path}");
2741            let modulo_hex: String = {
2742                use sha2::{Digest, Sha256};
2743                Sha256::digest(modulo_preimage.as_bytes())
2744                    .iter()
2745                    .map(|b| format!("{b:02x}"))
2746                    .collect()
2747            };
2748            sui_spec::derivation::remember_modulo_hash(&drv_path, &modulo_hex);
2749
2750            let mut out_paths = BTreeMap::new();
2751            out_paths.insert("out".to_string(), out_path);
2752            (drv_path, out_paths, drv)
2753        } else {
2754            // Input-addressed drv: algorithm lives in
2755            // `sui-spec/specs/derivation.lisp`.  Both the VM and the
2756            // tree-walker call `sui_spec::derivation::apply`, which
2757            // interprets that one authored spec.  Bug-fix history
2758            // (#11–#14 this session) was all spec drift between two
2759            // independently-maintained copies; this call is how we
2760            // make that drift impossible by construction.
2761            let algo = sui_spec::derivation::load_canonical().map_err(|e| {
2762                VMError::TypeError {
2763                    expected: "valid derivation algorithm spec",
2764                    got: "load error",
2765                    context: format!("sui-spec: {e}"),
2766                }
2767            })?;
2768            let (drv_path, out_paths, drv_final) =
2769                sui_spec::derivation::apply(&algo, drv, outputs.clone(), &name)
2770                    .map_err(|e| VMError::TypeError {
2771                        expected: "derivation interpreter success",
2772                        got: "interp error",
2773                        context: format!("sui-spec: {e}"),
2774                    })?;
2775            (drv_path, out_paths, drv_final)
2776        };
2777        // Update derivation outputs with final paths and write .drv file.
2778        for (output_name, output_path) in &out_paths {
2779            if let Some(output) = drv.outputs.get_mut(output_name) {
2780                if output.path.is_empty() {
2781                    output.path.clone_from(output_path);
2782                }
2783            }
2784            drv.env.insert(output_name.clone(), output_path.clone());
2785        }
2786        let drv_content_final = drv.serialize();
2787        let store_dir = std::env::var("SUI_STORE_DIR")
2788            .unwrap_or_else(|_| "/nix/store".to_string());
2789        let disk_path = if store_dir != "/nix/store" {
2790            drv_path.replacen("/nix/store", &store_dir, 1)
2791        } else {
2792            drv_path.clone()
2793        };
2794        let drv_file = std::path::Path::new(&disk_path);
2795        if !drv_file.exists() {
2796            if let Some(parent) = drv_file.parent() {
2797                std::fs::create_dir_all(parent).ok();
2798            }
2799            match std::fs::write(drv_file, drv_content_final.as_bytes()) {
2800                Ok(()) => {}
2801                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
2802                    let fallback_dir = std::env::temp_dir().join("sui-drv-cache");
2803                    std::fs::create_dir_all(&fallback_dir).ok();
2804                    let fallback_path = fallback_dir.join(
2805                        drv_file.file_name().unwrap_or_default(),
2806                    );
2807                    let _ = std::fs::write(&fallback_path, drv_content_final.as_bytes());
2808                }
2809                Err(e) => {
2810                    return Err(VMError::Throw(format!(
2811                        "derivation: failed to write {drv_path}: {e}"
2812                    )));
2813                }
2814            }
2815        }
2816        // Assemble result attrset (CppNix-compatible).
2817        let mut result: BTreeMap<Symbol, NanBox> = attrs.clone();
2818        let type_sym = self.interner.intern("type");
2819        result.insert(type_sym, NanBox::string("derivation".to_string()));
2820        let drv_path_sym = self.interner.intern("drvPath");
2821        result.insert(drv_path_sym, NanBox::string(drv_path.clone()));
2822        // CppNix: drvAttrs contains the original input attributes
2823        let drv_attrs_sym = self.interner.intern("drvAttrs");
2824        result.insert(drv_attrs_sym, NanBox::attrs(attrs));
2825        let primary_out = out_paths
2826            .get("out")
2827            .cloned()
2828            .or_else(|| out_paths.values().next().cloned())
2829            .unwrap_or_default();
2830        let out_path_sym = self.interner.intern("outPath");
2831        result.insert(out_path_sym, NanBox::string(primary_out));
2832        // CppNix: outputName is the primary output name
2833        let output_name_sym = self.interner.intern("outputName");
2834        let primary_output_name = if out_paths.contains_key("out") { "out" }
2835            else { out_paths.keys().next().map(|s| s.as_str()).unwrap_or("out") };
2836        result.insert(output_name_sym, NanBox::string(primary_output_name.to_string()));
2837        let mut all_outputs: Vec<NanBox> = Vec::new();
2838        for (output_name, output_path) in &out_paths {
2839            let mut out_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
2840            out_attrs.insert(out_path_sym, NanBox::string(output_path.clone()));
2841            out_attrs.insert(drv_path_sym, NanBox::string(drv_path.clone()));
2842            out_attrs.insert(type_sym, NanBox::string("derivation".to_string()));
2843            out_attrs.insert(output_name_sym, NanBox::string(output_name.clone()));
2844            let name_sym = self.interner.intern("name");
2845            out_attrs.insert(name_sym, NanBox::string(name.clone()));
2846            let out_val = NanBox::attrs(out_attrs);
2847            all_outputs.push(out_val.clone());
2848            let out_sym = self.interner.intern(output_name);
2849            result.insert(out_sym, out_val);
2850        }
2851        // CppNix: `all` is a list of all output derivation attrsets
2852        let all_sym = self.interner.intern("all");
2853        result.insert(all_sym, NanBox::list(all_outputs));
2854        Ok(NanBox::attrs(result))
2855    }
2856    /// Call a builtin function, intercepting scopedImport dispatch errors.
2857    fn call_builtin_with_scoped_import_dispatch(
2858        &mut self,
2859        func: Rc<dyn Fn(Vec<VMValue>) -> Result<VMValue, VMError>>,
2860        arg: VMValue,
2861    ) -> Result<NanBox, VMError> {
2862        // Defensive: force VMValue::Thunk args that leaked through.
2863        let arg = if let VMValue::Thunk(ref thunk) = arg {
2864            let nb = NanBox::from_vmvalue(&arg);
2865            self.force_value(nb)?.to_vmvalue()
2866        } else {
2867            arg
2868        };
2869        match func(vec![arg]) {
2870            Ok(result) => Ok(NanBox::from_vmvalue(&result)),
2871            Err(VMError::Throw(ref msg))
2872                if msg.starts_with("__scopedImport_dispatch__:") =>
2873            {
2874                let rest = &msg["__scopedImport_dispatch__:".len()..];
2875                if let Some(colon_pos) = rest.rfind(':') {
2876                    let scope_nix = &rest[..colon_pos];
2877                    let path = &rest[colon_pos + 1..];
2878                    self.vm_scoped_import(scope_nix, path)
2879                } else {
2880                    Err(VMError::Throw(msg.clone()))
2881                }
2882            }
2883            Err(e) => Err(e),
2884        }
2885    }
2886    /// Evaluate `builtins.getFlake` for a path-based flake reference.
2887    ///
2888    /// If a thread-local flake resolver has been installed (via
2889    /// [`set_flake_resolver`]), delegates to it — this lets `sui-eval`
2890    /// inject the tree-walker's full `evaluate_flake` implementation
2891    /// which handles all input types correctly.  Falls back to the VM's
2892    /// own limited resolver otherwise.
2893    fn vm_get_flake(&mut self, flake_ref: &str) -> Result<NanBox, VMError> {
2894        // Check for an external resolver first.
2895        let resolved = FLAKE_RESOLVER.with(|r| {
2896            let borrow = r.borrow();
2897            if let Some(ref resolver) = *borrow {
2898                Some(resolver(flake_ref))
2899            } else {
2900                None
2901            }
2902        });
2903        if let Some(result) = resolved {
2904            let sk = result.map_err(|e| VMError::Throw(format!("getFlake: {e}")))?;
2905            return Ok(self.string_keyed_to_nanbox(&sk));
2906        }
2907        // Fallback: VM-native resolution (path-based only).
2908        self.vm_get_flake_native(flake_ref)
2909    }
2910    /// Convert a `StringKeyedValue` to a `NanBox` for the VM stack.
2911    ///
2912    /// `StringKeyedValue::Thunk` variants are wrapped in `VMThunk`s with
2913    /// `NativeCallback` state so they are only evaluated when the VM
2914    /// actually forces the value. This keeps `getFlake` fast by deferring
2915    /// transitive input evaluation.
2916    fn string_keyed_to_nanbox(&mut self, sk: &crate::value::StringKeyedValue) -> NanBox {
2917        match sk {
2918            crate::value::StringKeyedValue::Null => NanBox::null(),
2919            crate::value::StringKeyedValue::Bool(b) => NanBox::bool(*b),
2920            crate::value::StringKeyedValue::Int(n) => NanBox::int(*n),
2921            crate::value::StringKeyedValue::Float(f) => NanBox::float(*f),
2922            crate::value::StringKeyedValue::String(s) => NanBox::string(s.clone()),
2923            crate::value::StringKeyedValue::Path(p) => NanBox::from_vmvalue(&VMValue::Path(p.clone())),
2924            crate::value::StringKeyedValue::List(items) => {
2925                let nb_items: Vec<NanBox> = items.iter().map(|v| self.string_keyed_to_nanbox(v)).collect();
2926                NanBox::list(nb_items)
2927            }
2928            crate::value::StringKeyedValue::Attrs(map) => {
2929                let mut nb_map: BTreeMap<Symbol, NanBox> = BTreeMap::new();
2930                for (k, v) in map {
2931                    let sym = self.interner.intern(k);
2932                    nb_map.insert(sym, self.string_keyed_to_nanbox(v));
2933                }
2934                NanBox::attrs(nb_map)
2935            }
2936            crate::value::StringKeyedValue::Lambda => NanBox::null(),
2937            crate::value::StringKeyedValue::Callable(cb) => {
2938                let cb_clone = Rc::clone(cb);
2939                let builtin = crate::value::VMBuiltin {
2940                    name: "<bridge-fn>",
2941                    arity: 1,
2942                    func: Rc::new(move |args: Vec<VMValue>| {
2943                        let interner = crate::intern::Interner::new();
2944                        let sk_arg = args.into_iter().next()
2945                            .unwrap_or(VMValue::Null)
2946                            .to_string_keyed(&interner);
2947                        let sk_result = cb_clone(sk_arg)
2948                            .map_err(|e| crate::error::VMError::Throw(e))?;
2949                        let mut tmp_interner = crate::intern::Interner::new();
2950                        Ok(crate::builtins::string_keyed_to_vmvalue(&sk_result, &mut tmp_interner))
2951                    }),
2952                };
2953                NanBox::builtin(builtin)
2954            }
2955            crate::value::StringKeyedValue::Thunk(cb) => {
2956                // Wrap the callback in a VMThunk with NativeCallback state.
2957                // The VM's force_value will call the callback on demand and
2958                // convert the resulting StringKeyedValue to a NanBox.
2959                let thunk = VMThunk {
2960                    state: Rc::new(Cell::new(Some(ThunkState::NativeCallback(Rc::clone(cb))))),
2961                };
2962                NanBox::thunk(thunk)
2963            }
2964        }
2965    }
2966    /// VM-native flake resolution (path-based inputs only).
2967    fn vm_get_flake_native(&mut self, flake_ref: &str) -> Result<NanBox, VMError> {
2968        let flake_dir = if flake_ref.starts_with('/') || flake_ref.starts_with('.') {
2969            std::path::PathBuf::from(flake_ref)
2970        } else if let Some(path) = flake_ref.strip_prefix("path:") {
2971            std::path::PathBuf::from(path)
2972        } else {
2973            return Err(VMError::Throw(format!(
2974                "getFlake: unsupported flake reference: {flake_ref} (only path: refs supported in VM)"
2975            )));
2976        };
2977        let flake_nix = flake_dir.join("flake.nix");
2978        if !flake_nix.exists() {
2979            return Err(VMError::Throw(format!(
2980                "getFlake: flake.nix not found in {}",
2981                flake_dir.display()
2982            )));
2983        }
2984        // Import flake.nix to get the raw flake attrset.
2985        let flake_nix_str = flake_nix.to_string_lossy().to_string();
2986        let flake_attrs = self.import_file(&flake_nix_str)?;
2987        let flake_attrs = self.force_value(flake_attrs)?;
2988        // Build the inputs attrset. For now, create a minimal `self` input.
2989        let self_sym = self.interner.intern("self");
2990        let out_path_sym = self.interner.intern("outPath");
2991        let flake_dir_str = flake_dir.to_string_lossy().to_string();
2992        let mut self_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
2993        self_attrs.insert(out_path_sym, NanBox::string(flake_dir_str.clone()));
2994        let mut inputs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
2995        inputs.insert(self_sym, NanBox::attrs(self_attrs));
2996        // Try to read flake.lock and resolve inputs.
2997        let lock_path = flake_dir.join("flake.lock");
2998        if lock_path.exists() {
2999            if let Ok(lock_str) = std::fs::read_to_string(&lock_path) {
3000                if let Ok(lock_json) = serde_json::from_str::<serde_json::Value>(&lock_str) {
3001                    self.resolve_flake_lock_inputs(&lock_json, &flake_dir, &mut inputs);
3002                }
3003            }
3004        }
3005        // Extract the `outputs` function and call it with the inputs attrset.
3006        let outputs_sym = self.interner.intern("outputs");
3007        if let Some(attrs) = flake_attrs.as_attrs() {
3008            if let Some(outputs_func) = attrs.get(&outputs_sym) {
3009                let outputs_func = outputs_func.clone();
3010                let outputs_func = self.force_value(outputs_func)?;
3011                let inputs_nb = NanBox::attrs(inputs);
3012                let result = self.call_callable(&outputs_func, inputs_nb)?;
3013                let mut result_forced = self.force_value(result)?;
3014                // Merge top-level metadata (description) into the result.
3015                let desc_sym = self.interner.intern("description");
3016                if let Some(desc) = attrs.get(&desc_sym) {
3017                    if let Some(result_attrs) = result_forced.as_attrs() {
3018                        let mut merged = result_attrs.clone();
3019                        merged.insert(desc_sym, desc.clone());
3020                        result_forced = NanBox::attrs(merged);
3021                    }
3022                }
3023                return Ok(result_forced);
3024            }
3025        }
3026        // If no outputs function, return the raw flake attrset.
3027        Ok(flake_attrs)
3028    }
3029    /// Resolve flake.lock inputs into the inputs attrset.
3030    fn resolve_flake_lock_inputs(
3031        &mut self,
3032        lock: &serde_json::Value,
3033        flake_dir: &std::path::Path,
3034        inputs: &mut BTreeMap<Symbol, NanBox>,
3035    ) {
3036        let nodes = match lock.get("nodes").and_then(|n| n.as_object()) {
3037            Some(n) => n,
3038            None => return,
3039        };
3040        let root_node = match lock.get("root").and_then(|r| r.as_str()) {
3041            Some(r) => r.to_string(),
3042            None => "root".to_string(),
3043        };
3044        let root_inputs = match nodes
3045            .get(&root_node)
3046            .and_then(|n| n.get("inputs"))
3047            .and_then(|i| i.as_object())
3048        {
3049            Some(i) => i,
3050            None => return,
3051        };
3052        for (input_name, node_ref) in root_inputs {
3053            let node_key = match node_ref.as_str() {
3054                Some(s) => s.to_string(),
3055                None => {
3056                    if let Some(arr) = node_ref.as_array() {
3057                        if let Some(s) = arr.first().and_then(|v| v.as_str()) {
3058                            s.to_string()
3059                        } else {
3060                            continue;
3061                        }
3062                    } else {
3063                        continue;
3064                    }
3065                }
3066            };
3067            if let Some(node) = nodes.get(&node_key) {
3068                if let Some(locked) = node.get("locked") {
3069                    let locked_type = locked.get("type").and_then(|t| t.as_str()).unwrap_or("");
3070                    let out_path = match locked_type {
3071                        "path" => {
3072                            if let Some(p) = locked.get("path").and_then(|p| p.as_str()) {
3073                                let path = if p.starts_with('/') {
3074                                    std::path::PathBuf::from(p)
3075                                } else {
3076                                    flake_dir.join(p)
3077                                };
3078                                path.to_string_lossy().to_string()
3079                            } else {
3080                                continue;
3081                            }
3082                        }
3083                        _ => continue, // Only path inputs for now
3084                    };
3085                    let input_sym = self.interner.intern(input_name);
3086                    let out_path_sym = self.interner.intern("outPath");
3087                    let mut input_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
3088                    input_attrs.insert(out_path_sym, NanBox::string(out_path));
3089                    inputs.insert(input_sym, NanBox::attrs(input_attrs));
3090                }
3091            }
3092        }
3093    }
3094    /// Import a file with a scope (for scopedImport).
3095    ///
3096    /// Handles the directory → `default.nix` fallback like `import_file`.
3097    fn vm_scoped_import(
3098        &mut self,
3099        scope_nix: &str,
3100        path: &str,
3101    ) -> Result<NanBox, VMError> {
3102        // Directory → default.nix fallback (Nix convention).
3103        let resolved = if std::path::Path::new(path).is_dir() {
3104            format!("{path}/default.nix")
3105        } else {
3106            path.to_string()
3107        };
3108        let source = std::fs::read_to_string(&resolved)
3109            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3110        // Wrap the source in `with <scope>; <source>` to inject the scope.
3111        let wrapped = format!("with {scope_nix}; {source}");
3112        let file_dir = std::path::Path::new(&resolved)
3113            .parent()
3114            .map(|p| p.to_path_buf())
3115            .unwrap_or_default();
3116        // Share the VM's interner so symbol IDs stay consistent.
3117        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
3118        let chunk = Compiler::compile_with_shared_interner(&wrapped, file_dir, shared_interner.clone())
3119            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3120        *self.interner = match Rc::try_unwrap(shared_interner) {
3121            Ok(cell) => cell.into_inner(),
3122            Err(rc) => rc.borrow().clone(),
3123        };
3124        if self.frames.len() >= MAX_CALL_DEPTH {
3125            return Err(VMError::StackOverflow);
3126        }
3127        let return_depth = self.frames.len();
3128        let stack_base = self.stack.len();
3129        self.frames.push(CallFrame {
3130            chunk: Rc::new(chunk),
3131            ip: 0,
3132            stack_base,
3133            upvalues: Vec::new(),
3134        });
3135        self.run_until(return_depth)
3136    }
3137    // -- Higher-order builtin execution -----------------------------------
3138    fn call_callable(&mut self, func: &NanBox, arg: NanBox) -> Result<NanBox, VMError> {
3139        if let Some(closure) = func.as_closure() {
3140            if self.frames.len() >= MAX_CALL_DEPTH {
3141                return Err(VMError::StackOverflow);
3142            }
3143            let upvalues = closure.upvalues.clone();
3144            let chunk = closure.chunk.clone();
3145            let return_depth = self.frames.len();
3146            let stack_base = self.stack.len();
3147            self.push(arg);
3148            self.frames.push(CallFrame {
3149                chunk,
3150                ip: 0,
3151                stack_base,
3152                upvalues,
3153            });
3154            let result = self.run_until(return_depth)?;
3155            self.stack.truncate(stack_base);
3156            // Force the result — callers expect concrete values
3157            // (e.g., filter checks is_truthy on predicate results).
3158            self.force_value(result)
3159        } else if func.is_higher_order_builtin() {
3160            let hob = func.as_higher_order_builtin().unwrap().clone();
3161            self.call_higher_order_builtin(&hob, arg)
3162        } else if let Some(builtin) = func.as_builtin() {
3163            // Force the arg for builtins — they expect concrete values.
3164            let arg = self.force_value(arg)?;
3165            if let Some(result) = self.try_vm_builtin(builtin.name, &arg)? {
3166                Ok(result)
3167            } else {
3168                // Deep-force: builtins iterate over container elements.
3169                let deep = self.deep_force(arg)?;
3170                let arg_vmval = deep.to_vmvalue();
3171                let builtin_func = builtin.func.clone();
3172                let result = self.call_builtin_with_scoped_import_dispatch(
3173                    builtin_func, arg_vmval,
3174                )?;
3175                Ok(result)
3176            }
3177        } else {
3178            Err(VMError::NotCallable(func.type_name().to_string()))
3179        }
3180    }
3181    #[allow(clippy::too_many_lines)]
3182    fn call_higher_order_builtin(
3183        &mut self,
3184        hob: &HigherOrderBuiltin,
3185        arg: NanBox,
3186    ) -> Result<NanBox, VMError> {
3187        use HigherOrderOp::*;
3188        // Force the argument — higher-order builtins need concrete values.
3189        // Use shallow_force_container to handle thunked list elements.
3190        let arg = self.force_value(arg)?;
3191        match hob.op {
3192            Map => {
3193                let list_val = arg.to_vmvalue();
3194                let list = match &list_val {
3195                    VMValue::List(l) => l,
3196                    other => return Err(VMError::TypeError {
3197                        expected: "list", got: other.type_name(),
3198                        context: "builtins.map".to_string(),
3199                    }),
3200                };
3201                let func_nb = NanBox::from_vmvalue(&hob.func);
3202                let mut results = Vec::with_capacity(list.len());
3203                for item in list {
3204                    let r = self.call_callable(&func_nb, NanBox::from_vmvalue(item))?;
3205                    results.push(r);
3206                }
3207                Ok(NanBox::list(results))
3208            }
3209            Filter => {
3210                let list_val = arg.to_vmvalue();
3211                let list = match &list_val {
3212                    VMValue::List(l) => l,
3213                    other => return Err(VMError::TypeError {
3214                        expected: "list", got: other.type_name(),
3215                        context: "builtins.filter".to_string(),
3216                    }),
3217                };
3218                let func_nb = NanBox::from_vmvalue(&hob.func);
3219                let mut results = Vec::new();
3220                for item in list {
3221                    let item_nb = NanBox::from_vmvalue(item);
3222                    let r = self.call_callable(&func_nb, item_nb.clone())?;
3223                    
3224                    if r.is_truthy()? { results.push(item_nb); }
3225                }
3226                Ok(NanBox::list(results))
3227            }
3228            FoldlP1 => {
3229                let init_vmval = arg.to_vmvalue();
3230                Ok(NanBox::from_vmvalue(&VMValue::HigherOrderBuiltin(
3231                    HigherOrderBuiltin {
3232                        op: FoldlP2,
3233                        func: hob.func.clone(),
3234                        extra_args: vec![init_vmval],
3235                    },
3236                )))
3237            }
3238            FoldlP2 => {
3239                let list_val = arg.to_vmvalue();
3240                let list = match &list_val {
3241                    VMValue::List(l) => l,
3242                    other => return Err(VMError::TypeError {
3243                        expected: "list", got: other.type_name(),
3244                        context: "builtins.foldl'".to_string(),
3245                    }),
3246                };
3247                let func_nb = NanBox::from_vmvalue(&hob.func);
3248                let mut acc = NanBox::from_vmvalue(&hob.extra_args[0]);
3249                for item in list {
3250                    let partial = self.call_callable(&func_nb, acc)?;
3251                    acc = self.call_callable(&partial, NanBox::from_vmvalue(item))?;
3252                }
3253                Ok(acc)
3254            }
3255            Sort => {
3256                let list_val = arg.to_vmvalue();
3257                let list = match &list_val {
3258                    VMValue::List(l) => l.clone(),
3259                    other => return Err(VMError::TypeError {
3260                        expected: "list", got: other.type_name(),
3261                        context: "builtins.sort".to_string(),
3262                    }),
3263                };
3264                if list.len() <= 1 {
3265                    return Ok(NanBox::from_vmvalue(&VMValue::List(list)));
3266                }
3267                let func_nb = NanBox::from_vmvalue(&hob.func);
3268                let mut sorted: Vec<VMValue> = Vec::with_capacity(list.len());
3269                for item in &list {
3270                    let item_nb = NanBox::from_vmvalue(item);
3271                    let mut pos = sorted.len();
3272                    for (i, existing) in sorted.iter().enumerate() {
3273                        let existing_nb = NanBox::from_vmvalue(existing);
3274                        let partial = self.call_callable(&func_nb, item_nb.clone())?;
3275                        let cmp_result = self.call_callable(&partial, existing_nb)?;
3276                        if cmp_result.is_truthy()? { pos = i; break; }
3277                    }
3278                    sorted.insert(pos, item.clone());
3279                }
3280                Ok(NanBox::from_vmvalue(&VMValue::List(sorted)))
3281            }
3282            GenList => {
3283                let n = match arg.to_vmvalue() {
3284                    VMValue::Int(n) => n,
3285                    other => return Err(VMError::TypeError {
3286                        expected: "int", got: other.type_name(),
3287                        context: "builtins.genList".to_string(),
3288                    }),
3289                };
3290                if n < 0 { return Err(VMError::Throw("genList: negative length".to_string())); }
3291                let func_nb = NanBox::from_vmvalue(&hob.func);
3292                let mut results = Vec::with_capacity(n as usize);
3293                for i in 0..n {
3294                    results.push(self.call_callable(&func_nb, NanBox::int(i))?);
3295                }
3296                Ok(NanBox::list(results))
3297            }
3298            ConcatMap => {
3299                let list_val = arg.to_vmvalue();
3300                let list = match &list_val {
3301                    VMValue::List(l) => l,
3302                    other => return Err(VMError::TypeError {
3303                        expected: "list", got: other.type_name(),
3304                        context: "builtins.concatMap".to_string(),
3305                    }),
3306                };
3307                let func_nb = NanBox::from_vmvalue(&hob.func);
3308                let mut results = Vec::new();
3309                for item in list {
3310                    let mapped = self.call_callable(&func_nb, NanBox::from_vmvalue(item))?;
3311                    match mapped.to_vmvalue() {
3312                        VMValue::List(inner) => {
3313                            for v in &inner { results.push(NanBox::from_vmvalue(v)); }
3314                        }
3315                        other => return Err(VMError::TypeError {
3316                            expected: "list", got: other.type_name(),
3317                            context: "builtins.concatMap result".to_string(),
3318                        }),
3319                    }
3320                }
3321                Ok(NanBox::list(results))
3322            }
3323            Any => {
3324                let list_val = arg.to_vmvalue();
3325                let list = match &list_val {
3326                    VMValue::List(l) => l,
3327                    other => return Err(VMError::TypeError {
3328                        expected: "list", got: other.type_name(),
3329                        context: "builtins.any".to_string(),
3330                    }),
3331                };
3332                let func_nb = NanBox::from_vmvalue(&hob.func);
3333                for item in list {
3334                    if self.call_callable(&func_nb, NanBox::from_vmvalue(item))?.is_truthy()? {
3335                        return Ok(NanBox::bool(true));
3336                    }
3337                }
3338                Ok(NanBox::bool(false))
3339            }
3340            All => {
3341                let list_val = arg.to_vmvalue();
3342                let list = match &list_val {
3343                    VMValue::List(l) => l,
3344                    other => return Err(VMError::TypeError {
3345                        expected: "list", got: other.type_name(),
3346                        context: "builtins.all".to_string(),
3347                    }),
3348                };
3349                let func_nb = NanBox::from_vmvalue(&hob.func);
3350                for item in list {
3351                    if !self.call_callable(&func_nb, NanBox::from_vmvalue(item))?.is_truthy()? {
3352                        return Ok(NanBox::bool(false));
3353                    }
3354                }
3355                Ok(NanBox::bool(true))
3356            }
3357            Partition => {
3358                let list_val = arg.to_vmvalue();
3359                let list = match &list_val {
3360                    VMValue::List(l) => l,
3361                    other => return Err(VMError::TypeError {
3362                        expected: "list", got: other.type_name(),
3363                        context: "builtins.partition".to_string(),
3364                    }),
3365                };
3366                let func_nb = NanBox::from_vmvalue(&hob.func);
3367                let (mut right, mut wrong) = (Vec::new(), Vec::new());
3368                for item in list {
3369                    let item_nb = NanBox::from_vmvalue(item);
3370                    if self.call_callable(&func_nb, item_nb.clone())?.is_truthy()? {
3371                        right.push(item_nb);
3372                    } else {
3373                        wrong.push(item_nb);
3374                    }
3375                }
3376                let rs = self.interner.intern("right");
3377                let ws = self.interner.intern("wrong");
3378                let mut attrs = BTreeMap::new();
3379                attrs.insert(rs, NanBox::list(right));
3380                attrs.insert(ws, NanBox::list(wrong));
3381                Ok(NanBox::attrs(attrs))
3382            }
3383            GroupBy => {
3384                let list_val = arg.to_vmvalue();
3385                let list = match &list_val {
3386                    VMValue::List(l) => l,
3387                    other => return Err(VMError::TypeError {
3388                        expected: "list", got: other.type_name(),
3389                        context: "builtins.groupBy".to_string(),
3390                    }),
3391                };
3392                let func_nb = NanBox::from_vmvalue(&hob.func);
3393                let mut groups: BTreeMap<String, Vec<NanBox>> = BTreeMap::new();
3394                for item in list {
3395                    let item_nb = NanBox::from_vmvalue(item);
3396                    let kr = self.call_callable(&func_nb, item_nb.clone())?;
3397                    let ks = kr.as_string().ok_or_else(|| VMError::TypeError {
3398                        expected: "string", got: kr.type_name(),
3399                        context: "builtins.groupBy key".to_string(),
3400                    })?.to_string();
3401                    groups.entry(ks).or_default().push(item_nb);
3402                }
3403                let mut attrs = BTreeMap::new();
3404                for (k, vs) in groups {
3405                    attrs.insert(self.interner.intern(&k), NanBox::list(vs));
3406                }
3407                Ok(NanBox::attrs(attrs))
3408            }
3409            MapAttrs => {
3410                let attrs_val = arg.to_vmvalue();
3411                let attrs = match &attrs_val {
3412                    VMValue::Attrs(a) => a,
3413                    other => return Err(VMError::TypeError {
3414                        expected: "set", got: other.type_name(),
3415                        context: "builtins.mapAttrs".to_string(),
3416                    }),
3417                };
3418                let func_nb = NanBox::from_vmvalue(&hob.func);
3419                let entries: Vec<_> = attrs.iter().map(|(k, v)| (*k, v.clone())).collect();
3420                let chunk = deferred_apply_chunk();
3421                let mut result = BTreeMap::new();
3422                for (sym, val) in entries {
3423                    let key_str = self.interner.resolve(sym).to_string();
3424                    // Eagerly apply f to the key name (partial application).
3425                    // This is cheap — it just creates a closure capturing the key.
3426                    let partial = self.call_callable(&func_nb, NanBox::string(key_str))?;
3427                    // Defer the second application (partial value) as a thunk.
3428                    // This matches CppNix semantics: mapAttrs is lazy in values.
3429                    // Upvalues are NanBoxes: `partial` already is one; `val`
3430                    // came off the VMValue attrset so convert it locally here
3431                    // (this is a per-entry conversion, not the per-Call/thunk
3432                    // round-trip the optimization removes).
3433                    let thunk = VMThunk::new(
3434                        chunk.clone(),
3435                        vec![partial, NanBox::from_vmvalue(&val)],
3436                    );
3437                    result.insert(sym, NanBox::thunk(thunk));
3438                }
3439                Ok(NanBox::attrs(result))
3440            }
3441            FilterAttrs => {
3442                let attrs_val = arg.to_vmvalue();
3443                let attrs = match &attrs_val {
3444                    VMValue::Attrs(a) => a,
3445                    other => return Err(VMError::TypeError {
3446                        expected: "set", got: other.type_name(),
3447                        context: "builtins.filterAttrs".to_string(),
3448                    }),
3449                };
3450                let func_nb = NanBox::from_vmvalue(&hob.func);
3451                let entries: Vec<_> = attrs.iter().map(|(k, v)| (*k, v.clone())).collect();
3452                let mut result = BTreeMap::new();
3453                for (sym, val) in entries {
3454                    let key_str = self.interner.resolve(sym).to_string();
3455                    let partial = self.call_callable(&func_nb, NanBox::string(key_str))?;
3456                    if self.call_callable(&partial, NanBox::from_vmvalue(&val))?.is_truthy()? {
3457                        result.insert(sym, NanBox::from_vmvalue(&val));
3458                    }
3459                }
3460                Ok(NanBox::attrs(result))
3461            }
3462            Elem => {
3463                // builtins.elem needle list — check if needle is in list.
3464                // Needs VM-level handling because list elements may be thunks
3465                // that must be forced before equality comparison.
3466                // Uses deep_eq which recursively forces nested values.
3467                let needle = NanBox::from_vmvalue(&hob.func);
3468                let forced_needle = self.force_value(needle)?;
3469                let list = if let Some(items) = arg.as_list() {
3470                    items.to_vec()
3471                } else {
3472                    let forced = self.force_value(arg)?;
3473                    if let Some(items) = forced.as_list() {
3474                        items.to_vec()
3475                    } else {
3476                        return Err(VMError::TypeError {
3477                            expected: "list",
3478                            got: forced.type_name(),
3479                            context: "builtins.elem".to_string(),
3480                        });
3481                    }
3482                };
3483                for item in &list {
3484                    let forced_item = self.force_value(item.clone())?;
3485                    if self.deep_eq(&forced_needle, &forced_item)? {
3486                        return Ok(NanBox::bool(true));
3487                    }
3488                }
3489                Ok(NanBox::bool(false))
3490            }
3491        }
3492    }
3493    // -- Import ---------------------------------------------------------
3494    /// Import a Nix file: compile it, execute it, cache the result.
3495    ///
3496    /// Handles the Nix convention that importing a directory is equivalent
3497    /// to importing `<directory>/default.nix`.
3498    fn import_file(&mut self, path: &str) -> Result<NanBox, VMError> {
3499        let resolved = std::fs::canonicalize(path)
3500            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3501        // Directory → default.nix fallback (Nix convention).
3502        let resolved = if resolved.is_dir() {
3503            resolved.join("default.nix")
3504        } else {
3505            resolved
3506        };
3507        let canonical = resolved.to_string_lossy().to_string();
3508        // Check cache.
3509        if let Some(cached) = self.import_cache.borrow().get(&canonical) {
3510            return Ok(NanBox::from_vmvalue(cached));
3511        }
3512        // Try VM compilation, falling back to tree-walker on CompileError.
3513        let chunk = self.try_compile_import(&resolved, &canonical)?;
3514        let chunk = match chunk {
3515            Some(c) => c,
3516            None => {
3517                // Compilation failed — fall back to tree-walker via bridge.
3518                return self.import_via_bridge(&canonical);
3519            }
3520        };
3521        if self.frames.len() >= MAX_CALL_DEPTH {
3522            return Err(VMError::StackOverflow);
3523        }
3524        let return_depth = self.frames.len();
3525        let stack_base = self.stack.len();
3526        self.frames.push(CallFrame {
3527            chunk,
3528            ip: 0,
3529            stack_base,
3530            upvalues: Vec::new(),
3531        });
3532        let result = match self.run_until(return_depth) {
3533            Ok(r) => r,
3534            Err(e @ VMError::Throw(_)) => {
3535                // Nix throw must propagate so tryEval can catch it.
3536                self.stack.truncate(stack_base);
3537                if self.frames.len() > return_depth {
3538                    self.frames.truncate(return_depth);
3539                }
3540                return Err(e);
3541            }
3542            Err(e) => {
3543                // Any other error — fall back to tree-walker for this file.
3544                // This includes AttrNotFound, TypeError, AssertionFailed, etc.
3545                eprintln!("[sui-vm] runtime fallback for {canonical}: {e}");
3546                use std::sync::atomic::Ordering;
3547                crate::vm::VM_FALLBACK_COUNT.fetch_add(1, Ordering::Relaxed);
3548                self.stack.truncate(stack_base);
3549                if self.frames.len() > return_depth {
3550                    self.frames.truncate(return_depth);
3551                }
3552                return self.import_via_bridge(&canonical);
3553            }
3554        };
3555        // Clean up the imported frame's stack slots.
3556        // Return at stop_depth skips truncation, so we must do it here.
3557        self.stack.truncate(stack_base);
3558        // Cache as VMValue and return as NanBox.
3559        let result_vmval = result.to_vmvalue();
3560        self.import_cache
3561            .borrow_mut()
3562            .insert(canonical, result_vmval);
3563        Ok(result)
3564    }
3565    /// Try to compile an imported file. Returns `Ok(Some(chunk))` on success,
3566    /// `Ok(None)` on `CompileError` (caller should fall back to tree-walker),
3567    /// or `Err` on I/O errors.
3568    fn try_compile_import(
3569        &mut self,
3570        resolved: &std::path::Path,
3571        canonical: &str,
3572    ) -> Result<Option<Rc<Chunk>>, VMError> {
3573        // Check compile cache — skip parse + compile if we've seen this file.
3574        if let Some(cached_chunk) = self.compile_cache.get(resolved) {
3575            return Ok(Some(cached_chunk.clone()));
3576        }
3577        // Read the file.
3578        let source = std::fs::read_to_string(canonical)
3579            .map_err(|e| VMError::ImportError(format!("{canonical}: {e}")))?;
3580        let file_dir = resolved
3581            .parent()
3582            .map(|p| p.to_path_buf())
3583            .unwrap_or_default();
3584        // Share the VM's interner with the compiler so that symbol IDs
3585        // are consistent — no need to clear key_symbols afterwards.
3586        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
3587        let compile_result =
3588            Compiler::compile_with_shared_interner(&source, file_dir, shared_interner.clone());
3589        *self.interner = match Rc::try_unwrap(shared_interner) {
3590            Ok(cell) => cell.into_inner(),
3591            Err(rc) => rc.borrow().clone(),
3592        };
3593        match compile_result {
3594            Ok(mut compiled) => {
3595                Self::set_source_file_recursive(&mut compiled, canonical);
3596                let chunk = Rc::new(compiled);
3597                self.compile_cache
3598                    .insert(resolved.to_path_buf(), chunk.clone());
3599                Ok(Some(chunk))
3600            }
3601            Err(compile_error) => {
3602                // Compilation failed (unsupported expression, etc.) —
3603                // signal caller to fall back to tree-walker.
3604                VM_FALLBACK_COUNT.fetch_add(1, Ordering::Relaxed);
3605                eprintln!("[sui-vm] fallback to tree-walker for {canonical}: {compile_error}");
3606                Ok(None)
3607            }
3608        }
3609    }
3610    /// Fall back to tree-walker evaluation for an imported file via the
3611    /// builtin bridge. Called when the bytecode compiler cannot handle
3612    /// the file (e.g. unsupported AST constructs).
3613    fn import_via_bridge(&mut self, canonical: &str) -> Result<NanBox, VMError> {
3614        match crate::bridge::call_builtin_bridge(
3615            "__import",
3616            vec![crate::value::StringKeyedValue::Path(canonical.to_string())],
3617        ) {
3618            Ok(Some(result)) => {
3619                let nanbox = self.string_keyed_to_nanbox(&result);
3620                // Force the top-level result so callers get a concrete
3621                // value (not a thunk). Bridge results may be thunked
3622                // when the tree-walker wraps unevaluated expressions.
3623                let nanbox = if nanbox.is_thunk() {
3624                    self.force_value(nanbox)?
3625                } else {
3626                    nanbox
3627                };
3628                // Cache as VMValue so subsequent imports hit the cache.
3629                let result_vmval = nanbox.to_vmvalue();
3630                self.import_cache
3631                    .borrow_mut()
3632                    .insert(canonical.to_string(), result_vmval);
3633                Ok(nanbox)
3634            }
3635            Ok(None) => Err(VMError::ImportError(format!(
3636                "compilation failed and no bridge installed for '{canonical}'"
3637            ))),
3638            Err(e) => Err(VMError::ImportError(format!(
3639                "bridge fallback error for '{canonical}': {e}"
3640            ))),
3641        }
3642    }
3643    /// Recursively set `source_file` on a chunk and all nested closure chunks.
3644    fn set_source_file_recursive(chunk: &mut Chunk, file: &str) {
3645        chunk.source_file = Some(file.to_string());
3646        for constant in &mut chunk.constants {
3647            if let VMValue::Closure(closure) = constant {
3648                if let Some(inner_chunk) = Rc::get_mut(&mut closure.chunk) {
3649                    Self::set_source_file_recursive(inner_chunk, file);
3650                }
3651            }
3652        }
3653    }
3654    /// Disassemble instructions around a given offset for error diagnostics.
3655    /// Returns a human-readable string showing `window` instructions before
3656    /// and after `center_ip`, with an arrow marking the center.
3657    fn disassemble_around(chunk: &Chunk, center_ip: usize, window: usize) -> String {
3658        let code = &chunk.code;
3659        let mut lines: Vec<String> = Vec::new();
3660        // Collect instruction boundaries by scanning from the start.
3661        let mut boundaries: Vec<usize> = Vec::new();
3662        let mut pos = 0;
3663        while pos < code.len() {
3664            boundaries.push(pos);
3665            pos += Self::instruction_width(code, pos);
3666        }
3667        // Find the boundary closest to center_ip.
3668        let center_idx = boundaries.iter().position(|&b| b >= center_ip).unwrap_or(0);
3669        let start_idx = center_idx.saturating_sub(window);
3670        let end_idx = (center_idx + window + 1).min(boundaries.len());
3671        for idx in start_idx..end_idx {
3672            let ip = boundaries[idx];
3673            let marker = if ip == center_ip { ">>>" } else { "   " };
3674            let line = chunk.lines.get(ip).copied().unwrap_or(0);
3675            if let Some(op) = OpCode::from_byte(code[ip]) {
3676                let operands = Self::format_operands(code, ip, op);
3677                lines.push(format!("    {marker} {ip:4}: {op:?}{operands}  (line {line})"));
3678            } else {
3679                lines.push(format!("    {marker} {ip:4}: <unknown {}>  (line {line})", code[ip]));
3680            }
3681        }
3682        lines.join("\n")
3683    }
3684    /// Determine the total byte width of an instruction at `pos`.
3685    fn instruction_width(code: &[u8], pos: usize) -> usize {
3686        let byte = code[pos];
3687        match OpCode::from_byte(byte) {
3688            Some(op) => match op {
3689                // No operands (1 byte):
3690                OpCode::Null | OpCode::True | OpCode::False
3691                | OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div | OpCode::Negate
3692                | OpCode::Not | OpCode::And | OpCode::Or | OpCode::Implication
3693                | OpCode::Equal | OpCode::NotEqual | OpCode::Less | OpCode::Greater
3694                | OpCode::LessEqual | OpCode::GreaterEqual
3695                | OpCode::UpdateAttrs | OpCode::Concat
3696                | OpCode::Call | OpCode::TailCall | OpCode::Return
3697                | OpCode::Assert | OpCode::Throw | OpCode::Pop | OpCode::Dup | OpCode::PushWith | OpCode::PopWith
3698                | OpCode::PushBuiltins | OpCode::Force | OpCode::Import
3699                | OpCode::DynGetAttr | OpCode::DynHasAttr
3700                | OpCode::DynSelectOrDefault | OpCode::Dup => 1,
3701                // 1 u16 operand (3 bytes):
3702                OpCode::Constant | OpCode::GetLocal | OpCode::SetLocal
3703                | OpCode::GetUpvalue | OpCode::SetUpvalue | OpCode::LookupWith
3704                | OpCode::GetAttr | OpCode::HasAttr | OpCode::MakeAttrs
3705                | OpCode::SelectOrDefault | OpCode::MakeList
3706                | OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue
3707                | OpCode::Interpolate => 3,
3708                // 2 u16 operands (5 bytes):
3709                OpCode::GetLocalAttr | OpCode::GetLocalCall | OpCode::CallBuiltin => 5,
3710                // MakeClosure: u16 const_idx, u16 uv_count, then uv_count * 3 bytes
3711                OpCode::MakeClosure => {
3712                    if pos + 5 <= code.len() {
3713                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3714                        5 + uv_count * 3
3715                    } else {
3716                        3 // truncated
3717                    }
3718                }
3719                // MakeThunk: u16 const_idx, u16 uv_count, then uv_count * 3 bytes
3720                OpCode::MakeThunk => {
3721                    if pos + 5 <= code.len() {
3722                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3723                        5 + uv_count * 3
3724                    } else {
3725                        3
3726                    }
3727                }
3728                // PatchThunkUpvalues: u16 slot, u16 uv_count, then uv_count * 3 bytes
3729                OpCode::PatchThunkUpvalues => {
3730                    if pos + 5 <= code.len() {
3731                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3732                        5 + uv_count * 3
3733                    } else {
3734                        3
3735                    }
3736                }
3737                // MakeLazyThunk: u16 src, u32 offset, u32 length, u16 dir, u16 uv_count, then uv_count * 3
3738                OpCode::MakeLazyThunk => {
3739                    if pos + 15 <= code.len() {
3740                        let uv_count = u16::from_le_bytes([code[pos + 13], code[pos + 14]]) as usize;
3741                        15 + uv_count * 3
3742                    } else {
3743                        3
3744                    }
3745                }
3746            },
3747            None => 1, // unknown opcode, skip 1
3748        }
3749    }
3750    /// Format inline operands for a single instruction (for disassembly).
3751    fn format_operands(code: &[u8], pos: usize, op: OpCode) -> String {
3752        let read_u16_at = |p: usize| -> Option<u16> {
3753            if p + 2 <= code.len() {
3754                Some(u16::from_le_bytes([code[p], code[p + 1]]))
3755            } else {
3756                None
3757            }
3758        };
3759        match op {
3760            OpCode::Constant | OpCode::GetLocal | OpCode::SetLocal
3761            | OpCode::GetUpvalue | OpCode::SetUpvalue | OpCode::LookupWith
3762            | OpCode::GetAttr | OpCode::HasAttr | OpCode::MakeAttrs
3763            | OpCode::SelectOrDefault | OpCode::MakeList
3764            | OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue
3765            | OpCode::Interpolate => {
3766                read_u16_at(pos + 1).map_or(String::new(), |v| format!(" {v}"))
3767            }
3768            OpCode::GetLocalAttr => {
3769                let s = read_u16_at(pos + 1).unwrap_or(0);
3770                let k = read_u16_at(pos + 3).unwrap_or(0);
3771                format!(" slot={s} key={k}")
3772            }
3773            OpCode::GetLocalCall => {
3774                read_u16_at(pos + 1).map_or(String::new(), |v| format!(" slot={v}"))
3775            }
3776            OpCode::CallBuiltin => {
3777                let idx = read_u16_at(pos + 1).unwrap_or(0);
3778                let argc = read_u16_at(pos + 3).unwrap_or(0);
3779                format!(" idx={idx} argc={argc}")
3780            }
3781            OpCode::MakeThunk | OpCode::MakeClosure => {
3782                let ci = read_u16_at(pos + 1).unwrap_or(0);
3783                let uv = read_u16_at(pos + 3).unwrap_or(0);
3784                format!(" const={ci} upvals={uv}")
3785            }
3786            OpCode::PatchThunkUpvalues => {
3787                let s = read_u16_at(pos + 1).unwrap_or(0);
3788                let uv = read_u16_at(pos + 3).unwrap_or(0);
3789                format!(" slot={s} upvals={uv}")
3790            }
3791            _ => String::new(),
3792        }
3793    }
3794}
3795#[cfg(test)]
3796mod tests {
3797    use super::*;
3798    use crate::compiler::Compiler;
3799    use crate::value::StringKeyedValue;
3800    fn eval(input: &str) -> VMValue {
3801        let (chunk, mut interner) =
3802            Compiler::compile(input).unwrap_or_else(|e| panic!("compile '{input}': {e}"));
3803        VM::execute(chunk, &mut interner).unwrap_or_else(|e| panic!("execute '{input}': {e}"))
3804    }
3805    fn eval_full_helper(input: &str) -> crate::StringKeyedValue {
3806        let result =
3807            crate::eval_full(input).unwrap_or_else(|e| panic!("eval_full '{input}': {e}"));
3808        result.to_string_keyed()
3809    }
3810    fn eval_err(input: &str) -> VMError {
3811        let (chunk, mut interner) =
3812            Compiler::compile(input).unwrap_or_else(|e| panic!("compile '{input}': {e}"));
3813        VM::execute(chunk, &mut interner).unwrap_err()
3814    }
3815    // -- Literals -------------------------------------------------------
3816    #[test]
3817    fn eval_integer() {
3818        assert_eq!(eval("42"), VMValue::Int(42));
3819    }
3820    #[test]
3821    fn eval_negative_integer() {
3822        assert_eq!(eval("-7"), VMValue::Int(-7));
3823    }
3824    #[test]
3825    fn eval_float() {
3826        assert_eq!(eval("3.14"), VMValue::Float(3.14));
3827    }
3828    #[test]
3829    fn eval_bool_true() {
3830        assert_eq!(eval("true"), VMValue::Bool(true));
3831    }
3832    #[test]
3833    fn eval_bool_false() {
3834        assert_eq!(eval("false"), VMValue::Bool(false));
3835    }
3836    #[test]
3837    fn eval_null() {
3838        assert_eq!(eval("null"), VMValue::Null);
3839    }
3840    #[test]
3841    fn eval_string() {
3842        assert_eq!(eval(r#""hello""#), VMValue::String("hello".to_string()));
3843    }
3844    // -- Arithmetic -----------------------------------------------------
3845    #[test]
3846    fn eval_add_int() {
3847        assert_eq!(eval("1 + 2"), VMValue::Int(3));
3848    }
3849    #[test]
3850    fn eval_sub_int() {
3851        assert_eq!(eval("10 - 3"), VMValue::Int(7));
3852    }
3853    #[test]
3854    fn eval_mul_int() {
3855        assert_eq!(eval("3 * 4"), VMValue::Int(12));
3856    }
3857    #[test]
3858    fn eval_div_int() {
3859        assert_eq!(eval("10 / 3"), VMValue::Int(3));
3860    }
3861    #[test]
3862    fn eval_div_zero() {
3863        assert!(matches!(eval_err("1 / 0"), VMError::DivisionByZero));
3864    }
3865    #[test]
3866    fn eval_float_arithmetic() {
3867        assert_eq!(eval("1.5 + 2.5"), VMValue::Float(4.0));
3868    }
3869    #[test]
3870    fn eval_mixed_arithmetic() {
3871        assert_eq!(eval("1 + 2.0"), VMValue::Float(3.0));
3872    }
3873    #[test]
3874    fn eval_compound_arithmetic() {
3875        assert_eq!(eval("2 * 3 + 1"), VMValue::Int(7));
3876    }
3877    #[test]
3878    fn eval_negate_float() {
3879        assert_eq!(eval("-3.14"), VMValue::Float(-3.14));
3880    }
3881    #[test]
3882    fn eval_string_concat() {
3883        assert_eq!(
3884            eval(r#""hello" + " " + "world""#),
3885            VMValue::String("hello world".to_string())
3886        );
3887    }
3888    // -- Comparison -----------------------------------------------------
3889    #[test]
3890    fn eval_equal() {
3891        assert_eq!(eval("1 == 1"), VMValue::Bool(true));
3892        assert_eq!(eval("1 == 2"), VMValue::Bool(false));
3893    }
3894    #[test]
3895    fn eval_not_equal() {
3896        assert_eq!(eval("1 != 2"), VMValue::Bool(true));
3897        assert_eq!(eval("1 != 1"), VMValue::Bool(false));
3898    }
3899    #[test]
3900    fn eval_less() {
3901        assert_eq!(eval("1 < 2"), VMValue::Bool(true));
3902        assert_eq!(eval("2 < 1"), VMValue::Bool(false));
3903    }
3904    #[test]
3905    fn eval_greater() {
3906        assert_eq!(eval("2 > 1"), VMValue::Bool(true));
3907        assert_eq!(eval("1 > 2"), VMValue::Bool(false));
3908    }
3909    #[test]
3910    fn eval_less_equal() {
3911        assert_eq!(eval("1 <= 1"), VMValue::Bool(true));
3912        assert_eq!(eval("1 <= 2"), VMValue::Bool(true));
3913        assert_eq!(eval("2 <= 1"), VMValue::Bool(false));
3914    }
3915    #[test]
3916    fn eval_greater_equal() {
3917        assert_eq!(eval("1 >= 1"), VMValue::Bool(true));
3918        assert_eq!(eval("2 >= 1"), VMValue::Bool(true));
3919        assert_eq!(eval("1 >= 2"), VMValue::Bool(false));
3920    }
3921    // -- Logical --------------------------------------------------------
3922    #[test]
3923    fn eval_not() {
3924        assert_eq!(eval("!true"), VMValue::Bool(false));
3925        assert_eq!(eval("!false"), VMValue::Bool(true));
3926    }
3927    #[test]
3928    fn eval_and_short_circuit() {
3929        assert_eq!(eval("true && true"), VMValue::Bool(true));
3930        assert_eq!(eval("true && false"), VMValue::Bool(false));
3931        assert_eq!(eval("false && true"), VMValue::Bool(false));
3932    }
3933    #[test]
3934    fn eval_or_short_circuit() {
3935        assert_eq!(eval("false || true"), VMValue::Bool(true));
3936        assert_eq!(eval("false || false"), VMValue::Bool(false));
3937        assert_eq!(eval("true || false"), VMValue::Bool(true));
3938    }
3939    #[test]
3940    fn eval_implication() {
3941        assert_eq!(eval("true -> true"), VMValue::Bool(true));
3942        assert_eq!(eval("true -> false"), VMValue::Bool(false));
3943        assert_eq!(eval("false -> true"), VMValue::Bool(true));
3944        assert_eq!(eval("false -> false"), VMValue::Bool(true));
3945    }
3946    // -- Conditionals ---------------------------------------------------
3947    #[test]
3948    fn eval_if_true() {
3949        assert_eq!(eval("if true then 1 else 2"), VMValue::Int(1));
3950    }
3951    #[test]
3952    fn eval_if_false() {
3953        assert_eq!(eval("if false then 1 else 2"), VMValue::Int(2));
3954    }
3955    #[test]
3956    fn eval_if_expression() {
3957        assert_eq!(
3958            eval("if 1 > 2 then \"yes\" else \"no\""),
3959            VMValue::String("no".to_string())
3960        );
3961    }
3962    #[test]
3963    fn eval_nested_if() {
3964        assert_eq!(
3965            eval("if true then (if false then 1 else 2) else 3"),
3966            VMValue::Int(2)
3967        );
3968    }
3969    // -- Let/in ---------------------------------------------------------
3970    #[test]
3971    fn eval_let_simple() {
3972        assert_eq!(eval("let x = 1; y = 2; in x + y"), VMValue::Int(3));
3973    }
3974    #[test]
3975    fn eval_let_nested() {
3976        assert_eq!(
3977            eval("let a = 10; in let b = 20; in a + b"),
3978            VMValue::Int(30)
3979        );
3980    }
3981    #[test]
3982    fn eval_let_shadow() {
3983        assert_eq!(eval("let x = 1; in let x = 2; in x"), VMValue::Int(2));
3984    }
3985    #[test]
3986    fn eval_let_with_expression() {
3987        assert_eq!(eval("let x = 2 * 3; in x + 1"), VMValue::Int(7));
3988    }
3989    // -- Lists ----------------------------------------------------------
3990    #[test]
3991    fn eval_empty_list() {
3992        assert_eq!(eval("[]"), VMValue::List(vec![]));
3993    }
3994    #[test]
3995    fn eval_list() {
3996        assert_eq!(
3997            eval("[1 2 3]"),
3998            VMValue::List(vec![VMValue::Int(1), VMValue::Int(2), VMValue::Int(3)])
3999        );
4000    }
4001    #[test]
4002    fn eval_list_concat() {
4003        assert_eq!(
4004            eval("[1 2] ++ [3 4]"),
4005            VMValue::List(vec![
4006                VMValue::Int(1),
4007                VMValue::Int(2),
4008                VMValue::Int(3),
4009                VMValue::Int(4),
4010            ])
4011        );
4012    }
4013    #[test]
4014    fn eval_list_concat_with_inline_map() {
4015        // Regression: call_callable did not truncate the stack after
4016        // run_until, so map's per-element calls leaked values that
4017        // shifted the Concat operands on the stack.
4018        assert_eq!(
4019            eval("[1] ++ builtins.map (a: a) [2 3]"),
4020            VMValue::List(vec![VMValue::Int(1), VMValue::Int(2), VMValue::Int(3)])
4021        );
4022    }
4023    #[test]
4024    fn eval_list_concat_with_inline_map_attrsets() {
4025        // Same regression with attrset-producing map (the nixpkgs pattern).
4026        let result = eval(r#"[{ x = 1; }] ++ builtins.map (a: { v = a; }) ["a" "b"]"#);
4027        match result {
4028            VMValue::List(items) => assert_eq!(items.len(), 3),
4029            other => panic!("expected list, got {:?}", other.type_name()),
4030        }
4031    }
4032    #[test]
4033    fn eval_list_concat_with_inline_filter() {
4034        // Also verify filter (another higher-order builtin) with ++.
4035        assert_eq!(
4036            eval("[0] ++ builtins.filter (x: x > 1) [1 2 3]"),
4037            VMValue::List(vec![VMValue::Int(0), VMValue::Int(2), VMValue::Int(3)])
4038        );
4039    }
4040    #[test]
4041    fn eval_list_mixed() {
4042        assert_eq!(
4043            eval(r#"[1 "hello" true]"#),
4044            VMValue::List(vec![
4045                VMValue::Int(1),
4046                VMValue::String("hello".to_string()),
4047                VMValue::Bool(true),
4048            ])
4049        );
4050    }
4051    // -- Attribute sets -------------------------------------------------
4052    #[test]
4053    fn eval_empty_attrset() {
4054        assert_eq!(eval("{ }"), VMValue::Attrs(BTreeMap::new()));
4055    }
4056    #[test]
4057    fn eval_attrset() {
4058        let result = eval_full_helper("{ a = 1; b = 2; }");
4059        let mut expected = BTreeMap::new();
4060        expected.insert("a".to_string(), crate::StringKeyedValue::Int(1));
4061        expected.insert("b".to_string(), crate::StringKeyedValue::Int(2));
4062        assert_eq!(result, crate::StringKeyedValue::Attrs(expected));
4063    }
4064    #[test]
4065    fn eval_attrset_select() {
4066        assert_eq!(eval("{ a = 1; b = 2; }.a"), VMValue::Int(1));
4067    }
4068    #[test]
4069    fn eval_attrset_update() {
4070        let result = eval_full_helper("{ a = 1; } // { b = 2; }");
4071        let mut expected = BTreeMap::new();
4072        expected.insert("a".to_string(), crate::StringKeyedValue::Int(1));
4073        expected.insert("b".to_string(), crate::StringKeyedValue::Int(2));
4074        assert_eq!(result, crate::StringKeyedValue::Attrs(expected));
4075    }
4076    #[test]
4077    fn eval_attrset_update_override() {
4078        assert_eq!(eval("({ a = 1; } // { a = 2; }).a"), VMValue::Int(2));
4079    }
4080    #[test]
4081    fn eval_has_attr_true() {
4082        assert_eq!(eval("{ a = 1; } ? a"), VMValue::Bool(true));
4083    }
4084    #[test]
4085    fn eval_has_attr_false() {
4086        assert_eq!(eval("{ a = 1; } ? b"), VMValue::Bool(false));
4087    }
4088    #[test]
4089    fn eval_select_or_default() {
4090        assert_eq!(eval("{ a = 1; }.b or 0"), VMValue::Int(0));
4091        assert_eq!(eval("{ a = 1; }.a or 0"), VMValue::Int(1));
4092    }
4093    #[test]
4094    fn eval_dyn_select_or_default_missing() {
4095        // Dynamic key missing → returns default.
4096        assert_eq!(
4097            eval(r#"let x = "missing"; in { a = 1; }.${ x } or 99"#),
4098            VMValue::Int(99),
4099        );
4100    }
4101    #[test]
4102    fn eval_dyn_select_or_default_found() {
4103        // Dynamic key present → returns actual value.
4104        assert_eq!(
4105            eval(r#"let x = "a"; in { a = 42; }.${ x } or 99"#),
4106            VMValue::Int(42),
4107        );
4108    }
4109    #[test]
4110    fn eval_dyn_select_or_default_dotted_key() {
4111        // Key containing dots treated as single flat key, not nested path.
4112        assert_eq!(
4113            eval(r#"let x = "a.b"; in { "a.b" = 7; }.${ x } or 0"#),
4114            VMValue::Int(7),
4115        );
4116    }
4117    #[test]
4118    fn eval_dyn_select_or_default_special_chars() {
4119        // Key with dots and plus signs (nixpkgs armv8 CPU feature pattern).
4120        assert_eq!(
4121            eval(r#"let x = "armv8.3-a+crypto+sha2"; in { "armv8-a" = 1; }.${ x } or 0"#),
4122            VMValue::Int(0),
4123        );
4124    }
4125    #[test]
4126    fn eval_dyn_select_or_default_non_attrset() {
4127        // Base is not an attrset → returns default.
4128        assert_eq!(
4129            eval(r#"let x = "a"; base = 42; in base.${ x } or 99"#),
4130            VMValue::Int(99),
4131        );
4132    }
4133    // -- Lambdas / Apply ------------------------------------------------
4134    #[test]
4135    fn eval_identity_lambda() {
4136        assert_eq!(eval("(x: x) 42"), VMValue::Int(42));
4137    }
4138    #[test]
4139    fn eval_lambda_arithmetic() {
4140        assert_eq!(eval("(x: x + 1) 5"), VMValue::Int(6));
4141    }
4142    #[test]
4143    #[ignore = "requires upvalue capture (Phase 2)"]
4144    fn eval_curried_lambda() {
4145        assert_eq!(eval("(x: y: x + y) 3 4"), VMValue::Int(7));
4146    }
4147    #[test]
4148    fn eval_let_lambda() {
4149        assert_eq!(
4150            eval("let f = x: x * 2; in f 5"),
4151            VMValue::Int(10)
4152        );
4153    }
4154    #[test]
4155    fn eval_pattern_lambda() {
4156        assert_eq!(eval("({ a, b }: a + b) { a = 3; b = 4; }"), VMValue::Int(7));
4157    }
4158    #[test]
4159    fn eval_pattern_lambda_default() {
4160        assert_eq!(
4161            eval("({ a, b ? 10 }: a + b) { a = 5; }"),
4162            VMValue::Int(15)
4163        );
4164    }
4165    #[test]
4166    fn eval_lambda_with_let() {
4167        assert_eq!(
4168            eval("let inc = x: x + 1; double = x: x * 2; in double (inc 3)"),
4169            VMValue::Int(8)
4170        );
4171    }
4172    // -- Assert ---------------------------------------------------------
4173    #[test]
4174    fn eval_assert_pass() {
4175        assert_eq!(eval("assert true; 42"), VMValue::Int(42));
4176    }
4177    #[test]
4178    fn eval_assert_fail() {
4179        assert!(matches!(eval_err("assert false; 42"), VMError::AssertionFailed));
4180    }
4181    // -- Deep equality (thunk forcing) ----------------------------------
4182    #[test]
4183    fn deep_eq_attrs_with_thunked_values() {
4184        // Attrsets from let bindings have thunked values;
4185        // == must force them before comparison.
4186        assert_eq!(
4187            eval("let a = { x = 1; }; b = { x = 1; }; in a == b"),
4188            VMValue::Bool(true)
4189        );
4190    }
4191    #[test]
4192    fn deep_eq_attrs_different_values() {
4193        assert_eq!(
4194            eval("let a = { x = 1; }; b = { x = 2; }; in a == b"),
4195            VMValue::Bool(false)
4196        );
4197    }
4198    #[test]
4199    fn deep_eq_nested_attrs() {
4200        assert_eq!(
4201            eval("let a = { x = { y = 1; }; }; b = { x = { y = 1; }; }; in a == b"),
4202            VMValue::Bool(true)
4203        );
4204    }
4205    #[test]
4206    fn deep_eq_list_with_thunked_elements() {
4207        assert_eq!(
4208            eval("let a = [ 1 2 ]; b = [ 1 2 ]; in a == b"),
4209            VMValue::Bool(true)
4210        );
4211    }
4212    // -- builtins.elem (thunk forcing) ----------------------------------
4213    #[test]
4214    fn eval_elem_thunked_attrsets() {
4215        // elem must force list elements before comparison.
4216        assert_eq!(
4217            eval("let a = { x = 1; }; b = { x = 1; }; in builtins.elem a [ b ]"),
4218            VMValue::Bool(true)
4219        );
4220    }
4221    #[test]
4222    fn eval_elem_basic_int() {
4223        assert_eq!(
4224            eval("builtins.elem 2 [ 1 2 3 ]"),
4225            VMValue::Bool(true)
4226        );
4227    }
4228    #[test]
4229    fn eval_elem_missing() {
4230        assert_eq!(
4231            eval("builtins.elem 4 [ 1 2 3 ]"),
4232            VMValue::Bool(false)
4233        );
4234    }
4235    #[test]
4236    fn eval_elem_string() {
4237        assert_eq!(
4238            eval(r#"builtins.elem "b" [ "a" "b" "c" ]"#),
4239            VMValue::Bool(true)
4240        );
4241    }
4242    #[test]
4243    fn eval_elem_thunked_list_elements() {
4244        assert_eq!(
4245            eval("let x = 1; in builtins.elem 1 [ x ]"),
4246            VMValue::Bool(true)
4247        );
4248    }
4249    // -- String interpolation -------------------------------------------
4250    #[test]
4251    fn eval_string_interpolation() {
4252        assert_eq!(
4253            eval(r#"let x = "world"; in "hello ${x}""#),
4254            VMValue::String("hello world".to_string()),
4255        );
4256    }
4257    #[test]
4258    #[ignore = "requires builtins.toString (Phase 2)"]
4259    fn eval_string_interpolation_int() {
4260        assert_eq!(
4261            eval(r#"let n = 42; in "value: ${toString n}""#),
4262            VMValue::String("value: 42".to_string()),
4263        );
4264    }
4265    // -- Path literals --------------------------------------------------
4266    #[test]
4267    fn eval_absolute_path() {
4268        assert_eq!(eval("/tmp/x"), VMValue::Path("/tmp/x".to_string()));
4269    }
4270    // -- Complex expressions --------------------------------------------
4271    #[test]
4272    fn eval_fibonacci_like() {
4273        assert_eq!(
4274            eval("let a = 1; b = 1; c = a + b; d = b + c; e = c + d; in e"),
4275            VMValue::Int(5)
4276        );
4277    }
4278    #[test]
4279    fn eval_nested_attrset_select() {
4280        assert_eq!(
4281            eval("{ a = { b = 42; }; }.a.b"),
4282            VMValue::Int(42)
4283        );
4284    }
4285    #[test]
4286    fn eval_let_with_attrset() {
4287        assert_eq!(
4288            eval("let set = { x = 10; y = 20; }; in set.x + set.y"),
4289            VMValue::Int(30)
4290        );
4291    }
4292    #[test]
4293    fn eval_conditional_attrset() {
4294        assert_eq!(
4295            eval("(if true then { a = 1; } else { a = 2; }).a"),
4296            VMValue::Int(1)
4297        );
4298    }
4299    // -- Builtin tests --------------------------------------------------
4300    #[test]
4301    fn builtin_length() {
4302        assert_eq!(eval("builtins.length [1 2 3]"), VMValue::Int(3));
4303    }
4304    #[test]
4305    fn builtin_length_empty() {
4306        assert_eq!(eval("builtins.length []"), VMValue::Int(0));
4307    }
4308    #[test]
4309    fn builtin_head() {
4310        assert_eq!(eval("builtins.head [10 20 30]"), VMValue::Int(10));
4311    }
4312    #[test]
4313    fn builtin_tail() {
4314        let result = eval_full_helper("builtins.tail [1 2 3]");
4315        assert_eq!(
4316            result,
4317            StringKeyedValue::List(vec![StringKeyedValue::Int(2), StringKeyedValue::Int(3)])
4318        );
4319    }
4320    #[test]
4321    fn builtin_type_of_int() {
4322        assert_eq!(
4323            eval("builtins.typeOf 42"),
4324            VMValue::String("int".to_string())
4325        );
4326    }
4327    #[test]
4328    fn builtin_type_of_string() {
4329        assert_eq!(
4330            eval("builtins.typeOf \"hello\""),
4331            VMValue::String("string".to_string())
4332        );
4333    }
4334    #[test]
4335    fn builtin_type_of_bool() {
4336        assert_eq!(
4337            eval("builtins.typeOf true"),
4338            VMValue::String("bool".to_string())
4339        );
4340    }
4341    #[test]
4342    fn builtin_type_of_null() {
4343        assert_eq!(
4344            eval("builtins.typeOf null"),
4345            VMValue::String("null".to_string())
4346        );
4347    }
4348    #[test]
4349    fn builtin_type_of_list() {
4350        assert_eq!(
4351            eval("builtins.typeOf [1 2]"),
4352            VMValue::String("list".to_string())
4353        );
4354    }
4355    #[test]
4356    fn builtin_type_of_set() {
4357        assert_eq!(
4358            eval("builtins.typeOf { a = 1; }"),
4359            VMValue::String("set".to_string())
4360        );
4361    }
4362    #[test]
4363    fn builtin_type_of_lambda() {
4364        assert_eq!(
4365            eval("builtins.typeOf (x: x)"),
4366            VMValue::String("lambda".to_string())
4367        );
4368    }
4369    #[test]
4370    fn builtin_is_int() {
4371        assert_eq!(eval("builtins.isInt 42"), VMValue::Bool(true));
4372        assert_eq!(
4373            eval("builtins.isInt \"hello\""),
4374            VMValue::Bool(false)
4375        );
4376    }
4377    #[test]
4378    fn builtin_is_string() {
4379        assert_eq!(eval("builtins.isString \"hi\""), VMValue::Bool(true));
4380        assert_eq!(eval("builtins.isString 42"), VMValue::Bool(false));
4381    }
4382    #[test]
4383    fn builtin_is_list() {
4384        assert_eq!(eval("builtins.isList [1]"), VMValue::Bool(true));
4385        assert_eq!(eval("builtins.isList 42"), VMValue::Bool(false));
4386    }
4387    #[test]
4388    fn builtin_is_attrs() {
4389        assert_eq!(
4390            eval("builtins.isAttrs { a = 1; }"),
4391            VMValue::Bool(true)
4392        );
4393        assert_eq!(eval("builtins.isAttrs 42"), VMValue::Bool(false));
4394    }
4395    #[test]
4396    fn builtin_is_function() {
4397        assert_eq!(
4398            eval("builtins.isFunction (x: x)"),
4399            VMValue::Bool(true)
4400        );
4401        assert_eq!(eval("builtins.isFunction 42"), VMValue::Bool(false));
4402    }
4403    #[test]
4404    fn builtin_is_bool() {
4405        assert_eq!(eval("builtins.isBool true"), VMValue::Bool(true));
4406        assert_eq!(eval("builtins.isBool 42"), VMValue::Bool(false));
4407    }
4408    #[test]
4409    fn builtin_is_null() {
4410        assert_eq!(eval("builtins.isNull null"), VMValue::Bool(true));
4411        assert_eq!(eval("builtins.isNull 42"), VMValue::Bool(false));
4412    }
4413    #[test]
4414    fn builtin_string_length() {
4415        assert_eq!(
4416            eval("builtins.stringLength \"hello\""),
4417            VMValue::Int(5)
4418        );
4419    }
4420    #[test]
4421    fn builtin_to_string_int() {
4422        assert_eq!(
4423            eval("builtins.toString 42"),
4424            VMValue::String("42".to_string())
4425        );
4426    }
4427    #[test]
4428    fn builtin_to_string_bool() {
4429        assert_eq!(
4430            eval("builtins.toString true"),
4431            VMValue::String("1".to_string())
4432        );
4433    }
4434    #[test]
4435    fn builtin_throw() {
4436        let result = eval_err("builtins.throw \"test error\"");
4437        assert!(matches!(result, VMError::Throw(_)));
4438    }
4439    #[test]
4440    fn builtin_abort() {
4441        let result = eval_err("builtins.abort \"fatal\"");
4442        assert!(matches!(result, VMError::Throw(_)));
4443    }
4444    #[test]
4445    fn builtin_add_curried() {
4446        assert_eq!(eval("builtins.add 3 4"), VMValue::Int(7));
4447    }
4448    #[test]
4449    fn builtin_sub_curried() {
4450        assert_eq!(eval("builtins.sub 10 3"), VMValue::Int(7));
4451    }
4452    #[test]
4453    fn builtin_mul_curried() {
4454        assert_eq!(eval("builtins.mul 6 7"), VMValue::Int(42));
4455    }
4456    #[test]
4457    fn builtin_div_curried() {
4458        assert_eq!(eval("builtins.div 42 6"), VMValue::Int(7));
4459    }
4460    #[test]
4461    fn builtin_elem_at() {
4462        assert_eq!(eval("builtins.elemAt [10 20 30] 1"), VMValue::Int(20));
4463    }
4464    #[test]
4465    fn builtin_elem() {
4466        assert_eq!(eval("builtins.elem 2 [1 2 3]"), VMValue::Bool(true));
4467        assert_eq!(eval("builtins.elem 5 [1 2 3]"), VMValue::Bool(false));
4468    }
4469    #[test]
4470    fn builtin_concat_lists() {
4471        let result = eval_full_helper("builtins.concatLists [[1 2] [3 4]]");
4472        assert_eq!(
4473            result,
4474            StringKeyedValue::List(vec![
4475                StringKeyedValue::Int(1),
4476                StringKeyedValue::Int(2),
4477                StringKeyedValue::Int(3),
4478                StringKeyedValue::Int(4),
4479            ])
4480        );
4481    }
4482    #[test]
4483    fn builtin_has_prefix() {
4484        assert_eq!(
4485            eval("builtins.hasPrefix \"he\" \"hello\""),
4486            VMValue::Bool(true)
4487        );
4488        assert_eq!(
4489            eval("builtins.hasPrefix \"wo\" \"hello\""),
4490            VMValue::Bool(false)
4491        );
4492    }
4493    #[test]
4494    fn builtin_has_suffix() {
4495        assert_eq!(
4496            eval("builtins.hasSuffix \"lo\" \"hello\""),
4497            VMValue::Bool(true)
4498        );
4499    }
4500    #[test]
4501    fn builtin_concat_strings_sep() {
4502        assert_eq!(
4503            eval("builtins.concatStringsSep \", \" [\"a\" \"b\" \"c\"]"),
4504            VMValue::String("a, b, c".to_string())
4505        );
4506    }
4507    #[test]
4508    fn builtin_to_lower() {
4509        assert_eq!(
4510            eval("builtins.toLower \"Hello World\""),
4511            VMValue::String("hello world".to_string())
4512        );
4513    }
4514    #[test]
4515    fn builtin_to_upper() {
4516        assert_eq!(
4517            eval("builtins.toUpper \"hello\""),
4518            VMValue::String("HELLO".to_string())
4519        );
4520    }
4521    #[test]
4522    fn builtin_from_json() {
4523        assert_eq!(
4524            eval("builtins.fromJSON \"42\""),
4525            VMValue::Int(42)
4526        );
4527        assert_eq!(
4528            eval("builtins.fromJSON \"true\""),
4529            VMValue::Bool(true)
4530        );
4531    }
4532    #[test]
4533    fn builtin_seq() {
4534        assert_eq!(eval("builtins.seq 1 42"), VMValue::Int(42));
4535    }
4536    #[test]
4537    fn builtin_deep_seq() {
4538        assert_eq!(eval("builtins.deepSeq [1 2] 42"), VMValue::Int(42));
4539    }
4540    #[test]
4541    fn builtin_trace() {
4542        assert_eq!(
4543            eval("builtins.trace \"debug\" 42"),
4544            VMValue::Int(42)
4545        );
4546    }
4547    #[test]
4548    fn builtin_ceil_floor() {
4549        assert_eq!(eval("builtins.ceil 3.2"), VMValue::Int(4));
4550        assert_eq!(eval("builtins.floor 3.8"), VMValue::Int(3));
4551    }
4552    #[test]
4553    fn builtin_bit_ops() {
4554        assert_eq!(eval("builtins.bitAnd 12 10"), VMValue::Int(8));
4555        assert_eq!(eval("builtins.bitOr 12 10"), VMValue::Int(14));
4556        assert_eq!(eval("builtins.bitXor 12 10"), VMValue::Int(6));
4557    }
4558    #[test]
4559    fn builtin_intersect_attrs() {
4560        let result =
4561            eval_full_helper("builtins.intersectAttrs { a = 1; b = 2; } { a = 10; c = 30; }");
4562        match result {
4563            StringKeyedValue::Attrs(map) => {
4564                assert_eq!(map.get("a"), Some(&StringKeyedValue::Int(10)));
4565                assert!(!map.contains_key("b"));
4566                assert!(!map.contains_key("c"));
4567            }
4568            _ => panic!("expected Attrs, got {result:?}"),
4569        }
4570    }
4571    #[test]
4572    fn builtin_attr_values() {
4573        let result = eval_full_helper("builtins.attrValues { a = 1; b = 2; }");
4574        match result {
4575            StringKeyedValue::List(items) => {
4576                assert_eq!(items.len(), 2);
4577                assert!(items.contains(&StringKeyedValue::Int(1)));
4578                assert!(items.contains(&StringKeyedValue::Int(2)));
4579            }
4580            _ => panic!("expected List, got {result:?}"),
4581        }
4582    }
4583    #[test]
4584    fn builtin_to_int() {
4585        assert_eq!(eval("builtins.toInt \"42\""), VMValue::Int(42));
4586    }
4587    #[test]
4588    fn builtin_replace_strings() {
4589        assert_eq!(
4590            eval("builtins.replaceStrings [\"o\"] [\"0\"] \"foo\""),
4591            VMValue::String("f00".to_string())
4592        );
4593    }
4594    #[test]
4595    fn builtin_substring() {
4596        assert_eq!(
4597            eval("builtins.substring 1 3 \"hello\""),
4598            VMValue::String("ell".to_string())
4599        );
4600    }
4601    // -- Import tests ---------------------------------------------------
4602    #[test]
4603    fn import_basic() {
4604        let dir = tempfile::tempdir().unwrap();
4605        let file_path = dir.path().join("test.nix");
4606        std::fs::write(&file_path, "42").unwrap();
4607        let nix_expr = format!("import {}", file_path.display());
4608        assert_eq!(eval(&nix_expr), VMValue::Int(42));
4609    }
4610    #[test]
4611    fn import_cached() {
4612        let dir = tempfile::tempdir().unwrap();
4613        let file_path = dir.path().join("cached.nix");
4614        std::fs::write(&file_path, "{ x = 1; }").unwrap();
4615        let nix_expr = format!(
4616            "let a = import {}; b = import {}; in a == b",
4617            file_path.display(),
4618            file_path.display()
4619        );
4620        assert_eq!(eval(&nix_expr), VMValue::Bool(true));
4621    }
4622    #[test]
4623    fn import_attrset() {
4624        let dir = tempfile::tempdir().unwrap();
4625        let file_path = dir.path().join("attrs.nix");
4626        std::fs::write(&file_path, "{ greeting = \"hello\"; }").unwrap();
4627        let nix_expr = format!("(import {}).greeting", file_path.display());
4628        assert_eq!(eval(&nix_expr), VMValue::String("hello".to_string()));
4629    }
4630    #[test]
4631    fn import_directory_default_nix() {
4632        // Importing a directory should resolve to <dir>/default.nix
4633        let dir = tempfile::tempdir().unwrap();
4634        let sub = dir.path().join("mylib");
4635        std::fs::create_dir(&sub).unwrap();
4636        std::fs::write(sub.join("default.nix"), "{ x = 42; }").unwrap();
4637        let nix_expr = format!("(import {}).x", sub.display());
4638        assert_eq!(eval(&nix_expr), VMValue::Int(42));
4639    }
4640    #[test]
4641    fn import_directory_cached() {
4642        // Importing the same directory twice should hit the cache.
4643        let dir = tempfile::tempdir().unwrap();
4644        let sub = dir.path().join("lib");
4645        std::fs::create_dir(&sub).unwrap();
4646        std::fs::write(sub.join("default.nix"), "{ v = 99; }").unwrap();
4647        let nix_expr = format!(
4648            "let a = import {}; b = import {}; in a == b",
4649            sub.display(),
4650            sub.display()
4651        );
4652        assert_eq!(eval(&nix_expr), VMValue::Bool(true));
4653    }
4654    #[test]
4655    fn import_directory_nested() {
4656        // Nested directory imports: lib/default.nix imports sub/default.nix
4657        let dir = tempfile::tempdir().unwrap();
4658        let lib = dir.path().join("lib");
4659        let sub = lib.join("sub");
4660        std::fs::create_dir_all(&sub).unwrap();
4661        std::fs::write(sub.join("default.nix"), "{ val = 7; }").unwrap();
4662        std::fs::write(
4663            lib.join("default.nix"),
4664            &format!("(import {}).val + 3", sub.display()),
4665        )
4666        .unwrap();
4667        let nix_expr = format!("import {}", lib.display());
4668        assert_eq!(eval(&nix_expr), VMValue::Int(10));
4669    }
4670    // -- Lazy evaluation tests ------------------------------------------
4671    #[test]
4672    fn lazy_unused_throw_in_attrset() {
4673        assert_eq!(
4674            eval("let s = { a = 1; }; in s.a"),
4675            VMValue::Int(1)
4676        );
4677    }
4678    #[test]
4679    fn lazy_unused_let_binding() {
4680        assert_eq!(eval("let x = 1; y = 2; in x"), VMValue::Int(1));
4681    }
4682    // -- Import handler tests -------------------------------------------
4683    #[test]
4684    fn import_forces_thunk_before_type_check() {
4685        // The import path is a thunk (non-trivial let binding); the VM
4686        // must force it to a path/string before checking the type.
4687        let dir = tempfile::tempdir().unwrap();
4688        let file_path = dir.path().join("forced.nix");
4689        std::fs::write(&file_path, "99").unwrap();
4690        let nix_expr = format!(
4691            "let p = {}; in import p",
4692            file_path.display()
4693        );
4694        assert_eq!(eval(&nix_expr), VMValue::Int(99));
4695    }
4696    #[test]
4697    fn import_with_path_value_succeeds() {
4698        let dir = tempfile::tempdir().unwrap();
4699        let file_path = dir.path().join("pathval.nix");
4700        std::fs::write(&file_path, "\"from-path\"").unwrap();
4701        let nix_expr = format!("import {}", file_path.display());
4702        assert_eq!(
4703            eval(&nix_expr),
4704            VMValue::String("from-path".to_string())
4705        );
4706    }
4707    #[test]
4708    fn import_with_string_value_succeeds() {
4709        let dir = tempfile::tempdir().unwrap();
4710        let file_path = dir.path().join("strval.nix");
4711        std::fs::write(&file_path, "\"from-string\"").unwrap();
4712        let nix_expr = format!(
4713            "let s = \"{}\"; in import s",
4714            file_path.display()
4715        );
4716        assert_eq!(
4717            eval(&nix_expr),
4718            VMValue::String("from-string".to_string())
4719        );
4720    }
4721    // -- TailCall opcode tests ------------------------------------------
4722    #[test]
4723    fn tail_call_deep_recursion_via_import() {
4724        // Test deep tail-recursive calls via import (self-referencing let
4725        // requires open upvalues, not yet implemented). Writing a recursive
4726        // function to a file and importing it exercises TailCall.
4727        let dir = tempfile::tempdir().unwrap();
4728        let file_path = dir.path().join("countdown.nix");
4729        std::fs::write(
4730            &file_path,
4731            "{ f, n }: if n == 0 then 0 else f { inherit f; n = n - 1; }",
4732        )
4733        .unwrap();
4734        // Use fixpoint pattern: pass function as argument to avoid
4735        // self-referencing let bindings.
4736        let nix_expr = format!(
4737            "let g = import {}; in g {{ f = g; n = 2000; }}",
4738            file_path.display()
4739        );
4740        assert_eq!(eval(&nix_expr), VMValue::Int(0));
4741    }
4742    #[test]
4743    fn tail_call_simple_lambda_chain() {
4744        // Non-recursive tail call: the last call in a lambda body should
4745        // reuse the frame. This verifies TailCall opcode is emitted and
4746        // executed for simple function composition.
4747        assert_eq!(
4748            eval("let g = x: x + 1; f = x: g x; in f 41"),
4749            VMValue::Int(42)
4750        );
4751    }
4752    #[test]
4753    fn tail_call_if_branches() {
4754        // Both if-then and if-else branches should produce tail calls
4755        // when in lambda body. This verifies TailCall works in both branches.
4756        assert_eq!(
4757            eval("let f = x: if x > 0 then x else x + 1; in f 10"),
4758            VMValue::Int(10)
4759        );
4760        assert_eq!(
4761            eval("let f = x: if x > 0 then x else x + 1; in f 0"),
4762            VMValue::Int(1)
4763        );
4764    }
4765    // -- Builtin dispatch tests -----------------------------------------
4766    #[test]
4767    fn builtin_get_env_returns_value() {
4768        // Set a known env var and verify getEnv returns it.
4769        // SAFETY: test runs single-threaded; no concurrent env access.
4770        unsafe { std::env::set_var("SUI_TEST_VAR", "hello_sui") };
4771        assert_eq!(
4772            eval("builtins.getEnv \"SUI_TEST_VAR\""),
4773            VMValue::String("hello_sui".to_string())
4774        );
4775        unsafe { std::env::remove_var("SUI_TEST_VAR") };
4776    }
4777    #[test]
4778    fn builtin_get_env_missing_returns_empty() {
4779        // getEnv with a missing var should return "".
4780        // SAFETY: test runs single-threaded; no concurrent env access.
4781        unsafe { std::env::remove_var("SUI_NONEXISTENT_VAR_12345") };
4782        assert_eq!(
4783            eval("builtins.getEnv \"SUI_NONEXISTENT_VAR_12345\""),
4784            VMValue::String(String::new())
4785        );
4786    }
4787    #[test]
4788    fn builtin_try_eval_success() {
4789        // tryEval with a successful expression returns { success=true; value=result; }.
4790        let result = eval_full_helper("builtins.tryEval 42");
4791        match result {
4792            StringKeyedValue::Attrs(map) => {
4793                assert_eq!(
4794                    map.get("success"),
4795                    Some(&StringKeyedValue::Bool(true))
4796                );
4797                assert_eq!(
4798                    map.get("value"),
4799                    Some(&StringKeyedValue::Int(42))
4800                );
4801            }
4802            _ => panic!("expected Attrs, got {result:?}"),
4803        }
4804    }
4805    #[test]
4806    fn builtin_try_eval_with_non_throwing_expr() {
4807        // tryEval wraps a non-throwing expression — still produces
4808        // { success = true; value = ...; }.
4809        let result = eval_full_helper(
4810            "builtins.tryEval (1 + 2)"
4811        );
4812        match result {
4813            StringKeyedValue::Attrs(map) => {
4814                assert_eq!(
4815                    map.get("success"),
4816                    Some(&StringKeyedValue::Bool(true))
4817                );
4818                assert_eq!(
4819                    map.get("value"),
4820                    Some(&StringKeyedValue::Int(3))
4821                );
4822            }
4823            _ => panic!("expected Attrs, got {result:?}"),
4824        }
4825    }
4826    #[test]
4827    fn builtin_try_eval_with_throw_catches() {
4828        // tryEval CATCHES a throwing expression (nix parity):
4829        // `{ success = false; value = false; }` — verified byte-identical
4830        // to cppnix (`nix eval --json` returns the same). The VM previously
4831        // PROPAGATED the throw (an open-upvalue dispatch limitation); that
4832        // is now fixed, so this test pins the correct catching behavior.
4833        let result = eval_full_helper(
4834            "let bad = builtins.throw \"oops\"; in builtins.tryEval bad"
4835        );
4836        match result {
4837            StringKeyedValue::Attrs(map) => {
4838                assert_eq!(
4839                    map.get("success"),
4840                    Some(&StringKeyedValue::Bool(false))
4841                );
4842                assert_eq!(
4843                    map.get("value"),
4844                    Some(&StringKeyedValue::Bool(false))
4845                );
4846            }
4847            _ => panic!("expected Attrs, got {result:?}"),
4848        }
4849    }
4850    // -- Regression: stack_depth tracking for branches -------------------
4851    #[test]
4852    fn if_else_in_let_body_stack_depth() {
4853        // If/else inside a let body should not corrupt stack_depth for
4854        // subsequent let bindings in an outer scope.
4855        assert_eq!(
4856            eval("let a = 1; in if a == 1 then 10 else 20"),
4857            VMValue::Int(10),
4858        );
4859    }
4860    #[test]
4861    fn nested_let_with_if_else() {
4862        // Inner let after an if/else: the if/else must not drift stack_depth.
4863        assert_eq!(
4864            eval(r#"
4865                let
4866                  a = 1;
4867                  b = if a == 1 then 2 else 3;
4868                in
4869                  let c = b + 10; in c
4870            "#),
4871            VMValue::Int(12),
4872        );
4873    }
4874    #[test]
4875    fn short_circuit_and_in_let_body() {
4876        // Short-circuit && inside a let body must track stack_depth correctly.
4877        assert_eq!(
4878            eval("let x = true; in x && false"),
4879            VMValue::Bool(false),
4880        );
4881    }
4882    #[test]
4883    fn short_circuit_or_in_let_body() {
4884        assert_eq!(
4885            eval("let x = false; in x || true"),
4886            VMValue::Bool(true),
4887        );
4888    }
4889    #[test]
4890    fn short_circuit_implication_in_let_body() {
4891        // a -> b is !a || b. false -> anything is true.
4892        assert_eq!(
4893            eval("let x = false; in x -> 42"),
4894            VMValue::Bool(true),
4895        );
4896    }
4897    #[test]
4898    fn inherit_from_in_attrset_stack_depth() {
4899        // inherit (source) in non-rec attrset must track stack_depth for
4900        // MakeThunk. This was the missing `stack_depth += 1` bug.
4901        assert_eq!(
4902            eval(r#"
4903                let
4904                  src = { a = 1; b = 2; };
4905                  result = { inherit (src) a b; c = 3; };
4906                in result.a + result.b + result.c
4907            "#),
4908            VMValue::Int(6),
4909        );
4910    }
4911    #[test]
4912    fn inherit_from_many_fields_stack_depth() {
4913        // Multiple inherit-from fields: each one was missing +1,
4914        // so stack_depth would drift further with each field.
4915        assert_eq!(
4916            eval(r#"
4917                let
4918                  s = { w = 1; x = 2; y = 3; z = 4; };
4919                  r = { inherit (s) w x y z; extra = 10; };
4920                in r.w + r.x + r.y + r.z + r.extra
4921            "#),
4922            VMValue::Int(20),
4923        );
4924    }
4925    #[test]
4926    fn if_else_followed_by_let_binding() {
4927        // The if/else result is used in a subsequent let binding.
4928        // Before the fix, the stack_depth drift from if/else would cause
4929        // the next binding's slot to be off.
4930        assert_eq!(
4931            eval(r#"
4932                let
4933                  a = 1;
4934                  b = 2;
4935                  c = 3;
4936                in
4937                  let
4938                    x = if a == 1 then b else c;
4939                    y = x + 100;
4940                  in y
4941            "#),
4942            VMValue::Int(102),
4943        );
4944    }
4945    #[test]
4946    fn multi_segment_hasattr_stack_depth() {
4947        // Multi-segment hasattr with short-circuit jumps must track
4948        // stack_depth correctly at branch merge points.
4949        assert_eq!(
4950            eval(r#"
4951                let
4952                  s = { a = { b = 1; }; };
4953                  has = s ? a.b;
4954                  val = if has then 42 else 0;
4955                in val
4956            "#),
4957            VMValue::Int(42),
4958        );
4959    }
4960    #[test]
4961    fn many_let_bindings_with_if_else() {
4962        // Stress test: many let bindings where some RHS contain if/else.
4963        // Before the stack_depth fix, the drift would accumulate and
4964        // eventually cause a GetLocal slot mismatch.
4965        assert_eq!(
4966            eval(r#"
4967                let
4968                  a = 1;
4969                  b = 2;
4970                  c = 3;
4971                  d = 4;
4972                  e = 5;
4973                  f = 6;
4974                  g = 7;
4975                  h = 8;
4976                  i = 9;
4977                  j = 10;
4978                in
4979                  let
4980                    x = if a == 1 then b else c;
4981                    y = if d == 4 then e else f;
4982                    z = if g == 7 then h else i;
4983                    w = j;
4984                  in x + y + z + w
4985            "#),
4986            VMValue::Int(25),
4987        );
4988    }
4989    #[test]
4990    fn import_in_pattern_default_stack_depth() {
4991        // The Import opcode is net 0 on the stack (pop path, push result).
4992        // Before the fix, it was tracked as +1, causing stack_depth drift
4993        // in pattern default expressions like `{ stdenvStages ? import ../stdenv, ... }`.
4994        // This test uses a pattern lambda with a default that involves a
4995        // function call (which compiles similarly to import + call).
4996        assert_eq!(
4997            eval(r#"
4998                let
4999                  f = { a ? 1, b ? 2, c ? 3 }:
5000                    a + b + c;
5001                in f {}
5002            "#),
5003            VMValue::Int(6),
5004        );
5005    }
5006    #[test]
5007    fn pattern_lambda_many_defaults_then_let() {
5008        // Pattern lambda with many defaults followed by let bindings.
5009        // This is the pattern that triggered the original nixpkgs bug:
5010        // { a, b ? x, c ? y, ... }: let ... in expr
5011        // The import stack_depth bug caused slots to drift by 1 for each
5012        // default expression that used import.
5013        assert_eq!(
5014            eval(r#"
5015                let
5016                  mk = { a, b ? 10, c ? 20, d ? 30, e ? 40 }:
5017                    let
5018                      sum = a + b + c + d + e;
5019                      doubled = sum + sum;
5020                    in doubled;
5021                in mk { a = 1; }
5022            "#),
5023            VMValue::Int(202),
5024        );
5025    }
5026    // -- Blocker #13: dotted attrs + lambda closure in rec ------------------
5027    #[test]
5028    fn rec_dotted_lambda_captures_sibling() {
5029        // Lambdas in rec attrsets must not be compiled as trivial values,
5030        // because MakeClosure captures upvalues eagerly.  Dotted entries
5031        // are appended after non-dotted bindings, so a lambda's upvalue
5032        // for a dotted sibling would see the null placeholder.
5033        let result = eval_full_helper(
5034            r#"rec { types.a = 1; types.b = 2; f = _: types; }.f 0"#,
5035        );
5036        match result {
5037            StringKeyedValue::Attrs(ref m) => {
5038                assert_eq!(m.get("a"), Some(&StringKeyedValue::Int(1)));
5039                assert_eq!(m.get("b"), Some(&StringKeyedValue::Int(2)));
5040            }
5041            other => panic!("expected attrset, got {other:?}"),
5042        }
5043    }
5044    #[test]
5045    fn rec_dotted_lambda_attr_select() {
5046        // Lambda body selects an attribute from a dotted sibling.
5047        assert_eq!(
5048            eval(r#"rec { types.a = 1; types.b = 2; f = x: types.b; result = f 0; }.result"#),
5049            VMValue::Int(2),
5050        );
5051    }
5052    #[test]
5053    fn rec_dotted_lambda_assert_check() {
5054        // Pattern from nixpkgs parse.nix: `mkSystem` uses
5055        //   assert types.parsedPlatform.check components; ...
5056        // which requires `types` to be resolved inside a lambda body.
5057        assert_eq!(
5058            eval(r#"
5059                rec {
5060                    types.parsedPlatform = { check = _: true; };
5061                    mkSystem = components:
5062                        assert types.parsedPlatform.check components;
5063                        components;
5064                    result = mkSystem 42;
5065                }.result
5066            "#),
5067            VMValue::Int(42),
5068        );
5069    }
5070    #[test]
5071    fn let_lambda_captures_rec_sibling() {
5072        // Let bindings are recursive — lambdas capturing siblings must
5073        // also use deferred thunks.
5074        assert_eq!(
5075            eval(r#"let a = 1 + 1; f = _: a; in f 0"#),
5076            VMValue::Int(2),
5077        );
5078    }
5079    #[test]
5080    fn rec_dotted_multiple_lambdas() {
5081        // Multiple lambdas capturing different dotted siblings.
5082        assert_eq!(
5083            eval(r#"
5084                rec {
5085                    a.x = 10;
5086                    b.y = 20;
5087                    f = _: a.x + b.y;
5088                    result = f 0;
5089                }.result
5090            "#),
5091            VMValue::Int(30),
5092        );
5093    }
5094}