Skip to main content

sui_bytecode/
builtins.rs

1//! Built-in function registry for the bytecode VM.
2//!
3//! Implements Nix builtins natively in the VM value system. These are
4//! the core builtins needed for nixpkgs evaluation. Each builtin is
5//! registered by name and index, and can be called via the `CallBuiltin`
6//! opcode or through the `builtins` attrset.
7//!
8//! Curried builtins (e.g., `map f list`) return a `VMBuiltin` partial
9//! application on the first call, then complete on the second.
10
11use std::cell::Cell;
12use std::collections::BTreeMap;
13use std::rc::Rc;
14
15use crate::error::VMError;
16use crate::intern::{Interner, Symbol};
17use crate::value::{HigherOrderBuiltin, HigherOrderOp, ThunkState, VMBuiltin, VMValue};
18
19/// Registry of builtin functions accessible from the VM.
20pub struct BuiltinRegistry {
21    /// Builtins indexed by name.
22    entries: Vec<BuiltinEntry>,
23}
24
25struct BuiltinEntry {
26    name: &'static str,
27    func: Rc<dyn Fn(Vec<VMValue>) -> Result<VMValue, VMError>>,
28    arity: u8,
29}
30
31impl BuiltinRegistry {
32    /// Create a new registry with all Nix builtins registered.
33    #[must_use]
34    pub fn new() -> Self {
35        let mut reg = Self {
36            entries: Vec::new(),
37        };
38        reg.register_all();
39        reg
40    }
41
42    /// Look up a builtin by name, returning its index.
43    #[must_use]
44    pub fn lookup(&self, name: &str) -> Option<u16> {
45        self.entries
46            .iter()
47            .position(|e| e.name == name)
48            .map(|i| i as u16)
49    }
50
51    /// Call a builtin by index.
52    pub fn call(&self, index: u16, args: Vec<VMValue>) -> Result<VMValue, VMError> {
53        let entry = self
54            .entries
55            .get(index as usize)
56            .ok_or_else(|| VMError::UnknownBuiltin(format!("index {index}")))?;
57        (entry.func)(args)
58    }
59
60    /// Build the `builtins` attribute set with all registered builtins.
61    pub fn make_builtins_attrset(&self, interner: &mut Interner) -> VMValue {
62        let mut attrs = BTreeMap::new();
63        for (i, entry) in self.entries.iter().enumerate() {
64            let sym = interner.intern(entry.name);
65            let builtin = VMValue::Builtin(VMBuiltin {
66                name: entry.name,
67                func: Rc::clone(&entry.func),
68                arity: entry.arity,
69            });
70            let _ = i;
71            attrs.insert(sym, builtin);
72        }
73
74        // Add builtins.currentSystem
75        let sys_sym = interner.intern("currentSystem");
76        let system = if cfg!(target_arch = "aarch64") {
77            if cfg!(target_os = "macos") {
78                "aarch64-darwin"
79            } else {
80                "aarch64-linux"
81            }
82        } else if cfg!(target_os = "macos") {
83            "x86_64-darwin"
84        } else {
85            "x86_64-linux"
86        };
87        attrs.insert(sys_sym, VMValue::String(system.to_string()));
88
89        // Add builtins.nixVersion.
90        //
91        // This literal was "2.24.0" while the tree-walker and sui-ir both said
92        // "2.34.7" — the VM was the arm left behind when the impersonation
93        // target was corrected. Because nixpkgs feature-gates on
94        // `lib.versionAtLeast builtins.nixVersion X`, the VM took the wrong
95        // branch of every such gate and silently evaluated a different
96        // derivation graph than the walker. Now derived, so it cannot happen
97        // again in either direction.
98        let ver_sym = interner.intern("nixVersion");
99        attrs.insert(
100            ver_sym,
101            VMValue::String(sui_compat::versions::IMPERSONATED_NIX_VERSION.to_string()),
102        );
103
104        // Add builtins.langVersion
105        let lang_sym = interner.intern("langVersion");
106        attrs.insert(lang_sym, VMValue::Int(sui_compat::versions::LANG_VERSION));
107
108        // Add builtins.true / builtins.false / builtins.null
109        let true_sym = interner.intern("true");
110        attrs.insert(true_sym, VMValue::Bool(true));
111        let false_sym = interner.intern("false");
112        attrs.insert(false_sym, VMValue::Bool(false));
113        let null_sym = interner.intern("null");
114        attrs.insert(null_sym, VMValue::Null);
115
116        // Add builtins.storeDir
117        let store_sym = interner.intern("storeDir");
118        attrs.insert(store_sym, VMValue::String("/nix/store".to_string()));
119
120        // Add builtins.nixPath from NIX_PATH environment variable
121        let nixpath_sym = interner.intern("nixPath");
122        let nix_path_list = {
123            let nix_path = std::env::var("NIX_PATH").unwrap_or_default();
124            let entries: Vec<VMValue> = nix_path
125                .split(':')
126                .filter(|s| !s.is_empty())
127                .map(|entry| {
128                    let (prefix, path) = if let Some(idx) = entry.find('=') {
129                        (entry[..idx].to_string(), entry[idx + 1..].to_string())
130                    } else {
131                        (String::new(), entry.to_string())
132                    };
133                    let prefix_sym = interner.intern("prefix");
134                    let path_sym = interner.intern("path");
135                    let mut entry_attrs = BTreeMap::new();
136                    entry_attrs.insert(prefix_sym, VMValue::String(prefix));
137                    entry_attrs.insert(path_sym, VMValue::String(path));
138                    VMValue::Attrs(entry_attrs)
139                })
140                .collect();
141            VMValue::List(entries)
142        };
143        attrs.insert(nixpath_sym, nix_path_list);
144
145        // Add builtins.currentTime (0 in pure eval mode)
146        let time_sym = interner.intern("currentTime");
147        attrs.insert(time_sym, VMValue::Int(0));
148
149        // `builtins.builtins` — nix's `builtins` attrset contains ITSELF (it is
150        // what `scopedImport`'s injected scope is built from, and `lib` probes
151        // it), so `builtins ? builtins` is TRUE on nix and on the tree-walker.
152        // It answered FALSE here.
153        //
154        // Nix's is infinitely self-referential; this is ONE level deep, because
155        // the VM's `builtins` is rebuilt eagerly on every `PushBuiltins` and a
156        // truly cyclic value would not terminate. One level answers every shape
157        // observed in the wild (`builtins ? builtins`, `builtins.builtins.X`);
158        // `builtins.builtins.builtins` is the honest remaining gap.
159        let self_sym = interner.intern("builtins");
160        let inner = attrs.clone();
161        attrs.insert(self_sym, VMValue::Attrs(inner));
162
163        VMValue::Attrs(attrs)
164    }
165
166    /// Get the name of a builtin by index.
167    #[must_use]
168    pub fn name(&self, index: u16) -> Option<&'static str> {
169        self.entries.get(index as usize).map(|e| e.name)
170    }
171
172    fn register(
173        &mut self,
174        name: &'static str,
175        arity: u8,
176        func: impl Fn(Vec<VMValue>) -> Result<VMValue, VMError> + 'static,
177    ) {
178        self.entries.push(BuiltinEntry {
179            name,
180            func: Rc::new(func),
181            arity,
182        });
183    }
184
185    fn register_all(&mut self) {
186        self.register_type_checks();
187        self.register_list_ops();
188        self.register_higher_order_ops();
189        self.register_attrset_ops();
190        self.register_string_ops();
191        self.register_conversion_ops();
192        self.register_control_ops();
193        self.register_arithmetic_ops();
194        self.register_derivation_ops();
195        self.register_missing_builtins();
196    }
197
198    // ── Type checking ─────────────────────────────────────────────
199
200    fn register_type_checks(&mut self) {
201        self.register("typeOf", 1, |args| {
202            let name = match &args[0] {
203                VMValue::Null => "null",
204                VMValue::Bool(_) => "bool",
205                VMValue::Int(_) => "int",
206                VMValue::Float(_) => "float",
207                VMValue::String(_) => "string",
208                VMValue::Path(_) => "path",
209                VMValue::List(_) => "list",
210                VMValue::Attrs(_) => "set",
211                VMValue::Closure(_) | VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => "lambda",
212                VMValue::Thunk(_) => "thunk",
213            };
214            Ok(VMValue::String(name.to_string()))
215        });
216        self.register("isNull", 1, |args| {
217            Ok(VMValue::Bool(matches!(args[0], VMValue::Null)))
218        });
219        self.register("isInt", 1, |args| {
220            Ok(VMValue::Bool(matches!(args[0], VMValue::Int(_))))
221        });
222        self.register("isFloat", 1, |args| {
223            Ok(VMValue::Bool(matches!(args[0], VMValue::Float(_))))
224        });
225        self.register("isBool", 1, |args| {
226            Ok(VMValue::Bool(matches!(args[0], VMValue::Bool(_))))
227        });
228        self.register("isString", 1, |args| {
229            Ok(VMValue::Bool(matches!(args[0], VMValue::String(_))))
230        });
231        self.register("isList", 1, |args| {
232            Ok(VMValue::Bool(matches!(args[0], VMValue::List(_))))
233        });
234        self.register("isAttrs", 1, |args| {
235            Ok(VMValue::Bool(matches!(args[0], VMValue::Attrs(_))))
236        });
237        self.register("isFunction", 1, |args| {
238            Ok(VMValue::Bool(matches!(
239                args[0],
240                VMValue::Closure(_) | VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_)
241            )))
242        });
243        self.register("isPath", 1, |args| {
244            Ok(VMValue::Bool(matches!(args[0], VMValue::Path(_))))
245        });
246    }
247
248    // ── List operations ───────────────────────────────────────────
249
250    fn register_list_ops(&mut self) {
251        self.register("length", 1, |args| {
252            let list = as_list(&args[0])?;
253            Ok(VMValue::Int(list.len() as i64))
254        });
255
256        self.register("head", 1, |args| {
257            let list = as_list(&args[0])?;
258            list.first()
259                .cloned()
260                .ok_or_else(|| VMError::Throw("head: empty list".to_string()))
261        });
262
263        self.register("tail", 1, |args| {
264            let list = as_list(&args[0])?;
265            if list.is_empty() {
266                return Err(VMError::Throw("tail: empty list".to_string()));
267            }
268            Ok(VMValue::List(list[1..].to_vec()))
269        });
270
271        self.register("elemAt", 1, |args| {
272            let list = as_list(&args[0])?.to_vec();
273            Ok(VMValue::Builtin(VMBuiltin {
274                name: "elemAt<partial>",
275                func: Rc::new(move |args2| {
276                    let idx = as_int(&args2[0])? as usize;
277                    list.get(idx).cloned().ok_or_else(|| {
278                        VMError::Throw(format!("elemAt: index {idx} out of bounds"))
279                    })
280                }),
281                arity: 1,
282            }))
283        });
284
285        self.register("elem", 1, |args| {
286            let needle = args[0].clone();
287            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
288                op: HigherOrderOp::Elem,
289                func: Box::new(needle),
290                extra_args: Vec::new(),
291            }))
292        });
293
294        self.register("genList", 1, |args| {
295            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
296                op: HigherOrderOp::GenList,
297                func: Box::new(args[0].clone()),
298                extra_args: Vec::new(),
299            }))
300        });
301
302        // map: curried, returns partial (VM handles closure calling)
303        self.register("map", 1, |args| {
304            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
305                op: HigherOrderOp::Map,
306                func: Box::new(args[0].clone()),
307                extra_args: Vec::new(),
308            }))
309        });
310
311        // filter: curried, returns partial (VM handles closure calling)
312        self.register("filter", 1, |args| {
313            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
314                op: HigherOrderOp::Filter,
315                func: Box::new(args[0].clone()),
316                extra_args: Vec::new(),
317            }))
318        });
319
320        self.register("concatLists", 1, |args| {
321            let lists = as_list(&args[0])?;
322            let mut result = Vec::new();
323            for v in &lists {
324                let inner = as_list(v)?;
325                result.extend(inner);
326            }
327            Ok(VMValue::List(result))
328        });
329
330        self.register("sort", 1, |args| {
331            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
332                op: HigherOrderOp::Sort,
333                func: Box::new(args[0].clone()),
334                extra_args: Vec::new(),
335            }))
336        });
337    }
338
339
340    // ── Higher-order operations (need VM access) ─────────────────
341
342    fn register_higher_order_ops(&mut self) {
343        self.register("foldl'", 1, |args| {
344            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
345                op: HigherOrderOp::FoldlP1,
346                func: Box::new(args[0].clone()),
347                extra_args: Vec::new(),
348            }))
349        });
350        self.register("concatMap", 1, |args| {
351            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
352                op: HigherOrderOp::ConcatMap,
353                func: Box::new(args[0].clone()),
354                extra_args: Vec::new(),
355            }))
356        });
357        self.register("any", 1, |args| {
358            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
359                op: HigherOrderOp::Any,
360                func: Box::new(args[0].clone()),
361                extra_args: Vec::new(),
362            }))
363        });
364        self.register("all", 1, |args| {
365            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
366                op: HigherOrderOp::All,
367                func: Box::new(args[0].clone()),
368                extra_args: Vec::new(),
369            }))
370        });
371        self.register("partition", 1, |args| {
372            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
373                op: HigherOrderOp::Partition,
374                func: Box::new(args[0].clone()),
375                extra_args: Vec::new(),
376            }))
377        });
378        self.register("groupBy", 1, |args| {
379            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
380                op: HigherOrderOp::GroupBy,
381                func: Box::new(args[0].clone()),
382                extra_args: Vec::new(),
383            }))
384        });
385        self.register("mapAttrs", 1, |args| {
386            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
387                op: HigherOrderOp::MapAttrs,
388                func: Box::new(args[0].clone()),
389                extra_args: Vec::new(),
390            }))
391        });
392        // `filterAttrs` was registered here and is GONE — it is nixpkgs
393        // `lib.attrsets.filterAttrs`, not a CppNix builtin at any feature
394        // level. nixpkgs feature-detects with `builtins ? filterAttrs`, so
395        // exposing it silently steered nixpkgs down a different branch than
396        // real nix takes. See sui-eval/tests/fixtures/BUILTIN-REGISTRY.json.
397        self.register("functionArgs", 1, |args| {
398            match &args[0] {
399                VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
400                    Ok(VMValue::Attrs(BTreeMap::new()))
401                }
402                VMValue::Closure(closure) => {
403                    let mut result = BTreeMap::new();
404                    let mut interner = crate::intern::Interner::new();
405                    for (name, has_default) in &closure.formals {
406                        let sym = interner.intern(name);
407                        result.insert(sym, VMValue::Bool(*has_default));
408                    }
409                    Ok(VMValue::Attrs(result))
410                }
411                other => Err(VMError::TypeError {
412                    expected: "lambda",
413                    got: other.type_name(),
414                    context: "functionArgs".to_string(),
415                }),
416            }
417        });
418        self.register("catAttrs", 1, |args| {
419            let name = as_string(&args[0])?.to_string();
420            Ok(VMValue::Builtin(VMBuiltin {
421                name: "catAttrs<partial>",
422                func: Rc::new(move |args2| {
423                    let list = force_as_list(&args2[0])?;
424                    let mut result: Vec<VMValue> = Vec::new();
425                    for item in &list {
426                        if let VMValue::Attrs(_) = item {
427                            let _ = &name;
428                        }
429                    }
430                    Err(VMError::Throw(
431                        "catAttrs: requires interner access (use VM dispatch)".to_string(),
432                    ))
433                }),
434                arity: 1,
435            }))
436        });
437    }
438
439    // ── Attrset operations ────────────────────────────────────────
440
441    fn register_attrset_ops(&mut self) {
442        self.register("attrNames", 1, |args| {
443            let attrs = as_attrs(&args[0])?;
444            // Note: we don't have the interner here, so we can't resolve
445            // Symbol keys. This builtin must be called through the VM
446            // which resolves symbols. For now, this is a placeholder.
447            let _ = attrs;
448            Err(VMError::Throw(
449                "attrNames: requires interner access (use VM dispatch)".to_string(),
450            ))
451        });
452
453        // attrValues needs the VM's interner to resolve Symbol keys to
454        // their string names for lex-sorting — which is what CppNix
455        // semantics require. Symbol itself is an intern-order u32,
456        // NOT a lex-sorted key, so BTreeMap's native iteration is
457        // intern-order and wrong whenever any transitive eval
458        // (e.g. nixpkgs/lib) has interned a "later" key before an
459        // "earlier" one. Route through VM dispatch the same way
460        // attrNames does. Placeholder error that the VM recognizes.
461        //
462        // Discovered while probing sui against real nixpkgs:
463        // `(import <nixpkgs>/lib).attrsets.mapAttrsToList
464        //    (n: v: "${n}=${toString v}") { a = 1; b = 2; }`
465        // returned `[ "b=2" "a=1" ]` instead of `[ "a=1" "b=2" ]`.
466        self.register("attrValues", 1, |_args| {
467            Err(VMError::Throw(
468                "attrValues: requires interner access (use VM dispatch)".to_string(),
469            ))
470        });
471
472        self.register("hasAttr", 1, |args| {
473            let name = as_string(&args[0])?.to_string();
474            Ok(VMValue::Builtin(VMBuiltin {
475                name: "hasAttr<partial>",
476                func: Rc::new(move |_args2| {
477                    // Needs interner to resolve the name to a Symbol.
478                    let _ = &name;
479                    Err(VMError::Throw(
480                        "hasAttr: requires interner access (use VM dispatch)".to_string(),
481                    ))
482                }),
483                arity: 1,
484            }))
485        });
486
487        self.register("getAttr", 1, |args| {
488            let name = as_string(&args[0])?.to_string();
489            Ok(VMValue::Builtin(VMBuiltin {
490                name: "getAttr<partial>",
491                func: Rc::new(move |_args2| {
492                    let _ = &name;
493                    Err(VMError::Throw(
494                        "getAttr: requires interner access (use VM dispatch)".to_string(),
495                    ))
496                }),
497                arity: 1,
498            }))
499        });
500
501        self.register("intersectAttrs", 1, |args| {
502            let a_attrs = as_attrs(&args[0])?.clone();
503            Ok(VMValue::Builtin(VMBuiltin {
504                name: "intersectAttrs<partial>",
505                func: Rc::new(move |args2| {
506                    let b_attrs = as_attrs(&args2[0])?;
507                    let mut result = BTreeMap::new();
508                    for (k, v) in b_attrs {
509                        if a_attrs.contains_key(k) {
510                            result.insert(*k, v.clone());
511                        }
512                    }
513                    Ok(VMValue::Attrs(result))
514                }),
515                arity: 1,
516            }))
517        });
518
519        self.register("removeAttrs", 1, |args| {
520            let set = as_attrs(&args[0])?.clone();
521            Ok(VMValue::Builtin(VMBuiltin {
522                name: "removeAttrs<partial>",
523                func: Rc::new(move |_args2| {
524                    // Needs interner for name resolution
525                    let _ = &set;
526                    Err(VMError::Throw(
527                        "removeAttrs: requires interner access".to_string(),
528                    ))
529                }),
530                arity: 1,
531            }))
532        });
533
534        self.register("listToAttrs", 1, |_args| {
535            Err(VMError::Throw(
536                "listToAttrs: requires interner access".to_string(),
537            ))
538        });
539    }
540
541    // ── String operations ─────────────────────────────────────────
542
543    fn register_string_ops(&mut self) {
544        self.register("stringLength", 1, |args| {
545            let s = as_string(&args[0])?;
546            Ok(VMValue::Int(s.len() as i64))
547        });
548
549        self.register("substring", 1, |args| {
550            // CppNix semantics (verified against 2.33):
551            //   - negative `len` means "to end of string"
552            //   - negative `start` yields empty string
553            //   - out-of-range start clamps; out-of-range end clamps
554            //
555            // sui's VM was previously casting `i64 as usize` immediately,
556            // which turned `-1` (a common CppNix convention for "rest of
557            // string", used by lib.strings.removePrefix) into usize::MAX
558            // and panicked with "begin <= end" on the arithmetic overflow.
559            // Discovered while probing `(import <nixpkgs>/lib).strings
560            //   .removePrefix "foo-" "foo-bar"` — fifth silent/loud bug
561            // of the session.
562            let start_i = as_int(&args[0])?;
563            Ok(VMValue::Builtin(VMBuiltin {
564                name: "substring<p1>",
565                func: Rc::new(move |args2| {
566                    let len_i = as_int(&args2[0])?;
567                    Ok(VMValue::Builtin(VMBuiltin {
568                        name: "substring<p2>",
569                        func: Rc::new(move |args3| {
570                            let s = as_string(&args3[0])?;
571                            if start_i < 0 {
572                                return Err(VMError::Throw(
573                                    "substring: negative start position".to_string(),
574                                ));
575                            }
576                            let s_len = s.len();
577                            let start = (start_i as usize).min(s_len);
578                            let end = if len_i < 0 {
579                                s_len
580                            } else {
581                                start.saturating_add(len_i as usize).min(s_len)
582                            };
583                            Ok(VMValue::String(s[start..end].to_string()))
584                        }),
585                        arity: 1,
586                    }))
587                }),
588                arity: 1,
589            }))
590        });
591
592        self.register("concatStringsSep", 1, |args| {
593            let sep = as_string(&args[0])?.to_string();
594            Ok(VMValue::Builtin(VMBuiltin {
595                name: "concatStringsSep<partial>",
596                func: Rc::new(move |args2| {
597                    let list = force_as_list(&args2[0])?;
598                    let strings: Result<Vec<String>, _> =
599                        list.iter().map(|v| force_as_string(v)).collect();
600                    Ok(VMValue::String(strings?.join(&sep)))
601                }),
602                arity: 1,
603            }))
604        });
605
606        self.register("replaceStrings", 1, |args| {
607            let from: Vec<String> = force_as_list(&args[0])?
608                .iter()
609                .map(|v| force_as_string(v))
610                .collect::<Result<_, _>>()?;
611            Ok(VMValue::Builtin(VMBuiltin {
612                name: "replaceStrings<p1>",
613                func: Rc::new(move |args2| {
614                    let to: Vec<String> = force_as_list(&args2[0])?
615                        .iter()
616                        .map(|v| force_as_string(v))
617                        .collect::<Result<_, _>>()?;
618                    let from2 = from.clone();
619                    Ok(VMValue::Builtin(VMBuiltin {
620                        name: "replaceStrings<p2>",
621                        func: Rc::new(move |args3| {
622                            let mut s = as_string(&args3[0])?.to_string();
623                            for (f, t) in from2.iter().zip(to.iter()) {
624                                if !f.is_empty() {
625                                    s = s.replace(f.as_str(), t);
626                                }
627                            }
628                            Ok(VMValue::String(s))
629                        }),
630                        arity: 1,
631                    }))
632                }),
633                arity: 1,
634            }))
635        });
636
637        // `hasPrefix` / `hasSuffix` / `toLower` / `toUpper` were registered
638        // here and are GONE — all four are nixpkgs `lib.strings` functions,
639        // not CppNix builtins at any feature level (verified absent from nix
640        // 2.31.5 with every experimental feature enabled). The VM must not be
641        // more permissive than nix any more than the walker may be.
642    }
643
644    // ── Conversion operations ─────────────────────────────────────
645
646    fn register_conversion_ops(&mut self) {
647        self.register("toString", 1, |args| {
648            vm_coerce_to_string(&args[0])
649        });
650
651        self.register("toJSON", 1, |args| {
652            let json = vm_value_to_json(&args[0])?;
653            let s = serde_json::to_string(&json)
654                .unwrap_or_else(|_| "null".to_string());
655            Ok(VMValue::String(s))
656        });
657
658        self.register("fromJSON", 1, |args| {
659            let s = as_string(&args[0])?;
660            let json: serde_json::Value = serde_json::from_str(s).map_err(|e| {
661                VMError::Throw(format!("fromJSON: {e}"))
662            })?;
663            Ok(json_to_vm_value(&json))
664        });
665
666        self.register("toInt", 1, |args| {
667            let s = as_string(&args[0])?;
668            let n: i64 = s.trim().parse().map_err(|e| {
669                VMError::Throw(format!("toInt: {e}"))
670            })?;
671            Ok(VMValue::Int(n))
672        });
673    }
674
675    // ── Control flow ──────────────────────────────────────────────
676
677    fn register_control_ops(&mut self) {
678        self.register("throw", 1, |args| {
679            let msg = as_string(&args[0])?;
680            Err(VMError::Throw(format!("throw: {msg}")))
681        });
682
683        self.register("abort", 1, |args| {
684            let msg = as_string(&args[0])?;
685            Err(VMError::Throw(format!("abort: {msg}")))
686        });
687
688        self.register("seq", 1, |args| {
689            let _forced = args[0].clone();
690            Ok(VMValue::Builtin(VMBuiltin {
691                name: "seq<partial>",
692                func: Rc::new(|args2| Ok(args2[0].clone())),
693                arity: 1,
694            }))
695        });
696
697        self.register("deepSeq", 1, |args| {
698            let _forced = args[0].clone();
699            Ok(VMValue::Builtin(VMBuiltin {
700                name: "deepSeq<partial>",
701                func: Rc::new(|args2| Ok(args2[0].clone())),
702                arity: 1,
703            }))
704        });
705
706        self.register("tryEval", 1, |args| {
707            // In the VM, tryEval just wraps the value since we don't
708            // have thunk forcing here. The VM handles the actual try/catch.
709            let val = args[0].clone();
710            // We can't actually catch throws here without interner access.
711            // Return success with the value for now.
712            // The VM will handle this specially.
713            let _ = val;
714            Err(VMError::Throw(
715                "tryEval: requires VM-level implementation".to_string(),
716            ))
717        });
718
719        self.register("trace", 1, |args| {
720            let msg = args[0].clone();
721            eprintln!("trace: {msg}");
722            Ok(VMValue::Builtin(VMBuiltin {
723                name: "trace<partial>",
724                func: Rc::new(|args2| Ok(args2[0].clone())),
725                arity: 1,
726            }))
727        });
728    }
729
730    // ── Arithmetic ────────────────────────────────────────────────
731
732    fn register_arithmetic_ops(&mut self) {
733        self.register("add", 1, |args| {
734            let a = args[0].clone();
735            Ok(VMValue::Builtin(VMBuiltin {
736                name: "add<partial>",
737                func: Rc::new(move |args2| match (&a, &args2[0]) {
738                    (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Int(x + y)),
739                    (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Float(x + y)),
740                    (VMValue::Int(x), VMValue::Float(y)) => Ok(VMValue::Float(*x as f64 + y)),
741                    (VMValue::Float(x), VMValue::Int(y)) => Ok(VMValue::Float(x + *y as f64)),
742                    _ => Err(VMError::Throw("add: expected numbers".to_string())),
743                }),
744                arity: 1,
745            }))
746        });
747
748        // sub/mul/div accept mixed Int+Float operands (CppNix
749        // semantics).  Previously int-only, which diverged on
750        // `builtins.div 10.0 3.0` and similar mixed expressions.
751        self.register("sub", 1, |args| {
752            let a = args[0].clone();
753            Ok(VMValue::Builtin(VMBuiltin {
754                name: "sub<partial>",
755                func: Rc::new(move |args2| vm_numeric_binop("sub", &a, &args2[0], |x, y| x - y, |x, y| x - y)),
756                arity: 1,
757            }))
758        });
759
760        self.register("mul", 1, |args| {
761            let a = args[0].clone();
762            Ok(VMValue::Builtin(VMBuiltin {
763                name: "mul<partial>",
764                func: Rc::new(move |args2| vm_numeric_binop("mul", &a, &args2[0], |x, y| x * y, |x, y| x * y)),
765                arity: 1,
766            }))
767        });
768
769        self.register("div", 1, |args| {
770            let a = args[0].clone();
771            Ok(VMValue::Builtin(VMBuiltin {
772                name: "div<partial>",
773                func: Rc::new(move |args2| match (&a, &args2[0]) {
774                    (VMValue::Int(_), VMValue::Int(0)) => Err(VMError::DivisionByZero),
775                    (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Int(x / y)),
776                    (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Float(x / y)),
777                    (VMValue::Int(x), VMValue::Float(y)) => Ok(VMValue::Float(*x as f64 / *y)),
778                    (VMValue::Float(x), VMValue::Int(y)) => Ok(VMValue::Float(*x / *y as f64)),
779                    _ => Err(VMError::Throw("div: expected numbers".to_string())),
780                }),
781                arity: 1,
782            }))
783        });
784
785        self.register("ceil", 1, |args| {
786            let f = as_float(&args[0])?;
787            Ok(VMValue::Int(f.ceil() as i64))
788        });
789
790        self.register("floor", 1, |args| {
791            let f = as_float(&args[0])?;
792            Ok(VMValue::Int(f.floor() as i64))
793        });
794
795        self.register("bitAnd", 1, |args| {
796            let a = as_int(&args[0])?;
797            Ok(VMValue::Builtin(VMBuiltin {
798                name: "bitAnd<partial>",
799                func: Rc::new(move |args2| {
800                    let b = as_int(&args2[0])?;
801                    Ok(VMValue::Int(a & b))
802                }),
803                arity: 1,
804            }))
805        });
806
807        self.register("bitOr", 1, |args| {
808            let a = as_int(&args[0])?;
809            Ok(VMValue::Builtin(VMBuiltin {
810                name: "bitOr<partial>",
811                func: Rc::new(move |args2| {
812                    let b = as_int(&args2[0])?;
813                    Ok(VMValue::Int(a | b))
814                }),
815                arity: 1,
816            }))
817        });
818
819        self.register("bitXor", 1, |args| {
820            let a = as_int(&args[0])?;
821            Ok(VMValue::Builtin(VMBuiltin {
822                name: "bitXor<partial>",
823                func: Rc::new(move |args2| {
824                    let b = as_int(&args2[0])?;
825                    Ok(VMValue::Int(a ^ b))
826                }),
827                arity: 1,
828            }))
829        });
830    }
831
832    // ── Derivation ────────────────────────────────────────────────
833
834    fn register_derivation_ops(&mut self) {
835        // Both `derivation` and `derivationStrict` delegate to the same impl.
836        // The actual implementation is at the VM level (vm_build_derivation)
837        // because it needs interner access. These stubs are intercepted by
838        // try_vm_builtin before they execute.
839        self.register("derivation", 1, |_args| {
840            Err(VMError::Throw(
841                "derivation: requires VM-level dispatch".to_string(),
842            ))
843        });
844        self.register("derivationStrict", 1, |_args| {
845            Err(VMError::Throw(
846                "derivationStrict: requires VM-level dispatch".to_string(),
847            ))
848        });
849        // getFlake: VM-level dispatch (needs import mechanism).
850        self.register("getFlake", 1, |_args| {
851            Err(VMError::Throw(
852                "getFlake: requires VM-level dispatch".to_string(),
853            ))
854        });
855        // scopedImport: VM-level dispatch (needs import + interner).
856        self.register("scopedImport", 1, |_args| {
857            Err(VMError::Throw(
858                "scopedImport: requires VM-level dispatch".to_string(),
859            ))
860        });
861
862        // ── Missing builtins needed for nixpkgs lib ─────────────────
863
864        // addErrorContext: in eval mode just returns the value (no-op wrapper)
865        self.register("addErrorContext", 1, |args| {
866            // Curried: addErrorContext context value → value
867            Ok(VMValue::Builtin(VMBuiltin {
868                name: "addErrorContext<partial>",
869                func: Rc::new(move |inner_args: Vec<VMValue>| Ok(inner_args[0].clone())),
870                arity: 1,
871            }))
872        });
873
874        // unsafeGetAttrPos: returns null (position info not tracked in VM)
875        self.register("unsafeGetAttrPos", 1, |_args| {
876            Ok(VMValue::Builtin(VMBuiltin {
877                name: "unsafeGetAttrPos<partial>",
878                func: Rc::new(|_args: Vec<VMValue>| Ok(VMValue::Null)),
879                arity: 1,
880            }))
881        });
882
883        // pathExists: check if a path exists on the filesystem
884        self.register("pathExists", 1, |args| {
885            let path = match &args[0] {
886                VMValue::Path(p) => p.clone(),
887                VMValue::String(s) => s.clone(),
888                other => {
889                    return Err(VMError::TypeError {
890                        expected: "path or string",
891                        got: other.type_name(),
892                        context: "pathExists".to_string(),
893                    })
894                }
895            };
896            // Redirect the READ through the installed path materializer: a
897            // flake input's `/nix/store/<narhash>-source` prefix is never on
898            // disk, so a bare `.exists()` answers NO for every file in a
899            // fetched input — silently, since `false` is a legal answer.
900            let read_path = crate::bridge::materialize(&path);
901            Ok(VMValue::Bool(std::path::Path::new(&read_path).exists()))
902        });
903
904        // readFile: read contents of a file
905        self.register("readFile", 1, |args| {
906            let path = match &args[0] {
907                VMValue::Path(p) => p.clone(),
908                VMValue::String(s) => s.clone(),
909                other => {
910                    return Err(VMError::TypeError {
911                        expected: "path or string",
912                        got: other.type_name(),
913                        context: "readFile".to_string(),
914                    })
915                }
916            };
917            // Same redirect as `pathExists` — the two MUST agree about a path,
918            // or a `if pathExists p then readFile p` guard passes and the read
919            // then ENOENTs.
920            let read_path = crate::bridge::materialize(&path);
921            let content = std::fs::read_to_string(&read_path)
922                .map_err(|e| VMError::Throw(format!("readFile {path}: {e}")))?;
923            Ok(VMValue::String(content))
924        });
925
926        // readDir: list directory entries
927        self.register("readDir", 1, |args| {
928            let path = match &args[0] {
929                VMValue::Path(p) => p.clone(),
930                VMValue::String(s) => s.clone(),
931                other => {
932                    return Err(VMError::TypeError {
933                        expected: "path or string",
934                        got: other.type_name(),
935                        context: "readDir".to_string(),
936                    })
937                }
938            };
939            // Unreachable whenever a bridge is installed: `builtins.readDir`
940            // is bridge-dispatched by name in `VM::try_vm_builtin`, so the
941            // tree-walker answers it — and the tree-walker's `readDir` already
942            // routes through `path::materialize`. This stub is the bridgeless
943            // path only; it cannot succeed, so there is nothing to redirect.
944            let _ = path;
945            Err(VMError::Throw(
946                "readDir: requires the tree-walker bridge (no interner access here)".to_string(),
947            ))
948        });
949
950        // baseNameOf: extract filename from a path
951        self.register("baseNameOf", 1, |args| {
952            let path = match &args[0] {
953                VMValue::Path(p) => p.clone(),
954                VMValue::String(s) => s.clone(),
955                other => {
956                    return Err(VMError::TypeError {
957                        expected: "path or string",
958                        got: other.type_name(),
959                        context: "baseNameOf".to_string(),
960                    })
961                }
962            };
963            let base = std::path::Path::new(&path)
964                .file_name()
965                .map(|f| f.to_string_lossy().to_string())
966                .unwrap_or_default();
967            Ok(VMValue::String(base))
968        });
969
970        // dirOf: extract directory from a path
971        self.register("dirOf", 1, |args| {
972            let path = match &args[0] {
973                VMValue::Path(p) => p.clone(),
974                VMValue::String(s) => s.clone(),
975                other => {
976                    return Err(VMError::TypeError {
977                        expected: "path or string",
978                        got: other.type_name(),
979                        context: "dirOf".to_string(),
980                    })
981                }
982            };
983            let dir = std::path::Path::new(&path)
984                .parent()
985                .map(|p| p.to_string_lossy().to_string())
986                .unwrap_or_else(|| ".".to_string());
987            Ok(VMValue::String(dir))
988        });
989
990        // genericClosure: transitive closure computation
991        self.register("genericClosure", 1, |_args| {
992            Err(VMError::Throw(
993                "genericClosure: requires VM-level dispatch".to_string(),
994            ))
995        });
996
997        // placeholder: returns placeholder string for derivation outputs
998        self.register("placeholder", 1, |args| {
999            let output = match &args[0] {
1000                VMValue::String(s) => s.clone(),
1001                _ => "out".to_string(),
1002            };
1003            Ok(VMValue::String(format!("/1rz4g4znpzjwh1xymhjpm42vipw92pr73vdgl6xs1hycac8kf2n9/{output}")))
1004        });
1005
1006        // split: regex split (requires VM-level dispatch for interner)
1007        self.register("split", 1, |args| {
1008            let _pattern = as_string(&args[0])?;
1009            Ok(VMValue::Builtin(VMBuiltin {
1010                name: "split<partial>",
1011                func: Rc::new(|_inner_args: Vec<VMValue>| {
1012                    Err(VMError::Throw("split: requires VM-level dispatch".to_string()))
1013                }),
1014                arity: 1,
1015            }))
1016        });
1017
1018        // match: regex match (requires VM-level dispatch for interner)
1019        self.register("match", 1, |args| {
1020            let _pattern = as_string(&args[0])?;
1021            Ok(VMValue::Builtin(VMBuiltin {
1022                name: "match<partial>",
1023                func: Rc::new(|_inner_args: Vec<VMValue>| {
1024                    Err(VMError::Throw("match: requires VM-level dispatch".to_string()))
1025                }),
1026                arity: 1,
1027            }))
1028        });
1029
1030        // fromTOML: parse a TOML string
1031        self.register("fromTOML", 1, |args| {
1032            let s = as_string(&args[0])?;
1033            // Simple stub - would need full TOML parser
1034            Err(VMError::Throw(format!("fromTOML: not yet implemented")))
1035        });
1036
1037        // concatStrings: concatenate a list of strings (used by nixpkgs lib)
1038        // Note: This isn't strictly a Nix builtin but is sometimes needed
1039        // In Nix it's actually builtins.concatStringsSep "" (already registered)
1040
1041        // storeDir: the Nix store directory
1042        // This is a constant, added in make_builtins_attrset
1043
1044        // fetchurl, fetchTarball, fetchGit, fetchTree stubs
1045        self.register("fetchurl", 1, |_args| {
1046            Err(VMError::Throw("fetchurl: not supported in eval mode".to_string()))
1047        });
1048        self.register("fetchTarball", 1, |_args| {
1049            Err(VMError::Throw("fetchTarball: not supported in eval mode".to_string()))
1050        });
1051        self.register("fetchGit", 1, |_args| {
1052            Err(VMError::Throw("fetchGit: not supported in eval mode".to_string()))
1053        });
1054        self.register("fetchTree", 1, |_args| {
1055            Err(VMError::Throw("fetchTree: not supported in eval mode".to_string()))
1056        });
1057        self.register("fetchMercurial", 1, |_args| {
1058            Err(VMError::Throw("fetchMercurial: not supported in eval mode".to_string()))
1059        });
1060
1061        // toFile: write a file to the Nix store (stub)
1062        self.register("toFile", 1, |_args| {
1063            Err(VMError::Throw("toFile: not supported in eval mode".to_string()))
1064        });
1065
1066        // toPath: convert string to path (deprecated in Nix, but used)
1067        self.register("toPath", 1, |args| {
1068            let s = as_string(&args[0])?;
1069            Ok(VMValue::Path(s.to_string()))
1070        });
1071
1072        // import: as a builtin value (not a special form)
1073        // Already handled at the compiler level via OpCode::Import
1074
1075        // parseDrvName: parse a derivation name-version string
1076        self.register("parseDrvName", 1, |args| {
1077            let name = as_string(&args[0])?;
1078            // Split at last hyphen followed by a digit
1079            let mut split_pos = None;
1080            let bytes = name.as_bytes();
1081            for i in (0..bytes.len()).rev() {
1082                if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
1083                    split_pos = Some(i);
1084                    break;
1085                }
1086            }
1087            match split_pos {
1088                Some(pos) => {
1089                    Err(VMError::Throw(
1090                        "parseDrvName: requires VM-level dispatch for interner access".to_string(),
1091                    ))
1092                }
1093                None => {
1094                    Err(VMError::Throw(
1095                        "parseDrvName: requires VM-level dispatch for interner access".to_string(),
1096                    ))
1097                }
1098            }
1099        });
1100
1101        // compareVersions — delegates to sui_compat::versions so the
1102        // tree-walker and VM stay in lock-step.  The previous naive
1103        // local implementation (split on `.` only, no `pre` handling)
1104        // diverged from cppnix on every nixpkgs version probe.
1105        self.register("compareVersions", 1, |args| {
1106            let a = as_string(&args[0])?.to_string();
1107            Ok(VMValue::Builtin(VMBuiltin {
1108                name: "compareVersions<partial>",
1109                func: Rc::new(move |inner_args: Vec<VMValue>| {
1110                    let b = as_string(&inner_args[0])?;
1111                    Ok(VMValue::Int(
1112                        sui_compat::versions::compare_versions(&a, b),
1113                    ))
1114                }),
1115                arity: 1,
1116            }))
1117        });
1118
1119        // splitVersion — delegates to sui_compat::versions for the
1120        // same reason compareVersions does.
1121        self.register("splitVersion", 1, |args| {
1122            let version = as_string(&args[0])?;
1123            let parts: Vec<VMValue> = sui_compat::versions::split_version(version)
1124                .into_iter()
1125                .map(VMValue::String)
1126                .collect();
1127            Ok(VMValue::List(parts))
1128        });
1129
1130        // `concatStrings` was registered here and is GONE. The comment 90
1131        // lines above already said it — "This isn't strictly a Nix builtin ...
1132        // In Nix it's actually builtins.concatStringsSep \"\" (already
1133        // registered)" — and it was registered anyway. Knowing a name is not a
1134        // builtin and exposing it regardless is how the whole leak class got
1135        // in; the correct spelling, `concatStringsSep ""`, is right there and
1136        // is what a nix program can legally write.
1137
1138        // ── String context builtins (no-ops in eval mode) ────────────
1139        // Nix string contexts track derivation dependencies. In eval-only
1140        // mode, strings have no context, so these are identity/no-ops.
1141        self.register("unsafeDiscardStringContext", 1, |args| {
1142            // Just return the string as-is (no context to discard).
1143            Ok(args[0].clone())
1144        });
1145        self.register("getContext", 1, |_args| {
1146            // No context in eval mode — return empty attrset.
1147            // Need VM dispatch for interner.
1148            Err(VMError::Throw(
1149                "getContext: requires VM-level dispatch for interner access".to_string(),
1150            ))
1151        });
1152        self.register("appendContext", 1, |args| {
1153            // No context to append — return string as-is.
1154            Ok(VMValue::Builtin(VMBuiltin {
1155                name: "appendContext<partial>",
1156                func: Rc::new(move |inner_args: Vec<VMValue>| Ok(inner_args[0].clone())),
1157                arity: 1,
1158            }))
1159        });
1160        self.register("hasContext", 1, |_args| {
1161            Ok(VMValue::Bool(false))
1162        });
1163        self.register("unsafeDiscardOutputDependency", 1, |args| {
1164            Ok(args[0].clone())
1165        });
1166        self.register("addDrvOutputDependencies", 1, |args| {
1167            Ok(args[0].clone())
1168        });
1169
1170        // ── Path/string conversion builtins ──────────────────────────
1171        self.register("storePath", 1, |args| {
1172            Ok(args[0].clone())
1173        });
1174        self.register("isStorePath", 1, |args| {
1175            let s = match &args[0] {
1176                VMValue::String(s) => s.as_str(),
1177                VMValue::Path(p) => p.as_str(),
1178                _ => return Ok(VMValue::Bool(false)),
1179            };
1180            Ok(VMValue::Bool(s.starts_with("/nix/store/")))
1181        });
1182        self.register("hashString", 1, |args| {
1183            let algo = as_string(&args[0])?.to_string();
1184            Ok(VMValue::Builtin(VMBuiltin {
1185                name: "hashString<partial>",
1186                func: Rc::new(move |inner_args: Vec<VMValue>| {
1187                    let s = as_string(&inner_args[0])?;
1188                    match algo.as_str() {
1189                        "sha256" => {
1190                            use sha2::{Sha256, Digest};
1191                            let mut hasher = Sha256::new();
1192                            hasher.update(s.as_bytes());
1193                            let result = hasher.finalize();
1194                            let hex: String = result
1195                                .iter()
1196                                .map(|b| format!("{b:02x}"))
1197                                .collect();
1198                            Ok(VMValue::String(hex))
1199                        }
1200                        _ => Err(VMError::Throw(format!("hashString: unsupported algorithm: {algo}")))
1201                    }
1202                }),
1203                arity: 1,
1204            }))
1205        });
1206        self.register("hashFile", 1, |_args| {
1207            Err(VMError::Throw("hashFile: not supported in eval mode".to_string()))
1208        });
1209
1210        // import as a value (not the special form in Apply).
1211        // When used as `import path`, the compiler handles it via OpCode::Import.
1212        // But when `import` is passed as a function value (e.g., `map import paths`),
1213        // it needs to be callable. The VM dispatches this specially.
1214        self.register("import", 1, |_args| {
1215            Err(VMError::Throw(
1216                "import: requires VM-level dispatch".to_string(),
1217            ))
1218        });
1219
1220        // ── Misc builtins needed by nixpkgs lib ─────────────────────
1221        self.register("zipAttrsWith", 1, |_args| {
1222            Err(VMError::Throw(
1223                "zipAttrsWith: requires VM-level dispatch".to_string(),
1224            ))
1225        });
1226    }
1227
1228    // ── Missing builtins: direct implementations + bridge stubs ────
1229    //
1230    // These are builtins that the tree-walker has but the VM was missing.
1231    // Simple ones are implemented directly; complex ones delegate to the
1232    // builtin bridge (which calls back into the tree-walker).
1233
1234    fn register_missing_builtins(&mut self) {
1235        // ── Direct implementations (simple, no tree-walker state) ────
1236
1237        // getEnv: look up environment variable (returns "" if unset)
1238        self.register("getEnv", 1, |args| {
1239            let name = as_string(&args[0])?;
1240            let val = std::env::var(name).unwrap_or_default();
1241            Ok(VMValue::String(val))
1242        });
1243
1244        // readFileType: return file type as string
1245        self.register("readFileType", 1, |args| {
1246            let path = match &args[0] {
1247                VMValue::Path(p) => p.clone(),
1248                VMValue::String(s) => s.clone(),
1249                other => {
1250                    return Err(VMError::TypeError {
1251                        expected: "path or string",
1252                        got: other.type_name(),
1253                        context: "readFileType".to_string(),
1254                    });
1255                }
1256            };
1257            // Same redirect as `pathExists`/`readFile`.
1258            let read_path = crate::bridge::materialize(&path);
1259            match std::fs::symlink_metadata(&read_path) {
1260                Ok(meta) => {
1261                    let kind = if meta.is_symlink() {
1262                        "symlink"
1263                    } else if meta.is_dir() {
1264                        "directory"
1265                    } else if meta.is_file() {
1266                        "regular"
1267                    } else {
1268                        "unknown"
1269                    };
1270                    Ok(VMValue::String(kind.to_string()))
1271                }
1272                Err(e) => Err(VMError::Throw(format!("readFileType {path}: {e}"))),
1273            }
1274        });
1275
1276        // findFile: curried, search NIX_PATH entries for a file
1277        self.register("findFile", 1, |args| {
1278            let search_path = as_list(&args[0])?.clone();
1279            Ok(VMValue::Builtin(VMBuiltin {
1280                name: "findFile<partial>",
1281                func: Rc::new(move |args2| {
1282                    let name = as_string(&args2[0])?;
1283                    for entry in &search_path {
1284                        if let VMValue::Attrs(a) = entry {
1285                            // We need the interner to look up "prefix" and "path" keys.
1286                            // Since this is a bridge builtin, delegate to the bridge.
1287                            // But first try a string-key lookup on a best-effort basis.
1288                            // The bridge will handle the real implementation.
1289                            let _ = a;
1290                        }
1291                    }
1292                    // Delegate to bridge for proper implementation
1293                    bridge_call("findFile", vec![
1294                        VMValue::List(search_path.clone()),
1295                        VMValue::String(name.to_string()),
1296                    ])
1297                }),
1298                arity: 1,
1299            }))
1300        });
1301
1302        // lessThan: curried comparison (missing from VM arithmetic ops)
1303        self.register("lessThan", 1, |args| {
1304            let a = args[0].clone();
1305            Ok(VMValue::Builtin(VMBuiltin {
1306                name: "lessThan<partial>",
1307                func: Rc::new(move |args2| match (&a, &args2[0]) {
1308                    (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Bool(*x < *y)),
1309                    (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Bool(*x < *y)),
1310                    (VMValue::Int(x), VMValue::Float(y)) => {
1311                        Ok(VMValue::Bool((*x as f64) < *y))
1312                    }
1313                    (VMValue::Float(x), VMValue::Int(y)) => {
1314                        Ok(VMValue::Bool(*x < (*y as f64)))
1315                    }
1316                    (VMValue::String(x), VMValue::String(y)) => Ok(VMValue::Bool(*x < *y)),
1317                    _ => Err(VMError::Throw(
1318                        "lessThan: expected comparable types".to_string(),
1319                    )),
1320                }),
1321                arity: 1,
1322            }))
1323        });
1324
1325        // warn: like trace, prints warning and returns identity
1326        self.register("warn", 1, |args| {
1327            if let Ok(msg) = as_string(&args[0]) {
1328                eprintln!("evaluation warning: {msg}");
1329            }
1330            Ok(VMValue::Builtin(VMBuiltin {
1331                name: "warn<partial>",
1332                func: Rc::new(|args2| Ok(args2[0].clone())),
1333                arity: 1,
1334            }))
1335        });
1336
1337        // traceVerbose: like trace but only when SUI_TRACE_VERBOSE=1
1338        self.register("traceVerbose", 1, |args| {
1339            if std::env::var("SUI_TRACE_VERBOSE").ok().as_deref() == Some("1") {
1340                eprintln!("trace: {}", args[0]);
1341            }
1342            Ok(VMValue::Builtin(VMBuiltin {
1343                name: "traceVerbose<partial>",
1344                func: Rc::new(|args2| Ok(args2[0].clone())),
1345                arity: 1,
1346            }))
1347        });
1348
1349        // break: debug breakpoint, just returns its argument
1350        self.register("break", 1, |args| Ok(args[0].clone()));
1351
1352        // ── Bridge-delegating stubs ─────────────────────────────────
1353        //
1354        // These builtins are complex (need tree-walker state, regex cache,
1355        // TOML parser, hash algorithms, etc.) and are delegated to the
1356        // builtin bridge which calls back into the tree-walker.
1357
1358        // Names of builtins that should be bridged and their arities.
1359        // When called, they convert args to StringKeyedValue, call the
1360        // bridge, and convert back.
1361        //
1362        // Note: Some of these are already registered above as stubs that
1363        // throw "requires VM-level dispatch". The bridge versions below
1364        // replace the error with actual functionality when a bridge is set.
1365        // We register them with unique names to avoid conflicts, and the
1366        // VM's try_vm_builtin handles dispatch.
1367
1368        // Bridge complex builtins to tree-walker.
1369        // These need tree-walker state, complex algorithms, or I/O.
1370        //
1371        // ★ REGISTERING A BRIDGE-DISPATCHED BUILTIN IS NOT OPTIONAL: the name
1372        // must appear HERE even though `VM::try_vm_builtin` dispatches it by
1373        // name, because `make_builtins_attrset` is built from THIS registry
1374        // and it is what `builtins ? <name>` answers from. `path`,
1375        // `parseFlakeRef` and `flakeRefToString` were dispatched but never
1376        // registered, so `builtins ? path` answered FALSE while nix and the
1377        // tree-walker answer TRUE — and nixpkgs `lib` gates on exactly that
1378        // shape, so a false answer silently takes the other branch.
1379        for name in &["convertHash", "toXML", "toFile", "filterSource",
1380                      "fetchClosure", "outputOf", "hashFile", "hashString",
1381                      "path", "parseFlakeRef", "flakeRefToString"]
1382        {
1383            let n = (*name).to_string();
1384            self.register(name, 1, move |args| {
1385                bridge_call(&n, args.to_vec())
1386            });
1387        }
1388    }
1389}
1390
1391/// Helper: delegate a builtin call to the tree-walker bridge.
1392///
1393/// Converts `VMValue` args to `StringKeyedValue`, calls the bridge,
1394/// and converts the result back. Returns an error if no bridge is set.
1395/// Apply a curried numeric binop with CppNix mixed-type semantics:
1396/// Int+Int → Int, Float+Float → Float, mixed → Float.  Used by
1397/// sub / mul (div has its own /0 trap).
1398fn vm_numeric_binop(
1399    name: &'static str,
1400    a: &VMValue,
1401    b: &VMValue,
1402    int_op: impl Fn(i64, i64) -> i64,
1403    float_op: impl Fn(f64, f64) -> f64,
1404) -> Result<VMValue, VMError> {
1405    match (a, b) {
1406        (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Int(int_op(*x, *y))),
1407        (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Float(float_op(*x, *y))),
1408        (VMValue::Int(x), VMValue::Float(y)) => Ok(VMValue::Float(float_op(*x as f64, *y))),
1409        (VMValue::Float(x), VMValue::Int(y)) => Ok(VMValue::Float(float_op(*x, *y as f64))),
1410        _ => Err(VMError::Throw(format!("{name}: expected numbers"))),
1411    }
1412}
1413
1414fn bridge_call(name: &str, args: Vec<VMValue>) -> Result<VMValue, VMError> {
1415    use crate::intern::Interner;
1416    // Convert VMValue args to StringKeyedValue (interner-free).
1417    // For this we need a temporary interner to resolve any Symbol keys.
1418    let tmp_interner = Interner::new();
1419    let sk_args: Vec<crate::value::StringKeyedValue> = args
1420        .iter()
1421        .map(|a| a.to_string_keyed(&tmp_interner))
1422        .collect();
1423
1424    // Counted, never fatal — bridging a builtin is the VM's architecture, not
1425    // a failure (it has no native getEnv/match/split/fromTOML/readDir/…). A
1426    // caller that wants to prove the VM computed something WITHOUT the walker
1427    // asserts `fallback::count(Layer::Builtin) == 0` for itself. Making this
1428    // arm fatal under strict would leave strict mode unable to evaluate
1429    // anything, which is a strict mode nobody can use.
1430    let _ = crate::fallback::record(crate::fallback::Layer::Builtin, name);
1431
1432    match crate::bridge::call_builtin_bridge(name, sk_args) {
1433        Ok(Some(result)) => Ok(string_keyed_to_vmvalue(&result, &mut Interner::new())),
1434        Ok(None) => Err(VMError::Throw(format!(
1435            "builtin '{name}' requires bridge but no bridge is set"
1436        ))),
1437        Err(e) => Err(VMError::Throw(e)),
1438    }
1439}
1440
1441/// Convert a `StringKeyedValue` back to a `VMValue`.
1442///
1443/// Requires an interner to create Symbol keys for attrsets.
1444/// Public so the VM's `try_vm_builtin` can use it for bridge dispatch.
1445pub fn string_keyed_to_vmvalue(
1446    sk: &crate::value::StringKeyedValue,
1447    interner: &mut crate::intern::Interner,
1448) -> VMValue {
1449    use crate::value::StringKeyedValue;
1450    match sk {
1451        StringKeyedValue::Null => VMValue::Null,
1452        StringKeyedValue::Bool(b) => VMValue::Bool(*b),
1453        StringKeyedValue::Int(n) => VMValue::Int(*n),
1454        StringKeyedValue::Float(f) => VMValue::Float(*f),
1455        StringKeyedValue::String(s) => VMValue::String(s.clone()),
1456        StringKeyedValue::Path(p) => VMValue::Path(p.clone()),
1457        StringKeyedValue::List(items) => VMValue::List(
1458            items
1459                .iter()
1460                .map(|v| string_keyed_to_vmvalue(v, interner))
1461                .collect(),
1462        ),
1463        StringKeyedValue::Attrs(map) => {
1464            let mut attrs = BTreeMap::new();
1465            for (k, v) in map {
1466                let sym = interner.intern(k);
1467                attrs.insert(sym, string_keyed_to_vmvalue(v, interner));
1468            }
1469            VMValue::Attrs(attrs)
1470        }
1471        StringKeyedValue::Lambda => VMValue::Null,
1472        StringKeyedValue::Callable(cb) => {
1473            let cb_clone = Rc::clone(cb);
1474            VMValue::Builtin(crate::value::VMBuiltin {
1475                name: "<bridge-fn>",
1476                arity: 1,
1477                func: Rc::new(move |args: Vec<VMValue>| {
1478                    let interner = crate::intern::Interner::new();
1479                    let sk_arg = args.into_iter().next()
1480                        .unwrap_or(VMValue::Null)
1481                        .to_string_keyed(&interner);
1482                    let sk_result = cb_clone(sk_arg)
1483                        .map_err(|e| VMError::Throw(e))?;
1484                    let mut tmp_interner = crate::intern::Interner::new();
1485                    Ok(string_keyed_to_vmvalue(&sk_result, &mut tmp_interner))
1486                }),
1487            })
1488        }
1489        StringKeyedValue::Thunk(cb) => {
1490            // Wrap the StringKeyedValue thunk as a VMThunk.
1491            let cb_clone = Rc::clone(cb);
1492            VMValue::Thunk(crate::value::VMThunk::new_native(move || {
1493                let sk_val = cb_clone().map_err(|e| VMError::Throw(e))?;
1494                // Use a fresh interner for the result conversion.
1495                let mut tmp = crate::intern::Interner::new();
1496                Ok(string_keyed_to_vmvalue(&sk_val, &mut tmp))
1497            }))
1498        }
1499    }
1500}
1501
1502impl Default for BuiltinRegistry {
1503    fn default() -> Self {
1504        Self::new()
1505    }
1506}
1507
1508// ── Helper functions ──────────────────────────────────────────────
1509
1510/// Try to extract a concrete value from a `Done` thunk without VM access.
1511/// Returns the inner value for already-evaluated thunks. For non-thunks,
1512/// returns `None` (use the value directly). For pending thunks, returns
1513/// an error that will cause the VM to fall back to the tree-walker.
1514fn try_unwrap_done_thunk(v: &VMValue) -> Option<Result<VMValue, VMError>> {
1515    match v {
1516        VMValue::Thunk(thunk) => {
1517            let state = thunk.state.take();
1518            match state {
1519                Some(ThunkState::Done(boxed)) => {
1520                    let inner = *boxed.clone();
1521                    thunk.state.set(Some(ThunkState::Done(boxed)));
1522                    // Recursively unwrap in case the result is itself a Done thunk.
1523                    match &inner {
1524                        VMValue::Thunk(_) => Some(try_unwrap_done_thunk(&inner)
1525                            .unwrap_or(Ok(inner))),
1526                        _ => Some(Ok(inner)),
1527                    }
1528                }
1529                other => {
1530                    thunk.state.set(other);
1531                    Some(Err(VMError::TypeError {
1532                        expected: "concrete value",
1533                        got: "thunk (pending)",
1534                        context: "builtin argument (thunk needs VM to force)".to_string(),
1535                    }))
1536                }
1537            }
1538        }
1539        _ => None, // Not a thunk — caller uses value directly
1540    }
1541}
1542
1543/// Extract a list, forcing thunks if needed. Returns an owned Vec
1544/// because thunk forcing may produce a value we can't borrow.
1545fn as_list(v: &VMValue) -> Result<Vec<VMValue>, VMError> {
1546    match v {
1547        VMValue::List(l) => Ok(l.clone()),
1548        VMValue::Thunk(_) => {
1549            let forced = force_vmvalue(v.clone())?;
1550            match forced {
1551                VMValue::List(l) => Ok(l),
1552                other => Err(VMError::TypeError {
1553                    expected: "list",
1554                    got: other.type_name(),
1555                    context: "builtin argument".to_string(),
1556                }),
1557            }
1558        }
1559        other => Err(VMError::TypeError {
1560            expected: "list",
1561            got: other.type_name(),
1562            context: "builtin argument".to_string(),
1563        }),
1564    }
1565}
1566
1567/// Force a VMValue if it's a thunk, returning the resolved value.
1568/// Handles Done thunks directly, NativeCallback via bridge, and
1569/// Pending thunks cause a fallback error.
1570fn force_vmvalue(v: VMValue) -> Result<VMValue, VMError> {
1571    match v {
1572        VMValue::Thunk(ref thunk) => {
1573            let state = thunk.state.take();
1574            match state {
1575                Some(ThunkState::Done(boxed)) => {
1576                    let inner = *boxed.clone();
1577                    thunk.state.set(Some(ThunkState::Done(boxed)));
1578                    force_vmvalue(inner) // Recursively unwrap
1579                }
1580                Some(ThunkState::NativeCallback(cb)) => {
1581                    thunk.state.set(Some(ThunkState::Evaluating));
1582                    match cb() {
1583                        Ok(sk_val) => {
1584                            // Convert StringKeyedValue back to VMValue
1585                            let result = sk_to_vmvalue(&sk_val);
1586                            thunk.state.set(Some(ThunkState::Done(Box::new(result.clone()))));
1587                            force_vmvalue(result)
1588                        }
1589                        Err(e) => {
1590                            thunk.state.set(Some(ThunkState::NativeCallback(cb)));
1591                            Err(VMError::Throw(e))
1592                        }
1593                    }
1594                }
1595                other => {
1596                    thunk.state.set(other);
1597                    // Pending/LazySource/Evaluating — needs VM to force.
1598                    Err(VMError::TypeError {
1599                        expected: "concrete value",
1600                        got: "thunk (pending)",
1601                        context: "builtin argument (thunk needs VM to force)".to_string(),
1602                    })
1603                }
1604            }
1605        }
1606        other => Ok(other),
1607    }
1608}
1609
1610/// Convert StringKeyedValue → VMValue (inverse of to_string_keyed).
1611fn sk_to_vmvalue(sk: &crate::value::StringKeyedValue) -> VMValue {
1612    use crate::value::StringKeyedValue;
1613    match sk {
1614        StringKeyedValue::Null => VMValue::Null,
1615        StringKeyedValue::Bool(b) => VMValue::Bool(*b),
1616        StringKeyedValue::Int(n) => VMValue::Int(*n),
1617        StringKeyedValue::Float(f) => VMValue::Float(*f),
1618        StringKeyedValue::String(s) => VMValue::String(s.clone()),
1619        StringKeyedValue::Path(p) => VMValue::Path(p.clone()),
1620        StringKeyedValue::List(items) => {
1621            VMValue::List(items.iter().map(|i| sk_to_vmvalue(i)).collect())
1622        }
1623        StringKeyedValue::Attrs(map) => {
1624            // Use the global interner for symbol resolution
1625            let mut interner = crate::intern::Interner::new();
1626            VMValue::Attrs(map.iter().map(|(k, v)| {
1627                (interner.intern(k), sk_to_vmvalue(v))
1628            }).collect())
1629        }
1630        StringKeyedValue::Lambda => VMValue::Null, // Can't reconstruct closures
1631        StringKeyedValue::Thunk(cb) => {
1632            // Wrap as a NativeCallback VMThunk for lazy evaluation
1633            let cb = cb.clone();
1634            VMValue::Thunk(crate::value::VMThunk {
1635                state: Rc::new(Cell::new(Some(ThunkState::NativeCallback(cb)))),
1636            })
1637        }
1638        StringKeyedValue::Callable(_) => VMValue::Null, // Can't reconstruct
1639    }
1640}
1641
1642fn as_attrs(v: &VMValue) -> Result<&BTreeMap<Symbol, VMValue>, VMError> {
1643    match v {
1644        VMValue::Attrs(a) => Ok(a),
1645        VMValue::Thunk(_) => Err(VMError::TypeError {
1646            expected: "set",
1647            got: "thunk",
1648            context: "builtin argument".to_string(),
1649        }),
1650        other => Err(VMError::TypeError {
1651            expected: "set",
1652            got: other.type_name(),
1653            context: "builtin argument".to_string(),
1654        }),
1655    }
1656}
1657
1658fn as_string(v: &VMValue) -> Result<&str, VMError> {
1659    match v {
1660        VMValue::String(s) => Ok(s),
1661        other => Err(VMError::TypeError {
1662            expected: "string",
1663            got: other.type_name(),
1664            context: "builtin argument".to_string(),
1665        }),
1666    }
1667}
1668
1669/// Force-aware string extraction: forces thunks before extracting.
1670/// Use this when iterating over list elements that may be thunks.
1671fn force_as_string(v: &VMValue) -> Result<String, VMError> {
1672    match v {
1673        VMValue::String(s) => Ok(s.clone()),
1674        VMValue::Thunk(_) => {
1675            let forced = force_vmvalue(v.clone())?;
1676            match forced {
1677                VMValue::String(s) => Ok(s),
1678                other => Err(VMError::TypeError {
1679                    expected: "string",
1680                    got: other.type_name(),
1681                    context: "builtin argument (after forcing thunk)".to_string(),
1682                }),
1683            }
1684        }
1685        other => Err(VMError::TypeError {
1686            expected: "string",
1687            got: other.type_name(),
1688            context: "builtin argument".to_string(),
1689        }),
1690    }
1691}
1692
1693/// Force-aware list extraction: forces thunks before extracting.
1694fn force_as_list(v: &VMValue) -> Result<Vec<VMValue>, VMError> {
1695    match v {
1696        VMValue::List(l) => Ok(l.clone()),
1697        VMValue::Thunk(_) => {
1698            let forced = force_vmvalue(v.clone())?;
1699            match forced {
1700                VMValue::List(l) => Ok(l),
1701                other => Err(VMError::TypeError {
1702                    expected: "list",
1703                    got: other.type_name(),
1704                    context: "builtin argument (after forcing thunk)".to_string(),
1705                }),
1706            }
1707        }
1708        other => Err(VMError::TypeError {
1709            expected: "list",
1710            got: other.type_name(),
1711            context: "builtin argument".to_string(),
1712        }),
1713    }
1714}
1715
1716fn as_int(v: &VMValue) -> Result<i64, VMError> {
1717    match v {
1718        VMValue::Int(n) => Ok(*n),
1719        other => Err(VMError::TypeError {
1720            expected: "int",
1721            got: other.type_name(),
1722            context: "builtin argument".to_string(),
1723        }),
1724    }
1725}
1726
1727fn as_float(v: &VMValue) -> Result<f64, VMError> {
1728    match v {
1729        VMValue::Float(f) => Ok(*f),
1730        VMValue::Int(n) => Ok(*n as f64),
1731        other => Err(VMError::TypeError {
1732            expected: "float",
1733            got: other.type_name(),
1734            context: "builtin argument".to_string(),
1735        }),
1736    }
1737}
1738
1739/// Coerce a VMValue to string, matching CppNix's `builtins.toString` semantics:
1740/// - Strings, ints, floats, bools, null, paths: straightforward conversion
1741/// - Attrsets with `__toString`: call the function with the attrset as argument
1742///   (handled by VM fallback — here we just check `outPath`)
1743/// - Attrsets with `outPath`: coerce the outPath value
1744/// - Lists: space-join coerced elements
1745fn vm_coerce_to_string(v: &VMValue) -> Result<VMValue, VMError> {
1746    match v {
1747        VMValue::String(s) => Ok(VMValue::String(s.clone())),
1748        VMValue::Int(n) => Ok(VMValue::String(n.to_string())),
1749        // 6-decimal fixed-point to match CppNix's `%f` float coercion.
1750        VMValue::Float(f) => Ok(VMValue::String(format!("{f:.6}"))),
1751        VMValue::Bool(true) => Ok(VMValue::String("1".to_string())),
1752        VMValue::Bool(false) => Ok(VMValue::String(String::new())),
1753        VMValue::Null => Ok(VMValue::String(String::new())),
1754        VMValue::Path(p) => Ok(VMValue::String(p.clone())),
1755        VMValue::Attrs(attrs) => {
1756            // Check __toString first (requires calling a function — if present,
1757            // we fall back to the VM bridge for now)
1758            let to_str_sym = crate::intern::intern("__toString");
1759            if attrs.contains_key(&to_str_sym) {
1760                // __toString requires calling a closure with the attrset.
1761                // This can't be done from a pure builtin — the VM will handle
1762                // this via the bridge fallback.
1763                return Err(VMError::Throw(
1764                    "toString: __toString requires VM bridge".to_string(),
1765                ));
1766            }
1767            let out_path_sym = crate::intern::intern("outPath");
1768            if let Some(out_path) = attrs.get(&out_path_sym) {
1769                vm_coerce_to_string(out_path)
1770            } else {
1771                Err(VMError::Throw(
1772                    "cannot coerce a set to a string, but it has no __toString or outPath".to_string(),
1773                ))
1774            }
1775        }
1776        VMValue::List(items) => {
1777            let mut parts = Vec::with_capacity(items.len());
1778            for item in items {
1779                match vm_coerce_to_string(item)? {
1780                    VMValue::String(s) => parts.push(s),
1781                    _ => unreachable!("vm_coerce_to_string always returns String"),
1782                }
1783            }
1784            Ok(VMValue::String(parts.join(" ")))
1785        }
1786        VMValue::Closure(_) | VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
1787            Err(VMError::Throw(
1788                "cannot coerce a function to a string".to_string(),
1789            ))
1790        }
1791        VMValue::Thunk(_) => {
1792            Err(VMError::Throw(
1793                "toString: thunk should be forced first".to_string(),
1794            ))
1795        }
1796    }
1797}
1798
1799/// Convert a VMValue to serde_json::Value for toJSON.
1800fn vm_value_to_json(v: &VMValue) -> Result<serde_json::Value, VMError> {
1801    match v {
1802        VMValue::Null => Ok(serde_json::Value::Null),
1803        VMValue::Bool(b) => Ok(serde_json::Value::Bool(*b)),
1804        VMValue::Int(n) => Ok(serde_json::Value::Number(
1805            serde_json::Number::from(*n),
1806        )),
1807        VMValue::Float(f) => serde_json::Number::from_f64(*f)
1808            .map(serde_json::Value::Number)
1809            .ok_or_else(|| VMError::Throw("toJSON: invalid float".to_string())),
1810        VMValue::String(s) => Ok(serde_json::Value::String(s.clone())),
1811        VMValue::Path(p) => Ok(serde_json::Value::String(p.clone())),
1812        VMValue::List(items) => {
1813            let arr: Result<Vec<_>, _> = items.iter().map(vm_value_to_json).collect();
1814            Ok(serde_json::Value::Array(arr?))
1815        }
1816        VMValue::Attrs(_) => {
1817            // Can't convert attrsets without interner access for key names
1818            Err(VMError::Throw(
1819                "toJSON: attrset conversion requires interner".to_string(),
1820            ))
1821        }
1822        VMValue::Closure(_) | VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
1823            Err(VMError::Throw("toJSON: cannot convert function".to_string()))
1824        }
1825        VMValue::Thunk(_) => {
1826            Err(VMError::Throw("toJSON: thunk should be forced first".to_string()))
1827        }
1828    }
1829}
1830
1831/// Convert a serde_json::Value to VMValue for fromJSON.
1832fn json_to_vm_value(v: &serde_json::Value) -> VMValue {
1833    match v {
1834        serde_json::Value::Null => VMValue::Null,
1835        serde_json::Value::Bool(b) => VMValue::Bool(*b),
1836        serde_json::Value::Number(n) => {
1837            if let Some(i) = n.as_i64() {
1838                VMValue::Int(i)
1839            } else {
1840                VMValue::Float(n.as_f64().unwrap_or(0.0))
1841            }
1842        }
1843        serde_json::Value::String(s) => VMValue::String(s.clone()),
1844        serde_json::Value::Array(arr) => {
1845            VMValue::List(arr.iter().map(json_to_vm_value).collect())
1846        }
1847        serde_json::Value::Object(_) => {
1848            // Can't create Symbol-keyed attrsets without an interner.
1849            // Return null as a fallback; real usage goes through VM.
1850            VMValue::Null
1851        }
1852    }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858
1859    #[test]
1860    fn registry_has_builtins() {
1861        let reg = BuiltinRegistry::new();
1862        assert!(reg.lookup("length").is_some());
1863        assert!(reg.lookup("typeOf").is_some());
1864        assert!(reg.lookup("head").is_some());
1865        assert!(reg.lookup("tail").is_some());
1866        assert!(reg.lookup("throw").is_some());
1867        assert!(reg.lookup("nonexistent").is_none());
1868    }
1869
1870    #[test]
1871    fn call_length() {
1872        let reg = BuiltinRegistry::new();
1873        let idx = reg.lookup("length").unwrap();
1874        let result = reg
1875            .call(idx, vec![VMValue::List(vec![VMValue::Int(1), VMValue::Int(2)])])
1876            .unwrap();
1877        assert_eq!(result, VMValue::Int(2));
1878    }
1879
1880    #[test]
1881    fn call_head() {
1882        let reg = BuiltinRegistry::new();
1883        let idx = reg.lookup("head").unwrap();
1884        let result = reg
1885            .call(idx, vec![VMValue::List(vec![VMValue::Int(10)])])
1886            .unwrap();
1887        assert_eq!(result, VMValue::Int(10));
1888    }
1889
1890    #[test]
1891    fn call_head_empty() {
1892        let reg = BuiltinRegistry::new();
1893        let idx = reg.lookup("head").unwrap();
1894        let result = reg.call(idx, vec![VMValue::List(vec![])]);
1895        assert!(result.is_err());
1896    }
1897
1898    #[test]
1899    fn call_type_of() {
1900        let reg = BuiltinRegistry::new();
1901        let idx = reg.lookup("typeOf").unwrap();
1902        assert_eq!(
1903            reg.call(idx, vec![VMValue::Int(42)]).unwrap(),
1904            VMValue::String("int".to_string())
1905        );
1906        assert_eq!(
1907            reg.call(idx, vec![VMValue::String("hello".to_string())])
1908                .unwrap(),
1909            VMValue::String("string".to_string())
1910        );
1911    }
1912
1913    #[test]
1914    fn call_string_length() {
1915        let reg = BuiltinRegistry::new();
1916        let idx = reg.lookup("stringLength").unwrap();
1917        let result = reg
1918            .call(idx, vec![VMValue::String("hello".to_string())])
1919            .unwrap();
1920        assert_eq!(result, VMValue::Int(5));
1921    }
1922
1923    #[test]
1924    fn call_throw() {
1925        let reg = BuiltinRegistry::new();
1926        let idx = reg.lookup("throw").unwrap();
1927        let result = reg.call(idx, vec![VMValue::String("test error".to_string())]);
1928        assert!(matches!(result, Err(VMError::Throw(_))));
1929    }
1930
1931    #[test]
1932    fn call_to_string() {
1933        let reg = BuiltinRegistry::new();
1934        let idx = reg.lookup("toString").unwrap();
1935        assert_eq!(
1936            reg.call(idx, vec![VMValue::Int(42)]).unwrap(),
1937            VMValue::String("42".to_string())
1938        );
1939        assert_eq!(
1940            reg.call(idx, vec![VMValue::Bool(true)]).unwrap(),
1941            VMValue::String("1".to_string())
1942        );
1943    }
1944
1945    #[test]
1946    fn builtins_attrset() {
1947        let reg = BuiltinRegistry::new();
1948        let mut interner = Interner::new();
1949        let builtins = reg.make_builtins_attrset(&mut interner);
1950        match &builtins {
1951            VMValue::Attrs(attrs) => {
1952                let length_sym = interner.lookup("length").unwrap();
1953                assert!(attrs.contains_key(&length_sym));
1954            }
1955            _ => panic!("expected Attrs"),
1956        }
1957    }
1958}