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                // Count the crossing. This site was MISSED when the counter was
2323                // introduced, and it is the primary one: `try_vm_builtin` is
2324                // consulted BEFORE the registry, so for every name in the arm
2325                // above the recorded call in `builtins.rs` is shadowed and dead.
2326                // The counter's reachable surface was `fetchClosure`,
2327                // `outputOf` and `hashString` — none of the names its own
2328                // module doc cites as the reason it exists.
2329                let _ = crate::fallback::record(crate::fallback::Layer::Builtin, name);
2330                match crate::bridge::call_builtin_bridge(name, vec![sk]) {
2331                    Ok(Some(result)) => {
2332                        let vm_result = crate::builtins::string_keyed_to_vmvalue(
2333                            &result,
2334                            self.interner,
2335                        );
2336                        Ok(Some(NanBox::from_vmvalue(&vm_result)))
2337                    }
2338                    Ok(None) => {
2339                        // No bridge set — fall through to registry stub
2340                        // which will produce the appropriate error.
2341                        Ok(None)
2342                    }
2343                    Err(e) => Err(VMError::Internal(format!("bridge error in '{name}': {e}"))),
2344                }
2345            }
2346            // match and split are curried: first call takes pattern,
2347            // returns partial that takes the string.
2348            "match" | "split" => {
2349                let forced = self.force_value(arg.clone())?;
2350                let pattern = match forced.to_vmvalue() {
2351                    VMValue::String(s) => s,
2352                    other => {
2353                        return Err(VMError::TypeError {
2354                            expected: "string",
2355                            got: other.type_name(),
2356                            context: name.to_string(),
2357                        });
2358                    }
2359                };
2360                let builtin_name = name.to_string();
2361                Ok(Some(NanBox::from_vmvalue(&VMValue::Builtin(
2362                    crate::value::VMBuiltin {
2363                        name: if name == "match" {
2364                            "match<partial>"
2365                        } else {
2366                            "split<partial>"
2367                        },
2368                        func: Rc::new(move |args2| {
2369                            let input = match &args2[0] {
2370                                VMValue::String(s) => s.clone(),
2371                                other => {
2372                                    return Err(VMError::TypeError {
2373                                        expected: "string",
2374                                        got: other.type_name(),
2375                                        context: builtin_name.clone(),
2376                                    });
2377                                }
2378                            };
2379                            // Delegate to bridge with both args
2380                            let sk_args = vec![
2381                                crate::value::StringKeyedValue::String(pattern.clone()),
2382                                crate::value::StringKeyedValue::String(input),
2383                            ];
2384                            // Second missed site — `match` / `split` reach the
2385                            // walker through here, never through the counted
2386                            // registry path.
2387                            let _ = crate::fallback::record(
2388                                crate::fallback::Layer::Builtin,
2389                                &builtin_name,
2390                            );
2391                            match crate::bridge::call_builtin_bridge(&builtin_name, sk_args) {
2392                                Ok(Some(result)) => {
2393                                    let mut tmp = crate::intern::Interner::new();
2394                                    Ok(crate::builtins::string_keyed_to_vmvalue(&result, &mut tmp))
2395                                }
2396                                Ok(None) => Err(VMError::Throw(format!(
2397                                    "{builtin_name}: requires bridge but no bridge is set"
2398                                ))),
2399                                Err(e) => Err(VMError::Internal(format!("bridge error in '{builtin_name}': {e}"))),
2400                            }
2401                        }),
2402                        arity: 1,
2403                    },
2404                ))))
2405            }
2406            _ => Ok(None),
2407        }
2408    }
2409    /// Coerce an already-forced [`VMValue`] to a derivation-env string the way
2410    /// CppNix (and the tree-walker's `coerce_to_string_copy_to_store`) does.
2411    ///
2412    /// Returns `None` for values with no meaningful string form (closures,
2413    /// builtins, un-`outPath`'d attrsets) — the caller skips those env entries
2414    /// rather than erroring, matching the tree-walker's `_opt` coercion.
2415    ///
2416    /// Mirrors `sui-eval/src/value.rs::coerce_to_string_impl` for every value
2417    /// type the VM can represent:
2418    ///   - `Float` → `%f` (6 decimals), NOT Rust's shortest form.
2419    ///   - `List` → items coerced + space-joined.
2420    ///   - `Attrs` → `outPath` (or `__toString`) coerced; else `None`.
2421    ///
2422    /// NOTE (parity tier): the VM does NOT track string context (VMValue::String
2423    /// carries no context — deferred to Phase 2), so this coercion cannot
2424    /// populate inputDrvs/inputSrcs edges the way the tree-walker does. For
2425    /// context-free derivation shapes the env bytes match; context-bearing
2426    /// shapes still diverge until the VM's Phase-2 context work lands. The
2427    /// differential test names exactly which shapes reach parity here.
2428    fn coerce_drv_env_value(&mut self, v: &VMValue) -> Option<String> {
2429        match v {
2430            VMValue::String(s) => Some(s.clone()),
2431            VMValue::Path(p) => Some(p.clone()),
2432            VMValue::Int(n) => Some(n.to_string()),
2433            // CppNix uses C printf "%f" → always 6 decimals (`1.5` → "1.500000").
2434            // Rust's `{}` strips trailing zeros; match the tree-walker's `{f:.6}`.
2435            VMValue::Float(f) => Some(format!("{f:.6}")),
2436            VMValue::Bool(true) => Some("1".to_string()),
2437            VMValue::Bool(false) => Some(String::new()),
2438            VMValue::Null => Some(String::new()),
2439            VMValue::List(items) => {
2440                let mut parts = Vec::with_capacity(items.len());
2441                for item in items {
2442                    // Force each item then coerce (tree-walker forces list items).
2443                    let forced = self
2444                        .force_value(NanBox::from_vmvalue(item))
2445                        .ok()?
2446                        .to_vmvalue();
2447                    parts.push(self.coerce_drv_env_value(&forced)?);
2448                }
2449                Some(parts.join(" "))
2450            }
2451            VMValue::Attrs(map) => {
2452                // CppNix: an attrset coerces via `__toString` then `outPath`;
2453                // otherwise it has no string form (tree-walker errors, but the
2454                // env loop uses the `_opt` variant → skip).
2455                let to_string_sym = self.interner.intern("__toString");
2456                if map.contains_key(&to_string_sym) {
2457                    // A `__toString`-bearing attrset requires applying the
2458                    // function; that goes through the tree-walker seam the VM
2459                    // does not have here. Leave to the tree-walker (skip) rather
2460                    // than emit a wrong value — honest under-approximation.
2461                    return None;
2462                }
2463                let out_path_sym = self.interner.intern("outPath");
2464                let out_path = map.get(&out_path_sym)?;
2465                let forced = self
2466                    .force_value(NanBox::from_vmvalue(out_path))
2467                    .ok()?
2468                    .to_vmvalue();
2469                self.coerce_drv_env_value(&forced)
2470            }
2471            _ => None,
2472        }
2473    }
2474    /// Build a derivation from a VM attrset (with interner access).
2475    fn vm_build_derivation(&mut self, arg: NanBox) -> Result<NanBox, VMError> {
2476        use sui_compat::derivation::{Derivation, DerivationOutput};
2477        let attrs = match arg.as_attrs() {
2478            Some(a) => a.clone(),
2479            None => {
2480                return Err(VMError::TypeError {
2481                    expected: "set",
2482                    got: arg.type_name(),
2483                    context: "derivation".to_string(),
2484                });
2485            }
2486        };
2487        // Helper: resolve a symbol key and get string value.
2488        let get_str = |attrs: &BTreeMap<Symbol, NanBox>,
2489                       interner: &mut Interner,
2490                       key: &str|
2491         -> Result<String, VMError> {
2492            let sym = interner.intern(key);
2493            let val = attrs.get(&sym).ok_or_else(|| {
2494                VMError::AttrNotFound(key.to_string())
2495            })?;
2496            match val.to_vmvalue() {
2497                VMValue::String(s) => Ok(s),
2498                other => Err(VMError::TypeError {
2499                    expected: "string",
2500                    got: other.type_name(),
2501                    context: format!("derivation attr '{key}'"),
2502                }),
2503            }
2504        };
2505        let get_str_opt = |attrs: &BTreeMap<Symbol, NanBox>,
2506                           interner: &mut Interner,
2507                           key: &str|
2508         -> Result<Option<String>, VMError> {
2509            let sym = interner.intern(key);
2510            match attrs.get(&sym) {
2511                None => Ok(None),
2512                Some(val) => match val.to_vmvalue() {
2513                    VMValue::String(s) => Ok(Some(s)),
2514                    other => Err(VMError::TypeError {
2515                        expected: "string",
2516                        got: other.type_name(),
2517                        context: format!("derivation attr '{key}'"),
2518                    }),
2519                },
2520            }
2521        };
2522        let name = get_str(&attrs, self.interner, "name")?;
2523        let system = get_str(&attrs, self.interner, "system")?;
2524        let builder = get_str(&attrs, self.interner, "builder")?;
2525        // Optional `args` list of strings.
2526        // IMPORTANT: List items come as NanBox entries that are often
2527        // still thunks — they MUST be forced before coercion, else
2528        // every string arg vanishes. Previous code pattern-matched
2529        // directly on `VMValue::Thunk(_)` → `_ => push("")`, which
2530        // emitted empty strings and caused every derivation with
2531        // computed args to have args=[] in its ATerm. That made the
2532        // .drv path diverge from CppNix on any non-trivial derivation.
2533        let args_sym = self.interner.intern("args");
2534        let args_list: Vec<String> = if let Some(a) = attrs.get(&args_sym) {
2535            let forced_a = self.force_value(a.clone())?;
2536            let vmval = forced_a.to_vmvalue();
2537            match vmval {
2538                VMValue::List(l) => {
2539                    let mut out = Vec::with_capacity(l.len());
2540                    for item in &l {
2541                        // Each item may still be a thunk — force it.
2542                        let forced = self.force_value(NanBox::from_vmvalue(item))?;
2543                        match forced.to_vmvalue() {
2544                            VMValue::String(s) => out.push(s.clone()),
2545                            VMValue::Int(n) => out.push(n.to_string()),
2546                            VMValue::Float(f) => out.push(format!("{f:.6}")),
2547                            VMValue::Bool(true) => out.push("1".to_string()),
2548                            VMValue::Bool(false) => out.push(String::new()),
2549                            VMValue::Null => out.push(String::new()),
2550                            VMValue::Path(p) => out.push(p.clone()),
2551                            _ => out.push(String::new()),
2552                        }
2553                    }
2554                    out
2555                }
2556                _ => Vec::new(),
2557            }
2558        } else {
2559            Vec::new()
2560        };
2561        // Optional `outputs` list.
2562        // IMPORTANT (parity fix): list items arrive as NanBox entries that are
2563        // frequently still thunks. The previous reader pattern-matched directly
2564        // on `VMValue::String(s)` and SKIPPED thunks — so every multi-output
2565        // derivation (glibc/openssl/systemd/gcc/most of stdenv) silently
2566        // collapsed to a single `out`-only drv, diverging the .drv path from
2567        // both nix and the tree-walker. Force each item exactly like the `args`
2568        // reader above so declared outputs survive.
2569        let outputs_sym = self.interner.intern("outputs");
2570        let outputs: Vec<String> = if let Some(o) = attrs.get(&outputs_sym) {
2571            let forced_o = self.force_value(o.clone())?;
2572            match forced_o.to_vmvalue() {
2573                VMValue::List(l) => {
2574                    let mut out = Vec::with_capacity(l.len());
2575                    for item in &l {
2576                        let forced = self.force_value(NanBox::from_vmvalue(item))?;
2577                        if let VMValue::String(s) = forced.to_vmvalue() {
2578                            out.push(s);
2579                        }
2580                    }
2581                    if out.is_empty() {
2582                        vec!["out".to_string()]
2583                    } else {
2584                        out
2585                    }
2586                }
2587                _ => vec!["out".to_string()],
2588            }
2589        } else {
2590            vec!["out".to_string()]
2591        };
2592        // `__ignoreNulls = true` (CppNix): attrs whose value is null are dropped
2593        // from the env, and `__ignoreNulls` itself is consumed (never emitted).
2594        // Every stdenv mkDerivation sets this, so without it the VM env carried
2595        // extra `__ignoreNulls` + any null attr, diverging the modulo hash from
2596        // both nix and the tree-walker (derivation.rs ~224).
2597        let ignore_nulls_sym = self.interner.intern("__ignoreNulls");
2598        let ignore_nulls = attrs
2599            .get(&ignore_nulls_sym)
2600            .map(|v| self.force_value(v.clone()))
2601            .transpose()?
2602            .map(|v| matches!(v.to_vmvalue(), VMValue::Bool(true)))
2603            .unwrap_or(false);
2604
2605        // Build env vars from non-special attributes.
2606        // Excluded from env: `name`/`system`/`builder` (re-inserted below from
2607        // the coerced locals), `args` (structural, not an env var), and the
2608        // control flags CppNix consumes rather than emits (`__ignoreNulls`,
2609        // `__impure`, `__contentAddressed`). NOT excluded — matching the
2610        // tree-walker (derivation.rs ~286): `outputs` (coerced to "out dev …")
2611        // and `__structuredAttrs` (coerced to "" for a non-structured drv);
2612        // CppNix emits both, and dropping either diverges the modulo hash.
2613        let special = [
2614            "name", "system", "builder", "args",
2615            "__ignoreNulls", "__impure", "__contentAddressed",
2616        ];
2617        let special_syms: Vec<Symbol> = special
2618            .iter()
2619            .map(|s| self.interner.intern(s))
2620            .collect();
2621        let mut env_vars: BTreeMap<String, String> = BTreeMap::new();
2622        // Collect the (sym, key_str) pairs first to avoid borrowing `attrs`
2623        // across the `&mut self` force calls in the loop below.
2624        let env_keys: Vec<(Symbol, String)> = attrs
2625            .iter()
2626            .filter(|(k, _)| !special_syms.contains(k))
2627            .map(|(k, _)| (*k, self.interner.resolve(*k).to_string()))
2628            .collect();
2629        for (k, key_str) in env_keys {
2630            let Some(v) = attrs.get(&k) else { continue };
2631            // Force the value BEFORE coercion: nearly every real env attr is a
2632            // thunk (the previous `v.to_vmvalue()` + `_ => continue` dropped
2633            // every thunk-valued env var — i.e. almost all of them). This
2634            // mirrors the tree-walker's force-then-coerce in construct_derivation.
2635            let forced = self.force_value(v.clone())?;
2636            let fv = forced.to_vmvalue();
2637            // `__ignoreNulls` drops null-valued attrs entirely.
2638            if ignore_nulls && matches!(fv, VMValue::Null) {
2639                continue;
2640            }
2641            // Coerce with the SAME semantics as the tree-walker's
2642            // `coerce_to_string_copy_to_store` for the value types the VM can
2643            // represent (lists space-join, attrs use outPath, floats use %f).
2644            // A value with no meaningful string form is skipped (matches the
2645            // tree-walker's `coerce_..._opt` returning None), not errored.
2646            match self.coerce_drv_env_value(&fv) {
2647                Some(s) => {
2648                    env_vars.insert(key_str, s);
2649                }
2650                None => continue,
2651            }
2652        }
2653        env_vars.insert("name".to_string(), name.clone());
2654        env_vars.insert("system".to_string(), system.clone());
2655        env_vars.insert("builder".to_string(), builder.clone());
2656        // Detect fixed-output derivation.
2657        let output_hash_sym = self.interner.intern("outputHash");
2658        let is_fod = attrs.contains_key(&output_hash_sym);
2659        let mut drv = Derivation {
2660            outputs: BTreeMap::new(),
2661            input_derivations: BTreeMap::new(),
2662            input_sources: Vec::new(),
2663            system,
2664            builder,
2665            args: args_list,
2666            env: env_vars,
2667        };
2668        let (drv_path, out_paths, mut drv) = if is_fod {
2669            let raw_output_hash = get_str(&attrs, self.interner, "outputHash")?;
2670            let raw_algo = get_str_opt(&attrs, self.interner, "outputHashAlgo")?
2671                .unwrap_or_default();
2672            let output_hash_mode = get_str_opt(&attrs, self.interner, "outputHashMode")?
2673                .unwrap_or_else(|| "flat".to_string());
2674            let is_recursive =
2675                output_hash_mode == "recursive" || output_hash_mode == "nar";
2676            // Empty outputHashAlgo: infer from SRI prefix (cppnix
2677            // semantics), else default to sha256.
2678            let output_hash_algo = if raw_algo.is_empty() {
2679                ["sha256", "sha512", "sha1", "md5"].iter()
2680                    .find(|a| raw_output_hash.starts_with(&format!("{a}-")))
2681                    .map(|s| (*s).to_string())
2682                    .unwrap_or_else(|| "sha256".to_string())
2683            } else {
2684                raw_algo
2685            };
2686            // Normalize hex/nix-base32/SRI → lowercase hex before
2687            // building the fixed:out:<algo>:<hex>: fingerprint.  See
2688            // sui-compat::hash::NixHash::parse_any for the contract.
2689            let algo = sui_compat::hash::HashAlgorithm::from_nix_str(&output_hash_algo)
2690                .map_err(|e| VMError::Internal(format!(
2691                    "derivation: invalid outputHashAlgo {output_hash_algo:?}: {e}",
2692                )))?;
2693            let parsed = sui_compat::hash::NixHash::parse_any(algo, &raw_output_hash)
2694                .map_err(|e| VMError::Internal(format!(
2695                    "derivation: invalid outputHash {raw_output_hash:?}: {e}",
2696                )))?;
2697            let output_hash_hex = parsed.to_hex();
2698            let out_path = sui_compat::store_path::compute_fixed_output_hash(
2699                &output_hash_algo,
2700                &output_hash_hex,
2701                is_recursive,
2702                &name,
2703            );
2704            drv.outputs.insert(
2705                "out".to_string(),
2706                DerivationOutput {
2707                    path: out_path.clone(),
2708                    hash_algo: if is_recursive {
2709                        format!("r:{output_hash_algo}")
2710                    } else {
2711                        output_hash_algo.clone()
2712                    },
2713                    hash: output_hash_hex,
2714                },
2715            );
2716            // CppNix hashes the FOD with `env["out"] = <out-path>` present (the
2717            // input-addressed spec's FillOutputs phase sets it; this hand-rolled
2718            // fixed-output branch skipped it) — without it the FOD drvPath
2719            // diverges from nix + the tree-walker while its outPath already
2720            // matches (derivation.rs ~437).
2721            drv.env.insert("out".to_string(), out_path.clone());
2722
2723            let drv_content = drv.serialize();
2724            // Fold the .drv's references (inputDrvs + inputSrcs) into the store
2725            // path — CppNix's makeTextPath does this for EVERY derivation,
2726            // including fixed-output ones. A fetchurl FOD consumes curl /
2727            // mirrors-list / stdenv as inputDrvs, so without the refs its .drv
2728            // path diverges from nix. (A bare FOD with no inputs has an empty
2729            // ref set, so the simple FOD case matched even while this hid.)
2730            // NOTE: the VM does not yet collect string context, so
2731            // `input_derivations`/`input_sources` are empty here — this fold is
2732            // a no-op today but matches the tree-walker's construction so the
2733            // path stays correct once VM context lands (derivation.rs ~446).
2734            let drv_refs: Vec<String> = drv.input_derivations.keys().cloned()
2735                .chain(drv.input_sources.iter().cloned())
2736                .collect();
2737            let drv_path = sui_compat::store_path::compute_drv_path_with_refs(
2738                drv_content.as_bytes(), &name, &drv_refs);
2739
2740            // R3: emit the ATerm when SUI_EMIT_DRV is set. See the twin in
2741            // sui-eval/src/builtins/derivation.rs for why this exists — a diverging
2742            // drvPath is undiagnosable without the bytes whose hash it IS.
2743            if let Ok(dir) = std::env::var("SUI_EMIT_DRV") {
2744                if !dir.is_empty() {
2745                    let base = drv_path.rsplit('/').next().unwrap_or(&drv_path);
2746                    let _ = std::fs::create_dir_all(&dir);
2747                    let _ = std::fs::write(
2748                        std::path::Path::new(&dir).join(base),
2749                        drv_content.as_bytes(),
2750                    );
2751                }
2752            }
2753
2754            // CppNix `hashDerivationModulo` for a FIXED-OUTPUT derivation is the
2755            // special sha256("fixed:out:<methodAlgo>:<hashHex>:<outPath>"), NOT
2756            // the input-addressed ATerm hash. Cache it against this FOD's drv
2757            // path so every input-addressed derivation that consumes this FOD
2758            // substitutes the correct modulo hash — without it the consumer's
2759            // output path (and everything transitively above it) diverges from
2760            // nix + the tree-walker (derivation.rs ~452).
2761            let out_output = drv.outputs.get("out");
2762            let method_algo = out_output
2763                .map(|o| o.hash_algo.clone())
2764                .unwrap_or_default();
2765            let output_hash_hex = out_output
2766                .map(|o| o.hash.clone())
2767                .unwrap_or_default();
2768            let modulo_preimage =
2769                format!("fixed:out:{method_algo}:{output_hash_hex}:{out_path}");
2770            let modulo_hex: String = {
2771                use sha2::{Digest, Sha256};
2772                Sha256::digest(modulo_preimage.as_bytes())
2773                    .iter()
2774                    .map(|b| format!("{b:02x}"))
2775                    .collect()
2776            };
2777            sui_spec::derivation::remember_modulo_hash(&drv_path, &modulo_hex);
2778
2779            let mut out_paths = BTreeMap::new();
2780            out_paths.insert("out".to_string(), out_path);
2781            (drv_path, out_paths, drv)
2782        } else {
2783            // Input-addressed drv: algorithm lives in
2784            // `sui-spec/specs/derivation.lisp`.  Both the VM and the
2785            // tree-walker call `sui_spec::derivation::apply`, which
2786            // interprets that one authored spec.  Bug-fix history
2787            // (#11–#14 this session) was all spec drift between two
2788            // independently-maintained copies; this call is how we
2789            // make that drift impossible by construction.
2790            let algo = sui_spec::derivation::load_canonical().map_err(|e| {
2791                VMError::TypeError {
2792                    expected: "valid derivation algorithm spec",
2793                    got: "load error",
2794                    context: format!("sui-spec: {e}"),
2795                }
2796            })?;
2797            let (drv_path, out_paths, drv_final) =
2798                sui_spec::derivation::apply(&algo, drv, outputs.clone(), &name)
2799                    .map_err(|e| VMError::TypeError {
2800                        expected: "derivation interpreter success",
2801                        got: "interp error",
2802                        context: format!("sui-spec: {e}"),
2803                    })?;
2804            (drv_path, out_paths, drv_final)
2805        };
2806        // Update derivation outputs with final paths and write .drv file.
2807        for (output_name, output_path) in &out_paths {
2808            if let Some(output) = drv.outputs.get_mut(output_name) {
2809                if output.path.is_empty() {
2810                    output.path.clone_from(output_path);
2811                }
2812            }
2813            drv.env.insert(output_name.clone(), output_path.clone());
2814        }
2815        let drv_content_final = drv.serialize();
2816        let store_dir = std::env::var("SUI_STORE_DIR")
2817            .unwrap_or_else(|_| "/nix/store".to_string());
2818        let disk_path = if store_dir != "/nix/store" {
2819            drv_path.replacen("/nix/store", &store_dir, 1)
2820        } else {
2821            drv_path.clone()
2822        };
2823        let drv_file = std::path::Path::new(&disk_path);
2824        if !drv_file.exists() {
2825            if let Some(parent) = drv_file.parent() {
2826                std::fs::create_dir_all(parent).ok();
2827            }
2828            match std::fs::write(drv_file, drv_content_final.as_bytes()) {
2829                Ok(()) => {}
2830                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
2831                    let fallback_dir = std::env::temp_dir().join("sui-drv-cache");
2832                    std::fs::create_dir_all(&fallback_dir).ok();
2833                    let fallback_path = fallback_dir.join(
2834                        drv_file.file_name().unwrap_or_default(),
2835                    );
2836                    let _ = std::fs::write(&fallback_path, drv_content_final.as_bytes());
2837                }
2838                Err(e) => {
2839                    return Err(VMError::Throw(format!(
2840                        "derivation: failed to write {drv_path}: {e}"
2841                    )));
2842                }
2843            }
2844        }
2845        // Assemble result attrset (CppNix-compatible).
2846        let mut result: BTreeMap<Symbol, NanBox> = attrs.clone();
2847        let type_sym = self.interner.intern("type");
2848        result.insert(type_sym, NanBox::string("derivation".to_string()));
2849        let drv_path_sym = self.interner.intern("drvPath");
2850        result.insert(drv_path_sym, NanBox::string(drv_path.clone()));
2851        // CppNix: drvAttrs contains the original input attributes
2852        let drv_attrs_sym = self.interner.intern("drvAttrs");
2853        result.insert(drv_attrs_sym, NanBox::attrs(attrs));
2854        let primary_out = out_paths
2855            .get("out")
2856            .cloned()
2857            .or_else(|| out_paths.values().next().cloned())
2858            .unwrap_or_default();
2859        let out_path_sym = self.interner.intern("outPath");
2860        result.insert(out_path_sym, NanBox::string(primary_out));
2861        // CppNix: outputName is the primary output name
2862        let output_name_sym = self.interner.intern("outputName");
2863        let primary_output_name = if out_paths.contains_key("out") { "out" }
2864            else { out_paths.keys().next().map(|s| s.as_str()).unwrap_or("out") };
2865        result.insert(output_name_sym, NanBox::string(primary_output_name.to_string()));
2866        let mut all_outputs: Vec<NanBox> = Vec::new();
2867        for (output_name, output_path) in &out_paths {
2868            let mut out_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
2869            out_attrs.insert(out_path_sym, NanBox::string(output_path.clone()));
2870            out_attrs.insert(drv_path_sym, NanBox::string(drv_path.clone()));
2871            out_attrs.insert(type_sym, NanBox::string("derivation".to_string()));
2872            out_attrs.insert(output_name_sym, NanBox::string(output_name.clone()));
2873            let name_sym = self.interner.intern("name");
2874            out_attrs.insert(name_sym, NanBox::string(name.clone()));
2875            let out_val = NanBox::attrs(out_attrs);
2876            all_outputs.push(out_val.clone());
2877            let out_sym = self.interner.intern(output_name);
2878            result.insert(out_sym, out_val);
2879        }
2880        // CppNix: `all` is a list of all output derivation attrsets
2881        let all_sym = self.interner.intern("all");
2882        result.insert(all_sym, NanBox::list(all_outputs));
2883        Ok(NanBox::attrs(result))
2884    }
2885    /// Call a builtin function, intercepting scopedImport dispatch errors.
2886    fn call_builtin_with_scoped_import_dispatch(
2887        &mut self,
2888        func: Rc<dyn Fn(Vec<VMValue>) -> Result<VMValue, VMError>>,
2889        arg: VMValue,
2890    ) -> Result<NanBox, VMError> {
2891        // Defensive: force VMValue::Thunk args that leaked through.
2892        let arg = if let VMValue::Thunk(ref thunk) = arg {
2893            let nb = NanBox::from_vmvalue(&arg);
2894            self.force_value(nb)?.to_vmvalue()
2895        } else {
2896            arg
2897        };
2898        match func(vec![arg]) {
2899            Ok(result) => Ok(NanBox::from_vmvalue(&result)),
2900            Err(VMError::Throw(ref msg))
2901                if msg.starts_with("__scopedImport_dispatch__:") =>
2902            {
2903                let rest = &msg["__scopedImport_dispatch__:".len()..];
2904                if let Some(colon_pos) = rest.rfind(':') {
2905                    let scope_nix = &rest[..colon_pos];
2906                    let path = &rest[colon_pos + 1..];
2907                    self.vm_scoped_import(scope_nix, path)
2908                } else {
2909                    Err(VMError::Throw(msg.clone()))
2910                }
2911            }
2912            Err(e) => Err(e),
2913        }
2914    }
2915    /// Evaluate `builtins.getFlake` for a path-based flake reference.
2916    ///
2917    /// If a thread-local flake resolver has been installed (via
2918    /// [`set_flake_resolver`]), delegates to it — this lets `sui-eval`
2919    /// inject the tree-walker's full `evaluate_flake` implementation
2920    /// which handles all input types correctly.  Falls back to the VM's
2921    /// own limited resolver otherwise.
2922    fn vm_get_flake(&mut self, flake_ref: &str) -> Result<NanBox, VMError> {
2923        // Check for an external resolver first.
2924        let resolved = FLAKE_RESOLVER.with(|r| {
2925            let borrow = r.borrow();
2926            if let Some(ref resolver) = *borrow {
2927                Some(resolver(flake_ref))
2928            } else {
2929                None
2930            }
2931        });
2932        if let Some(result) = resolved {
2933            let sk = result.map_err(|e| VMError::Throw(format!("getFlake: {e}")))?;
2934            return Ok(self.string_keyed_to_nanbox(&sk));
2935        }
2936        // Fallback: VM-native resolution (path-based only).
2937        self.vm_get_flake_native(flake_ref)
2938    }
2939    /// Convert a `StringKeyedValue` to a `NanBox` for the VM stack.
2940    ///
2941    /// `StringKeyedValue::Thunk` variants are wrapped in `VMThunk`s with
2942    /// `NativeCallback` state so they are only evaluated when the VM
2943    /// actually forces the value. This keeps `getFlake` fast by deferring
2944    /// transitive input evaluation.
2945    fn string_keyed_to_nanbox(&mut self, sk: &crate::value::StringKeyedValue) -> NanBox {
2946        match sk {
2947            crate::value::StringKeyedValue::Null => NanBox::null(),
2948            crate::value::StringKeyedValue::Bool(b) => NanBox::bool(*b),
2949            crate::value::StringKeyedValue::Int(n) => NanBox::int(*n),
2950            crate::value::StringKeyedValue::Float(f) => NanBox::float(*f),
2951            crate::value::StringKeyedValue::String(s) => NanBox::string(s.clone()),
2952            crate::value::StringKeyedValue::Path(p) => NanBox::from_vmvalue(&VMValue::Path(p.clone())),
2953            crate::value::StringKeyedValue::List(items) => {
2954                let nb_items: Vec<NanBox> = items.iter().map(|v| self.string_keyed_to_nanbox(v)).collect();
2955                NanBox::list(nb_items)
2956            }
2957            crate::value::StringKeyedValue::Attrs(map) => {
2958                let mut nb_map: BTreeMap<Symbol, NanBox> = BTreeMap::new();
2959                for (k, v) in map {
2960                    let sym = self.interner.intern(k);
2961                    nb_map.insert(sym, self.string_keyed_to_nanbox(v));
2962                }
2963                NanBox::attrs(nb_map)
2964            }
2965            crate::value::StringKeyedValue::Lambda => NanBox::null(),
2966            crate::value::StringKeyedValue::Callable(cb) => {
2967                let cb_clone = Rc::clone(cb);
2968                let builtin = crate::value::VMBuiltin {
2969                    name: "<bridge-fn>",
2970                    arity: 1,
2971                    func: Rc::new(move |args: Vec<VMValue>| {
2972                        let interner = crate::intern::Interner::new();
2973                        let sk_arg = args.into_iter().next()
2974                            .unwrap_or(VMValue::Null)
2975                            .to_string_keyed(&interner);
2976                        let sk_result = cb_clone(sk_arg)
2977                            .map_err(|e| crate::error::VMError::Throw(e))?;
2978                        let mut tmp_interner = crate::intern::Interner::new();
2979                        Ok(crate::builtins::string_keyed_to_vmvalue(&sk_result, &mut tmp_interner))
2980                    }),
2981                };
2982                NanBox::builtin(builtin)
2983            }
2984            crate::value::StringKeyedValue::Thunk(cb) => {
2985                // Wrap the callback in a VMThunk with NativeCallback state.
2986                // The VM's force_value will call the callback on demand and
2987                // convert the resulting StringKeyedValue to a NanBox.
2988                let thunk = VMThunk {
2989                    state: Rc::new(Cell::new(Some(ThunkState::NativeCallback(Rc::clone(cb))))),
2990                };
2991                NanBox::thunk(thunk)
2992            }
2993        }
2994    }
2995    /// VM-native flake resolution (path-based inputs only).
2996    fn vm_get_flake_native(&mut self, flake_ref: &str) -> Result<NanBox, VMError> {
2997        let flake_dir = if flake_ref.starts_with('/') || flake_ref.starts_with('.') {
2998            std::path::PathBuf::from(flake_ref)
2999        } else if let Some(path) = flake_ref.strip_prefix("path:") {
3000            std::path::PathBuf::from(path)
3001        } else {
3002            return Err(VMError::Throw(format!(
3003                "getFlake: unsupported flake reference: {flake_ref} (only path: refs supported in VM)"
3004            )));
3005        };
3006        let flake_nix = flake_dir.join("flake.nix");
3007        if !flake_nix.exists() {
3008            return Err(VMError::Throw(format!(
3009                "getFlake: flake.nix not found in {}",
3010                flake_dir.display()
3011            )));
3012        }
3013        // Import flake.nix to get the raw flake attrset.
3014        let flake_nix_str = flake_nix.to_string_lossy().to_string();
3015        let flake_attrs = self.import_file(&flake_nix_str)?;
3016        let flake_attrs = self.force_value(flake_attrs)?;
3017        // Build the inputs attrset. For now, create a minimal `self` input.
3018        let self_sym = self.interner.intern("self");
3019        let out_path_sym = self.interner.intern("outPath");
3020        let flake_dir_str = flake_dir.to_string_lossy().to_string();
3021        let mut self_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
3022        self_attrs.insert(out_path_sym, NanBox::string(flake_dir_str.clone()));
3023        let mut inputs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
3024        inputs.insert(self_sym, NanBox::attrs(self_attrs));
3025        // Try to read flake.lock and resolve inputs.
3026        let lock_path = flake_dir.join("flake.lock");
3027        if lock_path.exists() {
3028            if let Ok(lock_str) = std::fs::read_to_string(&lock_path) {
3029                if let Ok(lock_json) = serde_json::from_str::<serde_json::Value>(&lock_str) {
3030                    self.resolve_flake_lock_inputs(&lock_json, &flake_dir, &mut inputs);
3031                }
3032            }
3033        }
3034        // Extract the `outputs` function and call it with the inputs attrset.
3035        let outputs_sym = self.interner.intern("outputs");
3036        if let Some(attrs) = flake_attrs.as_attrs() {
3037            if let Some(outputs_func) = attrs.get(&outputs_sym) {
3038                let outputs_func = outputs_func.clone();
3039                let outputs_func = self.force_value(outputs_func)?;
3040                let inputs_nb = NanBox::attrs(inputs);
3041                let result = self.call_callable(&outputs_func, inputs_nb)?;
3042                let mut result_forced = self.force_value(result)?;
3043                // Merge top-level metadata (description) into the result.
3044                let desc_sym = self.interner.intern("description");
3045                if let Some(desc) = attrs.get(&desc_sym) {
3046                    if let Some(result_attrs) = result_forced.as_attrs() {
3047                        let mut merged = result_attrs.clone();
3048                        merged.insert(desc_sym, desc.clone());
3049                        result_forced = NanBox::attrs(merged);
3050                    }
3051                }
3052                return Ok(result_forced);
3053            }
3054        }
3055        // If no outputs function, return the raw flake attrset.
3056        Ok(flake_attrs)
3057    }
3058    /// Resolve flake.lock inputs into the inputs attrset.
3059    fn resolve_flake_lock_inputs(
3060        &mut self,
3061        lock: &serde_json::Value,
3062        flake_dir: &std::path::Path,
3063        inputs: &mut BTreeMap<Symbol, NanBox>,
3064    ) {
3065        let nodes = match lock.get("nodes").and_then(|n| n.as_object()) {
3066            Some(n) => n,
3067            None => return,
3068        };
3069        let root_node = match lock.get("root").and_then(|r| r.as_str()) {
3070            Some(r) => r.to_string(),
3071            None => "root".to_string(),
3072        };
3073        let root_inputs = match nodes
3074            .get(&root_node)
3075            .and_then(|n| n.get("inputs"))
3076            .and_then(|i| i.as_object())
3077        {
3078            Some(i) => i,
3079            None => return,
3080        };
3081        for (input_name, node_ref) in root_inputs {
3082            let node_key = match node_ref.as_str() {
3083                Some(s) => s.to_string(),
3084                None => {
3085                    if let Some(arr) = node_ref.as_array() {
3086                        if let Some(s) = arr.first().and_then(|v| v.as_str()) {
3087                            s.to_string()
3088                        } else {
3089                            continue;
3090                        }
3091                    } else {
3092                        continue;
3093                    }
3094                }
3095            };
3096            if let Some(node) = nodes.get(&node_key) {
3097                if let Some(locked) = node.get("locked") {
3098                    let locked_type = locked.get("type").and_then(|t| t.as_str()).unwrap_or("");
3099                    let out_path = match locked_type {
3100                        "path" => {
3101                            if let Some(p) = locked.get("path").and_then(|p| p.as_str()) {
3102                                let path = if p.starts_with('/') {
3103                                    std::path::PathBuf::from(p)
3104                                } else {
3105                                    flake_dir.join(p)
3106                                };
3107                                path.to_string_lossy().to_string()
3108                            } else {
3109                                continue;
3110                            }
3111                        }
3112                        _ => continue, // Only path inputs for now
3113                    };
3114                    let input_sym = self.interner.intern(input_name);
3115                    let out_path_sym = self.interner.intern("outPath");
3116                    let mut input_attrs: BTreeMap<Symbol, NanBox> = BTreeMap::new();
3117                    input_attrs.insert(out_path_sym, NanBox::string(out_path));
3118                    inputs.insert(input_sym, NanBox::attrs(input_attrs));
3119                }
3120            }
3121        }
3122    }
3123    /// Import a file with a scope (for scopedImport).
3124    ///
3125    /// Handles the directory → `default.nix` fallback like `import_file`.
3126    fn vm_scoped_import(
3127        &mut self,
3128        scope_nix: &str,
3129        path: &str,
3130    ) -> Result<NanBox, VMError> {
3131        // Same materializer redirect as `import_file` — `scopedImport` reads
3132        // the same trees `import` does.
3133        let read_path = crate::bridge::materialize(path);
3134        // Directory → default.nix fallback (Nix convention).
3135        let resolved = if std::path::Path::new(&read_path).is_dir() {
3136            format!("{read_path}/default.nix")
3137        } else {
3138            read_path
3139        };
3140        let source = std::fs::read_to_string(&resolved)
3141            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3142        // Wrap the source in `with <scope>; <source>` to inject the scope.
3143        let wrapped = format!("with {scope_nix}; {source}");
3144        let file_dir = std::path::Path::new(&resolved)
3145            .parent()
3146            .map(|p| p.to_path_buf())
3147            .unwrap_or_default();
3148        // Share the VM's interner so symbol IDs stay consistent.
3149        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
3150        let chunk = Compiler::compile_with_shared_interner(&wrapped, file_dir, shared_interner.clone())
3151            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3152        *self.interner = match Rc::try_unwrap(shared_interner) {
3153            Ok(cell) => cell.into_inner(),
3154            Err(rc) => rc.borrow().clone(),
3155        };
3156        if self.frames.len() >= MAX_CALL_DEPTH {
3157            return Err(VMError::StackOverflow);
3158        }
3159        let return_depth = self.frames.len();
3160        let stack_base = self.stack.len();
3161        self.frames.push(CallFrame {
3162            chunk: Rc::new(chunk),
3163            ip: 0,
3164            stack_base,
3165            upvalues: Vec::new(),
3166        });
3167        self.run_until(return_depth)
3168    }
3169    // -- Higher-order builtin execution -----------------------------------
3170    fn call_callable(&mut self, func: &NanBox, arg: NanBox) -> Result<NanBox, VMError> {
3171        if let Some(closure) = func.as_closure() {
3172            if self.frames.len() >= MAX_CALL_DEPTH {
3173                return Err(VMError::StackOverflow);
3174            }
3175            let upvalues = closure.upvalues.clone();
3176            let chunk = closure.chunk.clone();
3177            let return_depth = self.frames.len();
3178            let stack_base = self.stack.len();
3179            self.push(arg);
3180            self.frames.push(CallFrame {
3181                chunk,
3182                ip: 0,
3183                stack_base,
3184                upvalues,
3185            });
3186            let result = self.run_until(return_depth)?;
3187            self.stack.truncate(stack_base);
3188            // Force the result — callers expect concrete values
3189            // (e.g., filter checks is_truthy on predicate results).
3190            self.force_value(result)
3191        } else if func.is_higher_order_builtin() {
3192            let hob = func.as_higher_order_builtin().unwrap().clone();
3193            self.call_higher_order_builtin(&hob, arg)
3194        } else if let Some(builtin) = func.as_builtin() {
3195            // Force the arg for builtins — they expect concrete values.
3196            let arg = self.force_value(arg)?;
3197            if let Some(result) = self.try_vm_builtin(builtin.name, &arg)? {
3198                Ok(result)
3199            } else {
3200                // Deep-force: builtins iterate over container elements.
3201                let deep = self.deep_force(arg)?;
3202                let arg_vmval = deep.to_vmvalue();
3203                let builtin_func = builtin.func.clone();
3204                let result = self.call_builtin_with_scoped_import_dispatch(
3205                    builtin_func, arg_vmval,
3206                )?;
3207                Ok(result)
3208            }
3209        } else {
3210            Err(VMError::NotCallable(func.type_name().to_string()))
3211        }
3212    }
3213    #[allow(clippy::too_many_lines)]
3214    fn call_higher_order_builtin(
3215        &mut self,
3216        hob: &HigherOrderBuiltin,
3217        arg: NanBox,
3218    ) -> Result<NanBox, VMError> {
3219        use HigherOrderOp::*;
3220        // Force the argument — higher-order builtins need concrete values.
3221        // Use shallow_force_container to handle thunked list elements.
3222        let arg = self.force_value(arg)?;
3223        match hob.op {
3224            Map => {
3225                let list_val = arg.to_vmvalue();
3226                let list = match &list_val {
3227                    VMValue::List(l) => l,
3228                    other => return Err(VMError::TypeError {
3229                        expected: "list", got: other.type_name(),
3230                        context: "builtins.map".to_string(),
3231                    }),
3232                };
3233                let func_nb = NanBox::from_vmvalue(&hob.func);
3234                let mut results = Vec::with_capacity(list.len());
3235                for item in list {
3236                    let r = self.call_callable(&func_nb, NanBox::from_vmvalue(item))?;
3237                    results.push(r);
3238                }
3239                Ok(NanBox::list(results))
3240            }
3241            Filter => {
3242                let list_val = arg.to_vmvalue();
3243                let list = match &list_val {
3244                    VMValue::List(l) => l,
3245                    other => return Err(VMError::TypeError {
3246                        expected: "list", got: other.type_name(),
3247                        context: "builtins.filter".to_string(),
3248                    }),
3249                };
3250                let func_nb = NanBox::from_vmvalue(&hob.func);
3251                let mut results = Vec::new();
3252                for item in list {
3253                    let item_nb = NanBox::from_vmvalue(item);
3254                    let r = self.call_callable(&func_nb, item_nb.clone())?;
3255                    
3256                    if r.is_truthy()? { results.push(item_nb); }
3257                }
3258                Ok(NanBox::list(results))
3259            }
3260            FoldlP1 => {
3261                let init_vmval = arg.to_vmvalue();
3262                Ok(NanBox::from_vmvalue(&VMValue::HigherOrderBuiltin(
3263                    HigherOrderBuiltin {
3264                        op: FoldlP2,
3265                        func: hob.func.clone(),
3266                        extra_args: vec![init_vmval],
3267                    },
3268                )))
3269            }
3270            FoldlP2 => {
3271                let list_val = arg.to_vmvalue();
3272                let list = match &list_val {
3273                    VMValue::List(l) => l,
3274                    other => return Err(VMError::TypeError {
3275                        expected: "list", got: other.type_name(),
3276                        context: "builtins.foldl'".to_string(),
3277                    }),
3278                };
3279                let func_nb = NanBox::from_vmvalue(&hob.func);
3280                let mut acc = NanBox::from_vmvalue(&hob.extra_args[0]);
3281                for item in list {
3282                    let partial = self.call_callable(&func_nb, acc)?;
3283                    acc = self.call_callable(&partial, NanBox::from_vmvalue(item))?;
3284                }
3285                Ok(acc)
3286            }
3287            Sort => {
3288                let list_val = arg.to_vmvalue();
3289                let list = match &list_val {
3290                    VMValue::List(l) => l.clone(),
3291                    other => return Err(VMError::TypeError {
3292                        expected: "list", got: other.type_name(),
3293                        context: "builtins.sort".to_string(),
3294                    }),
3295                };
3296                if list.len() <= 1 {
3297                    return Ok(NanBox::from_vmvalue(&VMValue::List(list)));
3298                }
3299                let func_nb = NanBox::from_vmvalue(&hob.func);
3300                let mut sorted: Vec<VMValue> = Vec::with_capacity(list.len());
3301                for item in &list {
3302                    let item_nb = NanBox::from_vmvalue(item);
3303                    let mut pos = sorted.len();
3304                    for (i, existing) in sorted.iter().enumerate() {
3305                        let existing_nb = NanBox::from_vmvalue(existing);
3306                        let partial = self.call_callable(&func_nb, item_nb.clone())?;
3307                        let cmp_result = self.call_callable(&partial, existing_nb)?;
3308                        if cmp_result.is_truthy()? { pos = i; break; }
3309                    }
3310                    sorted.insert(pos, item.clone());
3311                }
3312                Ok(NanBox::from_vmvalue(&VMValue::List(sorted)))
3313            }
3314            GenList => {
3315                let n = match arg.to_vmvalue() {
3316                    VMValue::Int(n) => n,
3317                    other => return Err(VMError::TypeError {
3318                        expected: "int", got: other.type_name(),
3319                        context: "builtins.genList".to_string(),
3320                    }),
3321                };
3322                if n < 0 { return Err(VMError::Throw("genList: negative length".to_string())); }
3323                let func_nb = NanBox::from_vmvalue(&hob.func);
3324                let mut results = Vec::with_capacity(n as usize);
3325                for i in 0..n {
3326                    results.push(self.call_callable(&func_nb, NanBox::int(i))?);
3327                }
3328                Ok(NanBox::list(results))
3329            }
3330            ConcatMap => {
3331                let list_val = arg.to_vmvalue();
3332                let list = match &list_val {
3333                    VMValue::List(l) => l,
3334                    other => return Err(VMError::TypeError {
3335                        expected: "list", got: other.type_name(),
3336                        context: "builtins.concatMap".to_string(),
3337                    }),
3338                };
3339                let func_nb = NanBox::from_vmvalue(&hob.func);
3340                let mut results = Vec::new();
3341                for item in list {
3342                    let mapped = self.call_callable(&func_nb, NanBox::from_vmvalue(item))?;
3343                    match mapped.to_vmvalue() {
3344                        VMValue::List(inner) => {
3345                            for v in &inner { results.push(NanBox::from_vmvalue(v)); }
3346                        }
3347                        other => return Err(VMError::TypeError {
3348                            expected: "list", got: other.type_name(),
3349                            context: "builtins.concatMap result".to_string(),
3350                        }),
3351                    }
3352                }
3353                Ok(NanBox::list(results))
3354            }
3355            Any => {
3356                let list_val = arg.to_vmvalue();
3357                let list = match &list_val {
3358                    VMValue::List(l) => l,
3359                    other => return Err(VMError::TypeError {
3360                        expected: "list", got: other.type_name(),
3361                        context: "builtins.any".to_string(),
3362                    }),
3363                };
3364                let func_nb = NanBox::from_vmvalue(&hob.func);
3365                for item in list {
3366                    if self.call_callable(&func_nb, NanBox::from_vmvalue(item))?.is_truthy()? {
3367                        return Ok(NanBox::bool(true));
3368                    }
3369                }
3370                Ok(NanBox::bool(false))
3371            }
3372            All => {
3373                let list_val = arg.to_vmvalue();
3374                let list = match &list_val {
3375                    VMValue::List(l) => l,
3376                    other => return Err(VMError::TypeError {
3377                        expected: "list", got: other.type_name(),
3378                        context: "builtins.all".to_string(),
3379                    }),
3380                };
3381                let func_nb = NanBox::from_vmvalue(&hob.func);
3382                for item in list {
3383                    if !self.call_callable(&func_nb, NanBox::from_vmvalue(item))?.is_truthy()? {
3384                        return Ok(NanBox::bool(false));
3385                    }
3386                }
3387                Ok(NanBox::bool(true))
3388            }
3389            Partition => {
3390                let list_val = arg.to_vmvalue();
3391                let list = match &list_val {
3392                    VMValue::List(l) => l,
3393                    other => return Err(VMError::TypeError {
3394                        expected: "list", got: other.type_name(),
3395                        context: "builtins.partition".to_string(),
3396                    }),
3397                };
3398                let func_nb = NanBox::from_vmvalue(&hob.func);
3399                let (mut right, mut wrong) = (Vec::new(), Vec::new());
3400                for item in list {
3401                    let item_nb = NanBox::from_vmvalue(item);
3402                    if self.call_callable(&func_nb, item_nb.clone())?.is_truthy()? {
3403                        right.push(item_nb);
3404                    } else {
3405                        wrong.push(item_nb);
3406                    }
3407                }
3408                let rs = self.interner.intern("right");
3409                let ws = self.interner.intern("wrong");
3410                let mut attrs = BTreeMap::new();
3411                attrs.insert(rs, NanBox::list(right));
3412                attrs.insert(ws, NanBox::list(wrong));
3413                Ok(NanBox::attrs(attrs))
3414            }
3415            GroupBy => {
3416                let list_val = arg.to_vmvalue();
3417                let list = match &list_val {
3418                    VMValue::List(l) => l,
3419                    other => return Err(VMError::TypeError {
3420                        expected: "list", got: other.type_name(),
3421                        context: "builtins.groupBy".to_string(),
3422                    }),
3423                };
3424                let func_nb = NanBox::from_vmvalue(&hob.func);
3425                let mut groups: BTreeMap<String, Vec<NanBox>> = BTreeMap::new();
3426                for item in list {
3427                    let item_nb = NanBox::from_vmvalue(item);
3428                    let kr = self.call_callable(&func_nb, item_nb.clone())?;
3429                    let ks = kr.as_string().ok_or_else(|| VMError::TypeError {
3430                        expected: "string", got: kr.type_name(),
3431                        context: "builtins.groupBy key".to_string(),
3432                    })?.to_string();
3433                    groups.entry(ks).or_default().push(item_nb);
3434                }
3435                let mut attrs = BTreeMap::new();
3436                for (k, vs) in groups {
3437                    attrs.insert(self.interner.intern(&k), NanBox::list(vs));
3438                }
3439                Ok(NanBox::attrs(attrs))
3440            }
3441            MapAttrs => {
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.mapAttrs".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 chunk = deferred_apply_chunk();
3453                let mut result = BTreeMap::new();
3454                for (sym, val) in entries {
3455                    let key_str = self.interner.resolve(sym).to_string();
3456                    // Eagerly apply f to the key name (partial application).
3457                    // This is cheap — it just creates a closure capturing the key.
3458                    let partial = self.call_callable(&func_nb, NanBox::string(key_str))?;
3459                    // Defer the second application (partial value) as a thunk.
3460                    // This matches CppNix semantics: mapAttrs is lazy in values.
3461                    // Upvalues are NanBoxes: `partial` already is one; `val`
3462                    // came off the VMValue attrset so convert it locally here
3463                    // (this is a per-entry conversion, not the per-Call/thunk
3464                    // round-trip the optimization removes).
3465                    let thunk = VMThunk::new(
3466                        chunk.clone(),
3467                        vec![partial, NanBox::from_vmvalue(&val)],
3468                    );
3469                    result.insert(sym, NanBox::thunk(thunk));
3470                }
3471                Ok(NanBox::attrs(result))
3472            }
3473            Elem => {
3474                // builtins.elem needle list — check if needle is in list.
3475                // Needs VM-level handling because list elements may be thunks
3476                // that must be forced before equality comparison.
3477                // Uses deep_eq which recursively forces nested values.
3478                let needle = NanBox::from_vmvalue(&hob.func);
3479                let forced_needle = self.force_value(needle)?;
3480                let list = if let Some(items) = arg.as_list() {
3481                    items.to_vec()
3482                } else {
3483                    let forced = self.force_value(arg)?;
3484                    if let Some(items) = forced.as_list() {
3485                        items.to_vec()
3486                    } else {
3487                        return Err(VMError::TypeError {
3488                            expected: "list",
3489                            got: forced.type_name(),
3490                            context: "builtins.elem".to_string(),
3491                        });
3492                    }
3493                };
3494                for item in &list {
3495                    let forced_item = self.force_value(item.clone())?;
3496                    if self.deep_eq(&forced_needle, &forced_item)? {
3497                        return Ok(NanBox::bool(true));
3498                    }
3499                }
3500                Ok(NanBox::bool(false))
3501            }
3502        }
3503    }
3504    // -- Import ---------------------------------------------------------
3505    /// Import a Nix file: compile it, execute it, cache the result.
3506    ///
3507    /// Handles the Nix convention that importing a directory is equivalent
3508    /// to importing `<directory>/default.nix`.
3509    fn import_file(&mut self, path: &str) -> Result<NanBox, VMError> {
3510        // Redirect the DISK side through the installed path materializer
3511        // before canonicalizing. A flake input's `/nix/store/<narhash>-source`
3512        // prefix is never written to disk by sui, so `canonicalize` ENOENTs on
3513        // it — and redirecting `pathExists` while leaving `import` bare is the
3514        // exact guard-passes-then-read-ENOENTs shape that broke every fleet
3515        // rebuild through `hashFile`. Identity when nothing is installed.
3516        let read_path = crate::bridge::materialize(path);
3517        let resolved = std::fs::canonicalize(&read_path)
3518            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3519        // Directory → default.nix fallback (Nix convention).
3520        let resolved = if resolved.is_dir() {
3521            resolved.join("default.nix")
3522        } else {
3523            resolved
3524        };
3525        let canonical = resolved.to_string_lossy().to_string();
3526        // Check cache.
3527        if let Some(cached) = self.import_cache.borrow().get(&canonical) {
3528            return Ok(NanBox::from_vmvalue(cached));
3529        }
3530        // Try VM compilation, falling back to tree-walker on CompileError.
3531        let chunk = self.try_compile_import(&resolved, &canonical)?;
3532        let chunk = match chunk {
3533            Some(c) => c,
3534            None => {
3535                // Compilation failed — fall back to tree-walker via bridge.
3536                return self.import_via_bridge(&canonical);
3537            }
3538        };
3539        if self.frames.len() >= MAX_CALL_DEPTH {
3540            return Err(VMError::StackOverflow);
3541        }
3542        let return_depth = self.frames.len();
3543        let stack_base = self.stack.len();
3544        self.frames.push(CallFrame {
3545            chunk,
3546            ip: 0,
3547            stack_base,
3548            upvalues: Vec::new(),
3549        });
3550        let result = match self.run_until(return_depth) {
3551            Ok(r) => r,
3552            Err(e @ VMError::Throw(_)) => {
3553                // Nix throw must propagate so tryEval can catch it.
3554                self.stack.truncate(stack_base);
3555                if self.frames.len() > return_depth {
3556                    self.frames.truncate(return_depth);
3557                }
3558                return Err(e);
3559            }
3560            Err(e) => {
3561                // Any other error — fall back to tree-walker for this file.
3562                // This includes AttrNotFound, TypeError, AssertionFailed, etc.
3563                //
3564                // Under SUI_VM_STRICT this is a refusal, not a fallback: the
3565                // walker covering for the VM here is exactly what made a
3566                // VM-vs-walker comparison answerable by the walker on both
3567                // sides.
3568                crate::fallback::record(
3569                    crate::fallback::Layer::ImportedFile,
3570                    &format!("runtime error importing {canonical}: {e}"),
3571                )
3572                .map_err(VMError::Throw)?;
3573                eprintln!("[sui-vm] runtime fallback for {canonical}: {e}");
3574                use std::sync::atomic::Ordering;
3575                crate::vm::VM_FALLBACK_COUNT.fetch_add(1, Ordering::Relaxed);
3576                self.stack.truncate(stack_base);
3577                if self.frames.len() > return_depth {
3578                    self.frames.truncate(return_depth);
3579                }
3580                return self.import_via_bridge(&canonical);
3581            }
3582        };
3583        // Clean up the imported frame's stack slots.
3584        // Return at stop_depth skips truncation, so we must do it here.
3585        self.stack.truncate(stack_base);
3586        // Cache as VMValue and return as NanBox.
3587        let result_vmval = result.to_vmvalue();
3588        self.import_cache
3589            .borrow_mut()
3590            .insert(canonical, result_vmval);
3591        Ok(result)
3592    }
3593    /// Try to compile an imported file. Returns `Ok(Some(chunk))` on success,
3594    /// `Ok(None)` on `CompileError` (caller should fall back to tree-walker),
3595    /// or `Err` on I/O errors.
3596    fn try_compile_import(
3597        &mut self,
3598        resolved: &std::path::Path,
3599        canonical: &str,
3600    ) -> Result<Option<Rc<Chunk>>, VMError> {
3601        // Check compile cache — skip parse + compile if we've seen this file.
3602        if let Some(cached_chunk) = self.compile_cache.get(resolved) {
3603            return Ok(Some(cached_chunk.clone()));
3604        }
3605        // Read the file.
3606        let source = std::fs::read_to_string(canonical)
3607            .map_err(|e| VMError::ImportError(format!("{canonical}: {e}")))?;
3608        let file_dir = resolved
3609            .parent()
3610            .map(|p| p.to_path_buf())
3611            .unwrap_or_default();
3612        // Share the VM's interner with the compiler so that symbol IDs
3613        // are consistent — no need to clear key_symbols afterwards.
3614        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
3615        let compile_result =
3616            Compiler::compile_with_shared_interner(&source, file_dir, shared_interner.clone());
3617        *self.interner = match Rc::try_unwrap(shared_interner) {
3618            Ok(cell) => cell.into_inner(),
3619            Err(rc) => rc.borrow().clone(),
3620        };
3621        match compile_result {
3622            Ok(mut compiled) => {
3623                Self::set_source_file_recursive(&mut compiled, canonical);
3624                let chunk = Rc::new(compiled);
3625                self.compile_cache
3626                    .insert(resolved.to_path_buf(), chunk.clone());
3627                Ok(Some(chunk))
3628            }
3629            Err(compile_error) => {
3630                // Compilation failed (unsupported expression, etc.) —
3631                // signal caller to fall back to tree-walker. Under
3632                // SUI_VM_STRICT, refuse instead: "the VM cannot compile this"
3633                // is a fact a measurement needs, and silently handing the file
3634                // to the walker is how it stayed invisible.
3635                crate::fallback::record(
3636                    crate::fallback::Layer::ImportedFile,
3637                    &format!("cannot compile {canonical}: {compile_error}"),
3638                )
3639                .map_err(VMError::Throw)?;
3640                VM_FALLBACK_COUNT.fetch_add(1, Ordering::Relaxed);
3641                eprintln!("[sui-vm] fallback to tree-walker for {canonical}: {compile_error}");
3642                Ok(None)
3643            }
3644        }
3645    }
3646    /// Fall back to tree-walker evaluation for an imported file via the
3647    /// builtin bridge. Called when the bytecode compiler cannot handle
3648    /// the file (e.g. unsupported AST constructs).
3649    fn import_via_bridge(&mut self, canonical: &str) -> Result<NanBox, VMError> {
3650        match crate::bridge::call_builtin_bridge(
3651            "__import",
3652            vec![crate::value::StringKeyedValue::Path(canonical.to_string())],
3653        ) {
3654            Ok(Some(result)) => {
3655                let nanbox = self.string_keyed_to_nanbox(&result);
3656                // Force the top-level result so callers get a concrete
3657                // value (not a thunk). Bridge results may be thunked
3658                // when the tree-walker wraps unevaluated expressions.
3659                let nanbox = if nanbox.is_thunk() {
3660                    self.force_value(nanbox)?
3661                } else {
3662                    nanbox
3663                };
3664                // Cache as VMValue so subsequent imports hit the cache.
3665                let result_vmval = nanbox.to_vmvalue();
3666                self.import_cache
3667                    .borrow_mut()
3668                    .insert(canonical.to_string(), result_vmval);
3669                Ok(nanbox)
3670            }
3671            Ok(None) => Err(VMError::ImportError(format!(
3672                "compilation failed and no bridge installed for '{canonical}'"
3673            ))),
3674            Err(e) => Err(VMError::ImportError(format!(
3675                "bridge fallback error for '{canonical}': {e}"
3676            ))),
3677        }
3678    }
3679    /// Recursively set `source_file` on a chunk and all nested closure chunks.
3680    fn set_source_file_recursive(chunk: &mut Chunk, file: &str) {
3681        chunk.source_file = Some(file.to_string());
3682        for constant in &mut chunk.constants {
3683            if let VMValue::Closure(closure) = constant {
3684                if let Some(inner_chunk) = Rc::get_mut(&mut closure.chunk) {
3685                    Self::set_source_file_recursive(inner_chunk, file);
3686                }
3687            }
3688        }
3689    }
3690    /// Disassemble instructions around a given offset for error diagnostics.
3691    /// Returns a human-readable string showing `window` instructions before
3692    /// and after `center_ip`, with an arrow marking the center.
3693    fn disassemble_around(chunk: &Chunk, center_ip: usize, window: usize) -> String {
3694        let code = &chunk.code;
3695        let mut lines: Vec<String> = Vec::new();
3696        // Collect instruction boundaries by scanning from the start.
3697        let mut boundaries: Vec<usize> = Vec::new();
3698        let mut pos = 0;
3699        while pos < code.len() {
3700            boundaries.push(pos);
3701            pos += Self::instruction_width(code, pos);
3702        }
3703        // Find the boundary closest to center_ip.
3704        let center_idx = boundaries.iter().position(|&b| b >= center_ip).unwrap_or(0);
3705        let start_idx = center_idx.saturating_sub(window);
3706        let end_idx = (center_idx + window + 1).min(boundaries.len());
3707        for idx in start_idx..end_idx {
3708            let ip = boundaries[idx];
3709            let marker = if ip == center_ip { ">>>" } else { "   " };
3710            let line = chunk.lines.get(ip).copied().unwrap_or(0);
3711            if let Some(op) = OpCode::from_byte(code[ip]) {
3712                let operands = Self::format_operands(code, ip, op);
3713                lines.push(format!("    {marker} {ip:4}: {op:?}{operands}  (line {line})"));
3714            } else {
3715                lines.push(format!("    {marker} {ip:4}: <unknown {}>  (line {line})", code[ip]));
3716            }
3717        }
3718        lines.join("\n")
3719    }
3720    /// Determine the total byte width of an instruction at `pos`.
3721    fn instruction_width(code: &[u8], pos: usize) -> usize {
3722        let byte = code[pos];
3723        match OpCode::from_byte(byte) {
3724            Some(op) => match op {
3725                // No operands (1 byte):
3726                OpCode::Null | OpCode::True | OpCode::False
3727                | OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div | OpCode::Negate
3728                | OpCode::Not | OpCode::And | OpCode::Or | OpCode::Implication
3729                | OpCode::Equal | OpCode::NotEqual | OpCode::Less | OpCode::Greater
3730                | OpCode::LessEqual | OpCode::GreaterEqual
3731                | OpCode::UpdateAttrs | OpCode::Concat
3732                | OpCode::Call | OpCode::TailCall | OpCode::Return
3733                | OpCode::Assert | OpCode::Throw | OpCode::Pop | OpCode::Dup | OpCode::PushWith | OpCode::PopWith
3734                | OpCode::PushBuiltins | OpCode::Force | OpCode::Import
3735                | OpCode::DynGetAttr | OpCode::DynHasAttr
3736                | OpCode::DynSelectOrDefault | OpCode::Dup => 1,
3737                // 1 u16 operand (3 bytes):
3738                OpCode::Constant | OpCode::GetLocal | OpCode::SetLocal
3739                | OpCode::GetUpvalue | OpCode::SetUpvalue | OpCode::LookupWith
3740                | OpCode::GetAttr | OpCode::HasAttr | OpCode::MakeAttrs
3741                | OpCode::SelectOrDefault | OpCode::MakeList
3742                | OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue
3743                | OpCode::Interpolate => 3,
3744                // 2 u16 operands (5 bytes):
3745                OpCode::GetLocalAttr | OpCode::GetLocalCall | OpCode::CallBuiltin => 5,
3746                // MakeClosure: u16 const_idx, u16 uv_count, then uv_count * 3 bytes
3747                OpCode::MakeClosure => {
3748                    if pos + 5 <= code.len() {
3749                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3750                        5 + uv_count * 3
3751                    } else {
3752                        3 // truncated
3753                    }
3754                }
3755                // MakeThunk: u16 const_idx, u16 uv_count, then uv_count * 3 bytes
3756                OpCode::MakeThunk => {
3757                    if pos + 5 <= code.len() {
3758                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3759                        5 + uv_count * 3
3760                    } else {
3761                        3
3762                    }
3763                }
3764                // PatchThunkUpvalues: u16 slot, u16 uv_count, then uv_count * 3 bytes
3765                OpCode::PatchThunkUpvalues => {
3766                    if pos + 5 <= code.len() {
3767                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3768                        5 + uv_count * 3
3769                    } else {
3770                        3
3771                    }
3772                }
3773                // MakeLazyThunk: u16 src, u32 offset, u32 length, u16 dir, u16 uv_count, then uv_count * 3
3774                OpCode::MakeLazyThunk => {
3775                    if pos + 15 <= code.len() {
3776                        let uv_count = u16::from_le_bytes([code[pos + 13], code[pos + 14]]) as usize;
3777                        15 + uv_count * 3
3778                    } else {
3779                        3
3780                    }
3781                }
3782            },
3783            None => 1, // unknown opcode, skip 1
3784        }
3785    }
3786    /// Format inline operands for a single instruction (for disassembly).
3787    fn format_operands(code: &[u8], pos: usize, op: OpCode) -> String {
3788        let read_u16_at = |p: usize| -> Option<u16> {
3789            if p + 2 <= code.len() {
3790                Some(u16::from_le_bytes([code[p], code[p + 1]]))
3791            } else {
3792                None
3793            }
3794        };
3795        match op {
3796            OpCode::Constant | OpCode::GetLocal | OpCode::SetLocal
3797            | OpCode::GetUpvalue | OpCode::SetUpvalue | OpCode::LookupWith
3798            | OpCode::GetAttr | OpCode::HasAttr | OpCode::MakeAttrs
3799            | OpCode::SelectOrDefault | OpCode::MakeList
3800            | OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue
3801            | OpCode::Interpolate => {
3802                read_u16_at(pos + 1).map_or(String::new(), |v| format!(" {v}"))
3803            }
3804            OpCode::GetLocalAttr => {
3805                let s = read_u16_at(pos + 1).unwrap_or(0);
3806                let k = read_u16_at(pos + 3).unwrap_or(0);
3807                format!(" slot={s} key={k}")
3808            }
3809            OpCode::GetLocalCall => {
3810                read_u16_at(pos + 1).map_or(String::new(), |v| format!(" slot={v}"))
3811            }
3812            OpCode::CallBuiltin => {
3813                let idx = read_u16_at(pos + 1).unwrap_or(0);
3814                let argc = read_u16_at(pos + 3).unwrap_or(0);
3815                format!(" idx={idx} argc={argc}")
3816            }
3817            OpCode::MakeThunk | OpCode::MakeClosure => {
3818                let ci = read_u16_at(pos + 1).unwrap_or(0);
3819                let uv = read_u16_at(pos + 3).unwrap_or(0);
3820                format!(" const={ci} upvals={uv}")
3821            }
3822            OpCode::PatchThunkUpvalues => {
3823                let s = read_u16_at(pos + 1).unwrap_or(0);
3824                let uv = read_u16_at(pos + 3).unwrap_or(0);
3825                format!(" slot={s} upvals={uv}")
3826            }
3827            _ => String::new(),
3828        }
3829    }
3830}
3831#[cfg(test)]
3832mod tests {
3833    use super::*;
3834    use crate::compiler::Compiler;
3835    use crate::value::StringKeyedValue;
3836    fn eval(input: &str) -> VMValue {
3837        let (chunk, mut interner) =
3838            Compiler::compile(input).unwrap_or_else(|e| panic!("compile '{input}': {e}"));
3839        VM::execute(chunk, &mut interner).unwrap_or_else(|e| panic!("execute '{input}': {e}"))
3840    }
3841    fn eval_full_helper(input: &str) -> crate::StringKeyedValue {
3842        let result =
3843            crate::eval_full(input).unwrap_or_else(|e| panic!("eval_full '{input}': {e}"));
3844        result.to_string_keyed()
3845    }
3846    fn eval_err(input: &str) -> VMError {
3847        let (chunk, mut interner) =
3848            Compiler::compile(input).unwrap_or_else(|e| panic!("compile '{input}': {e}"));
3849        VM::execute(chunk, &mut interner).unwrap_err()
3850    }
3851    // -- The nixpkgs-lib-leak ratchet, VM side --------------------------
3852    //
3853    // The walker's registry is pinned by
3854    // `sui-eval/tests/fixtures/BUILTIN-REGISTRY.json`; that gate reads the
3855    // walker IN-PROCESS and so cannot see the VM's registry without spawning a
3856    // `sui` binary that may not be built when the test runs. That is why the
3857    // manifest recorded `pending-builtin-registry: gate the VM registry` rather
3858    // than gating it.
3859    //
3860    // This test is the in-process half for the VM: it needs no binary, no nix
3861    // and no network. It does NOT pin the VM's full key set (the walker/VM
3862    // delta is still real and still recorded in the manifest's
3863    // `cli_divergence`) — it pins the one-way part, that the six removed
3864    // nixpkgs `lib` leaks may not come back on this engine either.
3865    //
3866    // TIER: test-caught, not unrepresentable. Nothing stops someone writing
3867    // `self.register("toUpper", ...)` again; this turns red afterwards.
3868    #[test]
3869    fn vm_has_no_nixpkgs_lib_leaks() {
3870        // Kept in lockstep with `removed_nixpkgs_lib_leaks.names` in
3871        // sui-eval/tests/fixtures/BUILTIN-REGISTRY.json.
3872        const REMOVED_LEAKS: &[&str] = &[
3873            "concatStrings",
3874            "filterAttrs",
3875            "hasPrefix",
3876            "hasSuffix",
3877            "toLower",
3878            "toUpper",
3879        ];
3880
3881        let live = match eval("builtins.attrNames builtins") {
3882            VMValue::List(items) => items
3883                .iter()
3884                .map(|v| match v {
3885                    VMValue::String(s) => s.to_string(),
3886                    other => panic!("attrNames produced a non-string: {other:?}"),
3887                })
3888                .collect::<Vec<String>>(),
3889            other => panic!("attrNames must produce a list, got {other:?}"),
3890        };
3891
3892        // ── ANTI-VACUITY FLOOR, checked BEFORE the verdict. Without it a
3893        // registry that enumerated nothing would make every `contains` false
3894        // and this test would pass having checked nothing at all.
3895        assert!(
3896            live.len() >= 100,
3897            "the VM enumerated only {} builtins — that is not a usable subject, \
3898             so this test would pass without checking anything",
3899            live.len()
3900        );
3901
3902        let resurrected: Vec<&str> = REMOVED_LEAKS
3903            .iter()
3904            .filter(|n| live.iter().any(|l| l == *n))
3905            .copied()
3906            .collect();
3907
3908        // The verdict carries its own denominators — how many names were
3909        // checked and how large the registry was — so a collapsed scan fails
3910        // rather than passing.
3911        assert_eq!(
3912            (REMOVED_LEAKS.len(), live.len() >= 100, resurrected.as_slice()),
3913            (6, true, [].as_slice()),
3914            "a removed nixpkgs `lib` leak is back in the VM: {resurrected:?} \
3915             (checked {} names against a {}-name VM registry)",
3916            REMOVED_LEAKS.len(),
3917            live.len()
3918        );
3919
3920        eprintln!("vm_has_no_nixpkgs_lib_leaks: VM registry is {} names", live.len());
3921    }
3922
3923    // -- Literals -------------------------------------------------------
3924    #[test]
3925    fn eval_integer() {
3926        assert_eq!(eval("42"), VMValue::Int(42));
3927    }
3928    #[test]
3929    fn eval_negative_integer() {
3930        assert_eq!(eval("-7"), VMValue::Int(-7));
3931    }
3932    #[test]
3933    fn eval_float() {
3934        assert_eq!(eval("3.14"), VMValue::Float(3.14));
3935    }
3936    #[test]
3937    fn eval_bool_true() {
3938        assert_eq!(eval("true"), VMValue::Bool(true));
3939    }
3940    #[test]
3941    fn eval_bool_false() {
3942        assert_eq!(eval("false"), VMValue::Bool(false));
3943    }
3944    #[test]
3945    fn eval_null() {
3946        assert_eq!(eval("null"), VMValue::Null);
3947    }
3948    #[test]
3949    fn eval_string() {
3950        assert_eq!(eval(r#""hello""#), VMValue::String("hello".to_string()));
3951    }
3952    // -- Arithmetic -----------------------------------------------------
3953    #[test]
3954    fn eval_add_int() {
3955        assert_eq!(eval("1 + 2"), VMValue::Int(3));
3956    }
3957    #[test]
3958    fn eval_sub_int() {
3959        assert_eq!(eval("10 - 3"), VMValue::Int(7));
3960    }
3961    #[test]
3962    fn eval_mul_int() {
3963        assert_eq!(eval("3 * 4"), VMValue::Int(12));
3964    }
3965    #[test]
3966    fn eval_div_int() {
3967        assert_eq!(eval("10 / 3"), VMValue::Int(3));
3968    }
3969    #[test]
3970    fn eval_div_zero() {
3971        assert!(matches!(eval_err("1 / 0"), VMError::DivisionByZero));
3972    }
3973    #[test]
3974    fn eval_float_arithmetic() {
3975        assert_eq!(eval("1.5 + 2.5"), VMValue::Float(4.0));
3976    }
3977    #[test]
3978    fn eval_mixed_arithmetic() {
3979        assert_eq!(eval("1 + 2.0"), VMValue::Float(3.0));
3980    }
3981    #[test]
3982    fn eval_compound_arithmetic() {
3983        assert_eq!(eval("2 * 3 + 1"), VMValue::Int(7));
3984    }
3985    #[test]
3986    fn eval_negate_float() {
3987        assert_eq!(eval("-3.14"), VMValue::Float(-3.14));
3988    }
3989    #[test]
3990    fn eval_string_concat() {
3991        assert_eq!(
3992            eval(r#""hello" + " " + "world""#),
3993            VMValue::String("hello world".to_string())
3994        );
3995    }
3996    // -- Comparison -----------------------------------------------------
3997    #[test]
3998    fn eval_equal() {
3999        assert_eq!(eval("1 == 1"), VMValue::Bool(true));
4000        assert_eq!(eval("1 == 2"), VMValue::Bool(false));
4001    }
4002    #[test]
4003    fn eval_not_equal() {
4004        assert_eq!(eval("1 != 2"), VMValue::Bool(true));
4005        assert_eq!(eval("1 != 1"), VMValue::Bool(false));
4006    }
4007    #[test]
4008    fn eval_less() {
4009        assert_eq!(eval("1 < 2"), VMValue::Bool(true));
4010        assert_eq!(eval("2 < 1"), VMValue::Bool(false));
4011    }
4012    #[test]
4013    fn eval_greater() {
4014        assert_eq!(eval("2 > 1"), VMValue::Bool(true));
4015        assert_eq!(eval("1 > 2"), VMValue::Bool(false));
4016    }
4017    #[test]
4018    fn eval_less_equal() {
4019        assert_eq!(eval("1 <= 1"), VMValue::Bool(true));
4020        assert_eq!(eval("1 <= 2"), VMValue::Bool(true));
4021        assert_eq!(eval("2 <= 1"), VMValue::Bool(false));
4022    }
4023    #[test]
4024    fn eval_greater_equal() {
4025        assert_eq!(eval("1 >= 1"), VMValue::Bool(true));
4026        assert_eq!(eval("2 >= 1"), VMValue::Bool(true));
4027        assert_eq!(eval("1 >= 2"), VMValue::Bool(false));
4028    }
4029    // -- Logical --------------------------------------------------------
4030    #[test]
4031    fn eval_not() {
4032        assert_eq!(eval("!true"), VMValue::Bool(false));
4033        assert_eq!(eval("!false"), VMValue::Bool(true));
4034    }
4035    #[test]
4036    fn eval_and_short_circuit() {
4037        assert_eq!(eval("true && true"), VMValue::Bool(true));
4038        assert_eq!(eval("true && false"), VMValue::Bool(false));
4039        assert_eq!(eval("false && true"), VMValue::Bool(false));
4040    }
4041    #[test]
4042    fn eval_or_short_circuit() {
4043        assert_eq!(eval("false || true"), VMValue::Bool(true));
4044        assert_eq!(eval("false || false"), VMValue::Bool(false));
4045        assert_eq!(eval("true || false"), VMValue::Bool(true));
4046    }
4047    #[test]
4048    fn eval_implication() {
4049        assert_eq!(eval("true -> true"), VMValue::Bool(true));
4050        assert_eq!(eval("true -> false"), VMValue::Bool(false));
4051        assert_eq!(eval("false -> true"), VMValue::Bool(true));
4052        assert_eq!(eval("false -> false"), VMValue::Bool(true));
4053    }
4054    // -- Conditionals ---------------------------------------------------
4055    #[test]
4056    fn eval_if_true() {
4057        assert_eq!(eval("if true then 1 else 2"), VMValue::Int(1));
4058    }
4059    #[test]
4060    fn eval_if_false() {
4061        assert_eq!(eval("if false then 1 else 2"), VMValue::Int(2));
4062    }
4063    #[test]
4064    fn eval_if_expression() {
4065        assert_eq!(
4066            eval("if 1 > 2 then \"yes\" else \"no\""),
4067            VMValue::String("no".to_string())
4068        );
4069    }
4070    #[test]
4071    fn eval_nested_if() {
4072        assert_eq!(
4073            eval("if true then (if false then 1 else 2) else 3"),
4074            VMValue::Int(2)
4075        );
4076    }
4077    // -- Let/in ---------------------------------------------------------
4078    #[test]
4079    fn eval_let_simple() {
4080        assert_eq!(eval("let x = 1; y = 2; in x + y"), VMValue::Int(3));
4081    }
4082    #[test]
4083    fn eval_let_nested() {
4084        assert_eq!(
4085            eval("let a = 10; in let b = 20; in a + b"),
4086            VMValue::Int(30)
4087        );
4088    }
4089    #[test]
4090    fn eval_let_shadow() {
4091        assert_eq!(eval("let x = 1; in let x = 2; in x"), VMValue::Int(2));
4092    }
4093    #[test]
4094    fn eval_let_with_expression() {
4095        assert_eq!(eval("let x = 2 * 3; in x + 1"), VMValue::Int(7));
4096    }
4097    // -- Lists ----------------------------------------------------------
4098    #[test]
4099    fn eval_empty_list() {
4100        assert_eq!(eval("[]"), VMValue::List(vec![]));
4101    }
4102    #[test]
4103    fn eval_list() {
4104        assert_eq!(
4105            eval("[1 2 3]"),
4106            VMValue::List(vec![VMValue::Int(1), VMValue::Int(2), VMValue::Int(3)])
4107        );
4108    }
4109    #[test]
4110    fn eval_list_concat() {
4111        assert_eq!(
4112            eval("[1 2] ++ [3 4]"),
4113            VMValue::List(vec![
4114                VMValue::Int(1),
4115                VMValue::Int(2),
4116                VMValue::Int(3),
4117                VMValue::Int(4),
4118            ])
4119        );
4120    }
4121    #[test]
4122    fn eval_list_concat_with_inline_map() {
4123        // Regression: call_callable did not truncate the stack after
4124        // run_until, so map's per-element calls leaked values that
4125        // shifted the Concat operands on the stack.
4126        assert_eq!(
4127            eval("[1] ++ builtins.map (a: a) [2 3]"),
4128            VMValue::List(vec![VMValue::Int(1), VMValue::Int(2), VMValue::Int(3)])
4129        );
4130    }
4131    #[test]
4132    fn eval_list_concat_with_inline_map_attrsets() {
4133        // Same regression with attrset-producing map (the nixpkgs pattern).
4134        let result = eval(r#"[{ x = 1; }] ++ builtins.map (a: { v = a; }) ["a" "b"]"#);
4135        match result {
4136            VMValue::List(items) => assert_eq!(items.len(), 3),
4137            other => panic!("expected list, got {:?}", other.type_name()),
4138        }
4139    }
4140    #[test]
4141    fn eval_list_concat_with_inline_filter() {
4142        // Also verify filter (another higher-order builtin) with ++.
4143        assert_eq!(
4144            eval("[0] ++ builtins.filter (x: x > 1) [1 2 3]"),
4145            VMValue::List(vec![VMValue::Int(0), VMValue::Int(2), VMValue::Int(3)])
4146        );
4147    }
4148    #[test]
4149    fn eval_list_mixed() {
4150        assert_eq!(
4151            eval(r#"[1 "hello" true]"#),
4152            VMValue::List(vec![
4153                VMValue::Int(1),
4154                VMValue::String("hello".to_string()),
4155                VMValue::Bool(true),
4156            ])
4157        );
4158    }
4159    // -- Attribute sets -------------------------------------------------
4160    #[test]
4161    fn eval_empty_attrset() {
4162        assert_eq!(eval("{ }"), VMValue::Attrs(BTreeMap::new()));
4163    }
4164    #[test]
4165    fn eval_attrset() {
4166        let result = eval_full_helper("{ a = 1; b = 2; }");
4167        let mut expected = BTreeMap::new();
4168        expected.insert("a".to_string(), crate::StringKeyedValue::Int(1));
4169        expected.insert("b".to_string(), crate::StringKeyedValue::Int(2));
4170        assert_eq!(result, crate::StringKeyedValue::Attrs(expected));
4171    }
4172    #[test]
4173    fn eval_attrset_select() {
4174        assert_eq!(eval("{ a = 1; b = 2; }.a"), VMValue::Int(1));
4175    }
4176    #[test]
4177    fn eval_attrset_update() {
4178        let result = eval_full_helper("{ a = 1; } // { b = 2; }");
4179        let mut expected = BTreeMap::new();
4180        expected.insert("a".to_string(), crate::StringKeyedValue::Int(1));
4181        expected.insert("b".to_string(), crate::StringKeyedValue::Int(2));
4182        assert_eq!(result, crate::StringKeyedValue::Attrs(expected));
4183    }
4184    #[test]
4185    fn eval_attrset_update_override() {
4186        assert_eq!(eval("({ a = 1; } // { a = 2; }).a"), VMValue::Int(2));
4187    }
4188    #[test]
4189    fn eval_has_attr_true() {
4190        assert_eq!(eval("{ a = 1; } ? a"), VMValue::Bool(true));
4191    }
4192    #[test]
4193    fn eval_has_attr_false() {
4194        assert_eq!(eval("{ a = 1; } ? b"), VMValue::Bool(false));
4195    }
4196    #[test]
4197    fn eval_select_or_default() {
4198        assert_eq!(eval("{ a = 1; }.b or 0"), VMValue::Int(0));
4199        assert_eq!(eval("{ a = 1; }.a or 0"), VMValue::Int(1));
4200    }
4201    #[test]
4202    fn eval_dyn_select_or_default_missing() {
4203        // Dynamic key missing → returns default.
4204        assert_eq!(
4205            eval(r#"let x = "missing"; in { a = 1; }.${ x } or 99"#),
4206            VMValue::Int(99),
4207        );
4208    }
4209    #[test]
4210    fn eval_dyn_select_or_default_found() {
4211        // Dynamic key present → returns actual value.
4212        assert_eq!(
4213            eval(r#"let x = "a"; in { a = 42; }.${ x } or 99"#),
4214            VMValue::Int(42),
4215        );
4216    }
4217    #[test]
4218    fn eval_dyn_select_or_default_dotted_key() {
4219        // Key containing dots treated as single flat key, not nested path.
4220        assert_eq!(
4221            eval(r#"let x = "a.b"; in { "a.b" = 7; }.${ x } or 0"#),
4222            VMValue::Int(7),
4223        );
4224    }
4225    #[test]
4226    fn eval_dyn_select_or_default_special_chars() {
4227        // Key with dots and plus signs (nixpkgs armv8 CPU feature pattern).
4228        assert_eq!(
4229            eval(r#"let x = "armv8.3-a+crypto+sha2"; in { "armv8-a" = 1; }.${ x } or 0"#),
4230            VMValue::Int(0),
4231        );
4232    }
4233    #[test]
4234    fn eval_dyn_select_or_default_non_attrset() {
4235        // Base is not an attrset → returns default.
4236        assert_eq!(
4237            eval(r#"let x = "a"; base = 42; in base.${ x } or 99"#),
4238            VMValue::Int(99),
4239        );
4240    }
4241    // -- Lambdas / Apply ------------------------------------------------
4242    #[test]
4243    fn eval_identity_lambda() {
4244        assert_eq!(eval("(x: x) 42"), VMValue::Int(42));
4245    }
4246    #[test]
4247    fn eval_lambda_arithmetic() {
4248        assert_eq!(eval("(x: x + 1) 5"), VMValue::Int(6));
4249    }
4250    #[test]
4251    #[ignore = "requires upvalue capture (Phase 2)"]
4252    fn eval_curried_lambda() {
4253        assert_eq!(eval("(x: y: x + y) 3 4"), VMValue::Int(7));
4254    }
4255    #[test]
4256    fn eval_let_lambda() {
4257        assert_eq!(
4258            eval("let f = x: x * 2; in f 5"),
4259            VMValue::Int(10)
4260        );
4261    }
4262    #[test]
4263    fn eval_pattern_lambda() {
4264        assert_eq!(eval("({ a, b }: a + b) { a = 3; b = 4; }"), VMValue::Int(7));
4265    }
4266    #[test]
4267    fn eval_pattern_lambda_default() {
4268        assert_eq!(
4269            eval("({ a, b ? 10 }: a + b) { a = 5; }"),
4270            VMValue::Int(15)
4271        );
4272    }
4273    #[test]
4274    fn eval_lambda_with_let() {
4275        assert_eq!(
4276            eval("let inc = x: x + 1; double = x: x * 2; in double (inc 3)"),
4277            VMValue::Int(8)
4278        );
4279    }
4280    // -- Assert ---------------------------------------------------------
4281    #[test]
4282    fn eval_assert_pass() {
4283        assert_eq!(eval("assert true; 42"), VMValue::Int(42));
4284    }
4285    #[test]
4286    fn eval_assert_fail() {
4287        assert!(matches!(eval_err("assert false; 42"), VMError::AssertionFailed));
4288    }
4289    // -- Deep equality (thunk forcing) ----------------------------------
4290    #[test]
4291    fn deep_eq_attrs_with_thunked_values() {
4292        // Attrsets from let bindings have thunked values;
4293        // == must force them before comparison.
4294        assert_eq!(
4295            eval("let a = { x = 1; }; b = { x = 1; }; in a == b"),
4296            VMValue::Bool(true)
4297        );
4298    }
4299    #[test]
4300    fn deep_eq_attrs_different_values() {
4301        assert_eq!(
4302            eval("let a = { x = 1; }; b = { x = 2; }; in a == b"),
4303            VMValue::Bool(false)
4304        );
4305    }
4306    #[test]
4307    fn deep_eq_nested_attrs() {
4308        assert_eq!(
4309            eval("let a = { x = { y = 1; }; }; b = { x = { y = 1; }; }; in a == b"),
4310            VMValue::Bool(true)
4311        );
4312    }
4313    #[test]
4314    fn deep_eq_list_with_thunked_elements() {
4315        assert_eq!(
4316            eval("let a = [ 1 2 ]; b = [ 1 2 ]; in a == b"),
4317            VMValue::Bool(true)
4318        );
4319    }
4320    // -- builtins.elem (thunk forcing) ----------------------------------
4321    #[test]
4322    fn eval_elem_thunked_attrsets() {
4323        // elem must force list elements before comparison.
4324        assert_eq!(
4325            eval("let a = { x = 1; }; b = { x = 1; }; in builtins.elem a [ b ]"),
4326            VMValue::Bool(true)
4327        );
4328    }
4329    #[test]
4330    fn eval_elem_basic_int() {
4331        assert_eq!(
4332            eval("builtins.elem 2 [ 1 2 3 ]"),
4333            VMValue::Bool(true)
4334        );
4335    }
4336    #[test]
4337    fn eval_elem_missing() {
4338        assert_eq!(
4339            eval("builtins.elem 4 [ 1 2 3 ]"),
4340            VMValue::Bool(false)
4341        );
4342    }
4343    #[test]
4344    fn eval_elem_string() {
4345        assert_eq!(
4346            eval(r#"builtins.elem "b" [ "a" "b" "c" ]"#),
4347            VMValue::Bool(true)
4348        );
4349    }
4350    #[test]
4351    fn eval_elem_thunked_list_elements() {
4352        assert_eq!(
4353            eval("let x = 1; in builtins.elem 1 [ x ]"),
4354            VMValue::Bool(true)
4355        );
4356    }
4357    // -- String interpolation -------------------------------------------
4358    #[test]
4359    fn eval_string_interpolation() {
4360        assert_eq!(
4361            eval(r#"let x = "world"; in "hello ${x}""#),
4362            VMValue::String("hello world".to_string()),
4363        );
4364    }
4365    #[test]
4366    #[ignore = "requires builtins.toString (Phase 2)"]
4367    fn eval_string_interpolation_int() {
4368        assert_eq!(
4369            eval(r#"let n = 42; in "value: ${toString n}""#),
4370            VMValue::String("value: 42".to_string()),
4371        );
4372    }
4373    // -- Path literals --------------------------------------------------
4374    #[test]
4375    fn eval_absolute_path() {
4376        assert_eq!(eval("/tmp/x"), VMValue::Path("/tmp/x".to_string()));
4377    }
4378    // -- Complex expressions --------------------------------------------
4379    #[test]
4380    fn eval_fibonacci_like() {
4381        assert_eq!(
4382            eval("let a = 1; b = 1; c = a + b; d = b + c; e = c + d; in e"),
4383            VMValue::Int(5)
4384        );
4385    }
4386    #[test]
4387    fn eval_nested_attrset_select() {
4388        assert_eq!(
4389            eval("{ a = { b = 42; }; }.a.b"),
4390            VMValue::Int(42)
4391        );
4392    }
4393    #[test]
4394    fn eval_let_with_attrset() {
4395        assert_eq!(
4396            eval("let set = { x = 10; y = 20; }; in set.x + set.y"),
4397            VMValue::Int(30)
4398        );
4399    }
4400    #[test]
4401    fn eval_conditional_attrset() {
4402        assert_eq!(
4403            eval("(if true then { a = 1; } else { a = 2; }).a"),
4404            VMValue::Int(1)
4405        );
4406    }
4407    // -- Builtin tests --------------------------------------------------
4408    #[test]
4409    fn builtin_length() {
4410        assert_eq!(eval("builtins.length [1 2 3]"), VMValue::Int(3));
4411    }
4412    #[test]
4413    fn builtin_length_empty() {
4414        assert_eq!(eval("builtins.length []"), VMValue::Int(0));
4415    }
4416    #[test]
4417    fn builtin_head() {
4418        assert_eq!(eval("builtins.head [10 20 30]"), VMValue::Int(10));
4419    }
4420    #[test]
4421    fn builtin_tail() {
4422        let result = eval_full_helper("builtins.tail [1 2 3]");
4423        assert_eq!(
4424            result,
4425            StringKeyedValue::List(vec![StringKeyedValue::Int(2), StringKeyedValue::Int(3)])
4426        );
4427    }
4428    #[test]
4429    fn builtin_type_of_int() {
4430        assert_eq!(
4431            eval("builtins.typeOf 42"),
4432            VMValue::String("int".to_string())
4433        );
4434    }
4435    #[test]
4436    fn builtin_type_of_string() {
4437        assert_eq!(
4438            eval("builtins.typeOf \"hello\""),
4439            VMValue::String("string".to_string())
4440        );
4441    }
4442    #[test]
4443    fn builtin_type_of_bool() {
4444        assert_eq!(
4445            eval("builtins.typeOf true"),
4446            VMValue::String("bool".to_string())
4447        );
4448    }
4449    #[test]
4450    fn builtin_type_of_null() {
4451        assert_eq!(
4452            eval("builtins.typeOf null"),
4453            VMValue::String("null".to_string())
4454        );
4455    }
4456    #[test]
4457    fn builtin_type_of_list() {
4458        assert_eq!(
4459            eval("builtins.typeOf [1 2]"),
4460            VMValue::String("list".to_string())
4461        );
4462    }
4463    #[test]
4464    fn builtin_type_of_set() {
4465        assert_eq!(
4466            eval("builtins.typeOf { a = 1; }"),
4467            VMValue::String("set".to_string())
4468        );
4469    }
4470    #[test]
4471    fn builtin_type_of_lambda() {
4472        assert_eq!(
4473            eval("builtins.typeOf (x: x)"),
4474            VMValue::String("lambda".to_string())
4475        );
4476    }
4477    #[test]
4478    fn builtin_is_int() {
4479        assert_eq!(eval("builtins.isInt 42"), VMValue::Bool(true));
4480        assert_eq!(
4481            eval("builtins.isInt \"hello\""),
4482            VMValue::Bool(false)
4483        );
4484    }
4485    #[test]
4486    fn builtin_is_string() {
4487        assert_eq!(eval("builtins.isString \"hi\""), VMValue::Bool(true));
4488        assert_eq!(eval("builtins.isString 42"), VMValue::Bool(false));
4489    }
4490    #[test]
4491    fn builtin_is_list() {
4492        assert_eq!(eval("builtins.isList [1]"), VMValue::Bool(true));
4493        assert_eq!(eval("builtins.isList 42"), VMValue::Bool(false));
4494    }
4495    #[test]
4496    fn builtin_is_attrs() {
4497        assert_eq!(
4498            eval("builtins.isAttrs { a = 1; }"),
4499            VMValue::Bool(true)
4500        );
4501        assert_eq!(eval("builtins.isAttrs 42"), VMValue::Bool(false));
4502    }
4503    #[test]
4504    fn builtin_is_function() {
4505        assert_eq!(
4506            eval("builtins.isFunction (x: x)"),
4507            VMValue::Bool(true)
4508        );
4509        assert_eq!(eval("builtins.isFunction 42"), VMValue::Bool(false));
4510    }
4511    #[test]
4512    fn builtin_is_bool() {
4513        assert_eq!(eval("builtins.isBool true"), VMValue::Bool(true));
4514        assert_eq!(eval("builtins.isBool 42"), VMValue::Bool(false));
4515    }
4516    #[test]
4517    fn builtin_is_null() {
4518        assert_eq!(eval("builtins.isNull null"), VMValue::Bool(true));
4519        assert_eq!(eval("builtins.isNull 42"), VMValue::Bool(false));
4520    }
4521    #[test]
4522    fn builtin_string_length() {
4523        assert_eq!(
4524            eval("builtins.stringLength \"hello\""),
4525            VMValue::Int(5)
4526        );
4527    }
4528    #[test]
4529    fn builtin_to_string_int() {
4530        assert_eq!(
4531            eval("builtins.toString 42"),
4532            VMValue::String("42".to_string())
4533        );
4534    }
4535    #[test]
4536    fn builtin_to_string_bool() {
4537        assert_eq!(
4538            eval("builtins.toString true"),
4539            VMValue::String("1".to_string())
4540        );
4541    }
4542    #[test]
4543    fn builtin_throw() {
4544        let result = eval_err("builtins.throw \"test error\"");
4545        assert!(matches!(result, VMError::Throw(_)));
4546    }
4547    #[test]
4548    fn builtin_abort() {
4549        let result = eval_err("builtins.abort \"fatal\"");
4550        assert!(matches!(result, VMError::Throw(_)));
4551    }
4552    #[test]
4553    fn builtin_add_curried() {
4554        assert_eq!(eval("builtins.add 3 4"), VMValue::Int(7));
4555    }
4556    #[test]
4557    fn builtin_sub_curried() {
4558        assert_eq!(eval("builtins.sub 10 3"), VMValue::Int(7));
4559    }
4560    #[test]
4561    fn builtin_mul_curried() {
4562        assert_eq!(eval("builtins.mul 6 7"), VMValue::Int(42));
4563    }
4564    #[test]
4565    fn builtin_div_curried() {
4566        assert_eq!(eval("builtins.div 42 6"), VMValue::Int(7));
4567    }
4568    #[test]
4569    fn builtin_elem_at() {
4570        assert_eq!(eval("builtins.elemAt [10 20 30] 1"), VMValue::Int(20));
4571    }
4572    #[test]
4573    fn builtin_elem() {
4574        assert_eq!(eval("builtins.elem 2 [1 2 3]"), VMValue::Bool(true));
4575        assert_eq!(eval("builtins.elem 5 [1 2 3]"), VMValue::Bool(false));
4576    }
4577    #[test]
4578    fn builtin_concat_lists() {
4579        let result = eval_full_helper("builtins.concatLists [[1 2] [3 4]]");
4580        assert_eq!(
4581            result,
4582            StringKeyedValue::List(vec![
4583                StringKeyedValue::Int(1),
4584                StringKeyedValue::Int(2),
4585                StringKeyedValue::Int(3),
4586                StringKeyedValue::Int(4),
4587            ])
4588        );
4589    }
4590    #[test]
4591    fn builtin_concat_strings_sep() {
4592        assert_eq!(
4593            eval("builtins.concatStringsSep \", \" [\"a\" \"b\" \"c\"]"),
4594            VMValue::String("a, b, c".to_string())
4595        );
4596    }
4597    #[test]
4598    fn builtin_from_json() {
4599        assert_eq!(
4600            eval("builtins.fromJSON \"42\""),
4601            VMValue::Int(42)
4602        );
4603        assert_eq!(
4604            eval("builtins.fromJSON \"true\""),
4605            VMValue::Bool(true)
4606        );
4607    }
4608    #[test]
4609    fn builtin_seq() {
4610        assert_eq!(eval("builtins.seq 1 42"), VMValue::Int(42));
4611    }
4612    #[test]
4613    fn builtin_deep_seq() {
4614        assert_eq!(eval("builtins.deepSeq [1 2] 42"), VMValue::Int(42));
4615    }
4616    #[test]
4617    fn builtin_trace() {
4618        assert_eq!(
4619            eval("builtins.trace \"debug\" 42"),
4620            VMValue::Int(42)
4621        );
4622    }
4623    #[test]
4624    fn builtin_ceil_floor() {
4625        assert_eq!(eval("builtins.ceil 3.2"), VMValue::Int(4));
4626        assert_eq!(eval("builtins.floor 3.8"), VMValue::Int(3));
4627    }
4628    #[test]
4629    fn builtin_bit_ops() {
4630        assert_eq!(eval("builtins.bitAnd 12 10"), VMValue::Int(8));
4631        assert_eq!(eval("builtins.bitOr 12 10"), VMValue::Int(14));
4632        assert_eq!(eval("builtins.bitXor 12 10"), VMValue::Int(6));
4633    }
4634    #[test]
4635    fn builtin_intersect_attrs() {
4636        let result =
4637            eval_full_helper("builtins.intersectAttrs { a = 1; b = 2; } { a = 10; c = 30; }");
4638        match result {
4639            StringKeyedValue::Attrs(map) => {
4640                assert_eq!(map.get("a"), Some(&StringKeyedValue::Int(10)));
4641                assert!(!map.contains_key("b"));
4642                assert!(!map.contains_key("c"));
4643            }
4644            _ => panic!("expected Attrs, got {result:?}"),
4645        }
4646    }
4647    #[test]
4648    fn builtin_attr_values() {
4649        let result = eval_full_helper("builtins.attrValues { a = 1; b = 2; }");
4650        match result {
4651            StringKeyedValue::List(items) => {
4652                assert_eq!(items.len(), 2);
4653                assert!(items.contains(&StringKeyedValue::Int(1)));
4654                assert!(items.contains(&StringKeyedValue::Int(2)));
4655            }
4656            _ => panic!("expected List, got {result:?}"),
4657        }
4658    }
4659    #[test]
4660    fn builtin_to_int() {
4661        assert_eq!(eval("builtins.toInt \"42\""), VMValue::Int(42));
4662    }
4663    #[test]
4664    fn builtin_replace_strings() {
4665        assert_eq!(
4666            eval("builtins.replaceStrings [\"o\"] [\"0\"] \"foo\""),
4667            VMValue::String("f00".to_string())
4668        );
4669    }
4670    #[test]
4671    fn builtin_substring() {
4672        assert_eq!(
4673            eval("builtins.substring 1 3 \"hello\""),
4674            VMValue::String("ell".to_string())
4675        );
4676    }
4677    // -- Import tests ---------------------------------------------------
4678    #[test]
4679    fn import_basic() {
4680        let dir = tempfile::tempdir().unwrap();
4681        let file_path = dir.path().join("test.nix");
4682        std::fs::write(&file_path, "42").unwrap();
4683        let nix_expr = format!("import {}", file_path.display());
4684        assert_eq!(eval(&nix_expr), VMValue::Int(42));
4685    }
4686    #[test]
4687    fn import_cached() {
4688        let dir = tempfile::tempdir().unwrap();
4689        let file_path = dir.path().join("cached.nix");
4690        std::fs::write(&file_path, "{ x = 1; }").unwrap();
4691        let nix_expr = format!(
4692            "let a = import {}; b = import {}; in a == b",
4693            file_path.display(),
4694            file_path.display()
4695        );
4696        assert_eq!(eval(&nix_expr), VMValue::Bool(true));
4697    }
4698    #[test]
4699    fn import_attrset() {
4700        let dir = tempfile::tempdir().unwrap();
4701        let file_path = dir.path().join("attrs.nix");
4702        std::fs::write(&file_path, "{ greeting = \"hello\"; }").unwrap();
4703        let nix_expr = format!("(import {}).greeting", file_path.display());
4704        assert_eq!(eval(&nix_expr), VMValue::String("hello".to_string()));
4705    }
4706    #[test]
4707    fn import_directory_default_nix() {
4708        // Importing a directory should resolve to <dir>/default.nix
4709        let dir = tempfile::tempdir().unwrap();
4710        let sub = dir.path().join("mylib");
4711        std::fs::create_dir(&sub).unwrap();
4712        std::fs::write(sub.join("default.nix"), "{ x = 42; }").unwrap();
4713        let nix_expr = format!("(import {}).x", sub.display());
4714        assert_eq!(eval(&nix_expr), VMValue::Int(42));
4715    }
4716    #[test]
4717    fn import_directory_cached() {
4718        // Importing the same directory twice should hit the cache.
4719        let dir = tempfile::tempdir().unwrap();
4720        let sub = dir.path().join("lib");
4721        std::fs::create_dir(&sub).unwrap();
4722        std::fs::write(sub.join("default.nix"), "{ v = 99; }").unwrap();
4723        let nix_expr = format!(
4724            "let a = import {}; b = import {}; in a == b",
4725            sub.display(),
4726            sub.display()
4727        );
4728        assert_eq!(eval(&nix_expr), VMValue::Bool(true));
4729    }
4730    #[test]
4731    fn import_directory_nested() {
4732        // Nested directory imports: lib/default.nix imports sub/default.nix
4733        let dir = tempfile::tempdir().unwrap();
4734        let lib = dir.path().join("lib");
4735        let sub = lib.join("sub");
4736        std::fs::create_dir_all(&sub).unwrap();
4737        std::fs::write(sub.join("default.nix"), "{ val = 7; }").unwrap();
4738        std::fs::write(
4739            lib.join("default.nix"),
4740            &format!("(import {}).val + 3", sub.display()),
4741        )
4742        .unwrap();
4743        let nix_expr = format!("import {}", lib.display());
4744        assert_eq!(eval(&nix_expr), VMValue::Int(10));
4745    }
4746    // -- Lazy evaluation tests ------------------------------------------
4747    #[test]
4748    fn lazy_unused_throw_in_attrset() {
4749        assert_eq!(
4750            eval("let s = { a = 1; }; in s.a"),
4751            VMValue::Int(1)
4752        );
4753    }
4754    #[test]
4755    fn lazy_unused_let_binding() {
4756        assert_eq!(eval("let x = 1; y = 2; in x"), VMValue::Int(1));
4757    }
4758    // -- Import handler tests -------------------------------------------
4759    #[test]
4760    fn import_forces_thunk_before_type_check() {
4761        // The import path is a thunk (non-trivial let binding); the VM
4762        // must force it to a path/string before checking the type.
4763        let dir = tempfile::tempdir().unwrap();
4764        let file_path = dir.path().join("forced.nix");
4765        std::fs::write(&file_path, "99").unwrap();
4766        let nix_expr = format!(
4767            "let p = {}; in import p",
4768            file_path.display()
4769        );
4770        assert_eq!(eval(&nix_expr), VMValue::Int(99));
4771    }
4772    #[test]
4773    fn import_with_path_value_succeeds() {
4774        let dir = tempfile::tempdir().unwrap();
4775        let file_path = dir.path().join("pathval.nix");
4776        std::fs::write(&file_path, "\"from-path\"").unwrap();
4777        let nix_expr = format!("import {}", file_path.display());
4778        assert_eq!(
4779            eval(&nix_expr),
4780            VMValue::String("from-path".to_string())
4781        );
4782    }
4783    #[test]
4784    fn import_with_string_value_succeeds() {
4785        let dir = tempfile::tempdir().unwrap();
4786        let file_path = dir.path().join("strval.nix");
4787        std::fs::write(&file_path, "\"from-string\"").unwrap();
4788        let nix_expr = format!(
4789            "let s = \"{}\"; in import s",
4790            file_path.display()
4791        );
4792        assert_eq!(
4793            eval(&nix_expr),
4794            VMValue::String("from-string".to_string())
4795        );
4796    }
4797    // -- TailCall opcode tests ------------------------------------------
4798    #[test]
4799    fn tail_call_deep_recursion_via_import() {
4800        // Test deep tail-recursive calls via import (self-referencing let
4801        // requires open upvalues, not yet implemented). Writing a recursive
4802        // function to a file and importing it exercises TailCall.
4803        let dir = tempfile::tempdir().unwrap();
4804        let file_path = dir.path().join("countdown.nix");
4805        std::fs::write(
4806            &file_path,
4807            "{ f, n }: if n == 0 then 0 else f { inherit f; n = n - 1; }",
4808        )
4809        .unwrap();
4810        // Use fixpoint pattern: pass function as argument to avoid
4811        // self-referencing let bindings.
4812        let nix_expr = format!(
4813            "let g = import {}; in g {{ f = g; n = 2000; }}",
4814            file_path.display()
4815        );
4816        assert_eq!(eval(&nix_expr), VMValue::Int(0));
4817    }
4818    #[test]
4819    fn tail_call_simple_lambda_chain() {
4820        // Non-recursive tail call: the last call in a lambda body should
4821        // reuse the frame. This verifies TailCall opcode is emitted and
4822        // executed for simple function composition.
4823        assert_eq!(
4824            eval("let g = x: x + 1; f = x: g x; in f 41"),
4825            VMValue::Int(42)
4826        );
4827    }
4828    #[test]
4829    fn tail_call_if_branches() {
4830        // Both if-then and if-else branches should produce tail calls
4831        // when in lambda body. This verifies TailCall works in both branches.
4832        assert_eq!(
4833            eval("let f = x: if x > 0 then x else x + 1; in f 10"),
4834            VMValue::Int(10)
4835        );
4836        assert_eq!(
4837            eval("let f = x: if x > 0 then x else x + 1; in f 0"),
4838            VMValue::Int(1)
4839        );
4840    }
4841    // -- Builtin dispatch tests -----------------------------------------
4842    #[test]
4843    fn builtin_get_env_returns_value() {
4844        // Set a known env var and verify getEnv returns it.
4845        // SAFETY: test runs single-threaded; no concurrent env access.
4846        unsafe { std::env::set_var("SUI_TEST_VAR", "hello_sui") };
4847        assert_eq!(
4848            eval("builtins.getEnv \"SUI_TEST_VAR\""),
4849            VMValue::String("hello_sui".to_string())
4850        );
4851        unsafe { std::env::remove_var("SUI_TEST_VAR") };
4852    }
4853    #[test]
4854    fn builtin_get_env_missing_returns_empty() {
4855        // getEnv with a missing var should return "".
4856        // SAFETY: test runs single-threaded; no concurrent env access.
4857        unsafe { std::env::remove_var("SUI_NONEXISTENT_VAR_12345") };
4858        assert_eq!(
4859            eval("builtins.getEnv \"SUI_NONEXISTENT_VAR_12345\""),
4860            VMValue::String(String::new())
4861        );
4862    }
4863    #[test]
4864    fn builtin_try_eval_success() {
4865        // tryEval with a successful expression returns { success=true; value=result; }.
4866        let result = eval_full_helper("builtins.tryEval 42");
4867        match result {
4868            StringKeyedValue::Attrs(map) => {
4869                assert_eq!(
4870                    map.get("success"),
4871                    Some(&StringKeyedValue::Bool(true))
4872                );
4873                assert_eq!(
4874                    map.get("value"),
4875                    Some(&StringKeyedValue::Int(42))
4876                );
4877            }
4878            _ => panic!("expected Attrs, got {result:?}"),
4879        }
4880    }
4881    #[test]
4882    fn builtin_try_eval_with_non_throwing_expr() {
4883        // tryEval wraps a non-throwing expression — still produces
4884        // { success = true; value = ...; }.
4885        let result = eval_full_helper(
4886            "builtins.tryEval (1 + 2)"
4887        );
4888        match result {
4889            StringKeyedValue::Attrs(map) => {
4890                assert_eq!(
4891                    map.get("success"),
4892                    Some(&StringKeyedValue::Bool(true))
4893                );
4894                assert_eq!(
4895                    map.get("value"),
4896                    Some(&StringKeyedValue::Int(3))
4897                );
4898            }
4899            _ => panic!("expected Attrs, got {result:?}"),
4900        }
4901    }
4902    #[test]
4903    fn builtin_try_eval_with_throw_catches() {
4904        // tryEval CATCHES a throwing expression (nix parity):
4905        // `{ success = false; value = false; }` — verified byte-identical
4906        // to cppnix (`nix eval --json` returns the same). The VM previously
4907        // PROPAGATED the throw (an open-upvalue dispatch limitation); that
4908        // is now fixed, so this test pins the correct catching behavior.
4909        let result = eval_full_helper(
4910            "let bad = builtins.throw \"oops\"; in builtins.tryEval bad"
4911        );
4912        match result {
4913            StringKeyedValue::Attrs(map) => {
4914                assert_eq!(
4915                    map.get("success"),
4916                    Some(&StringKeyedValue::Bool(false))
4917                );
4918                assert_eq!(
4919                    map.get("value"),
4920                    Some(&StringKeyedValue::Bool(false))
4921                );
4922            }
4923            _ => panic!("expected Attrs, got {result:?}"),
4924        }
4925    }
4926    // -- Regression: stack_depth tracking for branches -------------------
4927    #[test]
4928    fn if_else_in_let_body_stack_depth() {
4929        // If/else inside a let body should not corrupt stack_depth for
4930        // subsequent let bindings in an outer scope.
4931        assert_eq!(
4932            eval("let a = 1; in if a == 1 then 10 else 20"),
4933            VMValue::Int(10),
4934        );
4935    }
4936    #[test]
4937    fn nested_let_with_if_else() {
4938        // Inner let after an if/else: the if/else must not drift stack_depth.
4939        assert_eq!(
4940            eval(r#"
4941                let
4942                  a = 1;
4943                  b = if a == 1 then 2 else 3;
4944                in
4945                  let c = b + 10; in c
4946            "#),
4947            VMValue::Int(12),
4948        );
4949    }
4950    #[test]
4951    fn short_circuit_and_in_let_body() {
4952        // Short-circuit && inside a let body must track stack_depth correctly.
4953        assert_eq!(
4954            eval("let x = true; in x && false"),
4955            VMValue::Bool(false),
4956        );
4957    }
4958    #[test]
4959    fn short_circuit_or_in_let_body() {
4960        assert_eq!(
4961            eval("let x = false; in x || true"),
4962            VMValue::Bool(true),
4963        );
4964    }
4965    #[test]
4966    fn short_circuit_implication_in_let_body() {
4967        // a -> b is !a || b. false -> anything is true.
4968        assert_eq!(
4969            eval("let x = false; in x -> 42"),
4970            VMValue::Bool(true),
4971        );
4972    }
4973    #[test]
4974    fn inherit_from_in_attrset_stack_depth() {
4975        // inherit (source) in non-rec attrset must track stack_depth for
4976        // MakeThunk. This was the missing `stack_depth += 1` bug.
4977        assert_eq!(
4978            eval(r#"
4979                let
4980                  src = { a = 1; b = 2; };
4981                  result = { inherit (src) a b; c = 3; };
4982                in result.a + result.b + result.c
4983            "#),
4984            VMValue::Int(6),
4985        );
4986    }
4987    #[test]
4988    fn inherit_from_many_fields_stack_depth() {
4989        // Multiple inherit-from fields: each one was missing +1,
4990        // so stack_depth would drift further with each field.
4991        assert_eq!(
4992            eval(r#"
4993                let
4994                  s = { w = 1; x = 2; y = 3; z = 4; };
4995                  r = { inherit (s) w x y z; extra = 10; };
4996                in r.w + r.x + r.y + r.z + r.extra
4997            "#),
4998            VMValue::Int(20),
4999        );
5000    }
5001    #[test]
5002    fn if_else_followed_by_let_binding() {
5003        // The if/else result is used in a subsequent let binding.
5004        // Before the fix, the stack_depth drift from if/else would cause
5005        // the next binding's slot to be off.
5006        assert_eq!(
5007            eval(r#"
5008                let
5009                  a = 1;
5010                  b = 2;
5011                  c = 3;
5012                in
5013                  let
5014                    x = if a == 1 then b else c;
5015                    y = x + 100;
5016                  in y
5017            "#),
5018            VMValue::Int(102),
5019        );
5020    }
5021    #[test]
5022    fn multi_segment_hasattr_stack_depth() {
5023        // Multi-segment hasattr with short-circuit jumps must track
5024        // stack_depth correctly at branch merge points.
5025        assert_eq!(
5026            eval(r#"
5027                let
5028                  s = { a = { b = 1; }; };
5029                  has = s ? a.b;
5030                  val = if has then 42 else 0;
5031                in val
5032            "#),
5033            VMValue::Int(42),
5034        );
5035    }
5036    #[test]
5037    fn many_let_bindings_with_if_else() {
5038        // Stress test: many let bindings where some RHS contain if/else.
5039        // Before the stack_depth fix, the drift would accumulate and
5040        // eventually cause a GetLocal slot mismatch.
5041        assert_eq!(
5042            eval(r#"
5043                let
5044                  a = 1;
5045                  b = 2;
5046                  c = 3;
5047                  d = 4;
5048                  e = 5;
5049                  f = 6;
5050                  g = 7;
5051                  h = 8;
5052                  i = 9;
5053                  j = 10;
5054                in
5055                  let
5056                    x = if a == 1 then b else c;
5057                    y = if d == 4 then e else f;
5058                    z = if g == 7 then h else i;
5059                    w = j;
5060                  in x + y + z + w
5061            "#),
5062            VMValue::Int(25),
5063        );
5064    }
5065    #[test]
5066    fn import_in_pattern_default_stack_depth() {
5067        // The Import opcode is net 0 on the stack (pop path, push result).
5068        // Before the fix, it was tracked as +1, causing stack_depth drift
5069        // in pattern default expressions like `{ stdenvStages ? import ../stdenv, ... }`.
5070        // This test uses a pattern lambda with a default that involves a
5071        // function call (which compiles similarly to import + call).
5072        assert_eq!(
5073            eval(r#"
5074                let
5075                  f = { a ? 1, b ? 2, c ? 3 }:
5076                    a + b + c;
5077                in f {}
5078            "#),
5079            VMValue::Int(6),
5080        );
5081    }
5082    #[test]
5083    fn pattern_lambda_many_defaults_then_let() {
5084        // Pattern lambda with many defaults followed by let bindings.
5085        // This is the pattern that triggered the original nixpkgs bug:
5086        // { a, b ? x, c ? y, ... }: let ... in expr
5087        // The import stack_depth bug caused slots to drift by 1 for each
5088        // default expression that used import.
5089        assert_eq!(
5090            eval(r#"
5091                let
5092                  mk = { a, b ? 10, c ? 20, d ? 30, e ? 40 }:
5093                    let
5094                      sum = a + b + c + d + e;
5095                      doubled = sum + sum;
5096                    in doubled;
5097                in mk { a = 1; }
5098            "#),
5099            VMValue::Int(202),
5100        );
5101    }
5102    // -- Blocker #13: dotted attrs + lambda closure in rec ------------------
5103    #[test]
5104    fn rec_dotted_lambda_captures_sibling() {
5105        // Lambdas in rec attrsets must not be compiled as trivial values,
5106        // because MakeClosure captures upvalues eagerly.  Dotted entries
5107        // are appended after non-dotted bindings, so a lambda's upvalue
5108        // for a dotted sibling would see the null placeholder.
5109        let result = eval_full_helper(
5110            r#"rec { types.a = 1; types.b = 2; f = _: types; }.f 0"#,
5111        );
5112        match result {
5113            StringKeyedValue::Attrs(ref m) => {
5114                assert_eq!(m.get("a"), Some(&StringKeyedValue::Int(1)));
5115                assert_eq!(m.get("b"), Some(&StringKeyedValue::Int(2)));
5116            }
5117            other => panic!("expected attrset, got {other:?}"),
5118        }
5119    }
5120    #[test]
5121    fn rec_dotted_lambda_attr_select() {
5122        // Lambda body selects an attribute from a dotted sibling.
5123        assert_eq!(
5124            eval(r#"rec { types.a = 1; types.b = 2; f = x: types.b; result = f 0; }.result"#),
5125            VMValue::Int(2),
5126        );
5127    }
5128    #[test]
5129    fn rec_dotted_lambda_assert_check() {
5130        // Pattern from nixpkgs parse.nix: `mkSystem` uses
5131        //   assert types.parsedPlatform.check components; ...
5132        // which requires `types` to be resolved inside a lambda body.
5133        assert_eq!(
5134            eval(r#"
5135                rec {
5136                    types.parsedPlatform = { check = _: true; };
5137                    mkSystem = components:
5138                        assert types.parsedPlatform.check components;
5139                        components;
5140                    result = mkSystem 42;
5141                }.result
5142            "#),
5143            VMValue::Int(42),
5144        );
5145    }
5146    #[test]
5147    fn let_lambda_captures_rec_sibling() {
5148        // Let bindings are recursive — lambdas capturing siblings must
5149        // also use deferred thunks.
5150        assert_eq!(
5151            eval(r#"let a = 1 + 1; f = _: a; in f 0"#),
5152            VMValue::Int(2),
5153        );
5154    }
5155    #[test]
5156    fn rec_dotted_multiple_lambdas() {
5157        // Multiple lambdas capturing different dotted siblings.
5158        assert_eq!(
5159            eval(r#"
5160                rec {
5161                    a.x = 10;
5162                    b.y = 20;
5163                    f = _: a.x + b.y;
5164                    result = f 0;
5165                }.result
5166            "#),
5167            VMValue::Int(30),
5168        );
5169    }
5170}