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        self.register("filterAttrs", 1, |args| {
393            Ok(VMValue::HigherOrderBuiltin(HigherOrderBuiltin {
394                op: HigherOrderOp::FilterAttrs,
395                func: Box::new(args[0].clone()),
396                extra_args: Vec::new(),
397            }))
398        });
399        self.register("functionArgs", 1, |args| {
400            match &args[0] {
401                VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
402                    Ok(VMValue::Attrs(BTreeMap::new()))
403                }
404                VMValue::Closure(closure) => {
405                    let mut result = BTreeMap::new();
406                    let mut interner = crate::intern::Interner::new();
407                    for (name, has_default) in &closure.formals {
408                        let sym = interner.intern(name);
409                        result.insert(sym, VMValue::Bool(*has_default));
410                    }
411                    Ok(VMValue::Attrs(result))
412                }
413                other => Err(VMError::TypeError {
414                    expected: "lambda",
415                    got: other.type_name(),
416                    context: "functionArgs".to_string(),
417                }),
418            }
419        });
420        self.register("catAttrs", 1, |args| {
421            let name = as_string(&args[0])?.to_string();
422            Ok(VMValue::Builtin(VMBuiltin {
423                name: "catAttrs<partial>",
424                func: Rc::new(move |args2| {
425                    let list = force_as_list(&args2[0])?;
426                    let mut result: Vec<VMValue> = Vec::new();
427                    for item in &list {
428                        if let VMValue::Attrs(_) = item {
429                            let _ = &name;
430                        }
431                    }
432                    Err(VMError::Throw(
433                        "catAttrs: requires interner access (use VM dispatch)".to_string(),
434                    ))
435                }),
436                arity: 1,
437            }))
438        });
439    }
440
441    // ── Attrset operations ────────────────────────────────────────
442
443    fn register_attrset_ops(&mut self) {
444        self.register("attrNames", 1, |args| {
445            let attrs = as_attrs(&args[0])?;
446            // Note: we don't have the interner here, so we can't resolve
447            // Symbol keys. This builtin must be called through the VM
448            // which resolves symbols. For now, this is a placeholder.
449            let _ = attrs;
450            Err(VMError::Throw(
451                "attrNames: requires interner access (use VM dispatch)".to_string(),
452            ))
453        });
454
455        // attrValues needs the VM's interner to resolve Symbol keys to
456        // their string names for lex-sorting — which is what CppNix
457        // semantics require. Symbol itself is an intern-order u32,
458        // NOT a lex-sorted key, so BTreeMap's native iteration is
459        // intern-order and wrong whenever any transitive eval
460        // (e.g. nixpkgs/lib) has interned a "later" key before an
461        // "earlier" one. Route through VM dispatch the same way
462        // attrNames does. Placeholder error that the VM recognizes.
463        //
464        // Discovered while probing sui against real nixpkgs:
465        // `(import <nixpkgs>/lib).attrsets.mapAttrsToList
466        //    (n: v: "${n}=${toString v}") { a = 1; b = 2; }`
467        // returned `[ "b=2" "a=1" ]` instead of `[ "a=1" "b=2" ]`.
468        self.register("attrValues", 1, |_args| {
469            Err(VMError::Throw(
470                "attrValues: requires interner access (use VM dispatch)".to_string(),
471            ))
472        });
473
474        self.register("hasAttr", 1, |args| {
475            let name = as_string(&args[0])?.to_string();
476            Ok(VMValue::Builtin(VMBuiltin {
477                name: "hasAttr<partial>",
478                func: Rc::new(move |_args2| {
479                    // Needs interner to resolve the name to a Symbol.
480                    let _ = &name;
481                    Err(VMError::Throw(
482                        "hasAttr: requires interner access (use VM dispatch)".to_string(),
483                    ))
484                }),
485                arity: 1,
486            }))
487        });
488
489        self.register("getAttr", 1, |args| {
490            let name = as_string(&args[0])?.to_string();
491            Ok(VMValue::Builtin(VMBuiltin {
492                name: "getAttr<partial>",
493                func: Rc::new(move |_args2| {
494                    let _ = &name;
495                    Err(VMError::Throw(
496                        "getAttr: requires interner access (use VM dispatch)".to_string(),
497                    ))
498                }),
499                arity: 1,
500            }))
501        });
502
503        self.register("intersectAttrs", 1, |args| {
504            let a_attrs = as_attrs(&args[0])?.clone();
505            Ok(VMValue::Builtin(VMBuiltin {
506                name: "intersectAttrs<partial>",
507                func: Rc::new(move |args2| {
508                    let b_attrs = as_attrs(&args2[0])?;
509                    let mut result = BTreeMap::new();
510                    for (k, v) in b_attrs {
511                        if a_attrs.contains_key(k) {
512                            result.insert(*k, v.clone());
513                        }
514                    }
515                    Ok(VMValue::Attrs(result))
516                }),
517                arity: 1,
518            }))
519        });
520
521        self.register("removeAttrs", 1, |args| {
522            let set = as_attrs(&args[0])?.clone();
523            Ok(VMValue::Builtin(VMBuiltin {
524                name: "removeAttrs<partial>",
525                func: Rc::new(move |_args2| {
526                    // Needs interner for name resolution
527                    let _ = &set;
528                    Err(VMError::Throw(
529                        "removeAttrs: requires interner access".to_string(),
530                    ))
531                }),
532                arity: 1,
533            }))
534        });
535
536        self.register("listToAttrs", 1, |_args| {
537            Err(VMError::Throw(
538                "listToAttrs: requires interner access".to_string(),
539            ))
540        });
541    }
542
543    // ── String operations ─────────────────────────────────────────
544
545    fn register_string_ops(&mut self) {
546        self.register("stringLength", 1, |args| {
547            let s = as_string(&args[0])?;
548            Ok(VMValue::Int(s.len() as i64))
549        });
550
551        self.register("substring", 1, |args| {
552            // CppNix semantics (verified against 2.33):
553            //   - negative `len` means "to end of string"
554            //   - negative `start` yields empty string
555            //   - out-of-range start clamps; out-of-range end clamps
556            //
557            // sui's VM was previously casting `i64 as usize` immediately,
558            // which turned `-1` (a common CppNix convention for "rest of
559            // string", used by lib.strings.removePrefix) into usize::MAX
560            // and panicked with "begin <= end" on the arithmetic overflow.
561            // Discovered while probing `(import <nixpkgs>/lib).strings
562            //   .removePrefix "foo-" "foo-bar"` — fifth silent/loud bug
563            // of the session.
564            let start_i = as_int(&args[0])?;
565            Ok(VMValue::Builtin(VMBuiltin {
566                name: "substring<p1>",
567                func: Rc::new(move |args2| {
568                    let len_i = as_int(&args2[0])?;
569                    Ok(VMValue::Builtin(VMBuiltin {
570                        name: "substring<p2>",
571                        func: Rc::new(move |args3| {
572                            let s = as_string(&args3[0])?;
573                            if start_i < 0 {
574                                return Err(VMError::Throw(
575                                    "substring: negative start position".to_string(),
576                                ));
577                            }
578                            let s_len = s.len();
579                            let start = (start_i as usize).min(s_len);
580                            let end = if len_i < 0 {
581                                s_len
582                            } else {
583                                start.saturating_add(len_i as usize).min(s_len)
584                            };
585                            Ok(VMValue::String(s[start..end].to_string()))
586                        }),
587                        arity: 1,
588                    }))
589                }),
590                arity: 1,
591            }))
592        });
593
594        self.register("concatStringsSep", 1, |args| {
595            let sep = as_string(&args[0])?.to_string();
596            Ok(VMValue::Builtin(VMBuiltin {
597                name: "concatStringsSep<partial>",
598                func: Rc::new(move |args2| {
599                    let list = force_as_list(&args2[0])?;
600                    let strings: Result<Vec<String>, _> =
601                        list.iter().map(|v| force_as_string(v)).collect();
602                    Ok(VMValue::String(strings?.join(&sep)))
603                }),
604                arity: 1,
605            }))
606        });
607
608        self.register("replaceStrings", 1, |args| {
609            let from: Vec<String> = force_as_list(&args[0])?
610                .iter()
611                .map(|v| force_as_string(v))
612                .collect::<Result<_, _>>()?;
613            Ok(VMValue::Builtin(VMBuiltin {
614                name: "replaceStrings<p1>",
615                func: Rc::new(move |args2| {
616                    let to: Vec<String> = force_as_list(&args2[0])?
617                        .iter()
618                        .map(|v| force_as_string(v))
619                        .collect::<Result<_, _>>()?;
620                    let from2 = from.clone();
621                    Ok(VMValue::Builtin(VMBuiltin {
622                        name: "replaceStrings<p2>",
623                        func: Rc::new(move |args3| {
624                            let mut s = as_string(&args3[0])?.to_string();
625                            for (f, t) in from2.iter().zip(to.iter()) {
626                                if !f.is_empty() {
627                                    s = s.replace(f.as_str(), t);
628                                }
629                            }
630                            Ok(VMValue::String(s))
631                        }),
632                        arity: 1,
633                    }))
634                }),
635                arity: 1,
636            }))
637        });
638
639        self.register("hasPrefix", 1, |args| {
640            let prefix = as_string(&args[0])?.to_string();
641            Ok(VMValue::Builtin(VMBuiltin {
642                name: "hasPrefix<partial>",
643                func: Rc::new(move |args2| {
644                    let s = as_string(&args2[0])?;
645                    Ok(VMValue::Bool(s.starts_with(&*prefix)))
646                }),
647                arity: 1,
648            }))
649        });
650
651        self.register("hasSuffix", 1, |args| {
652            let suffix = as_string(&args[0])?.to_string();
653            Ok(VMValue::Builtin(VMBuiltin {
654                name: "hasSuffix<partial>",
655                func: Rc::new(move |args2| {
656                    let s = as_string(&args2[0])?;
657                    Ok(VMValue::Bool(s.ends_with(&*suffix)))
658                }),
659                arity: 1,
660            }))
661        });
662
663        self.register("toLower", 1, |args| {
664            let s = as_string(&args[0])?;
665            Ok(VMValue::String(s.to_lowercase()))
666        });
667
668        self.register("toUpper", 1, |args| {
669            let s = as_string(&args[0])?;
670            Ok(VMValue::String(s.to_uppercase()))
671        });
672    }
673
674    // ── Conversion operations ─────────────────────────────────────
675
676    fn register_conversion_ops(&mut self) {
677        self.register("toString", 1, |args| {
678            vm_coerce_to_string(&args[0])
679        });
680
681        self.register("toJSON", 1, |args| {
682            let json = vm_value_to_json(&args[0])?;
683            let s = serde_json::to_string(&json)
684                .unwrap_or_else(|_| "null".to_string());
685            Ok(VMValue::String(s))
686        });
687
688        self.register("fromJSON", 1, |args| {
689            let s = as_string(&args[0])?;
690            let json: serde_json::Value = serde_json::from_str(s).map_err(|e| {
691                VMError::Throw(format!("fromJSON: {e}"))
692            })?;
693            Ok(json_to_vm_value(&json))
694        });
695
696        self.register("toInt", 1, |args| {
697            let s = as_string(&args[0])?;
698            let n: i64 = s.trim().parse().map_err(|e| {
699                VMError::Throw(format!("toInt: {e}"))
700            })?;
701            Ok(VMValue::Int(n))
702        });
703    }
704
705    // ── Control flow ──────────────────────────────────────────────
706
707    fn register_control_ops(&mut self) {
708        self.register("throw", 1, |args| {
709            let msg = as_string(&args[0])?;
710            Err(VMError::Throw(format!("throw: {msg}")))
711        });
712
713        self.register("abort", 1, |args| {
714            let msg = as_string(&args[0])?;
715            Err(VMError::Throw(format!("abort: {msg}")))
716        });
717
718        self.register("seq", 1, |args| {
719            let _forced = args[0].clone();
720            Ok(VMValue::Builtin(VMBuiltin {
721                name: "seq<partial>",
722                func: Rc::new(|args2| Ok(args2[0].clone())),
723                arity: 1,
724            }))
725        });
726
727        self.register("deepSeq", 1, |args| {
728            let _forced = args[0].clone();
729            Ok(VMValue::Builtin(VMBuiltin {
730                name: "deepSeq<partial>",
731                func: Rc::new(|args2| Ok(args2[0].clone())),
732                arity: 1,
733            }))
734        });
735
736        self.register("tryEval", 1, |args| {
737            // In the VM, tryEval just wraps the value since we don't
738            // have thunk forcing here. The VM handles the actual try/catch.
739            let val = args[0].clone();
740            // We can't actually catch throws here without interner access.
741            // Return success with the value for now.
742            // The VM will handle this specially.
743            let _ = val;
744            Err(VMError::Throw(
745                "tryEval: requires VM-level implementation".to_string(),
746            ))
747        });
748
749        self.register("trace", 1, |args| {
750            let msg = args[0].clone();
751            eprintln!("trace: {msg}");
752            Ok(VMValue::Builtin(VMBuiltin {
753                name: "trace<partial>",
754                func: Rc::new(|args2| Ok(args2[0].clone())),
755                arity: 1,
756            }))
757        });
758    }
759
760    // ── Arithmetic ────────────────────────────────────────────────
761
762    fn register_arithmetic_ops(&mut self) {
763        self.register("add", 1, |args| {
764            let a = args[0].clone();
765            Ok(VMValue::Builtin(VMBuiltin {
766                name: "add<partial>",
767                func: Rc::new(move |args2| match (&a, &args2[0]) {
768                    (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Int(x + y)),
769                    (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Float(x + y)),
770                    (VMValue::Int(x), VMValue::Float(y)) => Ok(VMValue::Float(*x as f64 + y)),
771                    (VMValue::Float(x), VMValue::Int(y)) => Ok(VMValue::Float(x + *y as f64)),
772                    _ => Err(VMError::Throw("add: expected numbers".to_string())),
773                }),
774                arity: 1,
775            }))
776        });
777
778        // sub/mul/div accept mixed Int+Float operands (CppNix
779        // semantics).  Previously int-only, which diverged on
780        // `builtins.div 10.0 3.0` and similar mixed expressions.
781        self.register("sub", 1, |args| {
782            let a = args[0].clone();
783            Ok(VMValue::Builtin(VMBuiltin {
784                name: "sub<partial>",
785                func: Rc::new(move |args2| vm_numeric_binop("sub", &a, &args2[0], |x, y| x - y, |x, y| x - y)),
786                arity: 1,
787            }))
788        });
789
790        self.register("mul", 1, |args| {
791            let a = args[0].clone();
792            Ok(VMValue::Builtin(VMBuiltin {
793                name: "mul<partial>",
794                func: Rc::new(move |args2| vm_numeric_binop("mul", &a, &args2[0], |x, y| x * y, |x, y| x * y)),
795                arity: 1,
796            }))
797        });
798
799        self.register("div", 1, |args| {
800            let a = args[0].clone();
801            Ok(VMValue::Builtin(VMBuiltin {
802                name: "div<partial>",
803                func: Rc::new(move |args2| match (&a, &args2[0]) {
804                    (VMValue::Int(_), VMValue::Int(0)) => Err(VMError::DivisionByZero),
805                    (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Int(x / y)),
806                    (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Float(x / y)),
807                    (VMValue::Int(x), VMValue::Float(y)) => Ok(VMValue::Float(*x as f64 / *y)),
808                    (VMValue::Float(x), VMValue::Int(y)) => Ok(VMValue::Float(*x / *y as f64)),
809                    _ => Err(VMError::Throw("div: expected numbers".to_string())),
810                }),
811                arity: 1,
812            }))
813        });
814
815        self.register("ceil", 1, |args| {
816            let f = as_float(&args[0])?;
817            Ok(VMValue::Int(f.ceil() as i64))
818        });
819
820        self.register("floor", 1, |args| {
821            let f = as_float(&args[0])?;
822            Ok(VMValue::Int(f.floor() as i64))
823        });
824
825        self.register("bitAnd", 1, |args| {
826            let a = as_int(&args[0])?;
827            Ok(VMValue::Builtin(VMBuiltin {
828                name: "bitAnd<partial>",
829                func: Rc::new(move |args2| {
830                    let b = as_int(&args2[0])?;
831                    Ok(VMValue::Int(a & b))
832                }),
833                arity: 1,
834            }))
835        });
836
837        self.register("bitOr", 1, |args| {
838            let a = as_int(&args[0])?;
839            Ok(VMValue::Builtin(VMBuiltin {
840                name: "bitOr<partial>",
841                func: Rc::new(move |args2| {
842                    let b = as_int(&args2[0])?;
843                    Ok(VMValue::Int(a | b))
844                }),
845                arity: 1,
846            }))
847        });
848
849        self.register("bitXor", 1, |args| {
850            let a = as_int(&args[0])?;
851            Ok(VMValue::Builtin(VMBuiltin {
852                name: "bitXor<partial>",
853                func: Rc::new(move |args2| {
854                    let b = as_int(&args2[0])?;
855                    Ok(VMValue::Int(a ^ b))
856                }),
857                arity: 1,
858            }))
859        });
860    }
861
862    // ── Derivation ────────────────────────────────────────────────
863
864    fn register_derivation_ops(&mut self) {
865        // Both `derivation` and `derivationStrict` delegate to the same impl.
866        // The actual implementation is at the VM level (vm_build_derivation)
867        // because it needs interner access. These stubs are intercepted by
868        // try_vm_builtin before they execute.
869        self.register("derivation", 1, |_args| {
870            Err(VMError::Throw(
871                "derivation: requires VM-level dispatch".to_string(),
872            ))
873        });
874        self.register("derivationStrict", 1, |_args| {
875            Err(VMError::Throw(
876                "derivationStrict: requires VM-level dispatch".to_string(),
877            ))
878        });
879        // getFlake: VM-level dispatch (needs import mechanism).
880        self.register("getFlake", 1, |_args| {
881            Err(VMError::Throw(
882                "getFlake: requires VM-level dispatch".to_string(),
883            ))
884        });
885        // scopedImport: VM-level dispatch (needs import + interner).
886        self.register("scopedImport", 1, |_args| {
887            Err(VMError::Throw(
888                "scopedImport: requires VM-level dispatch".to_string(),
889            ))
890        });
891
892        // ── Missing builtins needed for nixpkgs lib ─────────────────
893
894        // addErrorContext: in eval mode just returns the value (no-op wrapper)
895        self.register("addErrorContext", 1, |args| {
896            // Curried: addErrorContext context value → value
897            Ok(VMValue::Builtin(VMBuiltin {
898                name: "addErrorContext<partial>",
899                func: Rc::new(move |inner_args: Vec<VMValue>| Ok(inner_args[0].clone())),
900                arity: 1,
901            }))
902        });
903
904        // unsafeGetAttrPos: returns null (position info not tracked in VM)
905        self.register("unsafeGetAttrPos", 1, |_args| {
906            Ok(VMValue::Builtin(VMBuiltin {
907                name: "unsafeGetAttrPos<partial>",
908                func: Rc::new(|_args: Vec<VMValue>| Ok(VMValue::Null)),
909                arity: 1,
910            }))
911        });
912
913        // pathExists: check if a path exists on the filesystem
914        self.register("pathExists", 1, |args| {
915            let path = match &args[0] {
916                VMValue::Path(p) => p.clone(),
917                VMValue::String(s) => s.clone(),
918                other => {
919                    return Err(VMError::TypeError {
920                        expected: "path or string",
921                        got: other.type_name(),
922                        context: "pathExists".to_string(),
923                    })
924                }
925            };
926            // Redirect the READ through the installed path materializer: a
927            // flake input's `/nix/store/<narhash>-source` prefix is never on
928            // disk, so a bare `.exists()` answers NO for every file in a
929            // fetched input — silently, since `false` is a legal answer.
930            let read_path = crate::bridge::materialize(&path);
931            Ok(VMValue::Bool(std::path::Path::new(&read_path).exists()))
932        });
933
934        // readFile: read contents of a file
935        self.register("readFile", 1, |args| {
936            let path = match &args[0] {
937                VMValue::Path(p) => p.clone(),
938                VMValue::String(s) => s.clone(),
939                other => {
940                    return Err(VMError::TypeError {
941                        expected: "path or string",
942                        got: other.type_name(),
943                        context: "readFile".to_string(),
944                    })
945                }
946            };
947            // Same redirect as `pathExists` — the two MUST agree about a path,
948            // or a `if pathExists p then readFile p` guard passes and the read
949            // then ENOENTs.
950            let read_path = crate::bridge::materialize(&path);
951            let content = std::fs::read_to_string(&read_path)
952                .map_err(|e| VMError::Throw(format!("readFile {path}: {e}")))?;
953            Ok(VMValue::String(content))
954        });
955
956        // readDir: list directory entries
957        self.register("readDir", 1, |args| {
958            let path = match &args[0] {
959                VMValue::Path(p) => p.clone(),
960                VMValue::String(s) => s.clone(),
961                other => {
962                    return Err(VMError::TypeError {
963                        expected: "path or string",
964                        got: other.type_name(),
965                        context: "readDir".to_string(),
966                    })
967                }
968            };
969            // Unreachable whenever a bridge is installed: `builtins.readDir`
970            // is bridge-dispatched by name in `VM::try_vm_builtin`, so the
971            // tree-walker answers it — and the tree-walker's `readDir` already
972            // routes through `path::materialize`. This stub is the bridgeless
973            // path only; it cannot succeed, so there is nothing to redirect.
974            let _ = path;
975            Err(VMError::Throw(
976                "readDir: requires the tree-walker bridge (no interner access here)".to_string(),
977            ))
978        });
979
980        // baseNameOf: extract filename from a path
981        self.register("baseNameOf", 1, |args| {
982            let path = match &args[0] {
983                VMValue::Path(p) => p.clone(),
984                VMValue::String(s) => s.clone(),
985                other => {
986                    return Err(VMError::TypeError {
987                        expected: "path or string",
988                        got: other.type_name(),
989                        context: "baseNameOf".to_string(),
990                    })
991                }
992            };
993            let base = std::path::Path::new(&path)
994                .file_name()
995                .map(|f| f.to_string_lossy().to_string())
996                .unwrap_or_default();
997            Ok(VMValue::String(base))
998        });
999
1000        // dirOf: extract directory from a path
1001        self.register("dirOf", 1, |args| {
1002            let path = match &args[0] {
1003                VMValue::Path(p) => p.clone(),
1004                VMValue::String(s) => s.clone(),
1005                other => {
1006                    return Err(VMError::TypeError {
1007                        expected: "path or string",
1008                        got: other.type_name(),
1009                        context: "dirOf".to_string(),
1010                    })
1011                }
1012            };
1013            let dir = std::path::Path::new(&path)
1014                .parent()
1015                .map(|p| p.to_string_lossy().to_string())
1016                .unwrap_or_else(|| ".".to_string());
1017            Ok(VMValue::String(dir))
1018        });
1019
1020        // genericClosure: transitive closure computation
1021        self.register("genericClosure", 1, |_args| {
1022            Err(VMError::Throw(
1023                "genericClosure: requires VM-level dispatch".to_string(),
1024            ))
1025        });
1026
1027        // placeholder: returns placeholder string for derivation outputs
1028        self.register("placeholder", 1, |args| {
1029            let output = match &args[0] {
1030                VMValue::String(s) => s.clone(),
1031                _ => "out".to_string(),
1032            };
1033            Ok(VMValue::String(format!("/1rz4g4znpzjwh1xymhjpm42vipw92pr73vdgl6xs1hycac8kf2n9/{output}")))
1034        });
1035
1036        // split: regex split (requires VM-level dispatch for interner)
1037        self.register("split", 1, |args| {
1038            let _pattern = as_string(&args[0])?;
1039            Ok(VMValue::Builtin(VMBuiltin {
1040                name: "split<partial>",
1041                func: Rc::new(|_inner_args: Vec<VMValue>| {
1042                    Err(VMError::Throw("split: requires VM-level dispatch".to_string()))
1043                }),
1044                arity: 1,
1045            }))
1046        });
1047
1048        // match: regex match (requires VM-level dispatch for interner)
1049        self.register("match", 1, |args| {
1050            let _pattern = as_string(&args[0])?;
1051            Ok(VMValue::Builtin(VMBuiltin {
1052                name: "match<partial>",
1053                func: Rc::new(|_inner_args: Vec<VMValue>| {
1054                    Err(VMError::Throw("match: requires VM-level dispatch".to_string()))
1055                }),
1056                arity: 1,
1057            }))
1058        });
1059
1060        // fromTOML: parse a TOML string
1061        self.register("fromTOML", 1, |args| {
1062            let s = as_string(&args[0])?;
1063            // Simple stub - would need full TOML parser
1064            Err(VMError::Throw(format!("fromTOML: not yet implemented")))
1065        });
1066
1067        // concatStrings: concatenate a list of strings (used by nixpkgs lib)
1068        // Note: This isn't strictly a Nix builtin but is sometimes needed
1069        // In Nix it's actually builtins.concatStringsSep "" (already registered)
1070
1071        // storeDir: the Nix store directory
1072        // This is a constant, added in make_builtins_attrset
1073
1074        // fetchurl, fetchTarball, fetchGit, fetchTree stubs
1075        self.register("fetchurl", 1, |_args| {
1076            Err(VMError::Throw("fetchurl: not supported in eval mode".to_string()))
1077        });
1078        self.register("fetchTarball", 1, |_args| {
1079            Err(VMError::Throw("fetchTarball: not supported in eval mode".to_string()))
1080        });
1081        self.register("fetchGit", 1, |_args| {
1082            Err(VMError::Throw("fetchGit: not supported in eval mode".to_string()))
1083        });
1084        self.register("fetchTree", 1, |_args| {
1085            Err(VMError::Throw("fetchTree: not supported in eval mode".to_string()))
1086        });
1087        self.register("fetchMercurial", 1, |_args| {
1088            Err(VMError::Throw("fetchMercurial: not supported in eval mode".to_string()))
1089        });
1090
1091        // toFile: write a file to the Nix store (stub)
1092        self.register("toFile", 1, |_args| {
1093            Err(VMError::Throw("toFile: not supported in eval mode".to_string()))
1094        });
1095
1096        // toPath: convert string to path (deprecated in Nix, but used)
1097        self.register("toPath", 1, |args| {
1098            let s = as_string(&args[0])?;
1099            Ok(VMValue::Path(s.to_string()))
1100        });
1101
1102        // import: as a builtin value (not a special form)
1103        // Already handled at the compiler level via OpCode::Import
1104
1105        // parseDrvName: parse a derivation name-version string
1106        self.register("parseDrvName", 1, |args| {
1107            let name = as_string(&args[0])?;
1108            // Split at last hyphen followed by a digit
1109            let mut split_pos = None;
1110            let bytes = name.as_bytes();
1111            for i in (0..bytes.len()).rev() {
1112                if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
1113                    split_pos = Some(i);
1114                    break;
1115                }
1116            }
1117            match split_pos {
1118                Some(pos) => {
1119                    Err(VMError::Throw(
1120                        "parseDrvName: requires VM-level dispatch for interner access".to_string(),
1121                    ))
1122                }
1123                None => {
1124                    Err(VMError::Throw(
1125                        "parseDrvName: requires VM-level dispatch for interner access".to_string(),
1126                    ))
1127                }
1128            }
1129        });
1130
1131        // compareVersions — delegates to sui_compat::versions so the
1132        // tree-walker and VM stay in lock-step.  The previous naive
1133        // local implementation (split on `.` only, no `pre` handling)
1134        // diverged from cppnix on every nixpkgs version probe.
1135        self.register("compareVersions", 1, |args| {
1136            let a = as_string(&args[0])?.to_string();
1137            Ok(VMValue::Builtin(VMBuiltin {
1138                name: "compareVersions<partial>",
1139                func: Rc::new(move |inner_args: Vec<VMValue>| {
1140                    let b = as_string(&inner_args[0])?;
1141                    Ok(VMValue::Int(
1142                        sui_compat::versions::compare_versions(&a, b),
1143                    ))
1144                }),
1145                arity: 1,
1146            }))
1147        });
1148
1149        // splitVersion — delegates to sui_compat::versions for the
1150        // same reason compareVersions does.
1151        self.register("splitVersion", 1, |args| {
1152            let version = as_string(&args[0])?;
1153            let parts: Vec<VMValue> = sui_compat::versions::split_version(version)
1154                .into_iter()
1155                .map(VMValue::String)
1156                .collect();
1157            Ok(VMValue::List(parts))
1158        });
1159
1160        // concatStrings is used internally
1161        self.register("concatStrings", 1, |args| {
1162            let list = as_list(&args[0])?;
1163            let mut result = String::new();
1164            for item in &list {
1165                let item = force_vmvalue(item.clone()).unwrap_or_else(|_| item.clone());
1166                match &item {
1167                    VMValue::String(s) => result.push_str(s),
1168                    _ => {
1169                        return Err(VMError::TypeError {
1170                            expected: "string",
1171                            got: item.type_name(),
1172                            context: "concatStrings element".to_string(),
1173                        })
1174                    }
1175                }
1176            }
1177            Ok(VMValue::String(result))
1178        });
1179
1180        // ── String context builtins (no-ops in eval mode) ────────────
1181        // Nix string contexts track derivation dependencies. In eval-only
1182        // mode, strings have no context, so these are identity/no-ops.
1183        self.register("unsafeDiscardStringContext", 1, |args| {
1184            // Just return the string as-is (no context to discard).
1185            Ok(args[0].clone())
1186        });
1187        self.register("getContext", 1, |_args| {
1188            // No context in eval mode — return empty attrset.
1189            // Need VM dispatch for interner.
1190            Err(VMError::Throw(
1191                "getContext: requires VM-level dispatch for interner access".to_string(),
1192            ))
1193        });
1194        self.register("appendContext", 1, |args| {
1195            // No context to append — return string as-is.
1196            Ok(VMValue::Builtin(VMBuiltin {
1197                name: "appendContext<partial>",
1198                func: Rc::new(move |inner_args: Vec<VMValue>| Ok(inner_args[0].clone())),
1199                arity: 1,
1200            }))
1201        });
1202        self.register("hasContext", 1, |_args| {
1203            Ok(VMValue::Bool(false))
1204        });
1205        self.register("unsafeDiscardOutputDependency", 1, |args| {
1206            Ok(args[0].clone())
1207        });
1208        self.register("addDrvOutputDependencies", 1, |args| {
1209            Ok(args[0].clone())
1210        });
1211
1212        // ── Path/string conversion builtins ──────────────────────────
1213        self.register("storePath", 1, |args| {
1214            Ok(args[0].clone())
1215        });
1216        self.register("isStorePath", 1, |args| {
1217            let s = match &args[0] {
1218                VMValue::String(s) => s.as_str(),
1219                VMValue::Path(p) => p.as_str(),
1220                _ => return Ok(VMValue::Bool(false)),
1221            };
1222            Ok(VMValue::Bool(s.starts_with("/nix/store/")))
1223        });
1224        self.register("hashString", 1, |args| {
1225            let algo = as_string(&args[0])?.to_string();
1226            Ok(VMValue::Builtin(VMBuiltin {
1227                name: "hashString<partial>",
1228                func: Rc::new(move |inner_args: Vec<VMValue>| {
1229                    let s = as_string(&inner_args[0])?;
1230                    match algo.as_str() {
1231                        "sha256" => {
1232                            use sha2::{Sha256, Digest};
1233                            let mut hasher = Sha256::new();
1234                            hasher.update(s.as_bytes());
1235                            let result = hasher.finalize();
1236                            let hex: String = result
1237                                .iter()
1238                                .map(|b| format!("{b:02x}"))
1239                                .collect();
1240                            Ok(VMValue::String(hex))
1241                        }
1242                        _ => Err(VMError::Throw(format!("hashString: unsupported algorithm: {algo}")))
1243                    }
1244                }),
1245                arity: 1,
1246            }))
1247        });
1248        self.register("hashFile", 1, |_args| {
1249            Err(VMError::Throw("hashFile: not supported in eval mode".to_string()))
1250        });
1251
1252        // import as a value (not the special form in Apply).
1253        // When used as `import path`, the compiler handles it via OpCode::Import.
1254        // But when `import` is passed as a function value (e.g., `map import paths`),
1255        // it needs to be callable. The VM dispatches this specially.
1256        self.register("import", 1, |_args| {
1257            Err(VMError::Throw(
1258                "import: requires VM-level dispatch".to_string(),
1259            ))
1260        });
1261
1262        // ── Misc builtins needed by nixpkgs lib ─────────────────────
1263        self.register("zipAttrsWith", 1, |_args| {
1264            Err(VMError::Throw(
1265                "zipAttrsWith: requires VM-level dispatch".to_string(),
1266            ))
1267        });
1268    }
1269
1270    // ── Missing builtins: direct implementations + bridge stubs ────
1271    //
1272    // These are builtins that the tree-walker has but the VM was missing.
1273    // Simple ones are implemented directly; complex ones delegate to the
1274    // builtin bridge (which calls back into the tree-walker).
1275
1276    fn register_missing_builtins(&mut self) {
1277        // ── Direct implementations (simple, no tree-walker state) ────
1278
1279        // getEnv: look up environment variable (returns "" if unset)
1280        self.register("getEnv", 1, |args| {
1281            let name = as_string(&args[0])?;
1282            let val = std::env::var(name).unwrap_or_default();
1283            Ok(VMValue::String(val))
1284        });
1285
1286        // readFileType: return file type as string
1287        self.register("readFileType", 1, |args| {
1288            let path = match &args[0] {
1289                VMValue::Path(p) => p.clone(),
1290                VMValue::String(s) => s.clone(),
1291                other => {
1292                    return Err(VMError::TypeError {
1293                        expected: "path or string",
1294                        got: other.type_name(),
1295                        context: "readFileType".to_string(),
1296                    });
1297                }
1298            };
1299            // Same redirect as `pathExists`/`readFile`.
1300            let read_path = crate::bridge::materialize(&path);
1301            match std::fs::symlink_metadata(&read_path) {
1302                Ok(meta) => {
1303                    let kind = if meta.is_symlink() {
1304                        "symlink"
1305                    } else if meta.is_dir() {
1306                        "directory"
1307                    } else if meta.is_file() {
1308                        "regular"
1309                    } else {
1310                        "unknown"
1311                    };
1312                    Ok(VMValue::String(kind.to_string()))
1313                }
1314                Err(e) => Err(VMError::Throw(format!("readFileType {path}: {e}"))),
1315            }
1316        });
1317
1318        // findFile: curried, search NIX_PATH entries for a file
1319        self.register("findFile", 1, |args| {
1320            let search_path = as_list(&args[0])?.clone();
1321            Ok(VMValue::Builtin(VMBuiltin {
1322                name: "findFile<partial>",
1323                func: Rc::new(move |args2| {
1324                    let name = as_string(&args2[0])?;
1325                    for entry in &search_path {
1326                        if let VMValue::Attrs(a) = entry {
1327                            // We need the interner to look up "prefix" and "path" keys.
1328                            // Since this is a bridge builtin, delegate to the bridge.
1329                            // But first try a string-key lookup on a best-effort basis.
1330                            // The bridge will handle the real implementation.
1331                            let _ = a;
1332                        }
1333                    }
1334                    // Delegate to bridge for proper implementation
1335                    bridge_call("findFile", vec![
1336                        VMValue::List(search_path.clone()),
1337                        VMValue::String(name.to_string()),
1338                    ])
1339                }),
1340                arity: 1,
1341            }))
1342        });
1343
1344        // lessThan: curried comparison (missing from VM arithmetic ops)
1345        self.register("lessThan", 1, |args| {
1346            let a = args[0].clone();
1347            Ok(VMValue::Builtin(VMBuiltin {
1348                name: "lessThan<partial>",
1349                func: Rc::new(move |args2| match (&a, &args2[0]) {
1350                    (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Bool(*x < *y)),
1351                    (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Bool(*x < *y)),
1352                    (VMValue::Int(x), VMValue::Float(y)) => {
1353                        Ok(VMValue::Bool((*x as f64) < *y))
1354                    }
1355                    (VMValue::Float(x), VMValue::Int(y)) => {
1356                        Ok(VMValue::Bool(*x < (*y as f64)))
1357                    }
1358                    (VMValue::String(x), VMValue::String(y)) => Ok(VMValue::Bool(*x < *y)),
1359                    _ => Err(VMError::Throw(
1360                        "lessThan: expected comparable types".to_string(),
1361                    )),
1362                }),
1363                arity: 1,
1364            }))
1365        });
1366
1367        // warn: like trace, prints warning and returns identity
1368        self.register("warn", 1, |args| {
1369            if let Ok(msg) = as_string(&args[0]) {
1370                eprintln!("evaluation warning: {msg}");
1371            }
1372            Ok(VMValue::Builtin(VMBuiltin {
1373                name: "warn<partial>",
1374                func: Rc::new(|args2| Ok(args2[0].clone())),
1375                arity: 1,
1376            }))
1377        });
1378
1379        // traceVerbose: like trace but only when SUI_TRACE_VERBOSE=1
1380        self.register("traceVerbose", 1, |args| {
1381            if std::env::var("SUI_TRACE_VERBOSE").ok().as_deref() == Some("1") {
1382                eprintln!("trace: {}", args[0]);
1383            }
1384            Ok(VMValue::Builtin(VMBuiltin {
1385                name: "traceVerbose<partial>",
1386                func: Rc::new(|args2| Ok(args2[0].clone())),
1387                arity: 1,
1388            }))
1389        });
1390
1391        // break: debug breakpoint, just returns its argument
1392        self.register("break", 1, |args| Ok(args[0].clone()));
1393
1394        // ── Bridge-delegating stubs ─────────────────────────────────
1395        //
1396        // These builtins are complex (need tree-walker state, regex cache,
1397        // TOML parser, hash algorithms, etc.) and are delegated to the
1398        // builtin bridge which calls back into the tree-walker.
1399
1400        // Names of builtins that should be bridged and their arities.
1401        // When called, they convert args to StringKeyedValue, call the
1402        // bridge, and convert back.
1403        //
1404        // Note: Some of these are already registered above as stubs that
1405        // throw "requires VM-level dispatch". The bridge versions below
1406        // replace the error with actual functionality when a bridge is set.
1407        // We register them with unique names to avoid conflicts, and the
1408        // VM's try_vm_builtin handles dispatch.
1409
1410        // Bridge complex builtins to tree-walker.
1411        // These need tree-walker state, complex algorithms, or I/O.
1412        //
1413        // ★ REGISTERING A BRIDGE-DISPATCHED BUILTIN IS NOT OPTIONAL: the name
1414        // must appear HERE even though `VM::try_vm_builtin` dispatches it by
1415        // name, because `make_builtins_attrset` is built from THIS registry
1416        // and it is what `builtins ? <name>` answers from. `path`,
1417        // `parseFlakeRef` and `flakeRefToString` were dispatched but never
1418        // registered, so `builtins ? path` answered FALSE while nix and the
1419        // tree-walker answer TRUE — and nixpkgs `lib` gates on exactly that
1420        // shape, so a false answer silently takes the other branch.
1421        for name in &["convertHash", "toXML", "toFile", "filterSource",
1422                      "fetchClosure", "outputOf", "hashFile", "hashString",
1423                      "path", "parseFlakeRef", "flakeRefToString"]
1424        {
1425            let n = (*name).to_string();
1426            self.register(name, 1, move |args| {
1427                bridge_call(&n, args.to_vec())
1428            });
1429        }
1430    }
1431}
1432
1433/// Helper: delegate a builtin call to the tree-walker bridge.
1434///
1435/// Converts `VMValue` args to `StringKeyedValue`, calls the bridge,
1436/// and converts the result back. Returns an error if no bridge is set.
1437/// Apply a curried numeric binop with CppNix mixed-type semantics:
1438/// Int+Int → Int, Float+Float → Float, mixed → Float.  Used by
1439/// sub / mul (div has its own /0 trap).
1440fn vm_numeric_binop(
1441    name: &'static str,
1442    a: &VMValue,
1443    b: &VMValue,
1444    int_op: impl Fn(i64, i64) -> i64,
1445    float_op: impl Fn(f64, f64) -> f64,
1446) -> Result<VMValue, VMError> {
1447    match (a, b) {
1448        (VMValue::Int(x), VMValue::Int(y)) => Ok(VMValue::Int(int_op(*x, *y))),
1449        (VMValue::Float(x), VMValue::Float(y)) => Ok(VMValue::Float(float_op(*x, *y))),
1450        (VMValue::Int(x), VMValue::Float(y)) => Ok(VMValue::Float(float_op(*x as f64, *y))),
1451        (VMValue::Float(x), VMValue::Int(y)) => Ok(VMValue::Float(float_op(*x, *y as f64))),
1452        _ => Err(VMError::Throw(format!("{name}: expected numbers"))),
1453    }
1454}
1455
1456fn bridge_call(name: &str, args: Vec<VMValue>) -> Result<VMValue, VMError> {
1457    use crate::intern::Interner;
1458    // Convert VMValue args to StringKeyedValue (interner-free).
1459    // For this we need a temporary interner to resolve any Symbol keys.
1460    let tmp_interner = Interner::new();
1461    let sk_args: Vec<crate::value::StringKeyedValue> = args
1462        .iter()
1463        .map(|a| a.to_string_keyed(&tmp_interner))
1464        .collect();
1465
1466    match crate::bridge::call_builtin_bridge(name, sk_args) {
1467        Ok(Some(result)) => Ok(string_keyed_to_vmvalue(&result, &mut Interner::new())),
1468        Ok(None) => Err(VMError::Throw(format!(
1469            "builtin '{name}' requires bridge but no bridge is set"
1470        ))),
1471        Err(e) => Err(VMError::Throw(e)),
1472    }
1473}
1474
1475/// Convert a `StringKeyedValue` back to a `VMValue`.
1476///
1477/// Requires an interner to create Symbol keys for attrsets.
1478/// Public so the VM's `try_vm_builtin` can use it for bridge dispatch.
1479pub fn string_keyed_to_vmvalue(
1480    sk: &crate::value::StringKeyedValue,
1481    interner: &mut crate::intern::Interner,
1482) -> VMValue {
1483    use crate::value::StringKeyedValue;
1484    match sk {
1485        StringKeyedValue::Null => VMValue::Null,
1486        StringKeyedValue::Bool(b) => VMValue::Bool(*b),
1487        StringKeyedValue::Int(n) => VMValue::Int(*n),
1488        StringKeyedValue::Float(f) => VMValue::Float(*f),
1489        StringKeyedValue::String(s) => VMValue::String(s.clone()),
1490        StringKeyedValue::Path(p) => VMValue::Path(p.clone()),
1491        StringKeyedValue::List(items) => VMValue::List(
1492            items
1493                .iter()
1494                .map(|v| string_keyed_to_vmvalue(v, interner))
1495                .collect(),
1496        ),
1497        StringKeyedValue::Attrs(map) => {
1498            let mut attrs = BTreeMap::new();
1499            for (k, v) in map {
1500                let sym = interner.intern(k);
1501                attrs.insert(sym, string_keyed_to_vmvalue(v, interner));
1502            }
1503            VMValue::Attrs(attrs)
1504        }
1505        StringKeyedValue::Lambda => VMValue::Null,
1506        StringKeyedValue::Callable(cb) => {
1507            let cb_clone = Rc::clone(cb);
1508            VMValue::Builtin(crate::value::VMBuiltin {
1509                name: "<bridge-fn>",
1510                arity: 1,
1511                func: Rc::new(move |args: Vec<VMValue>| {
1512                    let interner = crate::intern::Interner::new();
1513                    let sk_arg = args.into_iter().next()
1514                        .unwrap_or(VMValue::Null)
1515                        .to_string_keyed(&interner);
1516                    let sk_result = cb_clone(sk_arg)
1517                        .map_err(|e| VMError::Throw(e))?;
1518                    let mut tmp_interner = crate::intern::Interner::new();
1519                    Ok(string_keyed_to_vmvalue(&sk_result, &mut tmp_interner))
1520                }),
1521            })
1522        }
1523        StringKeyedValue::Thunk(cb) => {
1524            // Wrap the StringKeyedValue thunk as a VMThunk.
1525            let cb_clone = Rc::clone(cb);
1526            VMValue::Thunk(crate::value::VMThunk::new_native(move || {
1527                let sk_val = cb_clone().map_err(|e| VMError::Throw(e))?;
1528                // Use a fresh interner for the result conversion.
1529                let mut tmp = crate::intern::Interner::new();
1530                Ok(string_keyed_to_vmvalue(&sk_val, &mut tmp))
1531            }))
1532        }
1533    }
1534}
1535
1536impl Default for BuiltinRegistry {
1537    fn default() -> Self {
1538        Self::new()
1539    }
1540}
1541
1542// ── Helper functions ──────────────────────────────────────────────
1543
1544/// Try to extract a concrete value from a `Done` thunk without VM access.
1545/// Returns the inner value for already-evaluated thunks. For non-thunks,
1546/// returns `None` (use the value directly). For pending thunks, returns
1547/// an error that will cause the VM to fall back to the tree-walker.
1548fn try_unwrap_done_thunk(v: &VMValue) -> Option<Result<VMValue, VMError>> {
1549    match v {
1550        VMValue::Thunk(thunk) => {
1551            let state = thunk.state.take();
1552            match state {
1553                Some(ThunkState::Done(boxed)) => {
1554                    let inner = *boxed.clone();
1555                    thunk.state.set(Some(ThunkState::Done(boxed)));
1556                    // Recursively unwrap in case the result is itself a Done thunk.
1557                    match &inner {
1558                        VMValue::Thunk(_) => Some(try_unwrap_done_thunk(&inner)
1559                            .unwrap_or(Ok(inner))),
1560                        _ => Some(Ok(inner)),
1561                    }
1562                }
1563                other => {
1564                    thunk.state.set(other);
1565                    Some(Err(VMError::TypeError {
1566                        expected: "concrete value",
1567                        got: "thunk (pending)",
1568                        context: "builtin argument (thunk needs VM to force)".to_string(),
1569                    }))
1570                }
1571            }
1572        }
1573        _ => None, // Not a thunk — caller uses value directly
1574    }
1575}
1576
1577/// Extract a list, forcing thunks if needed. Returns an owned Vec
1578/// because thunk forcing may produce a value we can't borrow.
1579fn as_list(v: &VMValue) -> Result<Vec<VMValue>, VMError> {
1580    match v {
1581        VMValue::List(l) => Ok(l.clone()),
1582        VMValue::Thunk(_) => {
1583            let forced = force_vmvalue(v.clone())?;
1584            match forced {
1585                VMValue::List(l) => Ok(l),
1586                other => Err(VMError::TypeError {
1587                    expected: "list",
1588                    got: other.type_name(),
1589                    context: "builtin argument".to_string(),
1590                }),
1591            }
1592        }
1593        other => Err(VMError::TypeError {
1594            expected: "list",
1595            got: other.type_name(),
1596            context: "builtin argument".to_string(),
1597        }),
1598    }
1599}
1600
1601/// Force a VMValue if it's a thunk, returning the resolved value.
1602/// Handles Done thunks directly, NativeCallback via bridge, and
1603/// Pending thunks cause a fallback error.
1604fn force_vmvalue(v: VMValue) -> Result<VMValue, VMError> {
1605    match v {
1606        VMValue::Thunk(ref thunk) => {
1607            let state = thunk.state.take();
1608            match state {
1609                Some(ThunkState::Done(boxed)) => {
1610                    let inner = *boxed.clone();
1611                    thunk.state.set(Some(ThunkState::Done(boxed)));
1612                    force_vmvalue(inner) // Recursively unwrap
1613                }
1614                Some(ThunkState::NativeCallback(cb)) => {
1615                    thunk.state.set(Some(ThunkState::Evaluating));
1616                    match cb() {
1617                        Ok(sk_val) => {
1618                            // Convert StringKeyedValue back to VMValue
1619                            let result = sk_to_vmvalue(&sk_val);
1620                            thunk.state.set(Some(ThunkState::Done(Box::new(result.clone()))));
1621                            force_vmvalue(result)
1622                        }
1623                        Err(e) => {
1624                            thunk.state.set(Some(ThunkState::NativeCallback(cb)));
1625                            Err(VMError::Throw(e))
1626                        }
1627                    }
1628                }
1629                other => {
1630                    thunk.state.set(other);
1631                    // Pending/LazySource/Evaluating — needs VM to force.
1632                    Err(VMError::TypeError {
1633                        expected: "concrete value",
1634                        got: "thunk (pending)",
1635                        context: "builtin argument (thunk needs VM to force)".to_string(),
1636                    })
1637                }
1638            }
1639        }
1640        other => Ok(other),
1641    }
1642}
1643
1644/// Convert StringKeyedValue → VMValue (inverse of to_string_keyed).
1645fn sk_to_vmvalue(sk: &crate::value::StringKeyedValue) -> VMValue {
1646    use crate::value::StringKeyedValue;
1647    match sk {
1648        StringKeyedValue::Null => VMValue::Null,
1649        StringKeyedValue::Bool(b) => VMValue::Bool(*b),
1650        StringKeyedValue::Int(n) => VMValue::Int(*n),
1651        StringKeyedValue::Float(f) => VMValue::Float(*f),
1652        StringKeyedValue::String(s) => VMValue::String(s.clone()),
1653        StringKeyedValue::Path(p) => VMValue::Path(p.clone()),
1654        StringKeyedValue::List(items) => {
1655            VMValue::List(items.iter().map(|i| sk_to_vmvalue(i)).collect())
1656        }
1657        StringKeyedValue::Attrs(map) => {
1658            // Use the global interner for symbol resolution
1659            let mut interner = crate::intern::Interner::new();
1660            VMValue::Attrs(map.iter().map(|(k, v)| {
1661                (interner.intern(k), sk_to_vmvalue(v))
1662            }).collect())
1663        }
1664        StringKeyedValue::Lambda => VMValue::Null, // Can't reconstruct closures
1665        StringKeyedValue::Thunk(cb) => {
1666            // Wrap as a NativeCallback VMThunk for lazy evaluation
1667            let cb = cb.clone();
1668            VMValue::Thunk(crate::value::VMThunk {
1669                state: Rc::new(Cell::new(Some(ThunkState::NativeCallback(cb)))),
1670            })
1671        }
1672        StringKeyedValue::Callable(_) => VMValue::Null, // Can't reconstruct
1673    }
1674}
1675
1676fn as_attrs(v: &VMValue) -> Result<&BTreeMap<Symbol, VMValue>, VMError> {
1677    match v {
1678        VMValue::Attrs(a) => Ok(a),
1679        VMValue::Thunk(_) => Err(VMError::TypeError {
1680            expected: "set",
1681            got: "thunk",
1682            context: "builtin argument".to_string(),
1683        }),
1684        other => Err(VMError::TypeError {
1685            expected: "set",
1686            got: other.type_name(),
1687            context: "builtin argument".to_string(),
1688        }),
1689    }
1690}
1691
1692fn as_string(v: &VMValue) -> Result<&str, VMError> {
1693    match v {
1694        VMValue::String(s) => Ok(s),
1695        other => Err(VMError::TypeError {
1696            expected: "string",
1697            got: other.type_name(),
1698            context: "builtin argument".to_string(),
1699        }),
1700    }
1701}
1702
1703/// Force-aware string extraction: forces thunks before extracting.
1704/// Use this when iterating over list elements that may be thunks.
1705fn force_as_string(v: &VMValue) -> Result<String, VMError> {
1706    match v {
1707        VMValue::String(s) => Ok(s.clone()),
1708        VMValue::Thunk(_) => {
1709            let forced = force_vmvalue(v.clone())?;
1710            match forced {
1711                VMValue::String(s) => Ok(s),
1712                other => Err(VMError::TypeError {
1713                    expected: "string",
1714                    got: other.type_name(),
1715                    context: "builtin argument (after forcing thunk)".to_string(),
1716                }),
1717            }
1718        }
1719        other => Err(VMError::TypeError {
1720            expected: "string",
1721            got: other.type_name(),
1722            context: "builtin argument".to_string(),
1723        }),
1724    }
1725}
1726
1727/// Force-aware list extraction: forces thunks before extracting.
1728fn force_as_list(v: &VMValue) -> Result<Vec<VMValue>, VMError> {
1729    match v {
1730        VMValue::List(l) => Ok(l.clone()),
1731        VMValue::Thunk(_) => {
1732            let forced = force_vmvalue(v.clone())?;
1733            match forced {
1734                VMValue::List(l) => Ok(l),
1735                other => Err(VMError::TypeError {
1736                    expected: "list",
1737                    got: other.type_name(),
1738                    context: "builtin argument (after forcing thunk)".to_string(),
1739                }),
1740            }
1741        }
1742        other => Err(VMError::TypeError {
1743            expected: "list",
1744            got: other.type_name(),
1745            context: "builtin argument".to_string(),
1746        }),
1747    }
1748}
1749
1750fn as_int(v: &VMValue) -> Result<i64, VMError> {
1751    match v {
1752        VMValue::Int(n) => Ok(*n),
1753        other => Err(VMError::TypeError {
1754            expected: "int",
1755            got: other.type_name(),
1756            context: "builtin argument".to_string(),
1757        }),
1758    }
1759}
1760
1761fn as_float(v: &VMValue) -> Result<f64, VMError> {
1762    match v {
1763        VMValue::Float(f) => Ok(*f),
1764        VMValue::Int(n) => Ok(*n as f64),
1765        other => Err(VMError::TypeError {
1766            expected: "float",
1767            got: other.type_name(),
1768            context: "builtin argument".to_string(),
1769        }),
1770    }
1771}
1772
1773/// Coerce a VMValue to string, matching CppNix's `builtins.toString` semantics:
1774/// - Strings, ints, floats, bools, null, paths: straightforward conversion
1775/// - Attrsets with `__toString`: call the function with the attrset as argument
1776///   (handled by VM fallback — here we just check `outPath`)
1777/// - Attrsets with `outPath`: coerce the outPath value
1778/// - Lists: space-join coerced elements
1779fn vm_coerce_to_string(v: &VMValue) -> Result<VMValue, VMError> {
1780    match v {
1781        VMValue::String(s) => Ok(VMValue::String(s.clone())),
1782        VMValue::Int(n) => Ok(VMValue::String(n.to_string())),
1783        // 6-decimal fixed-point to match CppNix's `%f` float coercion.
1784        VMValue::Float(f) => Ok(VMValue::String(format!("{f:.6}"))),
1785        VMValue::Bool(true) => Ok(VMValue::String("1".to_string())),
1786        VMValue::Bool(false) => Ok(VMValue::String(String::new())),
1787        VMValue::Null => Ok(VMValue::String(String::new())),
1788        VMValue::Path(p) => Ok(VMValue::String(p.clone())),
1789        VMValue::Attrs(attrs) => {
1790            // Check __toString first (requires calling a function — if present,
1791            // we fall back to the VM bridge for now)
1792            let to_str_sym = crate::intern::intern("__toString");
1793            if attrs.contains_key(&to_str_sym) {
1794                // __toString requires calling a closure with the attrset.
1795                // This can't be done from a pure builtin — the VM will handle
1796                // this via the bridge fallback.
1797                return Err(VMError::Throw(
1798                    "toString: __toString requires VM bridge".to_string(),
1799                ));
1800            }
1801            let out_path_sym = crate::intern::intern("outPath");
1802            if let Some(out_path) = attrs.get(&out_path_sym) {
1803                vm_coerce_to_string(out_path)
1804            } else {
1805                Err(VMError::Throw(
1806                    "cannot coerce a set to a string, but it has no __toString or outPath".to_string(),
1807                ))
1808            }
1809        }
1810        VMValue::List(items) => {
1811            let mut parts = Vec::with_capacity(items.len());
1812            for item in items {
1813                match vm_coerce_to_string(item)? {
1814                    VMValue::String(s) => parts.push(s),
1815                    _ => unreachable!("vm_coerce_to_string always returns String"),
1816                }
1817            }
1818            Ok(VMValue::String(parts.join(" ")))
1819        }
1820        VMValue::Closure(_) | VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
1821            Err(VMError::Throw(
1822                "cannot coerce a function to a string".to_string(),
1823            ))
1824        }
1825        VMValue::Thunk(_) => {
1826            Err(VMError::Throw(
1827                "toString: thunk should be forced first".to_string(),
1828            ))
1829        }
1830    }
1831}
1832
1833/// Convert a VMValue to serde_json::Value for toJSON.
1834fn vm_value_to_json(v: &VMValue) -> Result<serde_json::Value, VMError> {
1835    match v {
1836        VMValue::Null => Ok(serde_json::Value::Null),
1837        VMValue::Bool(b) => Ok(serde_json::Value::Bool(*b)),
1838        VMValue::Int(n) => Ok(serde_json::Value::Number(
1839            serde_json::Number::from(*n),
1840        )),
1841        VMValue::Float(f) => serde_json::Number::from_f64(*f)
1842            .map(serde_json::Value::Number)
1843            .ok_or_else(|| VMError::Throw("toJSON: invalid float".to_string())),
1844        VMValue::String(s) => Ok(serde_json::Value::String(s.clone())),
1845        VMValue::Path(p) => Ok(serde_json::Value::String(p.clone())),
1846        VMValue::List(items) => {
1847            let arr: Result<Vec<_>, _> = items.iter().map(vm_value_to_json).collect();
1848            Ok(serde_json::Value::Array(arr?))
1849        }
1850        VMValue::Attrs(_) => {
1851            // Can't convert attrsets without interner access for key names
1852            Err(VMError::Throw(
1853                "toJSON: attrset conversion requires interner".to_string(),
1854            ))
1855        }
1856        VMValue::Closure(_) | VMValue::Builtin(_) | VMValue::HigherOrderBuiltin(_) => {
1857            Err(VMError::Throw("toJSON: cannot convert function".to_string()))
1858        }
1859        VMValue::Thunk(_) => {
1860            Err(VMError::Throw("toJSON: thunk should be forced first".to_string()))
1861        }
1862    }
1863}
1864
1865/// Convert a serde_json::Value to VMValue for fromJSON.
1866fn json_to_vm_value(v: &serde_json::Value) -> VMValue {
1867    match v {
1868        serde_json::Value::Null => VMValue::Null,
1869        serde_json::Value::Bool(b) => VMValue::Bool(*b),
1870        serde_json::Value::Number(n) => {
1871            if let Some(i) = n.as_i64() {
1872                VMValue::Int(i)
1873            } else {
1874                VMValue::Float(n.as_f64().unwrap_or(0.0))
1875            }
1876        }
1877        serde_json::Value::String(s) => VMValue::String(s.clone()),
1878        serde_json::Value::Array(arr) => {
1879            VMValue::List(arr.iter().map(json_to_vm_value).collect())
1880        }
1881        serde_json::Value::Object(_) => {
1882            // Can't create Symbol-keyed attrsets without an interner.
1883            // Return null as a fallback; real usage goes through VM.
1884            VMValue::Null
1885        }
1886    }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    use super::*;
1892
1893    #[test]
1894    fn registry_has_builtins() {
1895        let reg = BuiltinRegistry::new();
1896        assert!(reg.lookup("length").is_some());
1897        assert!(reg.lookup("typeOf").is_some());
1898        assert!(reg.lookup("head").is_some());
1899        assert!(reg.lookup("tail").is_some());
1900        assert!(reg.lookup("throw").is_some());
1901        assert!(reg.lookup("nonexistent").is_none());
1902    }
1903
1904    #[test]
1905    fn call_length() {
1906        let reg = BuiltinRegistry::new();
1907        let idx = reg.lookup("length").unwrap();
1908        let result = reg
1909            .call(idx, vec![VMValue::List(vec![VMValue::Int(1), VMValue::Int(2)])])
1910            .unwrap();
1911        assert_eq!(result, VMValue::Int(2));
1912    }
1913
1914    #[test]
1915    fn call_head() {
1916        let reg = BuiltinRegistry::new();
1917        let idx = reg.lookup("head").unwrap();
1918        let result = reg
1919            .call(idx, vec![VMValue::List(vec![VMValue::Int(10)])])
1920            .unwrap();
1921        assert_eq!(result, VMValue::Int(10));
1922    }
1923
1924    #[test]
1925    fn call_head_empty() {
1926        let reg = BuiltinRegistry::new();
1927        let idx = reg.lookup("head").unwrap();
1928        let result = reg.call(idx, vec![VMValue::List(vec![])]);
1929        assert!(result.is_err());
1930    }
1931
1932    #[test]
1933    fn call_type_of() {
1934        let reg = BuiltinRegistry::new();
1935        let idx = reg.lookup("typeOf").unwrap();
1936        assert_eq!(
1937            reg.call(idx, vec![VMValue::Int(42)]).unwrap(),
1938            VMValue::String("int".to_string())
1939        );
1940        assert_eq!(
1941            reg.call(idx, vec![VMValue::String("hello".to_string())])
1942                .unwrap(),
1943            VMValue::String("string".to_string())
1944        );
1945    }
1946
1947    #[test]
1948    fn call_string_length() {
1949        let reg = BuiltinRegistry::new();
1950        let idx = reg.lookup("stringLength").unwrap();
1951        let result = reg
1952            .call(idx, vec![VMValue::String("hello".to_string())])
1953            .unwrap();
1954        assert_eq!(result, VMValue::Int(5));
1955    }
1956
1957    #[test]
1958    fn call_throw() {
1959        let reg = BuiltinRegistry::new();
1960        let idx = reg.lookup("throw").unwrap();
1961        let result = reg.call(idx, vec![VMValue::String("test error".to_string())]);
1962        assert!(matches!(result, Err(VMError::Throw(_))));
1963    }
1964
1965    #[test]
1966    fn call_to_string() {
1967        let reg = BuiltinRegistry::new();
1968        let idx = reg.lookup("toString").unwrap();
1969        assert_eq!(
1970            reg.call(idx, vec![VMValue::Int(42)]).unwrap(),
1971            VMValue::String("42".to_string())
1972        );
1973        assert_eq!(
1974            reg.call(idx, vec![VMValue::Bool(true)]).unwrap(),
1975            VMValue::String("1".to_string())
1976        );
1977    }
1978
1979    #[test]
1980    fn builtins_attrset() {
1981        let reg = BuiltinRegistry::new();
1982        let mut interner = Interner::new();
1983        let builtins = reg.make_builtins_attrset(&mut interner);
1984        match &builtins {
1985            VMValue::Attrs(attrs) => {
1986                let length_sym = interner.lookup("length").unwrap();
1987                assert!(attrs.contains_key(&length_sym));
1988            }
1989            _ => panic!("expected Attrs"),
1990        }
1991    }
1992}