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        // Directory → default.nix fallback (Nix convention).
3117        let resolved = if std::path::Path::new(path).is_dir() {
3118            format!("{path}/default.nix")
3119        } else {
3120            path.to_string()
3121        };
3122        let source = std::fs::read_to_string(&resolved)
3123            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3124        // Wrap the source in `with <scope>; <source>` to inject the scope.
3125        let wrapped = format!("with {scope_nix}; {source}");
3126        let file_dir = std::path::Path::new(&resolved)
3127            .parent()
3128            .map(|p| p.to_path_buf())
3129            .unwrap_or_default();
3130        // Share the VM's interner so symbol IDs stay consistent.
3131        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
3132        let chunk = Compiler::compile_with_shared_interner(&wrapped, file_dir, shared_interner.clone())
3133            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3134        *self.interner = match Rc::try_unwrap(shared_interner) {
3135            Ok(cell) => cell.into_inner(),
3136            Err(rc) => rc.borrow().clone(),
3137        };
3138        if self.frames.len() >= MAX_CALL_DEPTH {
3139            return Err(VMError::StackOverflow);
3140        }
3141        let return_depth = self.frames.len();
3142        let stack_base = self.stack.len();
3143        self.frames.push(CallFrame {
3144            chunk: Rc::new(chunk),
3145            ip: 0,
3146            stack_base,
3147            upvalues: Vec::new(),
3148        });
3149        self.run_until(return_depth)
3150    }
3151    // -- Higher-order builtin execution -----------------------------------
3152    fn call_callable(&mut self, func: &NanBox, arg: NanBox) -> Result<NanBox, VMError> {
3153        if let Some(closure) = func.as_closure() {
3154            if self.frames.len() >= MAX_CALL_DEPTH {
3155                return Err(VMError::StackOverflow);
3156            }
3157            let upvalues = closure.upvalues.clone();
3158            let chunk = closure.chunk.clone();
3159            let return_depth = self.frames.len();
3160            let stack_base = self.stack.len();
3161            self.push(arg);
3162            self.frames.push(CallFrame {
3163                chunk,
3164                ip: 0,
3165                stack_base,
3166                upvalues,
3167            });
3168            let result = self.run_until(return_depth)?;
3169            self.stack.truncate(stack_base);
3170            // Force the result — callers expect concrete values
3171            // (e.g., filter checks is_truthy on predicate results).
3172            self.force_value(result)
3173        } else if func.is_higher_order_builtin() {
3174            let hob = func.as_higher_order_builtin().unwrap().clone();
3175            self.call_higher_order_builtin(&hob, arg)
3176        } else if let Some(builtin) = func.as_builtin() {
3177            // Force the arg for builtins — they expect concrete values.
3178            let arg = self.force_value(arg)?;
3179            if let Some(result) = self.try_vm_builtin(builtin.name, &arg)? {
3180                Ok(result)
3181            } else {
3182                // Deep-force: builtins iterate over container elements.
3183                let deep = self.deep_force(arg)?;
3184                let arg_vmval = deep.to_vmvalue();
3185                let builtin_func = builtin.func.clone();
3186                let result = self.call_builtin_with_scoped_import_dispatch(
3187                    builtin_func, arg_vmval,
3188                )?;
3189                Ok(result)
3190            }
3191        } else {
3192            Err(VMError::NotCallable(func.type_name().to_string()))
3193        }
3194    }
3195    #[allow(clippy::too_many_lines)]
3196    fn call_higher_order_builtin(
3197        &mut self,
3198        hob: &HigherOrderBuiltin,
3199        arg: NanBox,
3200    ) -> Result<NanBox, VMError> {
3201        use HigherOrderOp::*;
3202        // Force the argument — higher-order builtins need concrete values.
3203        // Use shallow_force_container to handle thunked list elements.
3204        let arg = self.force_value(arg)?;
3205        match hob.op {
3206            Map => {
3207                let list_val = arg.to_vmvalue();
3208                let list = match &list_val {
3209                    VMValue::List(l) => l,
3210                    other => return Err(VMError::TypeError {
3211                        expected: "list", got: other.type_name(),
3212                        context: "builtins.map".to_string(),
3213                    }),
3214                };
3215                let func_nb = NanBox::from_vmvalue(&hob.func);
3216                let mut results = Vec::with_capacity(list.len());
3217                for item in list {
3218                    let r = self.call_callable(&func_nb, NanBox::from_vmvalue(item))?;
3219                    results.push(r);
3220                }
3221                Ok(NanBox::list(results))
3222            }
3223            Filter => {
3224                let list_val = arg.to_vmvalue();
3225                let list = match &list_val {
3226                    VMValue::List(l) => l,
3227                    other => return Err(VMError::TypeError {
3228                        expected: "list", got: other.type_name(),
3229                        context: "builtins.filter".to_string(),
3230                    }),
3231                };
3232                let func_nb = NanBox::from_vmvalue(&hob.func);
3233                let mut results = Vec::new();
3234                for item in list {
3235                    let item_nb = NanBox::from_vmvalue(item);
3236                    let r = self.call_callable(&func_nb, item_nb.clone())?;
3237                    
3238                    if r.is_truthy()? { results.push(item_nb); }
3239                }
3240                Ok(NanBox::list(results))
3241            }
3242            FoldlP1 => {
3243                let init_vmval = arg.to_vmvalue();
3244                Ok(NanBox::from_vmvalue(&VMValue::HigherOrderBuiltin(
3245                    HigherOrderBuiltin {
3246                        op: FoldlP2,
3247                        func: hob.func.clone(),
3248                        extra_args: vec![init_vmval],
3249                    },
3250                )))
3251            }
3252            FoldlP2 => {
3253                let list_val = arg.to_vmvalue();
3254                let list = match &list_val {
3255                    VMValue::List(l) => l,
3256                    other => return Err(VMError::TypeError {
3257                        expected: "list", got: other.type_name(),
3258                        context: "builtins.foldl'".to_string(),
3259                    }),
3260                };
3261                let func_nb = NanBox::from_vmvalue(&hob.func);
3262                let mut acc = NanBox::from_vmvalue(&hob.extra_args[0]);
3263                for item in list {
3264                    let partial = self.call_callable(&func_nb, acc)?;
3265                    acc = self.call_callable(&partial, NanBox::from_vmvalue(item))?;
3266                }
3267                Ok(acc)
3268            }
3269            Sort => {
3270                let list_val = arg.to_vmvalue();
3271                let list = match &list_val {
3272                    VMValue::List(l) => l.clone(),
3273                    other => return Err(VMError::TypeError {
3274                        expected: "list", got: other.type_name(),
3275                        context: "builtins.sort".to_string(),
3276                    }),
3277                };
3278                if list.len() <= 1 {
3279                    return Ok(NanBox::from_vmvalue(&VMValue::List(list)));
3280                }
3281                let func_nb = NanBox::from_vmvalue(&hob.func);
3282                let mut sorted: Vec<VMValue> = Vec::with_capacity(list.len());
3283                for item in &list {
3284                    let item_nb = NanBox::from_vmvalue(item);
3285                    let mut pos = sorted.len();
3286                    for (i, existing) in sorted.iter().enumerate() {
3287                        let existing_nb = NanBox::from_vmvalue(existing);
3288                        let partial = self.call_callable(&func_nb, item_nb.clone())?;
3289                        let cmp_result = self.call_callable(&partial, existing_nb)?;
3290                        if cmp_result.is_truthy()? { pos = i; break; }
3291                    }
3292                    sorted.insert(pos, item.clone());
3293                }
3294                Ok(NanBox::from_vmvalue(&VMValue::List(sorted)))
3295            }
3296            GenList => {
3297                let n = match arg.to_vmvalue() {
3298                    VMValue::Int(n) => n,
3299                    other => return Err(VMError::TypeError {
3300                        expected: "int", got: other.type_name(),
3301                        context: "builtins.genList".to_string(),
3302                    }),
3303                };
3304                if n < 0 { return Err(VMError::Throw("genList: negative length".to_string())); }
3305                let func_nb = NanBox::from_vmvalue(&hob.func);
3306                let mut results = Vec::with_capacity(n as usize);
3307                for i in 0..n {
3308                    results.push(self.call_callable(&func_nb, NanBox::int(i))?);
3309                }
3310                Ok(NanBox::list(results))
3311            }
3312            ConcatMap => {
3313                let list_val = arg.to_vmvalue();
3314                let list = match &list_val {
3315                    VMValue::List(l) => l,
3316                    other => return Err(VMError::TypeError {
3317                        expected: "list", got: other.type_name(),
3318                        context: "builtins.concatMap".to_string(),
3319                    }),
3320                };
3321                let func_nb = NanBox::from_vmvalue(&hob.func);
3322                let mut results = Vec::new();
3323                for item in list {
3324                    let mapped = self.call_callable(&func_nb, NanBox::from_vmvalue(item))?;
3325                    match mapped.to_vmvalue() {
3326                        VMValue::List(inner) => {
3327                            for v in &inner { results.push(NanBox::from_vmvalue(v)); }
3328                        }
3329                        other => return Err(VMError::TypeError {
3330                            expected: "list", got: other.type_name(),
3331                            context: "builtins.concatMap result".to_string(),
3332                        }),
3333                    }
3334                }
3335                Ok(NanBox::list(results))
3336            }
3337            Any => {
3338                let list_val = arg.to_vmvalue();
3339                let list = match &list_val {
3340                    VMValue::List(l) => l,
3341                    other => return Err(VMError::TypeError {
3342                        expected: "list", got: other.type_name(),
3343                        context: "builtins.any".to_string(),
3344                    }),
3345                };
3346                let func_nb = NanBox::from_vmvalue(&hob.func);
3347                for item in list {
3348                    if self.call_callable(&func_nb, NanBox::from_vmvalue(item))?.is_truthy()? {
3349                        return Ok(NanBox::bool(true));
3350                    }
3351                }
3352                Ok(NanBox::bool(false))
3353            }
3354            All => {
3355                let list_val = arg.to_vmvalue();
3356                let list = match &list_val {
3357                    VMValue::List(l) => l,
3358                    other => return Err(VMError::TypeError {
3359                        expected: "list", got: other.type_name(),
3360                        context: "builtins.all".to_string(),
3361                    }),
3362                };
3363                let func_nb = NanBox::from_vmvalue(&hob.func);
3364                for item in list {
3365                    if !self.call_callable(&func_nb, NanBox::from_vmvalue(item))?.is_truthy()? {
3366                        return Ok(NanBox::bool(false));
3367                    }
3368                }
3369                Ok(NanBox::bool(true))
3370            }
3371            Partition => {
3372                let list_val = arg.to_vmvalue();
3373                let list = match &list_val {
3374                    VMValue::List(l) => l,
3375                    other => return Err(VMError::TypeError {
3376                        expected: "list", got: other.type_name(),
3377                        context: "builtins.partition".to_string(),
3378                    }),
3379                };
3380                let func_nb = NanBox::from_vmvalue(&hob.func);
3381                let (mut right, mut wrong) = (Vec::new(), Vec::new());
3382                for item in list {
3383                    let item_nb = NanBox::from_vmvalue(item);
3384                    if self.call_callable(&func_nb, item_nb.clone())?.is_truthy()? {
3385                        right.push(item_nb);
3386                    } else {
3387                        wrong.push(item_nb);
3388                    }
3389                }
3390                let rs = self.interner.intern("right");
3391                let ws = self.interner.intern("wrong");
3392                let mut attrs = BTreeMap::new();
3393                attrs.insert(rs, NanBox::list(right));
3394                attrs.insert(ws, NanBox::list(wrong));
3395                Ok(NanBox::attrs(attrs))
3396            }
3397            GroupBy => {
3398                let list_val = arg.to_vmvalue();
3399                let list = match &list_val {
3400                    VMValue::List(l) => l,
3401                    other => return Err(VMError::TypeError {
3402                        expected: "list", got: other.type_name(),
3403                        context: "builtins.groupBy".to_string(),
3404                    }),
3405                };
3406                let func_nb = NanBox::from_vmvalue(&hob.func);
3407                let mut groups: BTreeMap<String, Vec<NanBox>> = BTreeMap::new();
3408                for item in list {
3409                    let item_nb = NanBox::from_vmvalue(item);
3410                    let kr = self.call_callable(&func_nb, item_nb.clone())?;
3411                    let ks = kr.as_string().ok_or_else(|| VMError::TypeError {
3412                        expected: "string", got: kr.type_name(),
3413                        context: "builtins.groupBy key".to_string(),
3414                    })?.to_string();
3415                    groups.entry(ks).or_default().push(item_nb);
3416                }
3417                let mut attrs = BTreeMap::new();
3418                for (k, vs) in groups {
3419                    attrs.insert(self.interner.intern(&k), NanBox::list(vs));
3420                }
3421                Ok(NanBox::attrs(attrs))
3422            }
3423            MapAttrs => {
3424                let attrs_val = arg.to_vmvalue();
3425                let attrs = match &attrs_val {
3426                    VMValue::Attrs(a) => a,
3427                    other => return Err(VMError::TypeError {
3428                        expected: "set", got: other.type_name(),
3429                        context: "builtins.mapAttrs".to_string(),
3430                    }),
3431                };
3432                let func_nb = NanBox::from_vmvalue(&hob.func);
3433                let entries: Vec<_> = attrs.iter().map(|(k, v)| (*k, v.clone())).collect();
3434                let chunk = deferred_apply_chunk();
3435                let mut result = BTreeMap::new();
3436                for (sym, val) in entries {
3437                    let key_str = self.interner.resolve(sym).to_string();
3438                    // Eagerly apply f to the key name (partial application).
3439                    // This is cheap — it just creates a closure capturing the key.
3440                    let partial = self.call_callable(&func_nb, NanBox::string(key_str))?;
3441                    // Defer the second application (partial value) as a thunk.
3442                    // This matches CppNix semantics: mapAttrs is lazy in values.
3443                    // Upvalues are NanBoxes: `partial` already is one; `val`
3444                    // came off the VMValue attrset so convert it locally here
3445                    // (this is a per-entry conversion, not the per-Call/thunk
3446                    // round-trip the optimization removes).
3447                    let thunk = VMThunk::new(
3448                        chunk.clone(),
3449                        vec![partial, NanBox::from_vmvalue(&val)],
3450                    );
3451                    result.insert(sym, NanBox::thunk(thunk));
3452                }
3453                Ok(NanBox::attrs(result))
3454            }
3455            FilterAttrs => {
3456                let attrs_val = arg.to_vmvalue();
3457                let attrs = match &attrs_val {
3458                    VMValue::Attrs(a) => a,
3459                    other => return Err(VMError::TypeError {
3460                        expected: "set", got: other.type_name(),
3461                        context: "builtins.filterAttrs".to_string(),
3462                    }),
3463                };
3464                let func_nb = NanBox::from_vmvalue(&hob.func);
3465                let entries: Vec<_> = attrs.iter().map(|(k, v)| (*k, v.clone())).collect();
3466                let mut result = BTreeMap::new();
3467                for (sym, val) in entries {
3468                    let key_str = self.interner.resolve(sym).to_string();
3469                    let partial = self.call_callable(&func_nb, NanBox::string(key_str))?;
3470                    if self.call_callable(&partial, NanBox::from_vmvalue(&val))?.is_truthy()? {
3471                        result.insert(sym, NanBox::from_vmvalue(&val));
3472                    }
3473                }
3474                Ok(NanBox::attrs(result))
3475            }
3476            Elem => {
3477                // builtins.elem needle list — check if needle is in list.
3478                // Needs VM-level handling because list elements may be thunks
3479                // that must be forced before equality comparison.
3480                // Uses deep_eq which recursively forces nested values.
3481                let needle = NanBox::from_vmvalue(&hob.func);
3482                let forced_needle = self.force_value(needle)?;
3483                let list = if let Some(items) = arg.as_list() {
3484                    items.to_vec()
3485                } else {
3486                    let forced = self.force_value(arg)?;
3487                    if let Some(items) = forced.as_list() {
3488                        items.to_vec()
3489                    } else {
3490                        return Err(VMError::TypeError {
3491                            expected: "list",
3492                            got: forced.type_name(),
3493                            context: "builtins.elem".to_string(),
3494                        });
3495                    }
3496                };
3497                for item in &list {
3498                    let forced_item = self.force_value(item.clone())?;
3499                    if self.deep_eq(&forced_needle, &forced_item)? {
3500                        return Ok(NanBox::bool(true));
3501                    }
3502                }
3503                Ok(NanBox::bool(false))
3504            }
3505        }
3506    }
3507    // -- Import ---------------------------------------------------------
3508    /// Import a Nix file: compile it, execute it, cache the result.
3509    ///
3510    /// Handles the Nix convention that importing a directory is equivalent
3511    /// to importing `<directory>/default.nix`.
3512    fn import_file(&mut self, path: &str) -> Result<NanBox, VMError> {
3513        let resolved = std::fs::canonicalize(path)
3514            .map_err(|e| VMError::ImportError(format!("{path}: {e}")))?;
3515        // Directory → default.nix fallback (Nix convention).
3516        let resolved = if resolved.is_dir() {
3517            resolved.join("default.nix")
3518        } else {
3519            resolved
3520        };
3521        let canonical = resolved.to_string_lossy().to_string();
3522        // Check cache.
3523        if let Some(cached) = self.import_cache.borrow().get(&canonical) {
3524            return Ok(NanBox::from_vmvalue(cached));
3525        }
3526        // Try VM compilation, falling back to tree-walker on CompileError.
3527        let chunk = self.try_compile_import(&resolved, &canonical)?;
3528        let chunk = match chunk {
3529            Some(c) => c,
3530            None => {
3531                // Compilation failed — fall back to tree-walker via bridge.
3532                return self.import_via_bridge(&canonical);
3533            }
3534        };
3535        if self.frames.len() >= MAX_CALL_DEPTH {
3536            return Err(VMError::StackOverflow);
3537        }
3538        let return_depth = self.frames.len();
3539        let stack_base = self.stack.len();
3540        self.frames.push(CallFrame {
3541            chunk,
3542            ip: 0,
3543            stack_base,
3544            upvalues: Vec::new(),
3545        });
3546        let result = match self.run_until(return_depth) {
3547            Ok(r) => r,
3548            Err(e @ VMError::Throw(_)) => {
3549                // Nix throw must propagate so tryEval can catch it.
3550                self.stack.truncate(stack_base);
3551                if self.frames.len() > return_depth {
3552                    self.frames.truncate(return_depth);
3553                }
3554                return Err(e);
3555            }
3556            Err(e) => {
3557                // Any other error — fall back to tree-walker for this file.
3558                // This includes AttrNotFound, TypeError, AssertionFailed, etc.
3559                eprintln!("[sui-vm] runtime fallback for {canonical}: {e}");
3560                use std::sync::atomic::Ordering;
3561                crate::vm::VM_FALLBACK_COUNT.fetch_add(1, Ordering::Relaxed);
3562                self.stack.truncate(stack_base);
3563                if self.frames.len() > return_depth {
3564                    self.frames.truncate(return_depth);
3565                }
3566                return self.import_via_bridge(&canonical);
3567            }
3568        };
3569        // Clean up the imported frame's stack slots.
3570        // Return at stop_depth skips truncation, so we must do it here.
3571        self.stack.truncate(stack_base);
3572        // Cache as VMValue and return as NanBox.
3573        let result_vmval = result.to_vmvalue();
3574        self.import_cache
3575            .borrow_mut()
3576            .insert(canonical, result_vmval);
3577        Ok(result)
3578    }
3579    /// Try to compile an imported file. Returns `Ok(Some(chunk))` on success,
3580    /// `Ok(None)` on `CompileError` (caller should fall back to tree-walker),
3581    /// or `Err` on I/O errors.
3582    fn try_compile_import(
3583        &mut self,
3584        resolved: &std::path::Path,
3585        canonical: &str,
3586    ) -> Result<Option<Rc<Chunk>>, VMError> {
3587        // Check compile cache — skip parse + compile if we've seen this file.
3588        if let Some(cached_chunk) = self.compile_cache.get(resolved) {
3589            return Ok(Some(cached_chunk.clone()));
3590        }
3591        // Read the file.
3592        let source = std::fs::read_to_string(canonical)
3593            .map_err(|e| VMError::ImportError(format!("{canonical}: {e}")))?;
3594        let file_dir = resolved
3595            .parent()
3596            .map(|p| p.to_path_buf())
3597            .unwrap_or_default();
3598        // Share the VM's interner with the compiler so that symbol IDs
3599        // are consistent — no need to clear key_symbols afterwards.
3600        let shared_interner = Rc::new(RefCell::new(std::mem::take(self.interner)));
3601        let compile_result =
3602            Compiler::compile_with_shared_interner(&source, file_dir, shared_interner.clone());
3603        *self.interner = match Rc::try_unwrap(shared_interner) {
3604            Ok(cell) => cell.into_inner(),
3605            Err(rc) => rc.borrow().clone(),
3606        };
3607        match compile_result {
3608            Ok(mut compiled) => {
3609                Self::set_source_file_recursive(&mut compiled, canonical);
3610                let chunk = Rc::new(compiled);
3611                self.compile_cache
3612                    .insert(resolved.to_path_buf(), chunk.clone());
3613                Ok(Some(chunk))
3614            }
3615            Err(compile_error) => {
3616                // Compilation failed (unsupported expression, etc.) —
3617                // signal caller to fall back to tree-walker.
3618                VM_FALLBACK_COUNT.fetch_add(1, Ordering::Relaxed);
3619                eprintln!("[sui-vm] fallback to tree-walker for {canonical}: {compile_error}");
3620                Ok(None)
3621            }
3622        }
3623    }
3624    /// Fall back to tree-walker evaluation for an imported file via the
3625    /// builtin bridge. Called when the bytecode compiler cannot handle
3626    /// the file (e.g. unsupported AST constructs).
3627    fn import_via_bridge(&mut self, canonical: &str) -> Result<NanBox, VMError> {
3628        match crate::bridge::call_builtin_bridge(
3629            "__import",
3630            vec![crate::value::StringKeyedValue::Path(canonical.to_string())],
3631        ) {
3632            Ok(Some(result)) => {
3633                let nanbox = self.string_keyed_to_nanbox(&result);
3634                // Force the top-level result so callers get a concrete
3635                // value (not a thunk). Bridge results may be thunked
3636                // when the tree-walker wraps unevaluated expressions.
3637                let nanbox = if nanbox.is_thunk() {
3638                    self.force_value(nanbox)?
3639                } else {
3640                    nanbox
3641                };
3642                // Cache as VMValue so subsequent imports hit the cache.
3643                let result_vmval = nanbox.to_vmvalue();
3644                self.import_cache
3645                    .borrow_mut()
3646                    .insert(canonical.to_string(), result_vmval);
3647                Ok(nanbox)
3648            }
3649            Ok(None) => Err(VMError::ImportError(format!(
3650                "compilation failed and no bridge installed for '{canonical}'"
3651            ))),
3652            Err(e) => Err(VMError::ImportError(format!(
3653                "bridge fallback error for '{canonical}': {e}"
3654            ))),
3655        }
3656    }
3657    /// Recursively set `source_file` on a chunk and all nested closure chunks.
3658    fn set_source_file_recursive(chunk: &mut Chunk, file: &str) {
3659        chunk.source_file = Some(file.to_string());
3660        for constant in &mut chunk.constants {
3661            if let VMValue::Closure(closure) = constant {
3662                if let Some(inner_chunk) = Rc::get_mut(&mut closure.chunk) {
3663                    Self::set_source_file_recursive(inner_chunk, file);
3664                }
3665            }
3666        }
3667    }
3668    /// Disassemble instructions around a given offset for error diagnostics.
3669    /// Returns a human-readable string showing `window` instructions before
3670    /// and after `center_ip`, with an arrow marking the center.
3671    fn disassemble_around(chunk: &Chunk, center_ip: usize, window: usize) -> String {
3672        let code = &chunk.code;
3673        let mut lines: Vec<String> = Vec::new();
3674        // Collect instruction boundaries by scanning from the start.
3675        let mut boundaries: Vec<usize> = Vec::new();
3676        let mut pos = 0;
3677        while pos < code.len() {
3678            boundaries.push(pos);
3679            pos += Self::instruction_width(code, pos);
3680        }
3681        // Find the boundary closest to center_ip.
3682        let center_idx = boundaries.iter().position(|&b| b >= center_ip).unwrap_or(0);
3683        let start_idx = center_idx.saturating_sub(window);
3684        let end_idx = (center_idx + window + 1).min(boundaries.len());
3685        for idx in start_idx..end_idx {
3686            let ip = boundaries[idx];
3687            let marker = if ip == center_ip { ">>>" } else { "   " };
3688            let line = chunk.lines.get(ip).copied().unwrap_or(0);
3689            if let Some(op) = OpCode::from_byte(code[ip]) {
3690                let operands = Self::format_operands(code, ip, op);
3691                lines.push(format!("    {marker} {ip:4}: {op:?}{operands}  (line {line})"));
3692            } else {
3693                lines.push(format!("    {marker} {ip:4}: <unknown {}>  (line {line})", code[ip]));
3694            }
3695        }
3696        lines.join("\n")
3697    }
3698    /// Determine the total byte width of an instruction at `pos`.
3699    fn instruction_width(code: &[u8], pos: usize) -> usize {
3700        let byte = code[pos];
3701        match OpCode::from_byte(byte) {
3702            Some(op) => match op {
3703                // No operands (1 byte):
3704                OpCode::Null | OpCode::True | OpCode::False
3705                | OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div | OpCode::Negate
3706                | OpCode::Not | OpCode::And | OpCode::Or | OpCode::Implication
3707                | OpCode::Equal | OpCode::NotEqual | OpCode::Less | OpCode::Greater
3708                | OpCode::LessEqual | OpCode::GreaterEqual
3709                | OpCode::UpdateAttrs | OpCode::Concat
3710                | OpCode::Call | OpCode::TailCall | OpCode::Return
3711                | OpCode::Assert | OpCode::Throw | OpCode::Pop | OpCode::Dup | OpCode::PushWith | OpCode::PopWith
3712                | OpCode::PushBuiltins | OpCode::Force | OpCode::Import
3713                | OpCode::DynGetAttr | OpCode::DynHasAttr
3714                | OpCode::DynSelectOrDefault | OpCode::Dup => 1,
3715                // 1 u16 operand (3 bytes):
3716                OpCode::Constant | OpCode::GetLocal | OpCode::SetLocal
3717                | OpCode::GetUpvalue | OpCode::SetUpvalue | OpCode::LookupWith
3718                | OpCode::GetAttr | OpCode::HasAttr | OpCode::MakeAttrs
3719                | OpCode::SelectOrDefault | OpCode::MakeList
3720                | OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue
3721                | OpCode::Interpolate => 3,
3722                // 2 u16 operands (5 bytes):
3723                OpCode::GetLocalAttr | OpCode::GetLocalCall | OpCode::CallBuiltin => 5,
3724                // MakeClosure: u16 const_idx, u16 uv_count, then uv_count * 3 bytes
3725                OpCode::MakeClosure => {
3726                    if pos + 5 <= code.len() {
3727                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3728                        5 + uv_count * 3
3729                    } else {
3730                        3 // truncated
3731                    }
3732                }
3733                // MakeThunk: u16 const_idx, u16 uv_count, then uv_count * 3 bytes
3734                OpCode::MakeThunk => {
3735                    if pos + 5 <= code.len() {
3736                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3737                        5 + uv_count * 3
3738                    } else {
3739                        3
3740                    }
3741                }
3742                // PatchThunkUpvalues: u16 slot, u16 uv_count, then uv_count * 3 bytes
3743                OpCode::PatchThunkUpvalues => {
3744                    if pos + 5 <= code.len() {
3745                        let uv_count = u16::from_le_bytes([code[pos + 3], code[pos + 4]]) as usize;
3746                        5 + uv_count * 3
3747                    } else {
3748                        3
3749                    }
3750                }
3751                // MakeLazyThunk: u16 src, u32 offset, u32 length, u16 dir, u16 uv_count, then uv_count * 3
3752                OpCode::MakeLazyThunk => {
3753                    if pos + 15 <= code.len() {
3754                        let uv_count = u16::from_le_bytes([code[pos + 13], code[pos + 14]]) as usize;
3755                        15 + uv_count * 3
3756                    } else {
3757                        3
3758                    }
3759                }
3760            },
3761            None => 1, // unknown opcode, skip 1
3762        }
3763    }
3764    /// Format inline operands for a single instruction (for disassembly).
3765    fn format_operands(code: &[u8], pos: usize, op: OpCode) -> String {
3766        let read_u16_at = |p: usize| -> Option<u16> {
3767            if p + 2 <= code.len() {
3768                Some(u16::from_le_bytes([code[p], code[p + 1]]))
3769            } else {
3770                None
3771            }
3772        };
3773        match op {
3774            OpCode::Constant | OpCode::GetLocal | OpCode::SetLocal
3775            | OpCode::GetUpvalue | OpCode::SetUpvalue | OpCode::LookupWith
3776            | OpCode::GetAttr | OpCode::HasAttr | OpCode::MakeAttrs
3777            | OpCode::SelectOrDefault | OpCode::MakeList
3778            | OpCode::Jump | OpCode::JumpIfFalse | OpCode::JumpIfTrue
3779            | OpCode::Interpolate => {
3780                read_u16_at(pos + 1).map_or(String::new(), |v| format!(" {v}"))
3781            }
3782            OpCode::GetLocalAttr => {
3783                let s = read_u16_at(pos + 1).unwrap_or(0);
3784                let k = read_u16_at(pos + 3).unwrap_or(0);
3785                format!(" slot={s} key={k}")
3786            }
3787            OpCode::GetLocalCall => {
3788                read_u16_at(pos + 1).map_or(String::new(), |v| format!(" slot={v}"))
3789            }
3790            OpCode::CallBuiltin => {
3791                let idx = read_u16_at(pos + 1).unwrap_or(0);
3792                let argc = read_u16_at(pos + 3).unwrap_or(0);
3793                format!(" idx={idx} argc={argc}")
3794            }
3795            OpCode::MakeThunk | OpCode::MakeClosure => {
3796                let ci = read_u16_at(pos + 1).unwrap_or(0);
3797                let uv = read_u16_at(pos + 3).unwrap_or(0);
3798                format!(" const={ci} upvals={uv}")
3799            }
3800            OpCode::PatchThunkUpvalues => {
3801                let s = read_u16_at(pos + 1).unwrap_or(0);
3802                let uv = read_u16_at(pos + 3).unwrap_or(0);
3803                format!(" slot={s} upvals={uv}")
3804            }
3805            _ => String::new(),
3806        }
3807    }
3808}
3809#[cfg(test)]
3810mod tests {
3811    use super::*;
3812    use crate::compiler::Compiler;
3813    use crate::value::StringKeyedValue;
3814    fn eval(input: &str) -> VMValue {
3815        let (chunk, mut interner) =
3816            Compiler::compile(input).unwrap_or_else(|e| panic!("compile '{input}': {e}"));
3817        VM::execute(chunk, &mut interner).unwrap_or_else(|e| panic!("execute '{input}': {e}"))
3818    }
3819    fn eval_full_helper(input: &str) -> crate::StringKeyedValue {
3820        let result =
3821            crate::eval_full(input).unwrap_or_else(|e| panic!("eval_full '{input}': {e}"));
3822        result.to_string_keyed()
3823    }
3824    fn eval_err(input: &str) -> VMError {
3825        let (chunk, mut interner) =
3826            Compiler::compile(input).unwrap_or_else(|e| panic!("compile '{input}': {e}"));
3827        VM::execute(chunk, &mut interner).unwrap_err()
3828    }
3829    // -- Literals -------------------------------------------------------
3830    #[test]
3831    fn eval_integer() {
3832        assert_eq!(eval("42"), VMValue::Int(42));
3833    }
3834    #[test]
3835    fn eval_negative_integer() {
3836        assert_eq!(eval("-7"), VMValue::Int(-7));
3837    }
3838    #[test]
3839    fn eval_float() {
3840        assert_eq!(eval("3.14"), VMValue::Float(3.14));
3841    }
3842    #[test]
3843    fn eval_bool_true() {
3844        assert_eq!(eval("true"), VMValue::Bool(true));
3845    }
3846    #[test]
3847    fn eval_bool_false() {
3848        assert_eq!(eval("false"), VMValue::Bool(false));
3849    }
3850    #[test]
3851    fn eval_null() {
3852        assert_eq!(eval("null"), VMValue::Null);
3853    }
3854    #[test]
3855    fn eval_string() {
3856        assert_eq!(eval(r#""hello""#), VMValue::String("hello".to_string()));
3857    }
3858    // -- Arithmetic -----------------------------------------------------
3859    #[test]
3860    fn eval_add_int() {
3861        assert_eq!(eval("1 + 2"), VMValue::Int(3));
3862    }
3863    #[test]
3864    fn eval_sub_int() {
3865        assert_eq!(eval("10 - 3"), VMValue::Int(7));
3866    }
3867    #[test]
3868    fn eval_mul_int() {
3869        assert_eq!(eval("3 * 4"), VMValue::Int(12));
3870    }
3871    #[test]
3872    fn eval_div_int() {
3873        assert_eq!(eval("10 / 3"), VMValue::Int(3));
3874    }
3875    #[test]
3876    fn eval_div_zero() {
3877        assert!(matches!(eval_err("1 / 0"), VMError::DivisionByZero));
3878    }
3879    #[test]
3880    fn eval_float_arithmetic() {
3881        assert_eq!(eval("1.5 + 2.5"), VMValue::Float(4.0));
3882    }
3883    #[test]
3884    fn eval_mixed_arithmetic() {
3885        assert_eq!(eval("1 + 2.0"), VMValue::Float(3.0));
3886    }
3887    #[test]
3888    fn eval_compound_arithmetic() {
3889        assert_eq!(eval("2 * 3 + 1"), VMValue::Int(7));
3890    }
3891    #[test]
3892    fn eval_negate_float() {
3893        assert_eq!(eval("-3.14"), VMValue::Float(-3.14));
3894    }
3895    #[test]
3896    fn eval_string_concat() {
3897        assert_eq!(
3898            eval(r#""hello" + " " + "world""#),
3899            VMValue::String("hello world".to_string())
3900        );
3901    }
3902    // -- Comparison -----------------------------------------------------
3903    #[test]
3904    fn eval_equal() {
3905        assert_eq!(eval("1 == 1"), VMValue::Bool(true));
3906        assert_eq!(eval("1 == 2"), VMValue::Bool(false));
3907    }
3908    #[test]
3909    fn eval_not_equal() {
3910        assert_eq!(eval("1 != 2"), VMValue::Bool(true));
3911        assert_eq!(eval("1 != 1"), VMValue::Bool(false));
3912    }
3913    #[test]
3914    fn eval_less() {
3915        assert_eq!(eval("1 < 2"), VMValue::Bool(true));
3916        assert_eq!(eval("2 < 1"), VMValue::Bool(false));
3917    }
3918    #[test]
3919    fn eval_greater() {
3920        assert_eq!(eval("2 > 1"), VMValue::Bool(true));
3921        assert_eq!(eval("1 > 2"), VMValue::Bool(false));
3922    }
3923    #[test]
3924    fn eval_less_equal() {
3925        assert_eq!(eval("1 <= 1"), VMValue::Bool(true));
3926        assert_eq!(eval("1 <= 2"), VMValue::Bool(true));
3927        assert_eq!(eval("2 <= 1"), VMValue::Bool(false));
3928    }
3929    #[test]
3930    fn eval_greater_equal() {
3931        assert_eq!(eval("1 >= 1"), VMValue::Bool(true));
3932        assert_eq!(eval("2 >= 1"), VMValue::Bool(true));
3933        assert_eq!(eval("1 >= 2"), VMValue::Bool(false));
3934    }
3935    // -- Logical --------------------------------------------------------
3936    #[test]
3937    fn eval_not() {
3938        assert_eq!(eval("!true"), VMValue::Bool(false));
3939        assert_eq!(eval("!false"), VMValue::Bool(true));
3940    }
3941    #[test]
3942    fn eval_and_short_circuit() {
3943        assert_eq!(eval("true && true"), VMValue::Bool(true));
3944        assert_eq!(eval("true && false"), VMValue::Bool(false));
3945        assert_eq!(eval("false && true"), VMValue::Bool(false));
3946    }
3947    #[test]
3948    fn eval_or_short_circuit() {
3949        assert_eq!(eval("false || true"), VMValue::Bool(true));
3950        assert_eq!(eval("false || false"), VMValue::Bool(false));
3951        assert_eq!(eval("true || false"), VMValue::Bool(true));
3952    }
3953    #[test]
3954    fn eval_implication() {
3955        assert_eq!(eval("true -> true"), VMValue::Bool(true));
3956        assert_eq!(eval("true -> false"), VMValue::Bool(false));
3957        assert_eq!(eval("false -> true"), VMValue::Bool(true));
3958        assert_eq!(eval("false -> false"), VMValue::Bool(true));
3959    }
3960    // -- Conditionals ---------------------------------------------------
3961    #[test]
3962    fn eval_if_true() {
3963        assert_eq!(eval("if true then 1 else 2"), VMValue::Int(1));
3964    }
3965    #[test]
3966    fn eval_if_false() {
3967        assert_eq!(eval("if false then 1 else 2"), VMValue::Int(2));
3968    }
3969    #[test]
3970    fn eval_if_expression() {
3971        assert_eq!(
3972            eval("if 1 > 2 then \"yes\" else \"no\""),
3973            VMValue::String("no".to_string())
3974        );
3975    }
3976    #[test]
3977    fn eval_nested_if() {
3978        assert_eq!(
3979            eval("if true then (if false then 1 else 2) else 3"),
3980            VMValue::Int(2)
3981        );
3982    }
3983    // -- Let/in ---------------------------------------------------------
3984    #[test]
3985    fn eval_let_simple() {
3986        assert_eq!(eval("let x = 1; y = 2; in x + y"), VMValue::Int(3));
3987    }
3988    #[test]
3989    fn eval_let_nested() {
3990        assert_eq!(
3991            eval("let a = 10; in let b = 20; in a + b"),
3992            VMValue::Int(30)
3993        );
3994    }
3995    #[test]
3996    fn eval_let_shadow() {
3997        assert_eq!(eval("let x = 1; in let x = 2; in x"), VMValue::Int(2));
3998    }
3999    #[test]
4000    fn eval_let_with_expression() {
4001        assert_eq!(eval("let x = 2 * 3; in x + 1"), VMValue::Int(7));
4002    }
4003    // -- Lists ----------------------------------------------------------
4004    #[test]
4005    fn eval_empty_list() {
4006        assert_eq!(eval("[]"), VMValue::List(vec![]));
4007    }
4008    #[test]
4009    fn eval_list() {
4010        assert_eq!(
4011            eval("[1 2 3]"),
4012            VMValue::List(vec![VMValue::Int(1), VMValue::Int(2), VMValue::Int(3)])
4013        );
4014    }
4015    #[test]
4016    fn eval_list_concat() {
4017        assert_eq!(
4018            eval("[1 2] ++ [3 4]"),
4019            VMValue::List(vec![
4020                VMValue::Int(1),
4021                VMValue::Int(2),
4022                VMValue::Int(3),
4023                VMValue::Int(4),
4024            ])
4025        );
4026    }
4027    #[test]
4028    fn eval_list_concat_with_inline_map() {
4029        // Regression: call_callable did not truncate the stack after
4030        // run_until, so map's per-element calls leaked values that
4031        // shifted the Concat operands on the stack.
4032        assert_eq!(
4033            eval("[1] ++ builtins.map (a: a) [2 3]"),
4034            VMValue::List(vec![VMValue::Int(1), VMValue::Int(2), VMValue::Int(3)])
4035        );
4036    }
4037    #[test]
4038    fn eval_list_concat_with_inline_map_attrsets() {
4039        // Same regression with attrset-producing map (the nixpkgs pattern).
4040        let result = eval(r#"[{ x = 1; }] ++ builtins.map (a: { v = a; }) ["a" "b"]"#);
4041        match result {
4042            VMValue::List(items) => assert_eq!(items.len(), 3),
4043            other => panic!("expected list, got {:?}", other.type_name()),
4044        }
4045    }
4046    #[test]
4047    fn eval_list_concat_with_inline_filter() {
4048        // Also verify filter (another higher-order builtin) with ++.
4049        assert_eq!(
4050            eval("[0] ++ builtins.filter (x: x > 1) [1 2 3]"),
4051            VMValue::List(vec![VMValue::Int(0), VMValue::Int(2), VMValue::Int(3)])
4052        );
4053    }
4054    #[test]
4055    fn eval_list_mixed() {
4056        assert_eq!(
4057            eval(r#"[1 "hello" true]"#),
4058            VMValue::List(vec![
4059                VMValue::Int(1),
4060                VMValue::String("hello".to_string()),
4061                VMValue::Bool(true),
4062            ])
4063        );
4064    }
4065    // -- Attribute sets -------------------------------------------------
4066    #[test]
4067    fn eval_empty_attrset() {
4068        assert_eq!(eval("{ }"), VMValue::Attrs(BTreeMap::new()));
4069    }
4070    #[test]
4071    fn eval_attrset() {
4072        let result = eval_full_helper("{ a = 1; b = 2; }");
4073        let mut expected = BTreeMap::new();
4074        expected.insert("a".to_string(), crate::StringKeyedValue::Int(1));
4075        expected.insert("b".to_string(), crate::StringKeyedValue::Int(2));
4076        assert_eq!(result, crate::StringKeyedValue::Attrs(expected));
4077    }
4078    #[test]
4079    fn eval_attrset_select() {
4080        assert_eq!(eval("{ a = 1; b = 2; }.a"), VMValue::Int(1));
4081    }
4082    #[test]
4083    fn eval_attrset_update() {
4084        let result = eval_full_helper("{ a = 1; } // { b = 2; }");
4085        let mut expected = BTreeMap::new();
4086        expected.insert("a".to_string(), crate::StringKeyedValue::Int(1));
4087        expected.insert("b".to_string(), crate::StringKeyedValue::Int(2));
4088        assert_eq!(result, crate::StringKeyedValue::Attrs(expected));
4089    }
4090    #[test]
4091    fn eval_attrset_update_override() {
4092        assert_eq!(eval("({ a = 1; } // { a = 2; }).a"), VMValue::Int(2));
4093    }
4094    #[test]
4095    fn eval_has_attr_true() {
4096        assert_eq!(eval("{ a = 1; } ? a"), VMValue::Bool(true));
4097    }
4098    #[test]
4099    fn eval_has_attr_false() {
4100        assert_eq!(eval("{ a = 1; } ? b"), VMValue::Bool(false));
4101    }
4102    #[test]
4103    fn eval_select_or_default() {
4104        assert_eq!(eval("{ a = 1; }.b or 0"), VMValue::Int(0));
4105        assert_eq!(eval("{ a = 1; }.a or 0"), VMValue::Int(1));
4106    }
4107    #[test]
4108    fn eval_dyn_select_or_default_missing() {
4109        // Dynamic key missing → returns default.
4110        assert_eq!(
4111            eval(r#"let x = "missing"; in { a = 1; }.${ x } or 99"#),
4112            VMValue::Int(99),
4113        );
4114    }
4115    #[test]
4116    fn eval_dyn_select_or_default_found() {
4117        // Dynamic key present → returns actual value.
4118        assert_eq!(
4119            eval(r#"let x = "a"; in { a = 42; }.${ x } or 99"#),
4120            VMValue::Int(42),
4121        );
4122    }
4123    #[test]
4124    fn eval_dyn_select_or_default_dotted_key() {
4125        // Key containing dots treated as single flat key, not nested path.
4126        assert_eq!(
4127            eval(r#"let x = "a.b"; in { "a.b" = 7; }.${ x } or 0"#),
4128            VMValue::Int(7),
4129        );
4130    }
4131    #[test]
4132    fn eval_dyn_select_or_default_special_chars() {
4133        // Key with dots and plus signs (nixpkgs armv8 CPU feature pattern).
4134        assert_eq!(
4135            eval(r#"let x = "armv8.3-a+crypto+sha2"; in { "armv8-a" = 1; }.${ x } or 0"#),
4136            VMValue::Int(0),
4137        );
4138    }
4139    #[test]
4140    fn eval_dyn_select_or_default_non_attrset() {
4141        // Base is not an attrset → returns default.
4142        assert_eq!(
4143            eval(r#"let x = "a"; base = 42; in base.${ x } or 99"#),
4144            VMValue::Int(99),
4145        );
4146    }
4147    // -- Lambdas / Apply ------------------------------------------------
4148    #[test]
4149    fn eval_identity_lambda() {
4150        assert_eq!(eval("(x: x) 42"), VMValue::Int(42));
4151    }
4152    #[test]
4153    fn eval_lambda_arithmetic() {
4154        assert_eq!(eval("(x: x + 1) 5"), VMValue::Int(6));
4155    }
4156    #[test]
4157    #[ignore = "requires upvalue capture (Phase 2)"]
4158    fn eval_curried_lambda() {
4159        assert_eq!(eval("(x: y: x + y) 3 4"), VMValue::Int(7));
4160    }
4161    #[test]
4162    fn eval_let_lambda() {
4163        assert_eq!(
4164            eval("let f = x: x * 2; in f 5"),
4165            VMValue::Int(10)
4166        );
4167    }
4168    #[test]
4169    fn eval_pattern_lambda() {
4170        assert_eq!(eval("({ a, b }: a + b) { a = 3; b = 4; }"), VMValue::Int(7));
4171    }
4172    #[test]
4173    fn eval_pattern_lambda_default() {
4174        assert_eq!(
4175            eval("({ a, b ? 10 }: a + b) { a = 5; }"),
4176            VMValue::Int(15)
4177        );
4178    }
4179    #[test]
4180    fn eval_lambda_with_let() {
4181        assert_eq!(
4182            eval("let inc = x: x + 1; double = x: x * 2; in double (inc 3)"),
4183            VMValue::Int(8)
4184        );
4185    }
4186    // -- Assert ---------------------------------------------------------
4187    #[test]
4188    fn eval_assert_pass() {
4189        assert_eq!(eval("assert true; 42"), VMValue::Int(42));
4190    }
4191    #[test]
4192    fn eval_assert_fail() {
4193        assert!(matches!(eval_err("assert false; 42"), VMError::AssertionFailed));
4194    }
4195    // -- Deep equality (thunk forcing) ----------------------------------
4196    #[test]
4197    fn deep_eq_attrs_with_thunked_values() {
4198        // Attrsets from let bindings have thunked values;
4199        // == must force them before comparison.
4200        assert_eq!(
4201            eval("let a = { x = 1; }; b = { x = 1; }; in a == b"),
4202            VMValue::Bool(true)
4203        );
4204    }
4205    #[test]
4206    fn deep_eq_attrs_different_values() {
4207        assert_eq!(
4208            eval("let a = { x = 1; }; b = { x = 2; }; in a == b"),
4209            VMValue::Bool(false)
4210        );
4211    }
4212    #[test]
4213    fn deep_eq_nested_attrs() {
4214        assert_eq!(
4215            eval("let a = { x = { y = 1; }; }; b = { x = { y = 1; }; }; in a == b"),
4216            VMValue::Bool(true)
4217        );
4218    }
4219    #[test]
4220    fn deep_eq_list_with_thunked_elements() {
4221        assert_eq!(
4222            eval("let a = [ 1 2 ]; b = [ 1 2 ]; in a == b"),
4223            VMValue::Bool(true)
4224        );
4225    }
4226    // -- builtins.elem (thunk forcing) ----------------------------------
4227    #[test]
4228    fn eval_elem_thunked_attrsets() {
4229        // elem must force list elements before comparison.
4230        assert_eq!(
4231            eval("let a = { x = 1; }; b = { x = 1; }; in builtins.elem a [ b ]"),
4232            VMValue::Bool(true)
4233        );
4234    }
4235    #[test]
4236    fn eval_elem_basic_int() {
4237        assert_eq!(
4238            eval("builtins.elem 2 [ 1 2 3 ]"),
4239            VMValue::Bool(true)
4240        );
4241    }
4242    #[test]
4243    fn eval_elem_missing() {
4244        assert_eq!(
4245            eval("builtins.elem 4 [ 1 2 3 ]"),
4246            VMValue::Bool(false)
4247        );
4248    }
4249    #[test]
4250    fn eval_elem_string() {
4251        assert_eq!(
4252            eval(r#"builtins.elem "b" [ "a" "b" "c" ]"#),
4253            VMValue::Bool(true)
4254        );
4255    }
4256    #[test]
4257    fn eval_elem_thunked_list_elements() {
4258        assert_eq!(
4259            eval("let x = 1; in builtins.elem 1 [ x ]"),
4260            VMValue::Bool(true)
4261        );
4262    }
4263    // -- String interpolation -------------------------------------------
4264    #[test]
4265    fn eval_string_interpolation() {
4266        assert_eq!(
4267            eval(r#"let x = "world"; in "hello ${x}""#),
4268            VMValue::String("hello world".to_string()),
4269        );
4270    }
4271    #[test]
4272    #[ignore = "requires builtins.toString (Phase 2)"]
4273    fn eval_string_interpolation_int() {
4274        assert_eq!(
4275            eval(r#"let n = 42; in "value: ${toString n}""#),
4276            VMValue::String("value: 42".to_string()),
4277        );
4278    }
4279    // -- Path literals --------------------------------------------------
4280    #[test]
4281    fn eval_absolute_path() {
4282        assert_eq!(eval("/tmp/x"), VMValue::Path("/tmp/x".to_string()));
4283    }
4284    // -- Complex expressions --------------------------------------------
4285    #[test]
4286    fn eval_fibonacci_like() {
4287        assert_eq!(
4288            eval("let a = 1; b = 1; c = a + b; d = b + c; e = c + d; in e"),
4289            VMValue::Int(5)
4290        );
4291    }
4292    #[test]
4293    fn eval_nested_attrset_select() {
4294        assert_eq!(
4295            eval("{ a = { b = 42; }; }.a.b"),
4296            VMValue::Int(42)
4297        );
4298    }
4299    #[test]
4300    fn eval_let_with_attrset() {
4301        assert_eq!(
4302            eval("let set = { x = 10; y = 20; }; in set.x + set.y"),
4303            VMValue::Int(30)
4304        );
4305    }
4306    #[test]
4307    fn eval_conditional_attrset() {
4308        assert_eq!(
4309            eval("(if true then { a = 1; } else { a = 2; }).a"),
4310            VMValue::Int(1)
4311        );
4312    }
4313    // -- Builtin tests --------------------------------------------------
4314    #[test]
4315    fn builtin_length() {
4316        assert_eq!(eval("builtins.length [1 2 3]"), VMValue::Int(3));
4317    }
4318    #[test]
4319    fn builtin_length_empty() {
4320        assert_eq!(eval("builtins.length []"), VMValue::Int(0));
4321    }
4322    #[test]
4323    fn builtin_head() {
4324        assert_eq!(eval("builtins.head [10 20 30]"), VMValue::Int(10));
4325    }
4326    #[test]
4327    fn builtin_tail() {
4328        let result = eval_full_helper("builtins.tail [1 2 3]");
4329        assert_eq!(
4330            result,
4331            StringKeyedValue::List(vec![StringKeyedValue::Int(2), StringKeyedValue::Int(3)])
4332        );
4333    }
4334    #[test]
4335    fn builtin_type_of_int() {
4336        assert_eq!(
4337            eval("builtins.typeOf 42"),
4338            VMValue::String("int".to_string())
4339        );
4340    }
4341    #[test]
4342    fn builtin_type_of_string() {
4343        assert_eq!(
4344            eval("builtins.typeOf \"hello\""),
4345            VMValue::String("string".to_string())
4346        );
4347    }
4348    #[test]
4349    fn builtin_type_of_bool() {
4350        assert_eq!(
4351            eval("builtins.typeOf true"),
4352            VMValue::String("bool".to_string())
4353        );
4354    }
4355    #[test]
4356    fn builtin_type_of_null() {
4357        assert_eq!(
4358            eval("builtins.typeOf null"),
4359            VMValue::String("null".to_string())
4360        );
4361    }
4362    #[test]
4363    fn builtin_type_of_list() {
4364        assert_eq!(
4365            eval("builtins.typeOf [1 2]"),
4366            VMValue::String("list".to_string())
4367        );
4368    }
4369    #[test]
4370    fn builtin_type_of_set() {
4371        assert_eq!(
4372            eval("builtins.typeOf { a = 1; }"),
4373            VMValue::String("set".to_string())
4374        );
4375    }
4376    #[test]
4377    fn builtin_type_of_lambda() {
4378        assert_eq!(
4379            eval("builtins.typeOf (x: x)"),
4380            VMValue::String("lambda".to_string())
4381        );
4382    }
4383    #[test]
4384    fn builtin_is_int() {
4385        assert_eq!(eval("builtins.isInt 42"), VMValue::Bool(true));
4386        assert_eq!(
4387            eval("builtins.isInt \"hello\""),
4388            VMValue::Bool(false)
4389        );
4390    }
4391    #[test]
4392    fn builtin_is_string() {
4393        assert_eq!(eval("builtins.isString \"hi\""), VMValue::Bool(true));
4394        assert_eq!(eval("builtins.isString 42"), VMValue::Bool(false));
4395    }
4396    #[test]
4397    fn builtin_is_list() {
4398        assert_eq!(eval("builtins.isList [1]"), VMValue::Bool(true));
4399        assert_eq!(eval("builtins.isList 42"), VMValue::Bool(false));
4400    }
4401    #[test]
4402    fn builtin_is_attrs() {
4403        assert_eq!(
4404            eval("builtins.isAttrs { a = 1; }"),
4405            VMValue::Bool(true)
4406        );
4407        assert_eq!(eval("builtins.isAttrs 42"), VMValue::Bool(false));
4408    }
4409    #[test]
4410    fn builtin_is_function() {
4411        assert_eq!(
4412            eval("builtins.isFunction (x: x)"),
4413            VMValue::Bool(true)
4414        );
4415        assert_eq!(eval("builtins.isFunction 42"), VMValue::Bool(false));
4416    }
4417    #[test]
4418    fn builtin_is_bool() {
4419        assert_eq!(eval("builtins.isBool true"), VMValue::Bool(true));
4420        assert_eq!(eval("builtins.isBool 42"), VMValue::Bool(false));
4421    }
4422    #[test]
4423    fn builtin_is_null() {
4424        assert_eq!(eval("builtins.isNull null"), VMValue::Bool(true));
4425        assert_eq!(eval("builtins.isNull 42"), VMValue::Bool(false));
4426    }
4427    #[test]
4428    fn builtin_string_length() {
4429        assert_eq!(
4430            eval("builtins.stringLength \"hello\""),
4431            VMValue::Int(5)
4432        );
4433    }
4434    #[test]
4435    fn builtin_to_string_int() {
4436        assert_eq!(
4437            eval("builtins.toString 42"),
4438            VMValue::String("42".to_string())
4439        );
4440    }
4441    #[test]
4442    fn builtin_to_string_bool() {
4443        assert_eq!(
4444            eval("builtins.toString true"),
4445            VMValue::String("1".to_string())
4446        );
4447    }
4448    #[test]
4449    fn builtin_throw() {
4450        let result = eval_err("builtins.throw \"test error\"");
4451        assert!(matches!(result, VMError::Throw(_)));
4452    }
4453    #[test]
4454    fn builtin_abort() {
4455        let result = eval_err("builtins.abort \"fatal\"");
4456        assert!(matches!(result, VMError::Throw(_)));
4457    }
4458    #[test]
4459    fn builtin_add_curried() {
4460        assert_eq!(eval("builtins.add 3 4"), VMValue::Int(7));
4461    }
4462    #[test]
4463    fn builtin_sub_curried() {
4464        assert_eq!(eval("builtins.sub 10 3"), VMValue::Int(7));
4465    }
4466    #[test]
4467    fn builtin_mul_curried() {
4468        assert_eq!(eval("builtins.mul 6 7"), VMValue::Int(42));
4469    }
4470    #[test]
4471    fn builtin_div_curried() {
4472        assert_eq!(eval("builtins.div 42 6"), VMValue::Int(7));
4473    }
4474    #[test]
4475    fn builtin_elem_at() {
4476        assert_eq!(eval("builtins.elemAt [10 20 30] 1"), VMValue::Int(20));
4477    }
4478    #[test]
4479    fn builtin_elem() {
4480        assert_eq!(eval("builtins.elem 2 [1 2 3]"), VMValue::Bool(true));
4481        assert_eq!(eval("builtins.elem 5 [1 2 3]"), VMValue::Bool(false));
4482    }
4483    #[test]
4484    fn builtin_concat_lists() {
4485        let result = eval_full_helper("builtins.concatLists [[1 2] [3 4]]");
4486        assert_eq!(
4487            result,
4488            StringKeyedValue::List(vec![
4489                StringKeyedValue::Int(1),
4490                StringKeyedValue::Int(2),
4491                StringKeyedValue::Int(3),
4492                StringKeyedValue::Int(4),
4493            ])
4494        );
4495    }
4496    #[test]
4497    fn builtin_has_prefix() {
4498        assert_eq!(
4499            eval("builtins.hasPrefix \"he\" \"hello\""),
4500            VMValue::Bool(true)
4501        );
4502        assert_eq!(
4503            eval("builtins.hasPrefix \"wo\" \"hello\""),
4504            VMValue::Bool(false)
4505        );
4506    }
4507    #[test]
4508    fn builtin_has_suffix() {
4509        assert_eq!(
4510            eval("builtins.hasSuffix \"lo\" \"hello\""),
4511            VMValue::Bool(true)
4512        );
4513    }
4514    #[test]
4515    fn builtin_concat_strings_sep() {
4516        assert_eq!(
4517            eval("builtins.concatStringsSep \", \" [\"a\" \"b\" \"c\"]"),
4518            VMValue::String("a, b, c".to_string())
4519        );
4520    }
4521    #[test]
4522    fn builtin_to_lower() {
4523        assert_eq!(
4524            eval("builtins.toLower \"Hello World\""),
4525            VMValue::String("hello world".to_string())
4526        );
4527    }
4528    #[test]
4529    fn builtin_to_upper() {
4530        assert_eq!(
4531            eval("builtins.toUpper \"hello\""),
4532            VMValue::String("HELLO".to_string())
4533        );
4534    }
4535    #[test]
4536    fn builtin_from_json() {
4537        assert_eq!(
4538            eval("builtins.fromJSON \"42\""),
4539            VMValue::Int(42)
4540        );
4541        assert_eq!(
4542            eval("builtins.fromJSON \"true\""),
4543            VMValue::Bool(true)
4544        );
4545    }
4546    #[test]
4547    fn builtin_seq() {
4548        assert_eq!(eval("builtins.seq 1 42"), VMValue::Int(42));
4549    }
4550    #[test]
4551    fn builtin_deep_seq() {
4552        assert_eq!(eval("builtins.deepSeq [1 2] 42"), VMValue::Int(42));
4553    }
4554    #[test]
4555    fn builtin_trace() {
4556        assert_eq!(
4557            eval("builtins.trace \"debug\" 42"),
4558            VMValue::Int(42)
4559        );
4560    }
4561    #[test]
4562    fn builtin_ceil_floor() {
4563        assert_eq!(eval("builtins.ceil 3.2"), VMValue::Int(4));
4564        assert_eq!(eval("builtins.floor 3.8"), VMValue::Int(3));
4565    }
4566    #[test]
4567    fn builtin_bit_ops() {
4568        assert_eq!(eval("builtins.bitAnd 12 10"), VMValue::Int(8));
4569        assert_eq!(eval("builtins.bitOr 12 10"), VMValue::Int(14));
4570        assert_eq!(eval("builtins.bitXor 12 10"), VMValue::Int(6));
4571    }
4572    #[test]
4573    fn builtin_intersect_attrs() {
4574        let result =
4575            eval_full_helper("builtins.intersectAttrs { a = 1; b = 2; } { a = 10; c = 30; }");
4576        match result {
4577            StringKeyedValue::Attrs(map) => {
4578                assert_eq!(map.get("a"), Some(&StringKeyedValue::Int(10)));
4579                assert!(!map.contains_key("b"));
4580                assert!(!map.contains_key("c"));
4581            }
4582            _ => panic!("expected Attrs, got {result:?}"),
4583        }
4584    }
4585    #[test]
4586    fn builtin_attr_values() {
4587        let result = eval_full_helper("builtins.attrValues { a = 1; b = 2; }");
4588        match result {
4589            StringKeyedValue::List(items) => {
4590                assert_eq!(items.len(), 2);
4591                assert!(items.contains(&StringKeyedValue::Int(1)));
4592                assert!(items.contains(&StringKeyedValue::Int(2)));
4593            }
4594            _ => panic!("expected List, got {result:?}"),
4595        }
4596    }
4597    #[test]
4598    fn builtin_to_int() {
4599        assert_eq!(eval("builtins.toInt \"42\""), VMValue::Int(42));
4600    }
4601    #[test]
4602    fn builtin_replace_strings() {
4603        assert_eq!(
4604            eval("builtins.replaceStrings [\"o\"] [\"0\"] \"foo\""),
4605            VMValue::String("f00".to_string())
4606        );
4607    }
4608    #[test]
4609    fn builtin_substring() {
4610        assert_eq!(
4611            eval("builtins.substring 1 3 \"hello\""),
4612            VMValue::String("ell".to_string())
4613        );
4614    }
4615    // -- Import tests ---------------------------------------------------
4616    #[test]
4617    fn import_basic() {
4618        let dir = tempfile::tempdir().unwrap();
4619        let file_path = dir.path().join("test.nix");
4620        std::fs::write(&file_path, "42").unwrap();
4621        let nix_expr = format!("import {}", file_path.display());
4622        assert_eq!(eval(&nix_expr), VMValue::Int(42));
4623    }
4624    #[test]
4625    fn import_cached() {
4626        let dir = tempfile::tempdir().unwrap();
4627        let file_path = dir.path().join("cached.nix");
4628        std::fs::write(&file_path, "{ x = 1; }").unwrap();
4629        let nix_expr = format!(
4630            "let a = import {}; b = import {}; in a == b",
4631            file_path.display(),
4632            file_path.display()
4633        );
4634        assert_eq!(eval(&nix_expr), VMValue::Bool(true));
4635    }
4636    #[test]
4637    fn import_attrset() {
4638        let dir = tempfile::tempdir().unwrap();
4639        let file_path = dir.path().join("attrs.nix");
4640        std::fs::write(&file_path, "{ greeting = \"hello\"; }").unwrap();
4641        let nix_expr = format!("(import {}).greeting", file_path.display());
4642        assert_eq!(eval(&nix_expr), VMValue::String("hello".to_string()));
4643    }
4644    #[test]
4645    fn import_directory_default_nix() {
4646        // Importing a directory should resolve to <dir>/default.nix
4647        let dir = tempfile::tempdir().unwrap();
4648        let sub = dir.path().join("mylib");
4649        std::fs::create_dir(&sub).unwrap();
4650        std::fs::write(sub.join("default.nix"), "{ x = 42; }").unwrap();
4651        let nix_expr = format!("(import {}).x", sub.display());
4652        assert_eq!(eval(&nix_expr), VMValue::Int(42));
4653    }
4654    #[test]
4655    fn import_directory_cached() {
4656        // Importing the same directory twice should hit the cache.
4657        let dir = tempfile::tempdir().unwrap();
4658        let sub = dir.path().join("lib");
4659        std::fs::create_dir(&sub).unwrap();
4660        std::fs::write(sub.join("default.nix"), "{ v = 99; }").unwrap();
4661        let nix_expr = format!(
4662            "let a = import {}; b = import {}; in a == b",
4663            sub.display(),
4664            sub.display()
4665        );
4666        assert_eq!(eval(&nix_expr), VMValue::Bool(true));
4667    }
4668    #[test]
4669    fn import_directory_nested() {
4670        // Nested directory imports: lib/default.nix imports sub/default.nix
4671        let dir = tempfile::tempdir().unwrap();
4672        let lib = dir.path().join("lib");
4673        let sub = lib.join("sub");
4674        std::fs::create_dir_all(&sub).unwrap();
4675        std::fs::write(sub.join("default.nix"), "{ val = 7; }").unwrap();
4676        std::fs::write(
4677            lib.join("default.nix"),
4678            &format!("(import {}).val + 3", sub.display()),
4679        )
4680        .unwrap();
4681        let nix_expr = format!("import {}", lib.display());
4682        assert_eq!(eval(&nix_expr), VMValue::Int(10));
4683    }
4684    // -- Lazy evaluation tests ------------------------------------------
4685    #[test]
4686    fn lazy_unused_throw_in_attrset() {
4687        assert_eq!(
4688            eval("let s = { a = 1; }; in s.a"),
4689            VMValue::Int(1)
4690        );
4691    }
4692    #[test]
4693    fn lazy_unused_let_binding() {
4694        assert_eq!(eval("let x = 1; y = 2; in x"), VMValue::Int(1));
4695    }
4696    // -- Import handler tests -------------------------------------------
4697    #[test]
4698    fn import_forces_thunk_before_type_check() {
4699        // The import path is a thunk (non-trivial let binding); the VM
4700        // must force it to a path/string before checking the type.
4701        let dir = tempfile::tempdir().unwrap();
4702        let file_path = dir.path().join("forced.nix");
4703        std::fs::write(&file_path, "99").unwrap();
4704        let nix_expr = format!(
4705            "let p = {}; in import p",
4706            file_path.display()
4707        );
4708        assert_eq!(eval(&nix_expr), VMValue::Int(99));
4709    }
4710    #[test]
4711    fn import_with_path_value_succeeds() {
4712        let dir = tempfile::tempdir().unwrap();
4713        let file_path = dir.path().join("pathval.nix");
4714        std::fs::write(&file_path, "\"from-path\"").unwrap();
4715        let nix_expr = format!("import {}", file_path.display());
4716        assert_eq!(
4717            eval(&nix_expr),
4718            VMValue::String("from-path".to_string())
4719        );
4720    }
4721    #[test]
4722    fn import_with_string_value_succeeds() {
4723        let dir = tempfile::tempdir().unwrap();
4724        let file_path = dir.path().join("strval.nix");
4725        std::fs::write(&file_path, "\"from-string\"").unwrap();
4726        let nix_expr = format!(
4727            "let s = \"{}\"; in import s",
4728            file_path.display()
4729        );
4730        assert_eq!(
4731            eval(&nix_expr),
4732            VMValue::String("from-string".to_string())
4733        );
4734    }
4735    // -- TailCall opcode tests ------------------------------------------
4736    #[test]
4737    fn tail_call_deep_recursion_via_import() {
4738        // Test deep tail-recursive calls via import (self-referencing let
4739        // requires open upvalues, not yet implemented). Writing a recursive
4740        // function to a file and importing it exercises TailCall.
4741        let dir = tempfile::tempdir().unwrap();
4742        let file_path = dir.path().join("countdown.nix");
4743        std::fs::write(
4744            &file_path,
4745            "{ f, n }: if n == 0 then 0 else f { inherit f; n = n - 1; }",
4746        )
4747        .unwrap();
4748        // Use fixpoint pattern: pass function as argument to avoid
4749        // self-referencing let bindings.
4750        let nix_expr = format!(
4751            "let g = import {}; in g {{ f = g; n = 2000; }}",
4752            file_path.display()
4753        );
4754        assert_eq!(eval(&nix_expr), VMValue::Int(0));
4755    }
4756    #[test]
4757    fn tail_call_simple_lambda_chain() {
4758        // Non-recursive tail call: the last call in a lambda body should
4759        // reuse the frame. This verifies TailCall opcode is emitted and
4760        // executed for simple function composition.
4761        assert_eq!(
4762            eval("let g = x: x + 1; f = x: g x; in f 41"),
4763            VMValue::Int(42)
4764        );
4765    }
4766    #[test]
4767    fn tail_call_if_branches() {
4768        // Both if-then and if-else branches should produce tail calls
4769        // when in lambda body. This verifies TailCall works in both branches.
4770        assert_eq!(
4771            eval("let f = x: if x > 0 then x else x + 1; in f 10"),
4772            VMValue::Int(10)
4773        );
4774        assert_eq!(
4775            eval("let f = x: if x > 0 then x else x + 1; in f 0"),
4776            VMValue::Int(1)
4777        );
4778    }
4779    // -- Builtin dispatch tests -----------------------------------------
4780    #[test]
4781    fn builtin_get_env_returns_value() {
4782        // Set a known env var and verify getEnv returns it.
4783        // SAFETY: test runs single-threaded; no concurrent env access.
4784        unsafe { std::env::set_var("SUI_TEST_VAR", "hello_sui") };
4785        assert_eq!(
4786            eval("builtins.getEnv \"SUI_TEST_VAR\""),
4787            VMValue::String("hello_sui".to_string())
4788        );
4789        unsafe { std::env::remove_var("SUI_TEST_VAR") };
4790    }
4791    #[test]
4792    fn builtin_get_env_missing_returns_empty() {
4793        // getEnv with a missing var should return "".
4794        // SAFETY: test runs single-threaded; no concurrent env access.
4795        unsafe { std::env::remove_var("SUI_NONEXISTENT_VAR_12345") };
4796        assert_eq!(
4797            eval("builtins.getEnv \"SUI_NONEXISTENT_VAR_12345\""),
4798            VMValue::String(String::new())
4799        );
4800    }
4801    #[test]
4802    fn builtin_try_eval_success() {
4803        // tryEval with a successful expression returns { success=true; value=result; }.
4804        let result = eval_full_helper("builtins.tryEval 42");
4805        match result {
4806            StringKeyedValue::Attrs(map) => {
4807                assert_eq!(
4808                    map.get("success"),
4809                    Some(&StringKeyedValue::Bool(true))
4810                );
4811                assert_eq!(
4812                    map.get("value"),
4813                    Some(&StringKeyedValue::Int(42))
4814                );
4815            }
4816            _ => panic!("expected Attrs, got {result:?}"),
4817        }
4818    }
4819    #[test]
4820    fn builtin_try_eval_with_non_throwing_expr() {
4821        // tryEval wraps a non-throwing expression — still produces
4822        // { success = true; value = ...; }.
4823        let result = eval_full_helper(
4824            "builtins.tryEval (1 + 2)"
4825        );
4826        match result {
4827            StringKeyedValue::Attrs(map) => {
4828                assert_eq!(
4829                    map.get("success"),
4830                    Some(&StringKeyedValue::Bool(true))
4831                );
4832                assert_eq!(
4833                    map.get("value"),
4834                    Some(&StringKeyedValue::Int(3))
4835                );
4836            }
4837            _ => panic!("expected Attrs, got {result:?}"),
4838        }
4839    }
4840    #[test]
4841    fn builtin_try_eval_with_throw_catches() {
4842        // tryEval CATCHES a throwing expression (nix parity):
4843        // `{ success = false; value = false; }` — verified byte-identical
4844        // to cppnix (`nix eval --json` returns the same). The VM previously
4845        // PROPAGATED the throw (an open-upvalue dispatch limitation); that
4846        // is now fixed, so this test pins the correct catching behavior.
4847        let result = eval_full_helper(
4848            "let bad = builtins.throw \"oops\"; in builtins.tryEval bad"
4849        );
4850        match result {
4851            StringKeyedValue::Attrs(map) => {
4852                assert_eq!(
4853                    map.get("success"),
4854                    Some(&StringKeyedValue::Bool(false))
4855                );
4856                assert_eq!(
4857                    map.get("value"),
4858                    Some(&StringKeyedValue::Bool(false))
4859                );
4860            }
4861            _ => panic!("expected Attrs, got {result:?}"),
4862        }
4863    }
4864    // -- Regression: stack_depth tracking for branches -------------------
4865    #[test]
4866    fn if_else_in_let_body_stack_depth() {
4867        // If/else inside a let body should not corrupt stack_depth for
4868        // subsequent let bindings in an outer scope.
4869        assert_eq!(
4870            eval("let a = 1; in if a == 1 then 10 else 20"),
4871            VMValue::Int(10),
4872        );
4873    }
4874    #[test]
4875    fn nested_let_with_if_else() {
4876        // Inner let after an if/else: the if/else must not drift stack_depth.
4877        assert_eq!(
4878            eval(r#"
4879                let
4880                  a = 1;
4881                  b = if a == 1 then 2 else 3;
4882                in
4883                  let c = b + 10; in c
4884            "#),
4885            VMValue::Int(12),
4886        );
4887    }
4888    #[test]
4889    fn short_circuit_and_in_let_body() {
4890        // Short-circuit && inside a let body must track stack_depth correctly.
4891        assert_eq!(
4892            eval("let x = true; in x && false"),
4893            VMValue::Bool(false),
4894        );
4895    }
4896    #[test]
4897    fn short_circuit_or_in_let_body() {
4898        assert_eq!(
4899            eval("let x = false; in x || true"),
4900            VMValue::Bool(true),
4901        );
4902    }
4903    #[test]
4904    fn short_circuit_implication_in_let_body() {
4905        // a -> b is !a || b. false -> anything is true.
4906        assert_eq!(
4907            eval("let x = false; in x -> 42"),
4908            VMValue::Bool(true),
4909        );
4910    }
4911    #[test]
4912    fn inherit_from_in_attrset_stack_depth() {
4913        // inherit (source) in non-rec attrset must track stack_depth for
4914        // MakeThunk. This was the missing `stack_depth += 1` bug.
4915        assert_eq!(
4916            eval(r#"
4917                let
4918                  src = { a = 1; b = 2; };
4919                  result = { inherit (src) a b; c = 3; };
4920                in result.a + result.b + result.c
4921            "#),
4922            VMValue::Int(6),
4923        );
4924    }
4925    #[test]
4926    fn inherit_from_many_fields_stack_depth() {
4927        // Multiple inherit-from fields: each one was missing +1,
4928        // so stack_depth would drift further with each field.
4929        assert_eq!(
4930            eval(r#"
4931                let
4932                  s = { w = 1; x = 2; y = 3; z = 4; };
4933                  r = { inherit (s) w x y z; extra = 10; };
4934                in r.w + r.x + r.y + r.z + r.extra
4935            "#),
4936            VMValue::Int(20),
4937        );
4938    }
4939    #[test]
4940    fn if_else_followed_by_let_binding() {
4941        // The if/else result is used in a subsequent let binding.
4942        // Before the fix, the stack_depth drift from if/else would cause
4943        // the next binding's slot to be off.
4944        assert_eq!(
4945            eval(r#"
4946                let
4947                  a = 1;
4948                  b = 2;
4949                  c = 3;
4950                in
4951                  let
4952                    x = if a == 1 then b else c;
4953                    y = x + 100;
4954                  in y
4955            "#),
4956            VMValue::Int(102),
4957        );
4958    }
4959    #[test]
4960    fn multi_segment_hasattr_stack_depth() {
4961        // Multi-segment hasattr with short-circuit jumps must track
4962        // stack_depth correctly at branch merge points.
4963        assert_eq!(
4964            eval(r#"
4965                let
4966                  s = { a = { b = 1; }; };
4967                  has = s ? a.b;
4968                  val = if has then 42 else 0;
4969                in val
4970            "#),
4971            VMValue::Int(42),
4972        );
4973    }
4974    #[test]
4975    fn many_let_bindings_with_if_else() {
4976        // Stress test: many let bindings where some RHS contain if/else.
4977        // Before the stack_depth fix, the drift would accumulate and
4978        // eventually cause a GetLocal slot mismatch.
4979        assert_eq!(
4980            eval(r#"
4981                let
4982                  a = 1;
4983                  b = 2;
4984                  c = 3;
4985                  d = 4;
4986                  e = 5;
4987                  f = 6;
4988                  g = 7;
4989                  h = 8;
4990                  i = 9;
4991                  j = 10;
4992                in
4993                  let
4994                    x = if a == 1 then b else c;
4995                    y = if d == 4 then e else f;
4996                    z = if g == 7 then h else i;
4997                    w = j;
4998                  in x + y + z + w
4999            "#),
5000            VMValue::Int(25),
5001        );
5002    }
5003    #[test]
5004    fn import_in_pattern_default_stack_depth() {
5005        // The Import opcode is net 0 on the stack (pop path, push result).
5006        // Before the fix, it was tracked as +1, causing stack_depth drift
5007        // in pattern default expressions like `{ stdenvStages ? import ../stdenv, ... }`.
5008        // This test uses a pattern lambda with a default that involves a
5009        // function call (which compiles similarly to import + call).
5010        assert_eq!(
5011            eval(r#"
5012                let
5013                  f = { a ? 1, b ? 2, c ? 3 }:
5014                    a + b + c;
5015                in f {}
5016            "#),
5017            VMValue::Int(6),
5018        );
5019    }
5020    #[test]
5021    fn pattern_lambda_many_defaults_then_let() {
5022        // Pattern lambda with many defaults followed by let bindings.
5023        // This is the pattern that triggered the original nixpkgs bug:
5024        // { a, b ? x, c ? y, ... }: let ... in expr
5025        // The import stack_depth bug caused slots to drift by 1 for each
5026        // default expression that used import.
5027        assert_eq!(
5028            eval(r#"
5029                let
5030                  mk = { a, b ? 10, c ? 20, d ? 30, e ? 40 }:
5031                    let
5032                      sum = a + b + c + d + e;
5033                      doubled = sum + sum;
5034                    in doubled;
5035                in mk { a = 1; }
5036            "#),
5037            VMValue::Int(202),
5038        );
5039    }
5040    // -- Blocker #13: dotted attrs + lambda closure in rec ------------------
5041    #[test]
5042    fn rec_dotted_lambda_captures_sibling() {
5043        // Lambdas in rec attrsets must not be compiled as trivial values,
5044        // because MakeClosure captures upvalues eagerly.  Dotted entries
5045        // are appended after non-dotted bindings, so a lambda's upvalue
5046        // for a dotted sibling would see the null placeholder.
5047        let result = eval_full_helper(
5048            r#"rec { types.a = 1; types.b = 2; f = _: types; }.f 0"#,
5049        );
5050        match result {
5051            StringKeyedValue::Attrs(ref m) => {
5052                assert_eq!(m.get("a"), Some(&StringKeyedValue::Int(1)));
5053                assert_eq!(m.get("b"), Some(&StringKeyedValue::Int(2)));
5054            }
5055            other => panic!("expected attrset, got {other:?}"),
5056        }
5057    }
5058    #[test]
5059    fn rec_dotted_lambda_attr_select() {
5060        // Lambda body selects an attribute from a dotted sibling.
5061        assert_eq!(
5062            eval(r#"rec { types.a = 1; types.b = 2; f = x: types.b; result = f 0; }.result"#),
5063            VMValue::Int(2),
5064        );
5065    }
5066    #[test]
5067    fn rec_dotted_lambda_assert_check() {
5068        // Pattern from nixpkgs parse.nix: `mkSystem` uses
5069        //   assert types.parsedPlatform.check components; ...
5070        // which requires `types` to be resolved inside a lambda body.
5071        assert_eq!(
5072            eval(r#"
5073                rec {
5074                    types.parsedPlatform = { check = _: true; };
5075                    mkSystem = components:
5076                        assert types.parsedPlatform.check components;
5077                        components;
5078                    result = mkSystem 42;
5079                }.result
5080            "#),
5081            VMValue::Int(42),
5082        );
5083    }
5084    #[test]
5085    fn let_lambda_captures_rec_sibling() {
5086        // Let bindings are recursive — lambdas capturing siblings must
5087        // also use deferred thunks.
5088        assert_eq!(
5089            eval(r#"let a = 1 + 1; f = _: a; in f 0"#),
5090            VMValue::Int(2),
5091        );
5092    }
5093    #[test]
5094    fn rec_dotted_multiple_lambdas() {
5095        // Multiple lambdas capturing different dotted siblings.
5096        assert_eq!(
5097            eval(r#"
5098                rec {
5099                    a.x = 10;
5100                    b.y = 20;
5101                    f = _: a.x + b.y;
5102                    result = f 0;
5103                }.result
5104            "#),
5105            VMValue::Int(30),
5106        );
5107    }
5108}