Skip to main content

sui_bytecode/
vm.rs

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