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