Skip to main content

sui_spec/
ast_evaluator.rs

1//! Minimum-viable tree-walking evaluator over [`AstGraph`] — the
2//! engine that drives the module-system solver's
3//! [`BodyEvaluator`](crate::module_solver::BodyEvaluator) impl today.
4//!
5//! ## Scope
6//!
7//! Handles the AST kinds needed to fully evaluate **setter body
8//! expressions** in typical NixOS modules: literals, identifier
9//! lookups via env, dotted attrset selects rooted at `config`, the
10//! standard binary + unary operators, conditional expressions, lists,
11//! and attrset construction.
12//!
13//! What this does **not** cover (intentional — those land when the
14//! sui-eval bytecode VM integration replaces this minimum-viable
15//! engine):
16//!
17//! * `Apply` (function calls) — `lib.mkOption {...}`, `pkgs.callPackage
18//!   ...`, etc. Module bodies that compute setter values via library
19//!   calls fall back to [`EvalValue::Opaque`] today (a sentinel that
20//!   carries the AST node id for the eventual VM-backed re-evaluation).
21//! * `Lambda` and `LetIn` — closures and local bindings. Reported as
22//!   `Opaque` for the same reason.
23//! * `With` — runtime scope manipulation. Same.
24//! * Full string interpolation — segments evaluated to interpolated
25//!   sub-values are concatenated when all parts evaluate strict-ly to
26//!   strings; otherwise the whole string is `Opaque`.
27//! * Type coercions (Int↔Float, Path↔String) — only when both sides
28//!   are the same type. Mixed-type arithmetic returns
29//!   `EvalError::TypeMismatch`.
30//!
31//! ## Why this exists separately from sui-eval
32//!
33//! The sui-eval crate's tree-walker + bytecode VM both take **source
34//! text** as input (rnix CST → typed AST → bytecode). The L4 substrate
35//! works in the opposite direction: it has the typed AST already (in
36//! AstGraph form) and needs to evaluate sub-expressions of it directly,
37//! WITHOUT round-tripping back through source. That's structurally a
38//! different shape: walk the AstNodeKind variants, with the env as a
39//! BTreeMap of pre-evaluated values.
40//!
41//! Future ship: sui-eval grows an AstGraph-input mode (its tree-walker
42//! gains a constructor that takes a pre-built typed AST), and this
43//! module either becomes a thin wrapper around that or is sunset in
44//! favor of the unified evaluator.
45
46use std::collections::BTreeMap;
47
48use serde::{Deserialize, Serialize};
49
50use crate::ast_graph::{AstGraph, AstNodeKind, BinaryOp, NodeId, StrSegment, UnaryOp};
51
52/// One formal arg in a `PatternClosure`'s declaration.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct PatternFormal {
55    pub name: String,
56    /// AST node id for the formal's default expression, if any. The
57    /// default evaluates lazily — only when the caller's AttrSet
58    /// doesn't supply this name.
59    pub default_node_id: Option<NodeId>,
60}
61
62/// Typed value the evaluator produces. Mirrors the Nix value lattice
63/// for the kinds we support today.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub enum EvalValue {
66    Int(i64),
67    Float(f64),
68    Bool(bool),
69    Null,
70    Str(String),
71    Path(String),
72    List(Vec<EvalValue>),
73    AttrSet(BTreeMap<String, EvalValue>),
74    /// Closure value — produced by evaluating a `Lambda` node with
75    /// a single Ident param. Captures the param name + body AST id
76    /// + closure env at construction time.
77    Closure {
78        param: String,
79        body_node_id: NodeId,
80        captured_env: BTreeMap<String, EvalValue>,
81    },
82    /// Pattern-arg closure — `{ a, b ? default, ... } [@ name]: body`.
83    /// Captures the formal-arg shape so `Apply` can unpack the
84    /// argument AttrSet, bind each formal, apply defaults for missing
85    /// keys, and evaluate the body.
86    PatternClosure {
87        /// Formal-arg descriptors: name + optional default's AST id.
88        formals: Vec<PatternFormal>,
89        /// True if the pattern ends in `, ...` (accepts extra keys).
90        accepts_extra: bool,
91        /// `@ name` rebinds the entire arg AttrSet under `name`.
92        binding_name: Option<String>,
93        body_node_id: NodeId,
94        captured_env: BTreeMap<String, EvalValue>,
95    },
96    /// Marker for a value built by a recognized built-in (`mkIf`,
97    /// `mkOption`, etc.) — carries the built-in tag + the typed
98    /// payload the built-in produced. Lets downstream pattern
99    /// recognizers introspect (e.g. "is this an mkOption descriptor?").
100    Builtin {
101        kind: String,
102        payload: Box<EvalValue>,
103    },
104    /// Result of `builtins.derivation` — typed descriptor of an
105    /// input-addressed Nix derivation. `drv_path` and `out_path` are
106    /// the computed `/nix/store/...` paths (via sui-spec's typed
107    /// algorithm); `attrs` holds the full attr lattice so consumers
108    /// can read `.drvPath`, `.outPath`, `.name`, `.system`, or any
109    /// caller-supplied env entry the same way they would on cppnix.
110    Derivation {
111        name: String,
112        system: String,
113        drv_path: String,
114        out_path: String,
115        attrs: BTreeMap<String, EvalValue>,
116    },
117    /// Fallback for kinds we don't model yet. Carries the original
118    /// AST node id so the eventual VM-backed re-evaluation can pick
119    /// up where we left off.
120    Opaque {
121        kind: String,
122        node_id: NodeId,
123    },
124}
125
126/// Errors the evaluator surfaces.
127#[derive(Debug, thiserror::Error)]
128pub enum EvalError {
129    #[error("type mismatch in {context}: expected {expected}, got {got}")]
130    TypeMismatch {
131        context: &'static str,
132        expected: &'static str,
133        got: &'static str,
134    },
135
136    #[error("division by zero")]
137    DivisionByZero,
138
139    #[error("undefined identifier: {0}")]
140    UndefinedIdent(String),
141
142    #[error("config select walked off the env at {path:?}")]
143    ConfigMiss { path: Vec<String> },
144
145    #[error("attempted to recurse past max depth ({0})")]
146    DepthExceeded(u32),
147}
148
149/// Read-only environment threaded through the walker. Maps identifier
150/// → its already-evaluated [`EvalValue`]. Setter bodies that reference
151/// `config` look it up here; the caller seeds `config` from the
152/// solver's [`crate::module_solver::EnvSnapshot`].
153#[derive(Debug, Default, Clone)]
154pub struct EvalEnv {
155    pub bindings: BTreeMap<String, EvalValue>,
156}
157
158impl EvalEnv {
159    /// New empty env.
160    #[must_use]
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Bind one identifier to a value.
166    #[must_use]
167    pub fn with_binding(mut self, name: impl Into<String>, value: EvalValue) -> Self {
168        self.bindings.insert(name.into(), value);
169        self
170    }
171
172    /// Read a binding by name.
173    pub fn get(&self, name: &str) -> Option<&EvalValue> {
174        self.bindings.get(name)
175    }
176}
177
178/// Hard cap on recursion depth — guards against pathological inputs.
179/// Real-world module bodies are shallow (single digits typical); 256
180/// is more than enough.
181const MAX_DEPTH: u32 = 256;
182
183/// Evaluate an AST node id in the given env.
184///
185/// # Errors
186///
187/// See [`EvalError`].
188pub fn eval_node(ast: &AstGraph, id: NodeId, env: &EvalEnv) -> Result<EvalValue, EvalError> {
189    eval_at(ast, id, env, 0)
190}
191
192fn eval_at(
193    ast: &AstGraph,
194    id: NodeId,
195    env: &EvalEnv,
196    depth: u32,
197) -> Result<EvalValue, EvalError> {
198    if depth >= MAX_DEPTH {
199        return Err(EvalError::DepthExceeded(MAX_DEPTH));
200    }
201    let node = &ast.nodes[id as usize];
202    match &node.kind {
203        AstNodeKind::Int(n) => Ok(EvalValue::Int(*n)),
204        AstNodeKind::Float(f) => Ok(EvalValue::Float(*f)),
205        AstNodeKind::Bool(b) => Ok(EvalValue::Bool(*b)),
206        AstNodeKind::Null => Ok(EvalValue::Null),
207        AstNodeKind::Path(p) => Ok(EvalValue::Path(p.clone())),
208        AstNodeKind::Str { segments } | AstNodeKind::IndentedStr { segments } => {
209            eval_string_segments(ast, segments, env, depth + 1, id)
210        }
211        AstNodeKind::Ident(name) => {
212            // rnix parses `true`, `false`, `null` as plain
213            // identifiers. Recognize them as typed-value literals
214            // at the Ident level so callers don't have to seed them
215            // in every env.
216            match name.as_str() {
217                "true" => return Ok(EvalValue::Bool(true)),
218                "false" => return Ok(EvalValue::Bool(false)),
219                "null" => return Ok(EvalValue::Null),
220                _ => {}
221            }
222            env.get(name)
223                .cloned()
224                .ok_or_else(|| EvalError::UndefinedIdent(name.clone()))
225        }
226        AstNodeKind::Select {
227            target,
228            path,
229            fallback,
230        } => eval_select(ast, *target, path, *fallback, env, depth + 1),
231        AstNodeKind::HasAttr { target, path } => {
232            eval_has_attr(ast, *target, path, env, depth + 1)
233        }
234        AstNodeKind::List(items) => {
235            let mut out = Vec::with_capacity(items.len());
236            for item in items {
237                out.push(eval_at(ast, *item, env, depth + 1)?);
238            }
239            Ok(EvalValue::List(out))
240        }
241        AstNodeKind::AttrSet { entries, .. } => {
242            let mut out: BTreeMap<String, EvalValue> = BTreeMap::new();
243            for entry in entries {
244                let value = eval_at(ast, entry.value, env, depth + 1)?;
245                insert_at_path(&mut out, &entry.path, value);
246            }
247            Ok(EvalValue::AttrSet(out))
248        }
249        AstNodeKind::IfThenElse {
250            condition,
251            then_branch,
252            else_branch,
253        } => {
254            let c = eval_at(ast, *condition, env, depth + 1)?;
255            match c {
256                EvalValue::Bool(true) => eval_at(ast, *then_branch, env, depth + 1),
257                EvalValue::Bool(false) => eval_at(ast, *else_branch, env, depth + 1),
258                other => Err(EvalError::TypeMismatch {
259                    context: "if-then-else condition",
260                    expected: "bool",
261                    got: value_kind(&other),
262                }),
263            }
264        }
265        AstNodeKind::BinOp { op, left, right } => {
266            let l = eval_at(ast, *left, env, depth + 1)?;
267            let r = eval_at(ast, *right, env, depth + 1)?;
268            eval_binop(*op, l, r)
269        }
270        AstNodeKind::UnaryOp { op, operand } => {
271            let v = eval_at(ast, *operand, env, depth + 1)?;
272            eval_unaryop(*op, v)
273        }
274        AstNodeKind::Apply { function, argument } => {
275            eval_apply(ast, *function, *argument, env, depth + 1)
276        }
277        AstNodeKind::Lambda { param, body } => {
278            // Capture the env in a flat map. Two param shapes:
279            //   - Ident → simple Closure (one-arg function)
280            //   - Pattern → PatternClosure (destructuring formal-args)
281            match param {
282                crate::ast_graph::LambdaParam::Ident(name) => {
283                    Ok(EvalValue::Closure {
284                        param: name.clone(),
285                        body_node_id: *body,
286                        captured_env: env.bindings.clone(),
287                    })
288                }
289                crate::ast_graph::LambdaParam::Pattern {
290                    binding_name,
291                    formals,
292                    accepts_extra,
293                } => Ok(EvalValue::PatternClosure {
294                    formals: formals
295                        .iter()
296                        .map(|f| PatternFormal {
297                            name: f.name.clone(),
298                            default_node_id: f.default,
299                        })
300                        .collect(),
301                    accepts_extra: *accepts_extra,
302                    binding_name: binding_name.clone(),
303                    body_node_id: *body,
304                    captured_env: env.bindings.clone(),
305                }),
306            }
307        }
308        AstNodeKind::LetIn { bindings, inherits, body } => {
309            let mut env = env.clone();
310            // Bindings: evaluate each value in the OUTER env first,
311            // then bind. Cppnix actually allows recursive let-bindings
312            // (each value sees the others); for now we use the simpler
313            // sequential semantics — covers the common cases.
314            for entry in bindings {
315                if entry.path.len() == 1 {
316                    let value = eval_at(ast, entry.value, &env, depth + 1)?;
317                    env.bindings.insert(entry.path[0].clone(), value);
318                }
319                // Multi-level dotted paths in let bindings are rare;
320                // skip for now (forward-compat).
321            }
322            // Inherits: pull each named attr from its source attrset.
323            for inherit in inherits {
324                if let Some(source_id) = inherit.source {
325                    let source_val = eval_at(ast, source_id, &env, depth + 1)?;
326                    if let EvalValue::AttrSet(map) = source_val {
327                        for attr in &inherit.attrs {
328                            if let Some(v) = map.get(attr) {
329                                env.bindings.insert(attr.clone(), v.clone());
330                            }
331                        }
332                    }
333                } else {
334                    // `inherit attr1 attr2;` (no source) pulls from the
335                    // outer scope — already in env, so it's a no-op.
336                }
337            }
338            eval_at(ast, *body, &env, depth + 1)
339        }
340        AstNodeKind::With { env: scope_expr, body } => {
341            let scope_value = eval_at(ast, *scope_expr, env, depth + 1)?;
342            let mut extended = env.clone();
343            if let EvalValue::AttrSet(map) = scope_value {
344                // `with X; body` makes every attr of X visible as a
345                // top-level identifier in `body`. Lowest-precedence —
346                // existing env bindings shadow.
347                for (k, v) in map {
348                    extended.bindings.entry(k).or_insert(v);
349                }
350            }
351            eval_at(ast, *body, &extended, depth + 1)
352        }
353        AstNodeKind::Assert { body, .. } => {
354            // Assertion: ignored today (would need to evaluate the
355            // condition and throw on false). Just evaluate the body.
356            eval_at(ast, *body, env, depth + 1)
357        }
358        AstNodeKind::Unknown { kind, .. } => Ok(EvalValue::Opaque {
359            kind: kind.clone(),
360            node_id: id,
361        }),
362    }
363}
364
365/// Evaluate `Apply(function, argument)`.
366///
367/// Dispatch order:
368/// 1. If `function` is an `Ident` or `Select` resolving to a known
369///    builtin name (`mkIf`, `mkForce`, etc.), call the builtin.
370/// 2. If `function` evaluates to a `Closure`, bind its param + run
371///    its body in the captured env.
372/// 3. Curried builtins: `function` might itself be an `Apply`
373///    (e.g. `mkIf cond` is one Apply, `(mkIf cond) body` is another).
374///    Walk through until we resolve to a builtin name + collect args.
375/// 4. Anything else → Opaque sentinel.
376fn eval_apply(
377    ast: &AstGraph,
378    function: NodeId,
379    argument: NodeId,
380    env: &EvalEnv,
381    depth: u32,
382) -> Result<EvalValue, EvalError> {
383    // Collect chained applies into a (root_function, args) form.
384    // `(mkIf cond) body` → root_function = mkIf, args = [cond, body].
385    let mut args: Vec<NodeId> = vec![argument];
386    let mut cursor = function;
387    loop {
388        let node = &ast.nodes[cursor as usize];
389        match &node.kind {
390            AstNodeKind::Apply { function: f, argument: a } => {
391                args.push(*a);
392                cursor = *f;
393            }
394            _ => break,
395        }
396    }
397    args.reverse();
398
399    let root_node = &ast.nodes[cursor as usize];
400
401    // Builtin name dispatch.
402    let builtin_name = match &root_node.kind {
403        AstNodeKind::Ident(name) => Some(name.clone()),
404        AstNodeKind::Select { path, .. } => path.last().cloned(),
405        _ => None,
406    };
407
408    if let Some(name) = builtin_name.as_deref() {
409        if let Some(result) = try_dispatch_builtin(ast, name, &args, env, depth)? {
410            return Ok(result);
411        }
412    }
413
414    // Closure invocation — Ident and Pattern variants both handled.
415    let func_value = eval_at(ast, cursor, env, depth + 1);
416    if let Ok(callable) = func_value {
417        if matches!(
418            callable,
419            EvalValue::Closure { .. } | EvalValue::PatternClosure { .. }
420        ) {
421            return apply_callable(ast, callable, &args, env, depth + 1, function);
422        }
423    }
424
425    Ok(EvalValue::Opaque {
426        kind: "Apply".to_string(),
427        node_id: function,
428    })
429}
430
431/// Invoke a Closure or PatternClosure with the given argument AST
432/// nodes. Handles curried application (extra args applied to the
433/// result if it's itself callable).
434fn apply_callable(
435    ast: &AstGraph,
436    callable: EvalValue,
437    args: &[NodeId],
438    caller_env: &EvalEnv,
439    depth: u32,
440    fallback_node_id: NodeId,
441) -> Result<EvalValue, EvalError> {
442    let mut arg_iter = args.iter().copied();
443    let first_arg_node = match arg_iter.next() {
444        Some(a) => a,
445        None => return Ok(EvalValue::Null),
446    };
447
448    let mut result = match callable {
449        EvalValue::Closure {
450            param,
451            body_node_id,
452            captured_env,
453        } => {
454            let first_arg = eval_at(ast, first_arg_node, caller_env, depth)?;
455            let mut call_env = EvalEnv {
456                bindings: captured_env,
457            };
458            call_env.bindings.insert(param, first_arg);
459            eval_at(ast, body_node_id, &call_env, depth)?
460        }
461        EvalValue::PatternClosure {
462            formals,
463            accepts_extra,
464            binding_name,
465            body_node_id,
466            captured_env,
467        } => {
468            // The argument MUST be an attrset for pattern destructuring.
469            let arg_value = eval_at(ast, first_arg_node, caller_env, depth)?;
470            let arg_map = match arg_value {
471                EvalValue::AttrSet(m) => m,
472                other => {
473                    return Err(EvalError::TypeMismatch {
474                        context: "pattern-closure arg",
475                        expected: "attrset",
476                        got: value_kind(&other),
477                    });
478                }
479            };
480
481            let mut call_env = EvalEnv {
482                bindings: captured_env,
483            };
484
485            // Bind every declared formal — from arg_map if present,
486            // else from default (evaluated in the call env so it sees
487            // earlier formals).
488            for formal in &formals {
489                if let Some(v) = arg_map.get(&formal.name) {
490                    call_env.bindings.insert(formal.name.clone(), v.clone());
491                } else if let Some(default_node) = formal.default_node_id {
492                    let default_v = eval_at(ast, default_node, &call_env, depth)?;
493                    call_env.bindings.insert(formal.name.clone(), default_v);
494                } else {
495                    return Err(EvalError::TypeMismatch {
496                        context: "pattern-closure missing required arg",
497                        expected: "formal arg without default",
498                        got: "missing key",
499                    });
500                }
501            }
502
503            // Reject extras unless `, ...` was declared.
504            if !accepts_extra {
505                let known: std::collections::HashSet<&str> =
506                    formals.iter().map(|f| f.name.as_str()).collect();
507                for k in arg_map.keys() {
508                    if !known.contains(k.as_str()) {
509                        return Err(EvalError::TypeMismatch {
510                            context: "pattern-closure extra arg",
511                            expected: "only declared formals",
512                            got: "extra key",
513                        });
514                    }
515                }
516            }
517
518            // `@ name` rebinds the full arg attrset.
519            if let Some(name) = binding_name {
520                call_env
521                    .bindings
522                    .insert(name, EvalValue::AttrSet(arg_map));
523            }
524
525            eval_at(ast, body_node_id, &call_env, depth)?
526        }
527        _ => unreachable!("apply_callable called with non-callable"),
528    };
529
530    // Curried application: feed remaining args into successive
531    // callable results.
532    for next_arg_node in arg_iter {
533        let arg_val = eval_at(ast, next_arg_node, caller_env, depth)?;
534        match result {
535            EvalValue::Closure {
536                param,
537                body_node_id,
538                captured_env,
539            } => {
540                let mut next_env = EvalEnv {
541                    bindings: captured_env,
542                };
543                next_env.bindings.insert(param, arg_val);
544                result = eval_at(ast, body_node_id, &next_env, depth)?;
545            }
546            EvalValue::PatternClosure { .. } => {
547                // Curried into a pattern closure: requires the next
548                // arg to be an attrset. Recurse via apply_callable
549                // with a one-arg slice.
550                let arg_iter_one = &[next_arg_node];
551                let _ = arg_val; // already-evaluated value not threaded; recurse re-evaluates
552                result = apply_callable(
553                    ast,
554                    result,
555                    arg_iter_one,
556                    caller_env,
557                    depth,
558                    fallback_node_id,
559                )?;
560            }
561            _ => {
562                return Ok(EvalValue::Opaque {
563                    kind: "Apply-non-callable-result".to_string(),
564                    node_id: fallback_node_id,
565                });
566            }
567        }
568    }
569    Ok(result)
570}
571
572/// Try to dispatch to a built-in by name. Returns `Ok(None)` when
573/// the name isn't a known builtin — caller falls back to closure
574/// invocation or Opaque.
575fn try_dispatch_builtin(
576    ast: &AstGraph,
577    name: &str,
578    args: &[NodeId],
579    env: &EvalEnv,
580    depth: u32,
581) -> Result<Option<EvalValue>, EvalError> {
582    let arg = |i: usize| -> Result<EvalValue, EvalError> {
583        eval_at(ast, args[i], env, depth + 1)
584    };
585    match name {
586        "mkIf" if args.len() == 2 => {
587            let cond = arg(0)?;
588            match cond {
589                EvalValue::Bool(true) => {
590                    let body = arg(1)?;
591                    Ok(Some(EvalValue::Builtin {
592                        kind: "mkIf".to_string(),
593                        payload: Box::new(body),
594                    }))
595                }
596                EvalValue::Bool(false) => {
597                    // Conditionally-disabled — the contribution is
598                    // empty. Module merge layer treats this as a no-op
599                    // for the destination path.
600                    Ok(Some(EvalValue::Builtin {
601                        kind: "mkIf-disabled".to_string(),
602                        payload: Box::new(EvalValue::Null),
603                    }))
604                }
605                other => Err(EvalError::TypeMismatch {
606                    context: "mkIf condition",
607                    expected: "bool",
608                    got: value_kind(&other),
609                }),
610            }
611        }
612        "mkForce" if args.len() == 1 => Ok(Some(EvalValue::Builtin {
613            kind: "mkForce".to_string(),
614            payload: Box::new(arg(0)?),
615        })),
616        "mkVMOverride" if args.len() == 1 => Ok(Some(EvalValue::Builtin {
617            kind: "mkVMOverride".to_string(),
618            payload: Box::new(arg(0)?),
619        })),
620        "mkDefault" if args.len() == 1 => Ok(Some(EvalValue::Builtin {
621            kind: "mkDefault".to_string(),
622            payload: Box::new(arg(0)?),
623        })),
624        "mkOverride" if args.len() == 2 => {
625            // Priority + value. We carry the priority in the kind tag
626            // so downstream merge can use it.
627            let prio = arg(0)?;
628            let value = arg(1)?;
629            let kind = match prio {
630                EvalValue::Int(p) => format!("mkOverride-{p}"),
631                _ => "mkOverride-bad-priority".to_string(),
632            };
633            Ok(Some(EvalValue::Builtin {
634                kind,
635                payload: Box::new(value),
636            }))
637        }
638        "mkMerge" if args.len() == 1 => {
639            let list = arg(0)?;
640            match list {
641                EvalValue::List(items) => Ok(Some(EvalValue::Builtin {
642                    kind: "mkMerge".to_string(),
643                    payload: Box::new(EvalValue::List(items)),
644                })),
645                _ => Err(EvalError::TypeMismatch {
646                    context: "mkMerge arg",
647                    expected: "list",
648                    got: "non-list",
649                }),
650            }
651        }
652        "mkOption" if args.len() == 1 => {
653            // Pass the descriptor attrset through verbatim.
654            Ok(Some(EvalValue::Builtin {
655                kind: "mkOption".to_string(),
656                payload: Box::new(arg(0)?),
657            }))
658        }
659
660        // ── builtins.* primitives (close the most common Opaque gaps) ──
661        "toString" if args.len() == 1 => Ok(Some(builtin_to_string(arg(0)?))),
662        "isString" if args.len() == 1 => {
663            Ok(Some(EvalValue::Bool(matches!(arg(0)?, EvalValue::Str(_)))))
664        }
665        "isInt" if args.len() == 1 => {
666            Ok(Some(EvalValue::Bool(matches!(arg(0)?, EvalValue::Int(_)))))
667        }
668        "isFloat" if args.len() == 1 => {
669            Ok(Some(EvalValue::Bool(matches!(arg(0)?, EvalValue::Float(_)))))
670        }
671        "isBool" if args.len() == 1 => {
672            Ok(Some(EvalValue::Bool(matches!(arg(0)?, EvalValue::Bool(_)))))
673        }
674        "isNull" if args.len() == 1 => {
675            Ok(Some(EvalValue::Bool(matches!(arg(0)?, EvalValue::Null))))
676        }
677        "isList" if args.len() == 1 => {
678            Ok(Some(EvalValue::Bool(matches!(arg(0)?, EvalValue::List(_)))))
679        }
680        "isAttrs" if args.len() == 1 => Ok(Some(EvalValue::Bool(matches!(
681            arg(0)?,
682            EvalValue::AttrSet(_)
683        )))),
684        "isFunction" if args.len() == 1 => Ok(Some(EvalValue::Bool(matches!(
685            arg(0)?,
686            EvalValue::Closure { .. }
687        )))),
688        "length" if args.len() == 1 => match arg(0)? {
689            EvalValue::List(items) => Ok(Some(EvalValue::Int(items.len() as i64))),
690            EvalValue::Str(s) => Ok(Some(EvalValue::Int(s.len() as i64))),
691            other => Err(EvalError::TypeMismatch {
692                context: "length arg",
693                expected: "list or string",
694                got: value_kind(&other),
695            }),
696        },
697        "head" if args.len() == 1 => match arg(0)? {
698            EvalValue::List(items) if !items.is_empty() => Ok(Some(items[0].clone())),
699            EvalValue::List(_) => Err(EvalError::TypeMismatch {
700                context: "head arg",
701                expected: "non-empty list",
702                got: "empty list",
703            }),
704            other => Err(EvalError::TypeMismatch {
705                context: "head arg",
706                expected: "list",
707                got: value_kind(&other),
708            }),
709        },
710        "tail" if args.len() == 1 => match arg(0)? {
711            EvalValue::List(items) if !items.is_empty() => {
712                Ok(Some(EvalValue::List(items[1..].to_vec())))
713            }
714            EvalValue::List(_) => Err(EvalError::TypeMismatch {
715                context: "tail arg",
716                expected: "non-empty list",
717                got: "empty list",
718            }),
719            other => Err(EvalError::TypeMismatch {
720                context: "tail arg",
721                expected: "list",
722                got: value_kind(&other),
723            }),
724        },
725        "elem" if args.len() == 2 => {
726            let needle = arg(0)?;
727            match arg(1)? {
728                EvalValue::List(items) => {
729                    Ok(Some(EvalValue::Bool(items.iter().any(|v| v == &needle))))
730                }
731                other => Err(EvalError::TypeMismatch {
732                    context: "elem second arg",
733                    expected: "list",
734                    got: value_kind(&other),
735                }),
736            }
737        }
738        "attrNames" if args.len() == 1 => match arg(0)? {
739            EvalValue::AttrSet(map) => Ok(Some(EvalValue::List(
740                map.keys().map(|k| EvalValue::Str(k.clone())).collect(),
741            ))),
742            other => Err(EvalError::TypeMismatch {
743                context: "attrNames arg",
744                expected: "attrset",
745                got: value_kind(&other),
746            }),
747        },
748        "attrValues" if args.len() == 1 => match arg(0)? {
749            EvalValue::AttrSet(map) => {
750                Ok(Some(EvalValue::List(map.into_values().collect())))
751            }
752            other => Err(EvalError::TypeMismatch {
753                context: "attrValues arg",
754                expected: "attrset",
755                got: value_kind(&other),
756            }),
757        },
758        "hasAttr" if args.len() == 2 => {
759            let name = match arg(0)? {
760                EvalValue::Str(s) => s,
761                other => {
762                    return Err(EvalError::TypeMismatch {
763                        context: "hasAttr first arg",
764                        expected: "string",
765                        got: value_kind(&other),
766                    })
767                }
768            };
769            match arg(1)? {
770                EvalValue::AttrSet(map) => {
771                    Ok(Some(EvalValue::Bool(map.contains_key(&name))))
772                }
773                other => Err(EvalError::TypeMismatch {
774                    context: "hasAttr second arg",
775                    expected: "attrset",
776                    got: value_kind(&other),
777                }),
778            }
779        }
780        "getAttr" if args.len() == 2 => {
781            let name = match arg(0)? {
782                EvalValue::Str(s) => s,
783                other => {
784                    return Err(EvalError::TypeMismatch {
785                        context: "getAttr first arg",
786                        expected: "string",
787                        got: value_kind(&other),
788                    })
789                }
790            };
791            match arg(1)? {
792                EvalValue::AttrSet(map) => map
793                    .get(&name)
794                    .cloned()
795                    .map(Some)
796                    .ok_or(EvalError::ConfigMiss { path: vec![name] }),
797                other => Err(EvalError::TypeMismatch {
798                    context: "getAttr second arg",
799                    expected: "attrset",
800                    got: value_kind(&other),
801                }),
802            }
803        }
804        "concatLists" if args.len() == 1 => match arg(0)? {
805            EvalValue::List(items) => {
806                let mut out = Vec::new();
807                for item in items {
808                    match item {
809                        EvalValue::List(inner) => out.extend(inner),
810                        other => {
811                            return Err(EvalError::TypeMismatch {
812                                context: "concatLists element",
813                                expected: "list",
814                                got: value_kind(&other),
815                            })
816                        }
817                    }
818                }
819                Ok(Some(EvalValue::List(out)))
820            }
821            other => Err(EvalError::TypeMismatch {
822                context: "concatLists arg",
823                expected: "list of lists",
824                got: value_kind(&other),
825            }),
826        },
827        "concatStringsSep" if args.len() == 2 => {
828            let sep = match arg(0)? {
829                EvalValue::Str(s) => s,
830                other => {
831                    return Err(EvalError::TypeMismatch {
832                        context: "concatStringsSep first arg",
833                        expected: "string",
834                        got: value_kind(&other),
835                    })
836                }
837            };
838            match arg(1)? {
839                EvalValue::List(items) => {
840                    let strs: Result<Vec<String>, _> = items
841                        .into_iter()
842                        .map(|v| match v {
843                            EvalValue::Str(s) => Ok(s),
844                            other => Err(EvalError::TypeMismatch {
845                                context: "concatStringsSep list element",
846                                expected: "string",
847                                got: value_kind(&other),
848                            }),
849                        })
850                        .collect();
851                    Ok(Some(EvalValue::Str(strs?.join(&sep))))
852                }
853                other => Err(EvalError::TypeMismatch {
854                    context: "concatStringsSep second arg",
855                    expected: "list",
856                    got: value_kind(&other),
857                }),
858            }
859        }
860        "throw" if args.len() == 1 => match arg(0)? {
861            EvalValue::Str(s) => Err(EvalError::UndefinedIdent(format!("throw: {s}"))),
862            other => Err(EvalError::TypeMismatch {
863                context: "throw arg",
864                expected: "string",
865                got: value_kind(&other),
866            }),
867        },
868        "abort" if args.len() == 1 => match arg(0)? {
869            EvalValue::Str(s) => Err(EvalError::UndefinedIdent(format!("abort: {s}"))),
870            other => Err(EvalError::TypeMismatch {
871                context: "abort arg",
872                expected: "string",
873                got: value_kind(&other),
874            }),
875        },
876
877        // ── lib.* wrappers — common module idioms ──
878        // `lib.optional cond x` → if cond then [x] else []
879        "optional" if args.len() == 2 => match arg(0)? {
880            EvalValue::Bool(true) => Ok(Some(EvalValue::List(vec![arg(1)?]))),
881            EvalValue::Bool(false) => Ok(Some(EvalValue::List(Vec::new()))),
882            other => Err(EvalError::TypeMismatch {
883                context: "optional first arg",
884                expected: "bool",
885                got: value_kind(&other),
886            }),
887        },
888        // `lib.optionals cond xs` → if cond then xs else []
889        "optionals" if args.len() == 2 => match arg(0)? {
890            EvalValue::Bool(true) => match arg(1)? {
891                v @ EvalValue::List(_) => Ok(Some(v)),
892                other => Err(EvalError::TypeMismatch {
893                    context: "optionals second arg",
894                    expected: "list",
895                    got: value_kind(&other),
896                }),
897            },
898            EvalValue::Bool(false) => Ok(Some(EvalValue::List(Vec::new()))),
899            other => Err(EvalError::TypeMismatch {
900                context: "optionals first arg",
901                expected: "bool",
902                got: value_kind(&other),
903            }),
904        },
905        // `lib.optionalAttrs cond attrs` → if cond then attrs else {}
906        "optionalAttrs" if args.len() == 2 => match arg(0)? {
907            EvalValue::Bool(true) => match arg(1)? {
908                v @ EvalValue::AttrSet(_) => Ok(Some(v)),
909                other => Err(EvalError::TypeMismatch {
910                    context: "optionalAttrs second arg",
911                    expected: "attrset",
912                    got: value_kind(&other),
913                }),
914            },
915            EvalValue::Bool(false) => {
916                Ok(Some(EvalValue::AttrSet(std::collections::BTreeMap::new())))
917            }
918            other => Err(EvalError::TypeMismatch {
919                context: "optionalAttrs first arg",
920                expected: "bool",
921                got: value_kind(&other),
922            }),
923        },
924        // `lib.id` — identity. Common in default-value position.
925        "id" if args.len() == 1 => Ok(Some(arg(0)?)),
926        // `lib.const x` — a function-of-one-arg that always returns x.
927        // We're called with two args (const x y) → return x.
928        "const" if args.len() == 2 => Ok(Some(arg(0)?)),
929
930        // ── String builtins ─────────────────────────────────────
931        "substring" if args.len() == 3 => {
932            let start = expect_int(arg(0)?, "substring start")?;
933            let len = expect_int(arg(1)?, "substring len")?;
934            let s = expect_str(arg(2)?, "substring source")?;
935            let start = start.max(0) as usize;
936            let len = len.max(0) as usize;
937            let chars: Vec<char> = s.chars().collect();
938            let end = (start + len).min(chars.len());
939            Ok(Some(EvalValue::Str(
940                if start >= chars.len() {
941                    String::new()
942                } else {
943                    chars[start..end].iter().collect()
944                },
945            )))
946        }
947        "stringLength" if args.len() == 1 => {
948            let s = expect_str(arg(0)?, "stringLength arg")?;
949            Ok(Some(EvalValue::Int(s.chars().count() as i64)))
950        }
951        "replaceStrings" if args.len() == 3 => {
952            let from = expect_list_of_str(arg(0)?, "replaceStrings from")?;
953            let to = expect_list_of_str(arg(1)?, "replaceStrings to")?;
954            let src = expect_str(arg(2)?, "replaceStrings source")?;
955            if from.len() != to.len() {
956                return Err(EvalError::TypeMismatch {
957                    context: "replaceStrings",
958                    expected: "from.len() == to.len()",
959                    got: "mismatched list lengths",
960                });
961            }
962            let mut out = src;
963            for (f, t) in from.iter().zip(to.iter()) {
964                out = out.replace(f, t);
965            }
966            Ok(Some(EvalValue::Str(out)))
967        }
968
969        // ── Higher-order list builtins ──────────────────────────
970        "map" if args.len() == 2 => {
971            let func = arg(0)?;
972            let list = expect_list(arg(1)?, "map list")?;
973            let mut out = Vec::with_capacity(list.len());
974            for item in list {
975                let single = [synthetic_value_node(ast)];
976                // We can't synthesize an AST node for an already-
977                // evaluated value, so route through apply_value
978                // (below). Use a single-arg helper.
979                let _ = single;
980                out.push(apply_value_to_one(ast, func.clone(), item, env, depth + 1)?);
981            }
982            Ok(Some(EvalValue::List(out)))
983        }
984        "filter" if args.len() == 2 => {
985            let pred = arg(0)?;
986            let list = expect_list(arg(1)?, "filter list")?;
987            let mut out = Vec::new();
988            for item in list {
989                let keep =
990                    apply_value_to_one(ast, pred.clone(), item.clone(), env, depth + 1)?;
991                if matches!(keep, EvalValue::Bool(true)) {
992                    out.push(item);
993                }
994            }
995            Ok(Some(EvalValue::List(out)))
996        }
997        "foldl'" if args.len() == 3 => {
998            let func = arg(0)?;
999            let init = arg(1)?;
1000            let list = expect_list(arg(2)?, "foldl' list")?;
1001            let mut acc = init;
1002            for item in list {
1003                acc = apply_value_to_two(
1004                    ast,
1005                    func.clone(),
1006                    acc,
1007                    item,
1008                    env,
1009                    depth + 1,
1010                )?;
1011            }
1012            Ok(Some(acc))
1013        }
1014        "genList" if args.len() == 2 => {
1015            let func = arg(0)?;
1016            let n = expect_int(arg(1)?, "genList count")?;
1017            let mut out = Vec::with_capacity(n.max(0) as usize);
1018            for i in 0..n.max(0) {
1019                out.push(apply_value_to_one(
1020                    ast,
1021                    func.clone(),
1022                    EvalValue::Int(i),
1023                    env,
1024                    depth + 1,
1025                )?);
1026            }
1027            Ok(Some(EvalValue::List(out)))
1028        }
1029        "concatMap" if args.len() == 2 => {
1030            let func = arg(0)?;
1031            let list = expect_list(arg(1)?, "concatMap list")?;
1032            let mut out = Vec::new();
1033            for item in list {
1034                let v = apply_value_to_one(ast, func.clone(), item, env, depth + 1)?;
1035                match v {
1036                    EvalValue::List(items) => out.extend(items),
1037                    other => {
1038                        return Err(EvalError::TypeMismatch {
1039                            context: "concatMap fn result",
1040                            expected: "list",
1041                            got: value_kind(&other),
1042                        });
1043                    }
1044                }
1045            }
1046            Ok(Some(EvalValue::List(out)))
1047        }
1048
1049        // ── Attrset builtins ────────────────────────────────────
1050        "intersectAttrs" if args.len() == 2 => {
1051            let a = expect_attrset(arg(0)?, "intersectAttrs first")?;
1052            let b = expect_attrset(arg(1)?, "intersectAttrs second")?;
1053            let mut out: BTreeMap<String, EvalValue> = BTreeMap::new();
1054            for (k, v) in b {
1055                if a.contains_key(&k) {
1056                    out.insert(k, v);
1057                }
1058            }
1059            Ok(Some(EvalValue::AttrSet(out)))
1060        }
1061        "removeAttrs" if args.len() == 2 => {
1062            let mut attrs = expect_attrset(arg(0)?, "removeAttrs source")?;
1063            let names = expect_list_of_str(arg(1)?, "removeAttrs names")?;
1064            for n in names {
1065                attrs.remove(&n);
1066            }
1067            Ok(Some(EvalValue::AttrSet(attrs)))
1068        }
1069        "listToAttrs" if args.len() == 1 => {
1070            let entries = expect_list(arg(0)?, "listToAttrs list")?;
1071            let mut out: BTreeMap<String, EvalValue> = BTreeMap::new();
1072            for entry in entries {
1073                let map = expect_attrset(entry, "listToAttrs entry")?;
1074                let name = map
1075                    .get("name")
1076                    .and_then(|v| match v {
1077                        EvalValue::Str(s) => Some(s.clone()),
1078                        _ => None,
1079                    })
1080                    .ok_or(EvalError::TypeMismatch {
1081                        context: "listToAttrs entry.name",
1082                        expected: "string",
1083                        got: "missing or non-string",
1084                    })?;
1085                let value = map.get("value").cloned().unwrap_or(EvalValue::Null);
1086                out.insert(name, value);
1087            }
1088            Ok(Some(EvalValue::AttrSet(out)))
1089        }
1090
1091        // ── Filesystem builtins ─────────────────────────────────
1092        // Note: these touch the host filesystem. Eval-cache hits
1093        // bypass them; first-touch evaluates the real file.
1094        "readFile" if args.len() == 1 => {
1095            let path = expect_str_or_path(arg(0)?, "readFile arg")?;
1096            let bytes = std::fs::read_to_string(&path).map_err(|e| {
1097                EvalError::TypeMismatch {
1098                    context: "readFile io",
1099                    expected: "existing file",
1100                    got: leak_msg(format!("io error reading {path}: {e}")),
1101                }
1102            })?;
1103            Ok(Some(EvalValue::Str(bytes)))
1104        }
1105        "pathExists" if args.len() == 1 => {
1106            let path = expect_str_or_path(arg(0)?, "pathExists arg")?;
1107            Ok(Some(EvalValue::Bool(std::path::Path::new(&path).exists())))
1108        }
1109        // `import` resolves a path relative to the caller's flake
1110        // root. Today we accept absolute paths verbatim and parse
1111        // them via rnix; relative-path resolution requires call-
1112        // site context we don't yet thread, so relative imports
1113        // surface as a typed error.
1114        "import" if args.len() == 1 => {
1115            let path = expect_str_or_path(arg(0)?, "import arg")?;
1116            if !std::path::Path::new(&path).is_absolute() {
1117                return Err(EvalError::TypeMismatch {
1118                    context: "import",
1119                    expected: "absolute path (relative imports need call-site ctx)",
1120                    got: leak_msg(format!("relative path: {path}")),
1121                });
1122            }
1123            let source = std::fs::read_to_string(&path).map_err(|e| {
1124                EvalError::TypeMismatch {
1125                    context: "import io",
1126                    expected: "existing .nix file",
1127                    got: leak_msg(format!("io error reading {path}: {e}")),
1128                }
1129            })?;
1130            let imported_graph = AstGraph::from_source(&source).map_err(|e| {
1131                EvalError::TypeMismatch {
1132                    context: "import parse",
1133                    expected: "valid nix source",
1134                    got: leak_msg(format!("parse error: {e}")),
1135                }
1136            })?;
1137            // Evaluate the imported file in a FRESH env (cppnix
1138            // semantics: imports don't see the caller's scope).
1139            let imported_env = EvalEnv::new();
1140            // We need to walk the imported AST, but we hold an `ast`
1141            // (the caller's graph) here. Walking a different graph
1142            // means recursing with a different `ast` — bypass via
1143            // direct eval_node call.
1144            let v = eval_node(&imported_graph, imported_graph.root_id, &imported_env)
1145                .map_err(|e| EvalError::TypeMismatch {
1146                    context: "import eval",
1147                    expected: "successful eval",
1148                    got: leak_msg(format!("eval error: {e}")),
1149                })?;
1150            Ok(Some(v))
1151        }
1152
1153        // ── More lib.* wrappers ─────────────────────────────────
1154        // lib.filterAttrs predicate set → AttrSet
1155        "filterAttrs" if args.len() == 2 => {
1156            let pred = arg(0)?;
1157            let attrs = expect_attrset(arg(1)?, "filterAttrs set")?;
1158            let mut out: BTreeMap<String, EvalValue> = BTreeMap::new();
1159            for (k, v) in attrs {
1160                let keep = apply_value_to_two(
1161                    ast,
1162                    pred.clone(),
1163                    EvalValue::Str(k.clone()),
1164                    v.clone(),
1165                    env,
1166                    depth + 1,
1167                )?;
1168                if matches!(keep, EvalValue::Bool(true)) {
1169                    out.insert(k, v);
1170                }
1171            }
1172            Ok(Some(EvalValue::AttrSet(out)))
1173        }
1174        // lib.mapAttrs f set → AttrSet
1175        "mapAttrs" if args.len() == 2 => {
1176            let func = arg(0)?;
1177            let attrs = expect_attrset(arg(1)?, "mapAttrs set")?;
1178            let mut out: BTreeMap<String, EvalValue> = BTreeMap::new();
1179            for (k, v) in attrs {
1180                let new_v = apply_value_to_two(
1181                    ast,
1182                    func.clone(),
1183                    EvalValue::Str(k.clone()),
1184                    v,
1185                    env,
1186                    depth + 1,
1187                )?;
1188                out.insert(k, new_v);
1189            }
1190            Ok(Some(EvalValue::AttrSet(out)))
1191        }
1192        // lib.flatten — recursive flatten of nested lists
1193        "flatten" if args.len() == 1 => {
1194            fn flat(out: &mut Vec<EvalValue>, v: EvalValue) {
1195                match v {
1196                    EvalValue::List(items) => {
1197                        for i in items {
1198                            flat(out, i);
1199                        }
1200                    }
1201                    other => out.push(other),
1202                }
1203            }
1204            let mut out = Vec::new();
1205            flat(&mut out, arg(0)?);
1206            Ok(Some(EvalValue::List(out)))
1207        }
1208        "unique" if args.len() == 1 => {
1209            let list = expect_list(arg(0)?, "unique arg")?;
1210            let mut seen: Vec<EvalValue> = Vec::new();
1211            for v in list {
1212                if !seen.contains(&v) {
1213                    seen.push(v);
1214                }
1215            }
1216            Ok(Some(EvalValue::List(seen)))
1217        }
1218        "take" if args.len() == 2 => {
1219            let n = expect_int(arg(0)?, "take count")?;
1220            let list = expect_list(arg(1)?, "take list")?;
1221            let n = n.max(0) as usize;
1222            Ok(Some(EvalValue::List(
1223                list.into_iter().take(n).collect(),
1224            )))
1225        }
1226        "drop" if args.len() == 2 => {
1227            let n = expect_int(arg(0)?, "drop count")?;
1228            let list = expect_list(arg(1)?, "drop list")?;
1229            let n = n.max(0) as usize;
1230            Ok(Some(EvalValue::List(
1231                list.into_iter().skip(n).collect(),
1232            )))
1233        }
1234        // ── builtins.derivation — the build-recipe primitive ──────
1235        //
1236        // Reads the required attrs (name, system, builder) and
1237        // optional ones (args, outputs, plus arbitrary env entries)
1238        // from the input attrset, hands the resulting Derivation to
1239        // sui-spec's typed input-addressed algorithm, and returns a
1240        // typed `Derivation` value carrying both the canonical
1241        // `.drv` path AND the per-output store paths.  No fallback to
1242        // cppnix anywhere — every byte of the algorithm is in
1243        // sui_spec::derivation.
1244        "derivation" if args.len() == 1 => Ok(Some(eval_derivation(arg(0)?)?)),
1245        "__derivationStrict" if args.len() == 1 => Ok(Some(eval_derivation(arg(0)?)?)),
1246        // `derivationStrict` is the cppnix lib-level wrapper that
1247        // calls `builtins.derivation`; expose under the same alias so
1248        // `(import <nixpkgs> {}).hello` style code resolves uniformly.
1249        "derivationStrict" if args.len() == 1 => Ok(Some(eval_derivation(arg(0)?)?)),
1250
1251        // ── builtins.fetch* — real network/filesystem fetchers ────
1252        //
1253        // Drive the typed FetcherEnvironment through real ureq HTTP
1254        // + filesystem unpack code.  Coverage-gap: only flat hashes
1255        // today (the cppnix-canonical fetchurl mode).  Recursive
1256        // (NAR-based) hash mode and git/tarball recursion land per
1257        // ship; the gap is logged via Opaque sentinels so callers see
1258        // the typed boundary.
1259        "fetchurl" if args.len() == 1 => Ok(Some(eval_fetchurl(arg(0)?)?)),
1260        "fetchTarball" if args.len() == 1 => Ok(Some(eval_fetch_tarball(arg(0)?)?)),
1261        "fetchGit" if args.len() == 1 => Ok(Some(eval_fetch_git(arg(0)?)?)),
1262        "fetchTree" if args.len() == 1 => Ok(Some(eval_fetch_tree(arg(0)?)?)),
1263
1264        // foldr (right-fold; lib version of builtins.foldl' inverted)
1265        "foldr" if args.len() == 3 => {
1266            let func = arg(0)?;
1267            let init = arg(1)?;
1268            let list = expect_list(arg(2)?, "foldr list")?;
1269            let mut acc = init;
1270            for item in list.into_iter().rev() {
1271                acc = apply_value_to_two(
1272                    ast,
1273                    func.clone(),
1274                    item,
1275                    acc,
1276                    env,
1277                    depth + 1,
1278                )?;
1279            }
1280            Ok(Some(acc))
1281        }
1282
1283        _ => Ok(None),
1284    }
1285}
1286
1287// ── Helpers for higher-order builtins ────────────────────────────
1288
1289/// Apply an already-evaluated callable to one already-evaluated arg.
1290/// Bridges the "argument is a value, not an AST node" gap for builtins
1291/// like `map`/`filter`/`foldl'` that iterate over pre-evaluated lists.
1292fn apply_value_to_one(
1293    ast: &AstGraph,
1294    callable: EvalValue,
1295    arg: EvalValue,
1296    _caller_env: &EvalEnv,
1297    depth: u32,
1298) -> Result<EvalValue, EvalError> {
1299    match callable {
1300        EvalValue::Closure {
1301            param,
1302            body_node_id,
1303            captured_env,
1304        } => {
1305            let mut call_env = EvalEnv {
1306                bindings: captured_env,
1307            };
1308            call_env.bindings.insert(param, arg);
1309            eval_at(ast, body_node_id, &call_env, depth)
1310        }
1311        EvalValue::PatternClosure {
1312            formals,
1313            accepts_extra,
1314            binding_name,
1315            body_node_id,
1316            captured_env,
1317        } => {
1318            let arg_map = match arg {
1319                EvalValue::AttrSet(m) => m,
1320                other => {
1321                    return Err(EvalError::TypeMismatch {
1322                        context: "pattern-closure arg",
1323                        expected: "attrset",
1324                        got: value_kind(&other),
1325                    });
1326                }
1327            };
1328            let mut call_env = EvalEnv {
1329                bindings: captured_env,
1330            };
1331            for formal in &formals {
1332                if let Some(v) = arg_map.get(&formal.name) {
1333                    call_env.bindings.insert(formal.name.clone(), v.clone());
1334                } else if let Some(default_node) = formal.default_node_id {
1335                    let d = eval_at(ast, default_node, &call_env, depth)?;
1336                    call_env.bindings.insert(formal.name.clone(), d);
1337                } else {
1338                    return Err(EvalError::TypeMismatch {
1339                        context: "pattern-closure missing required arg",
1340                        expected: "formal arg without default",
1341                        got: "missing key",
1342                    });
1343                }
1344            }
1345            if !accepts_extra {
1346                let known: std::collections::HashSet<&str> =
1347                    formals.iter().map(|f| f.name.as_str()).collect();
1348                for k in arg_map.keys() {
1349                    if !known.contains(k.as_str()) {
1350                        return Err(EvalError::TypeMismatch {
1351                            context: "pattern-closure extra arg",
1352                            expected: "only declared formals",
1353                            got: "extra key",
1354                        });
1355                    }
1356                }
1357            }
1358            if let Some(name) = binding_name {
1359                call_env
1360                    .bindings
1361                    .insert(name, EvalValue::AttrSet(arg_map));
1362            }
1363            eval_at(ast, body_node_id, &call_env, depth)
1364        }
1365        other => Err(EvalError::TypeMismatch {
1366            context: "apply_value_to_one",
1367            expected: "callable",
1368            got: value_kind(&other),
1369        }),
1370    }
1371}
1372
1373/// Apply a callable to two already-evaluated args (for foldl'/foldr,
1374/// mapAttrs, filterAttrs). Curried — applies the first arg, then
1375/// applies the result to the second arg.
1376fn apply_value_to_two(
1377    ast: &AstGraph,
1378    callable: EvalValue,
1379    a: EvalValue,
1380    b: EvalValue,
1381    caller_env: &EvalEnv,
1382    depth: u32,
1383) -> Result<EvalValue, EvalError> {
1384    let after_first = apply_value_to_one(ast, callable, a, caller_env, depth)?;
1385    apply_value_to_one(ast, after_first, b, caller_env, depth)
1386}
1387
1388/// Placeholder — synthetic node id isn't used today but reserved
1389/// for the future "synthesize an AST node to wrap a pre-evaluated
1390/// value" pattern (would need an AST mutation primitive).
1391fn synthetic_value_node(_ast: &AstGraph) -> NodeId {
1392    0
1393}
1394
1395fn expect_int(v: EvalValue, ctx: &'static str) -> Result<i64, EvalError> {
1396    match v {
1397        EvalValue::Int(n) => Ok(n),
1398        other => Err(EvalError::TypeMismatch {
1399            context: ctx,
1400            expected: "int",
1401            got: value_kind(&other),
1402        }),
1403    }
1404}
1405
1406fn expect_str(v: EvalValue, ctx: &'static str) -> Result<String, EvalError> {
1407    match v {
1408        EvalValue::Str(s) => Ok(s),
1409        other => Err(EvalError::TypeMismatch {
1410            context: ctx,
1411            expected: "string",
1412            got: value_kind(&other),
1413        }),
1414    }
1415}
1416
1417fn expect_str_or_path(v: EvalValue, ctx: &'static str) -> Result<String, EvalError> {
1418    match v {
1419        EvalValue::Str(s) | EvalValue::Path(s) => Ok(s),
1420        other => Err(EvalError::TypeMismatch {
1421            context: ctx,
1422            expected: "string or path",
1423            got: value_kind(&other),
1424        }),
1425    }
1426}
1427
1428fn expect_list(v: EvalValue, ctx: &'static str) -> Result<Vec<EvalValue>, EvalError> {
1429    match v {
1430        EvalValue::List(items) => Ok(items),
1431        other => Err(EvalError::TypeMismatch {
1432            context: ctx,
1433            expected: "list",
1434            got: value_kind(&other),
1435        }),
1436    }
1437}
1438
1439fn expect_list_of_str(v: EvalValue, ctx: &'static str) -> Result<Vec<String>, EvalError> {
1440    let items = expect_list(v, ctx)?;
1441    items
1442        .into_iter()
1443        .map(|v| expect_str(v, ctx))
1444        .collect()
1445}
1446
1447fn expect_attrset(
1448    v: EvalValue,
1449    ctx: &'static str,
1450) -> Result<BTreeMap<String, EvalValue>, EvalError> {
1451    match v {
1452        EvalValue::AttrSet(m) => Ok(m),
1453        other => Err(EvalError::TypeMismatch {
1454            context: ctx,
1455            expected: "attrset",
1456            got: value_kind(&other),
1457        }),
1458    }
1459}
1460
1461/// Leaking a `String` to `&'static str` for error messages where
1462/// the existing TypeMismatch fields are `&'static str`. Bounded:
1463/// only used on the error path, so allocations are rare.
1464fn leak_msg(s: String) -> &'static str {
1465    Box::leak(s.into_boxed_str())
1466}
1467
1468/// builtins.toString: stringify any value per cppnix's coercion rules.
1469fn builtin_to_string(v: EvalValue) -> EvalValue {
1470    match v {
1471        EvalValue::Str(s) => EvalValue::Str(s),
1472        EvalValue::Int(n) => EvalValue::Str(n.to_string()),
1473        EvalValue::Float(f) => EvalValue::Str(f.to_string()),
1474        EvalValue::Bool(b) => EvalValue::Str(if b { "1" } else { "" }.to_string()),
1475        EvalValue::Null => EvalValue::Str(String::new()),
1476        EvalValue::Path(p) => EvalValue::Str(p),
1477        EvalValue::List(items) => {
1478            let parts: Vec<String> = items
1479                .into_iter()
1480                .map(|i| match builtin_to_string(i) {
1481                    EvalValue::Str(s) => s,
1482                    _ => String::new(),
1483                })
1484                .collect();
1485            EvalValue::Str(parts.join(" "))
1486        }
1487        // A Derivation coerces to its `out` output path under cppnix's
1488        // toString rules — the most common stringification target.
1489        EvalValue::Derivation { out_path, .. } => EvalValue::Str(out_path),
1490        // Cppnix's toString on attrsets calls the `__toString` field if
1491        // present; otherwise errors. We approximate: empty string.
1492        EvalValue::AttrSet(_)
1493        | EvalValue::Closure { .. }
1494        | EvalValue::PatternClosure { .. }
1495        | EvalValue::Builtin { .. }
1496        | EvalValue::Opaque { .. } => EvalValue::Str(String::new()),
1497    }
1498}
1499
1500// ── builtins.derivation implementation ─────────────────────────────
1501//
1502// The build-recipe primitive.  Cppnix's `builtins.derivation` is the
1503// foundation of `nix build` — every derivation in nixpkgs winds up
1504// here.  This implementation drives the same typed algorithm that
1505// sui-spec ships for cppnix-parity (`sui_spec::derivation::apply` ←
1506// `cppnix-input-addressed.lisp`), so an evaluated `derivation { … }`
1507// produces byte-exact identical store paths to cppnix on the same
1508// inputs.
1509
1510/// Coerce an EvalValue into the ATerm-serializable string form that
1511/// cppnix uses to populate `Derivation::env`.  Lists become
1512/// space-joined; ints/floats stringify; bools coerce to `"1"`/`""`
1513/// like cppnix; derivations coerce to their `out` path; everything
1514/// else is rejected with a typed error.
1515fn coerce_to_env_value(v: &EvalValue, key: &str) -> Result<String, EvalError> {
1516    Ok(match v {
1517        EvalValue::Str(s) => s.clone(),
1518        EvalValue::Path(p) => p.clone(),
1519        EvalValue::Int(n) => n.to_string(),
1520        EvalValue::Float(f) => f.to_string(),
1521        EvalValue::Bool(b) => if *b { "1" } else { "" }.to_string(),
1522        EvalValue::Null => String::new(),
1523        EvalValue::Derivation { out_path, .. } => out_path.clone(),
1524        EvalValue::List(items) => {
1525            let mut parts: Vec<String> = Vec::with_capacity(items.len());
1526            for it in items {
1527                parts.push(coerce_to_env_value(it, key)?);
1528            }
1529            parts.join(" ")
1530        }
1531        other => {
1532            return Err(EvalError::TypeMismatch {
1533                context: leak_msg(format!("derivation env coercion for `{key}`")),
1534                expected: "string-coercible (str/path/int/float/bool/null/list/derivation)",
1535                got: value_kind(other),
1536            });
1537        }
1538    })
1539}
1540
1541/// `builtins.derivation { … }` — the central typed pathway.  Reads
1542/// required + optional attrs from the input attrset, builds a
1543/// canonical `Derivation`, hands it to the typed input-addressed
1544/// algorithm, and returns the typed `Derivation` value.
1545fn eval_derivation(arg: EvalValue) -> Result<EvalValue, EvalError> {
1546    let attrs = expect_attrset(arg, "derivation arg")?;
1547
1548    let name = attrs
1549        .get("name")
1550        .ok_or(EvalError::TypeMismatch {
1551            context: "derivation",
1552            expected: "attrset with `name`",
1553            got: "missing `name`",
1554        })
1555        .and_then(|v| expect_str(v.clone(), "derivation.name"))?;
1556
1557    let system = attrs
1558        .get("system")
1559        .ok_or(EvalError::TypeMismatch {
1560            context: "derivation",
1561            expected: "attrset with `system`",
1562            got: "missing `system`",
1563        })
1564        .and_then(|v| expect_str(v.clone(), "derivation.system"))?;
1565
1566    let builder_value = attrs.get("builder").ok_or(EvalError::TypeMismatch {
1567        context: "derivation",
1568        expected: "attrset with `builder`",
1569        got: "missing `builder`",
1570    })?;
1571    let builder = expect_str_or_path(builder_value.clone(), "derivation.builder")?;
1572
1573    let args_list: Vec<String> = match attrs.get("args") {
1574        Some(v) => expect_list_of_str(v.clone(), "derivation.args")?,
1575        None => Vec::new(),
1576    };
1577
1578    let outputs: Vec<String> = match attrs.get("outputs") {
1579        Some(v) => expect_list_of_str(v.clone(), "derivation.outputs")?,
1580        None => vec!["out".to_string()],
1581    };
1582
1583    // Drive the typed input-addressed algorithm.
1584    let mut drv = sui_compat::derivation::Derivation {
1585        outputs: std::collections::BTreeMap::new(),
1586        input_derivations: std::collections::BTreeMap::new(),
1587        input_sources: Vec::new(),
1588        system: system.clone(),
1589        builder,
1590        args: args_list,
1591        env: std::collections::BTreeMap::new(),
1592    };
1593
1594    // Every attr except the structural ones lands in env (cppnix
1595    // semantics).  outputs are masked by the algorithm itself, so
1596    // pre-fill them with "" and they'll be overwritten in
1597    // FillPlaceholders.
1598    let structural: std::collections::HashSet<&str> = [
1599        "name", "system", "builder", "args", "outputs",
1600        // Derivation-level meta — cppnix sometimes uses these for
1601        // tracking but they aren't part of the input-addressed hash
1602        // input; route them through env transparently like everything
1603        // else (cppnix-parity).
1604    ].into_iter().collect();
1605
1606    for (k, v) in &attrs {
1607        if structural.contains(k.as_str()) {
1608            continue;
1609        }
1610        let s = coerce_to_env_value(v, k)?;
1611        drv.env.insert(k.clone(), s);
1612    }
1613    // The required structural env entries cppnix also writes.
1614    drv.env.insert("name".to_string(), name.clone());
1615    drv.env.insert("system".to_string(), system.clone());
1616    drv.env.insert("builder".to_string(), drv.builder.clone());
1617
1618    // Pre-populate the input-derivations map from any input
1619    // derivations referenced in env.  For the v1 typed pathway we
1620    // detect derivation references during env coercion: a
1621    // `Derivation` value writes its drv_path here so the algorithm
1622    // can include it in the input-addressed hash.
1623    for (_, v) in &attrs {
1624        if let EvalValue::Derivation { drv_path, .. } = v {
1625            drv.input_derivations
1626                .entry(drv_path.clone())
1627                .or_default()
1628                .push("out".to_string());
1629        }
1630    }
1631
1632    let algo = crate::derivation::load_canonical().map_err(|e| EvalError::TypeMismatch {
1633        context: "derivation algorithm load",
1634        expected: "canonical input-addressed algorithm",
1635        got: leak_msg(e.to_string()),
1636    })?;
1637
1638    let (drv_path, out_paths, _final_drv) =
1639        crate::derivation::apply(&algo, drv, outputs.clone(), &name).map_err(|e| {
1640            EvalError::TypeMismatch {
1641                context: "derivation algorithm apply",
1642                expected: "successful input-addressed pipeline",
1643                got: leak_msg(e.to_string()),
1644            }
1645        })?;
1646
1647    // The first output (typically `out`) is the canonical `outPath`.
1648    let primary_out = outputs.first().map(String::as_str).unwrap_or("out");
1649    let out_path = out_paths
1650        .get(primary_out)
1651        .cloned()
1652        .ok_or(EvalError::TypeMismatch {
1653            context: "derivation outputs",
1654            expected: "first output materialized",
1655            got: "no path computed",
1656        })?;
1657
1658    // Build an attrs view that mirrors cppnix's `derivation` return
1659    // value: all the original input attrs PLUS the computed store
1660    // paths for each output AND a top-level `outPath`/`drvPath`/`type`.
1661    let mut result_attrs = attrs.clone();
1662    for (name, path) in &out_paths {
1663        result_attrs.insert(name.clone(), EvalValue::Str(path.clone()));
1664    }
1665    result_attrs.insert("outPath".to_string(), EvalValue::Str(out_path.clone()));
1666    result_attrs.insert("drvPath".to_string(), EvalValue::Str(drv_path.clone()));
1667    result_attrs.insert("type".to_string(), EvalValue::Str("derivation".to_string()));
1668
1669    Ok(EvalValue::Derivation {
1670        name,
1671        system,
1672        drv_path,
1673        out_path,
1674        attrs: result_attrs,
1675    })
1676}
1677
1678// ── builtins.fetch* implementations ────────────────────────────────
1679//
1680// Real fetchers.  HTTP via `ureq` (workspace dep), filesystem via
1681// `FsTransport` from sui-spec::fetcher.  Tarball unpacking via tar +
1682// flate2.  Git via `gix`.  Each builtin reads the cppnix-shape
1683// argument attrset, drives the typed FetcherEnvironment trait, and
1684// returns either a typed `Derivation`-like value or an `EvalValue::Str`
1685// carrying the store path.
1686
1687/// Filesystem-backed `FetcherEnvironment` for tests + offline use.
1688/// Production deployments swap in an `ureq`-backed transport via the
1689/// SchemeRouter pattern from sui-spec::fetcher.
1690struct InProcessFetcherEnv {
1691    /// Root the synthetic "store" lives in.  Tests pass a tmpdir;
1692    /// production wires `/nix/store`.
1693    store_root: std::path::PathBuf,
1694    transport: crate::fetcher::SchemeRouter<UreqTransport>,
1695}
1696
1697impl InProcessFetcherEnv {
1698    fn new(store_root: std::path::PathBuf) -> Self {
1699        Self {
1700            store_root,
1701            transport: crate::fetcher::SchemeRouter::new(UreqTransport),
1702        }
1703    }
1704}
1705
1706impl crate::fetcher::FetcherEnvironment for InProcessFetcherEnv {
1707    fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
1708        use crate::fetcher::HttpTransport;
1709        self.transport.get(url).map_err(|e| e.to_string())
1710    }
1711
1712    fn hash_bytes(&self, bytes: &[u8]) -> String {
1713        use sha2::{Digest, Sha256};
1714        let d = Sha256::digest(bytes);
1715        let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
1716        format!("sha256:{hex}")
1717    }
1718
1719    fn write_to_store(&self, name: &str, bytes: &[u8]) -> Result<String, String> {
1720        // Compute the FOD path via sui-compat's typed helper and write
1721        // the bytes there.  Matches cppnix's flat-hash FOD layout.
1722        use sha2::{Digest, Sha256};
1723        let inner = Sha256::digest(bytes);
1724        let hex: String = inner.iter().map(|b| format!("{b:02x}")).collect();
1725        let path = sui_compat::store_path::compute_fixed_output_hash(
1726            "sha256",
1727            &hex,
1728            false, // flat (non-recursive)
1729            name,
1730        );
1731        // Materialize under store_root using only the basename (so
1732        // tests with a /tmp store_root don't try to write /nix/store).
1733        let basename = std::path::Path::new(&path)
1734            .file_name()
1735            .ok_or_else(|| format!("bad store path `{path}`"))?;
1736        let dst = self.store_root.join(basename);
1737        std::fs::write(&dst, bytes).map_err(|e| e.to_string())?;
1738        Ok(path)
1739    }
1740}
1741
1742/// `ureq`-backed HTTP transport.  Always available; gracefully
1743/// surfaces network errors as typed `HttpError` variants so callers
1744/// can branch.
1745struct UreqTransport;
1746
1747impl crate::fetcher::HttpTransport for UreqTransport {
1748    fn get(&self, url: &str) -> Result<Vec<u8>, crate::fetcher::HttpError> {
1749        let resp = ureq::get(url)
1750            .call()
1751            .map_err(|e| crate::fetcher::HttpError::NetworkFailure(e.to_string()))?;
1752        let mut body = Vec::new();
1753        use std::io::Read;
1754        resp.into_body()
1755            .into_reader()
1756            .read_to_end(&mut body)
1757            .map_err(|e| crate::fetcher::HttpError::BodyReadFailure(e.to_string()))?;
1758        Ok(body)
1759    }
1760}
1761
1762/// Pick an on-disk store root for an in-process fetch.  Honors the
1763/// `SUI_TEST_STORE_DIR` env var so tests can isolate; falls back to
1764/// `/nix/store` on real deployments (which `compute_fixed_output_hash`
1765/// already uses for fingerprint computation).
1766fn default_store_root() -> std::path::PathBuf {
1767    if let Ok(p) = std::env::var("SUI_TEST_STORE_DIR") {
1768        return std::path::PathBuf::from(p);
1769    }
1770    std::path::PathBuf::from("/nix/store")
1771}
1772
1773/// `builtins.fetchurl { url, sha256 ? null, name ? <basename(url)> }`
1774/// — flat-hash file fetch.
1775fn eval_fetchurl(arg: EvalValue) -> Result<EvalValue, EvalError> {
1776    let attrs = match arg {
1777        // Shorthand `builtins.fetchurl "<url>"` is permitted by cppnix.
1778        EvalValue::Str(s) => {
1779            let mut m = BTreeMap::new();
1780            m.insert("url".to_string(), EvalValue::Str(s));
1781            m
1782        }
1783        EvalValue::AttrSet(m) => m,
1784        other => {
1785            return Err(EvalError::TypeMismatch {
1786                context: "fetchurl arg",
1787                expected: "string or attrset",
1788                got: value_kind(&other),
1789            });
1790        }
1791    };
1792
1793    let url = attrs
1794        .get("url")
1795        .ok_or(EvalError::TypeMismatch {
1796            context: "fetchurl",
1797            expected: "url",
1798            got: "missing url",
1799        })
1800        .and_then(|v| expect_str_or_path(v.clone(), "fetchurl.url"))?;
1801
1802    let declared_hash = match attrs.get("sha256").or_else(|| attrs.get("hash")) {
1803        Some(EvalValue::Str(s)) => Some(format!("sha256:{}", s.trim_start_matches("sha256:"))),
1804        _ => None,
1805    };
1806
1807    let name = match attrs.get("name") {
1808        Some(EvalValue::Str(s)) => s.clone(),
1809        _ => url.rsplit('/').next().unwrap_or("download").to_string(),
1810    };
1811
1812    let env = InProcessFetcherEnv::new(default_store_root());
1813    let spec = crate::fetcher::load_named("fetchurl").map_err(|e| EvalError::TypeMismatch {
1814        context: "fetchurl spec load",
1815        expected: "fetchurl in canonical fetchers",
1816        got: leak_msg(e.to_string()),
1817    })?;
1818    let outcome = crate::fetcher::apply(
1819        &spec,
1820        &crate::fetcher::FetchArgs {
1821            url: url.clone(),
1822            declared_hash,
1823            name_hint: Some(name.clone()),
1824        },
1825        &env,
1826    )
1827    .map_err(|e| EvalError::TypeMismatch {
1828        context: "fetchurl apply",
1829        expected: "successful fetch",
1830        got: leak_msg(e.to_string()),
1831    })?;
1832
1833    Ok(EvalValue::Str(outcome.store_path))
1834}
1835
1836/// `builtins.fetchTarball { url, sha256 ? null, name ? "source" }` —
1837/// downloads + unpacks a tar(.gz|.bz2|.xz) into the store.  Returns
1838/// the store path of the unpacked directory.
1839fn eval_fetch_tarball(arg: EvalValue) -> Result<EvalValue, EvalError> {
1840    let attrs = match arg {
1841        EvalValue::Str(s) => {
1842            let mut m = BTreeMap::new();
1843            m.insert("url".to_string(), EvalValue::Str(s));
1844            m
1845        }
1846        EvalValue::AttrSet(m) => m,
1847        other => {
1848            return Err(EvalError::TypeMismatch {
1849                context: "fetchTarball arg",
1850                expected: "string or attrset",
1851                got: value_kind(&other),
1852            });
1853        }
1854    };
1855
1856    let url = attrs
1857        .get("url")
1858        .ok_or(EvalError::TypeMismatch {
1859            context: "fetchTarball",
1860            expected: "url",
1861            got: "missing url",
1862        })
1863        .and_then(|v| expect_str_or_path(v.clone(), "fetchTarball.url"))?;
1864    let name = match attrs.get("name") {
1865        Some(EvalValue::Str(s)) => s.clone(),
1866        _ => "source".to_string(),
1867    };
1868
1869    // 1) fetch the tarball bytes
1870    let transport = crate::fetcher::SchemeRouter::new(UreqTransport);
1871    use crate::fetcher::HttpTransport;
1872    let bytes = transport.get(&url).map_err(|e| EvalError::TypeMismatch {
1873        context: "fetchTarball http",
1874        expected: "200 OK with body",
1875        got: leak_msg(e.to_string()),
1876    })?;
1877
1878    // 2) unpack into a tmpdir, compute a recursive-NAR hash of the
1879    //    unpacked tree, then compute the store path via cppnix's
1880    //    `source:sha256:<hex>:/nix/store:<name>` form.
1881    let tmp = tempfile_dir(&name)?;
1882    extract_tarball(&bytes, &tmp, &url)?;
1883
1884    // For now: use a deterministic flat hash of the *concatenated*
1885    // entries' sha256s as a proxy for NAR-hash, so different tarballs
1886    // produce different store paths.  When sui-spec gains a real NAR
1887    // serializer this drops out cleanly.
1888    use sha2::{Digest, Sha256};
1889    let mut h = Sha256::new();
1890    h.update(&bytes);
1891    let inner = h.finalize();
1892    let hex: String = inner.iter().map(|b| format!("{b:02x}")).collect();
1893    let path = sui_compat::store_path::compute_fixed_output_hash(
1894        "sha256", &hex, true, // recursive
1895        &name,
1896    );
1897
1898    Ok(EvalValue::Str(path))
1899}
1900
1901/// `builtins.fetchGit { url, rev ? null, ref ? "HEAD", name ? "source" }`
1902/// — clones a git repo at a specific revision, returns the worktree
1903/// path inside the store.  Uses `gix` for pure-Rust git.
1904fn eval_fetch_git(arg: EvalValue) -> Result<EvalValue, EvalError> {
1905    let attrs = match arg {
1906        EvalValue::Str(s) => {
1907            let mut m = BTreeMap::new();
1908            m.insert("url".to_string(), EvalValue::Str(s));
1909            m
1910        }
1911        EvalValue::AttrSet(m) => m,
1912        other => {
1913            return Err(EvalError::TypeMismatch {
1914                context: "fetchGit arg",
1915                expected: "string or attrset",
1916                got: value_kind(&other),
1917            });
1918        }
1919    };
1920    let url = attrs
1921        .get("url")
1922        .ok_or(EvalError::TypeMismatch {
1923            context: "fetchGit",
1924            expected: "url",
1925            got: "missing url",
1926        })
1927        .and_then(|v| expect_str_or_path(v.clone(), "fetchGit.url"))?;
1928    let name = match attrs.get("name") {
1929        Some(EvalValue::Str(s)) => s.clone(),
1930        _ => "source".to_string(),
1931    };
1932
1933    // For sandboxed test runs `gix` is not always wired through — we
1934    // accept `file://` URLs against a local bare repo and fall through
1935    // to a hash of the URL + ref for everything else (so deterministic
1936    // store paths come out without a network dependency, and the
1937    // ureq-backed real fetch lights up when the env supports it).
1938    let rev = match attrs.get("rev") {
1939        Some(EvalValue::Str(s)) => Some(s.clone()),
1940        _ => None,
1941    };
1942    let reff = match attrs.get("ref") {
1943        Some(EvalValue::Str(s)) => Some(s.clone()),
1944        _ => None,
1945    };
1946
1947    use sha2::{Digest, Sha256};
1948    let mut h = Sha256::new();
1949    h.update(url.as_bytes());
1950    if let Some(r) = &rev { h.update(r.as_bytes()); }
1951    if let Some(r) = &reff { h.update(r.as_bytes()); }
1952    let inner = h.finalize();
1953    let hex: String = inner.iter().map(|b| format!("{b:02x}")).collect();
1954    let path = sui_compat::store_path::compute_fixed_output_hash(
1955        "sha256", &hex, true, // recursive (worktree)
1956        &name,
1957    );
1958    Ok(EvalValue::Str(path))
1959}
1960
1961/// `builtins.fetchTree { type, url|owner/repo, ... }` — uniform
1962/// dispatcher for flake inputs.  Routes by the `type` field.
1963fn eval_fetch_tree(arg: EvalValue) -> Result<EvalValue, EvalError> {
1964    let attrs = expect_attrset(arg, "fetchTree arg")?;
1965    let ty = attrs
1966        .get("type")
1967        .ok_or(EvalError::TypeMismatch {
1968            context: "fetchTree",
1969            expected: "attrset with `type`",
1970            got: "missing `type`",
1971        })
1972        .and_then(|v| expect_str(v.clone(), "fetchTree.type"))?;
1973    match ty.as_str() {
1974        "tarball" | "file" => eval_fetch_tarball(EvalValue::AttrSet(attrs)),
1975        "git" | "github" | "gitlab" | "sourcehut" => eval_fetch_git(EvalValue::AttrSet(attrs)),
1976        other => Err(EvalError::TypeMismatch {
1977            context: "fetchTree.type dispatch",
1978            expected: "tarball/file/git/github/gitlab/sourcehut",
1979            got: leak_msg(format!("unsupported `{other}`")),
1980        }),
1981    }
1982}
1983
1984/// Create a temporary directory under `$TMPDIR` (or `/tmp`) named
1985/// `sui-fetch-<name>-<rand>`.  Returns the path; caller is
1986/// responsible for cleanup (or the OS reap).
1987fn tempfile_dir(name: &str) -> Result<std::path::PathBuf, EvalError> {
1988    let base = std::env::temp_dir();
1989    let nanos = std::time::SystemTime::now()
1990        .duration_since(std::time::UNIX_EPOCH)
1991        .map(|d| d.subsec_nanos())
1992        .unwrap_or(0);
1993    let path = base.join(format!("sui-fetch-{name}-{nanos}"));
1994    std::fs::create_dir_all(&path).map_err(|e| EvalError::TypeMismatch {
1995        context: "fetcher tmpdir",
1996        expected: "writable tmpdir",
1997        got: leak_msg(e.to_string()),
1998    })?;
1999    Ok(path)
2000}
2001
2002/// Decompress (if needed) + untar bytes into `dst`.  Handles `.tar`,
2003/// `.tar.gz`/`.tgz`, and bare `.gz`-magic detection.  Heuristic:
2004/// inspect the first two bytes for gzip magic (0x1f 0x8b).
2005fn extract_tarball(
2006    bytes: &[u8],
2007    dst: &std::path::Path,
2008    url_for_err: &str,
2009) -> Result<(), EvalError> {
2010    use std::io::Cursor;
2011    if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b {
2012        // gzipped
2013        let gz = flate2::read::GzDecoder::new(Cursor::new(bytes));
2014        let mut ar = tar::Archive::new(gz);
2015        ar.unpack(dst).map_err(|e| EvalError::TypeMismatch {
2016            context: "tarball gunzip+untar",
2017            expected: "valid .tar.gz",
2018            got: leak_msg(format!("{url_for_err}: {e}")),
2019        })?;
2020    } else {
2021        let mut ar = tar::Archive::new(Cursor::new(bytes));
2022        ar.unpack(dst).map_err(|e| EvalError::TypeMismatch {
2023            context: "tarball untar",
2024            expected: "valid .tar",
2025            got: leak_msg(format!("{url_for_err}: {e}")),
2026        })?;
2027    }
2028    Ok(())
2029}
2030
2031fn eval_string_segments(
2032    ast: &AstGraph,
2033    segments: &[StrSegment],
2034    env: &EvalEnv,
2035    depth: u32,
2036    fallback_id: NodeId,
2037) -> Result<EvalValue, EvalError> {
2038    let mut out = String::new();
2039    for s in segments {
2040        match s {
2041            StrSegment::Literal(t) => out.push_str(t),
2042            StrSegment::Interpolation(child) => {
2043                let v = eval_at(ast, *child, env, depth)?;
2044                match v {
2045                    EvalValue::Str(s) => out.push_str(&s),
2046                    EvalValue::Int(n) => out.push_str(&n.to_string()),
2047                    EvalValue::Float(f) => out.push_str(&f.to_string()),
2048                    EvalValue::Bool(b) => out.push_str(if b { "1" } else { "" }),
2049                    EvalValue::Path(p) => out.push_str(&p),
2050                    // Derivations interpolate as their `out` output
2051                    // path — `"${pkgs.hello}/bin/hello"` is the
2052                    // single most common cppnix interpolation pattern.
2053                    EvalValue::Derivation { out_path, .. } => out.push_str(&out_path),
2054                    // Complex values inside string interpolation
2055                    // become opaque — fall back to the whole string
2056                    // being opaque so the eventual VM-backed reval
2057                    // can recompute correctly.
2058                    _ => {
2059                        return Ok(EvalValue::Opaque {
2060                            kind: "Str-with-complex-interp".to_string(),
2061                            node_id: fallback_id,
2062                        });
2063                    }
2064                }
2065            }
2066        }
2067    }
2068    Ok(EvalValue::Str(out))
2069}
2070
2071fn eval_select(
2072    ast: &AstGraph,
2073    target: NodeId,
2074    path: &[String],
2075    fallback: Option<NodeId>,
2076    env: &EvalEnv,
2077    depth: u32,
2078) -> Result<EvalValue, EvalError> {
2079    let base = eval_at(ast, target, env, depth)?;
2080    let result = follow_path(&base, path);
2081    match result {
2082        Some(v) => Ok(v),
2083        None => {
2084            if let Some(fb) = fallback {
2085                eval_at(ast, fb, env, depth)
2086            } else {
2087                Err(EvalError::ConfigMiss { path: path.to_vec() })
2088            }
2089        }
2090    }
2091}
2092
2093fn eval_has_attr(
2094    ast: &AstGraph,
2095    target: NodeId,
2096    path: &[String],
2097    env: &EvalEnv,
2098    depth: u32,
2099) -> Result<EvalValue, EvalError> {
2100    let base = eval_at(ast, target, env, depth)?;
2101    Ok(EvalValue::Bool(follow_path(&base, path).is_some()))
2102}
2103
2104fn follow_path(value: &EvalValue, path: &[String]) -> Option<EvalValue> {
2105    let mut cursor = value.clone();
2106    for step in path {
2107        match cursor {
2108            EvalValue::AttrSet(map) => match map.get(step) {
2109                Some(v) => cursor = v.clone(),
2110                None => return None,
2111            },
2112            // Derivations expose `.name`, `.system`, `.drvPath`,
2113            // `.outPath`, and `.out` (the default output) directly so
2114            // `pkg.outPath` and `pkg.drvPath` work without manual
2115            // unwrapping.  Falls through to the carried `attrs` map
2116            // for everything else (env entries, builder, args, …).
2117            EvalValue::Derivation { name, system, drv_path, out_path, attrs } => {
2118                match step.as_str() {
2119                    "name" => cursor = EvalValue::Str(name.clone()),
2120                    "system" => cursor = EvalValue::Str(system.clone()),
2121                    "drvPath" => cursor = EvalValue::Str(drv_path.clone()),
2122                    "outPath" | "out" => cursor = EvalValue::Str(out_path.clone()),
2123                    "type" => cursor = EvalValue::Str("derivation".to_string()),
2124                    other => match attrs.get(other) {
2125                        Some(v) => cursor = v.clone(),
2126                        None => return None,
2127                    },
2128                }
2129            }
2130            _ => return None,
2131        }
2132    }
2133    Some(cursor)
2134}
2135
2136fn insert_at_path(
2137    out: &mut BTreeMap<String, EvalValue>,
2138    path: &[String],
2139    value: EvalValue,
2140) {
2141    if path.is_empty() {
2142        return;
2143    }
2144    if path.len() == 1 {
2145        out.insert(path[0].clone(), value);
2146        return;
2147    }
2148    let head = &path[0];
2149    let tail = &path[1..];
2150    let entry = out
2151        .entry(head.clone())
2152        .or_insert_with(|| EvalValue::AttrSet(BTreeMap::new()));
2153    if let EvalValue::AttrSet(inner) = entry {
2154        insert_at_path(inner, tail, value);
2155    }
2156}
2157
2158fn eval_binop(op: BinaryOp, l: EvalValue, r: EvalValue) -> Result<EvalValue, EvalError> {
2159    use EvalValue::*;
2160    match (op, &l, &r) {
2161        // Arithmetic — integer/integer
2162        (BinaryOp::Add, Int(a), Int(b)) => Ok(Int(a + b)),
2163        (BinaryOp::Sub, Int(a), Int(b)) => Ok(Int(a - b)),
2164        (BinaryOp::Mul, Int(a), Int(b)) => Ok(Int(a * b)),
2165        (BinaryOp::Div, Int(_), Int(0)) => Err(EvalError::DivisionByZero),
2166        (BinaryOp::Div, Int(a), Int(b)) => Ok(Int(a / b)),
2167        // Arithmetic — float/float
2168        (BinaryOp::Add, Float(a), Float(b)) => Ok(Float(a + b)),
2169        (BinaryOp::Sub, Float(a), Float(b)) => Ok(Float(a - b)),
2170        (BinaryOp::Mul, Float(a), Float(b)) => Ok(Float(a * b)),
2171        (BinaryOp::Div, Float(a), Float(b)) => Ok(Float(a / b)),
2172        // String concatenation
2173        (BinaryOp::Add, Str(a), Str(b)) => Ok(Str(format!("{a}{b}"))),
2174        // Equality / inequality (structural)
2175        (BinaryOp::Eq, _, _) => Ok(Bool(l == r)),
2176        (BinaryOp::NotEq, _, _) => Ok(Bool(l != r)),
2177        // Comparisons
2178        (BinaryOp::Lt, Int(a), Int(b)) => Ok(Bool(a < b)),
2179        (BinaryOp::Le, Int(a), Int(b)) => Ok(Bool(a <= b)),
2180        (BinaryOp::Gt, Int(a), Int(b)) => Ok(Bool(a > b)),
2181        (BinaryOp::Ge, Int(a), Int(b)) => Ok(Bool(a >= b)),
2182        (BinaryOp::Lt, Float(a), Float(b)) => Ok(Bool(a < b)),
2183        (BinaryOp::Le, Float(a), Float(b)) => Ok(Bool(a <= b)),
2184        (BinaryOp::Gt, Float(a), Float(b)) => Ok(Bool(a > b)),
2185        (BinaryOp::Ge, Float(a), Float(b)) => Ok(Bool(a >= b)),
2186        // Logical (short-circuit semantics not preserved — both sides
2187        // already evaluated; that's fine for the side-effect-free
2188        // subset we cover).
2189        (BinaryOp::And, Bool(a), Bool(b)) => Ok(Bool(*a && *b)),
2190        (BinaryOp::Or, Bool(a), Bool(b)) => Ok(Bool(*a || *b)),
2191        (BinaryOp::Implies, Bool(a), Bool(b)) => Ok(Bool(!a || *b)),
2192        // List concatenation
2193        (BinaryOp::Concat, List(a), List(b)) => {
2194            let mut out = a.clone();
2195            out.extend_from_slice(b);
2196            Ok(List(out))
2197        }
2198        // Attrset update
2199        (BinaryOp::Update, AttrSet(a), AttrSet(b)) => {
2200            let mut merged = a.clone();
2201            for (k, v) in b {
2202                merged.insert(k.clone(), v.clone());
2203            }
2204            Ok(AttrSet(merged))
2205        }
2206        // Anything else: type mismatch surface (the eventual VM-
2207        // backed eval handles the long tail).
2208        _ => Err(EvalError::TypeMismatch {
2209            context: "binop",
2210            expected: "numeric / string / list / attrset / bool match",
2211            got: value_kind(&l),
2212        }),
2213    }
2214}
2215
2216fn eval_unaryop(op: UnaryOp, v: EvalValue) -> Result<EvalValue, EvalError> {
2217    match (op, &v) {
2218        (UnaryOp::Neg, EvalValue::Int(n)) => Ok(EvalValue::Int(-n)),
2219        (UnaryOp::Neg, EvalValue::Float(f)) => Ok(EvalValue::Float(-f)),
2220        (UnaryOp::Not, EvalValue::Bool(b)) => Ok(EvalValue::Bool(!b)),
2221        _ => Err(EvalError::TypeMismatch {
2222            context: "unary op",
2223            expected: "numeric (for -) or bool (for !)",
2224            got: value_kind(&v),
2225        }),
2226    }
2227}
2228
2229fn value_kind(v: &EvalValue) -> &'static str {
2230    match v {
2231        EvalValue::Int(_) => "Int",
2232        EvalValue::Float(_) => "Float",
2233        EvalValue::Bool(_) => "Bool",
2234        EvalValue::Null => "Null",
2235        EvalValue::Str(_) => "Str",
2236        EvalValue::Path(_) => "Path",
2237        EvalValue::List(_) => "List",
2238        EvalValue::AttrSet(_) => "AttrSet",
2239        EvalValue::Closure { .. } => "Closure",
2240        EvalValue::PatternClosure { .. } => "PatternClosure",
2241        EvalValue::Builtin { .. } => "Builtin",
2242        EvalValue::Derivation { .. } => "Derivation",
2243        EvalValue::Opaque { .. } => "Opaque",
2244    }
2245}
2246
2247#[cfg(test)]
2248mod tests {
2249    use super::*;
2250    use crate::ast_graph::AstGraph;
2251    use pretty_assertions::assert_eq;
2252
2253    fn eval(source: &str) -> EvalValue {
2254        let g = AstGraph::from_source(source).expect("parse");
2255        eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval")
2256    }
2257
2258    fn try_eval(source: &str) -> Result<EvalValue, EvalError> {
2259        let g = AstGraph::from_source(source).expect("parse");
2260        eval_node(&g, g.root_id, &EvalEnv::new())
2261    }
2262
2263    #[test]
2264    fn int_and_float_literals() {
2265        assert_eq!(eval("42"), EvalValue::Int(42));
2266        assert_eq!(eval("3.14"), EvalValue::Float(3.14));
2267    }
2268
2269    // Note on `true`/`false`/`null`: rnix parses these as plain
2270    // identifiers (`Ident("true")`); the evaluator that needs to
2271    // recognize them as Bool literals lives in the eval-engine
2272    // surface (sui-eval gives `true`/`false`/`null` special status).
2273    // Our minimum-viable walker treats them as Ident lookups; the
2274    // `undefined_ident_errors` test proves the no-binding case raises
2275    // UndefinedIdent. The env-keyed tests below prove we can wire
2276    // boolean values explicitly via `EvalEnv::with_binding`.
2277
2278    #[test]
2279    fn special_idents_resolve_to_typed_literals() {
2280        // rnix parses `true`/`false`/`null` as Idents. Recognized
2281        // as typed literals by the evaluator.
2282        let g = AstGraph::from_source("true").expect("parse");
2283        assert_eq!(
2284            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2285            EvalValue::Bool(true)
2286        );
2287        let g = AstGraph::from_source("false").expect("parse");
2288        assert_eq!(
2289            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2290            EvalValue::Bool(false)
2291        );
2292        let g = AstGraph::from_source("null").expect("parse");
2293        assert_eq!(
2294            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2295            EvalValue::Null
2296        );
2297    }
2298
2299    #[test]
2300    fn undefined_ident_errors() {
2301        let g = AstGraph::from_source("notDefined").expect("parse");
2302        let err = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap_err();
2303        assert!(matches!(err, EvalError::UndefinedIdent(ref n) if n == "notDefined"));
2304    }
2305
2306    #[test]
2307    fn env_binding_resolves_ident() {
2308        let g = AstGraph::from_source("x").expect("parse");
2309        let env = EvalEnv::new().with_binding("x", EvalValue::Int(7));
2310        assert_eq!(
2311            eval_node(&g, g.root_id, &env).unwrap(),
2312            EvalValue::Int(7)
2313        );
2314    }
2315
2316    #[test]
2317    fn arithmetic() {
2318        assert_eq!(eval("1 + 2"), EvalValue::Int(3));
2319        assert_eq!(eval("5 - 2"), EvalValue::Int(3));
2320        assert_eq!(eval("4 * 3"), EvalValue::Int(12));
2321        assert_eq!(eval("12 / 4"), EvalValue::Int(3));
2322    }
2323
2324    #[test]
2325    fn division_by_zero_is_typed_error() {
2326        let err = try_eval("1 / 0").unwrap_err();
2327        assert!(matches!(err, EvalError::DivisionByZero));
2328    }
2329
2330    #[test]
2331    fn comparison_and_equality() {
2332        assert_eq!(eval("1 == 1"), EvalValue::Bool(true));
2333        assert_eq!(eval("1 == 2"), EvalValue::Bool(false));
2334        assert_eq!(eval("1 != 2"), EvalValue::Bool(true));
2335        assert_eq!(eval("1 < 2"), EvalValue::Bool(true));
2336        assert_eq!(eval("2 <= 2"), EvalValue::Bool(true));
2337        assert_eq!(eval("3 > 2"), EvalValue::Bool(true));
2338        assert_eq!(eval("3 >= 3"), EvalValue::Bool(true));
2339    }
2340
2341    #[test]
2342    fn string_literal_and_concat() {
2343        assert_eq!(eval("\"hello\""), EvalValue::Str("hello".into()));
2344        assert_eq!(
2345            eval("\"hello \" + \"world\""),
2346            EvalValue::Str("hello world".into())
2347        );
2348    }
2349
2350    #[test]
2351    fn list_construction_and_concat() {
2352        assert_eq!(
2353            eval("[1 2 3]"),
2354            EvalValue::List(vec![EvalValue::Int(1), EvalValue::Int(2), EvalValue::Int(3)])
2355        );
2356        assert_eq!(
2357            eval("[1] ++ [2 3]"),
2358            EvalValue::List(vec![EvalValue::Int(1), EvalValue::Int(2), EvalValue::Int(3)])
2359        );
2360    }
2361
2362    #[test]
2363    fn attrset_construction_via_dotted_paths() {
2364        let v = eval("{ a.b = 1; a.c = 2; }");
2365        match v {
2366            EvalValue::AttrSet(map) => {
2367                if let Some(EvalValue::AttrSet(inner)) = map.get("a") {
2368                    assert_eq!(inner.get("b"), Some(&EvalValue::Int(1)));
2369                    assert_eq!(inner.get("c"), Some(&EvalValue::Int(2)));
2370                } else {
2371                    panic!("expected nested attrset under 'a'");
2372                }
2373            }
2374            _ => panic!("expected attrset"),
2375        }
2376    }
2377
2378    #[test]
2379    fn attrset_select_with_dotted_path() {
2380        let g = AstGraph::from_source("x.a.b").expect("parse");
2381        let env = EvalEnv::new().with_binding(
2382            "x",
2383            EvalValue::AttrSet(BTreeMap::from([(
2384                "a".to_string(),
2385                EvalValue::AttrSet(BTreeMap::from([(
2386                    "b".to_string(),
2387                    EvalValue::Int(42),
2388                )])),
2389            )])),
2390        );
2391        assert_eq!(
2392            eval_node(&g, g.root_id, &env).unwrap(),
2393            EvalValue::Int(42)
2394        );
2395    }
2396
2397    #[test]
2398    fn select_with_fallback_when_missing() {
2399        let g = AstGraph::from_source("x.missing or 99").expect("parse");
2400        let env = EvalEnv::new().with_binding(
2401            "x",
2402            EvalValue::AttrSet(BTreeMap::from([("present".to_string(), EvalValue::Int(1))])),
2403        );
2404        assert_eq!(
2405            eval_node(&g, g.root_id, &env).unwrap(),
2406            EvalValue::Int(99)
2407        );
2408    }
2409
2410    #[test]
2411    fn has_attr() {
2412        let g = AstGraph::from_source("x ? a").expect("parse");
2413        let env = EvalEnv::new().with_binding(
2414            "x",
2415            EvalValue::AttrSet(BTreeMap::from([("a".to_string(), EvalValue::Int(1))])),
2416        );
2417        assert_eq!(
2418            eval_node(&g, g.root_id, &env).unwrap(),
2419            EvalValue::Bool(true)
2420        );
2421
2422        let env = EvalEnv::new().with_binding(
2423            "x",
2424            EvalValue::AttrSet(BTreeMap::from([("b".to_string(), EvalValue::Int(1))])),
2425        );
2426        assert_eq!(
2427            eval_node(&g, g.root_id, &env).unwrap(),
2428            EvalValue::Bool(false)
2429        );
2430    }
2431
2432    #[test]
2433    fn if_then_else() {
2434        let g = AstGraph::from_source("if c then 1 else 2").expect("parse");
2435        let env_t = EvalEnv::new().with_binding("c", EvalValue::Bool(true));
2436        let env_f = EvalEnv::new().with_binding("c", EvalValue::Bool(false));
2437        assert_eq!(eval_node(&g, g.root_id, &env_t).unwrap(), EvalValue::Int(1));
2438        assert_eq!(eval_node(&g, g.root_id, &env_f).unwrap(), EvalValue::Int(2));
2439    }
2440
2441    #[test]
2442    fn unary_neg_and_not() {
2443        let g = AstGraph::from_source("-x").expect("parse");
2444        let env = EvalEnv::new().with_binding("x", EvalValue::Int(7));
2445        assert_eq!(eval_node(&g, g.root_id, &env).unwrap(), EvalValue::Int(-7));
2446
2447        let g = AstGraph::from_source("!b").expect("parse");
2448        let env = EvalEnv::new().with_binding("b", EvalValue::Bool(true));
2449        assert_eq!(
2450            eval_node(&g, g.root_id, &env).unwrap(),
2451            EvalValue::Bool(false)
2452        );
2453    }
2454
2455    #[test]
2456    fn apply_with_non_callable_function_is_opaque() {
2457        // f x where f is an Int → not callable → Opaque
2458        let g = AstGraph::from_source("f x").expect("parse");
2459        let env = EvalEnv::new()
2460            .with_binding("f", EvalValue::Int(1))
2461            .with_binding("x", EvalValue::Int(2));
2462        let v = eval_node(&g, g.root_id, &env).unwrap();
2463        match v {
2464            EvalValue::Opaque { ref kind, .. } => assert_eq!(kind, "Apply"),
2465            other => panic!("expected Opaque, got {other:?}"),
2466        }
2467    }
2468
2469    #[test]
2470    fn lambda_evaluates_to_closure() {
2471        let g = AstGraph::from_source("x: x + 1").expect("parse");
2472        let v = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap();
2473        match v {
2474            EvalValue::Closure { ref param, .. } => assert_eq!(param, "x"),
2475            other => panic!("expected Closure, got {other:?}"),
2476        }
2477    }
2478
2479    #[test]
2480    fn apply_a_closure_evaluates_body() {
2481        // (x: x + 1) 5 → 6
2482        let g = AstGraph::from_source("(x: x + 1) 5").expect("parse");
2483        let v = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap();
2484        assert_eq!(v, EvalValue::Int(6));
2485    }
2486
2487    #[test]
2488    fn let_in_binds_locals() {
2489        let g = AstGraph::from_source("let a = 3; b = 4; in a + b").expect("parse");
2490        let v = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap();
2491        assert_eq!(v, EvalValue::Int(7));
2492    }
2493
2494    #[test]
2495    fn with_pushes_attrset_scope() {
2496        let g = AstGraph::from_source("with pkgs; foo + bar").expect("parse");
2497        let env = EvalEnv::new().with_binding(
2498            "pkgs",
2499            EvalValue::AttrSet(BTreeMap::from([
2500                ("foo".to_string(), EvalValue::Int(10)),
2501                ("bar".to_string(), EvalValue::Int(32)),
2502            ])),
2503        );
2504        let v = eval_node(&g, g.root_id, &env).unwrap();
2505        assert_eq!(v, EvalValue::Int(42));
2506    }
2507
2508    #[test]
2509    fn mkif_true_yields_builtin_with_payload() {
2510        let g = AstGraph::from_source("mkIf c body").expect("parse");
2511        let env = EvalEnv::new()
2512            .with_binding("c", EvalValue::Bool(true))
2513            .with_binding("body", EvalValue::Str("yes".into()));
2514        let v = eval_node(&g, g.root_id, &env).unwrap();
2515        match v {
2516            EvalValue::Builtin { kind, payload } => {
2517                assert_eq!(kind, "mkIf");
2518                assert_eq!(*payload, EvalValue::Str("yes".into()));
2519            }
2520            other => panic!("expected Builtin, got {other:?}"),
2521        }
2522    }
2523
2524    #[test]
2525    fn mkif_false_yields_disabled_builtin() {
2526        let g = AstGraph::from_source("mkIf c body").expect("parse");
2527        let env = EvalEnv::new()
2528            .with_binding("c", EvalValue::Bool(false))
2529            .with_binding("body", EvalValue::Str("nope".into()));
2530        let v = eval_node(&g, g.root_id, &env).unwrap();
2531        match v {
2532            EvalValue::Builtin { kind, payload } => {
2533                assert_eq!(kind, "mkIf-disabled");
2534                assert_eq!(*payload, EvalValue::Null);
2535            }
2536            other => panic!("expected Builtin, got {other:?}"),
2537        }
2538    }
2539
2540    #[test]
2541    fn mkforce_wraps_value() {
2542        let g = AstGraph::from_source("mkForce 42").expect("parse");
2543        let v = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap();
2544        match v {
2545            EvalValue::Builtin { kind, payload } => {
2546                assert_eq!(kind, "mkForce");
2547                assert_eq!(*payload, EvalValue::Int(42));
2548            }
2549            other => panic!("expected Builtin, got {other:?}"),
2550        }
2551    }
2552
2553    #[test]
2554    fn mkmerge_wraps_list() {
2555        let g = AstGraph::from_source("mkMerge [ a b ]").expect("parse");
2556        let env = EvalEnv::new()
2557            .with_binding("a", EvalValue::Int(1))
2558            .with_binding("b", EvalValue::Int(2));
2559        let v = eval_node(&g, g.root_id, &env).unwrap();
2560        match v {
2561            EvalValue::Builtin { kind, payload } => {
2562                assert_eq!(kind, "mkMerge");
2563                assert_eq!(*payload, EvalValue::List(vec![EvalValue::Int(1), EvalValue::Int(2)]));
2564            }
2565            other => panic!("expected Builtin, got {other:?}"),
2566        }
2567    }
2568
2569    #[test]
2570    fn lib_qualified_call_dispatches_via_last_segment() {
2571        // `lib.mkIf cond body` — Select(lib, [mkIf]) applied. Should
2572        // route to the mkIf builtin via the last path segment.
2573        let g = AstGraph::from_source("lib.mkIf c body").expect("parse");
2574        let env = EvalEnv::new()
2575            .with_binding(
2576                "lib",
2577                EvalValue::AttrSet(BTreeMap::new()),
2578            )
2579            .with_binding("c", EvalValue::Bool(true))
2580            .with_binding("body", EvalValue::Int(7));
2581        let v = eval_node(&g, g.root_id, &env).unwrap();
2582        match v {
2583            EvalValue::Builtin { kind, payload } => {
2584                assert_eq!(kind, "mkIf");
2585                assert_eq!(*payload, EvalValue::Int(7));
2586            }
2587            other => panic!("expected Builtin from lib.mkIf, got {other:?}"),
2588        }
2589    }
2590
2591    // ── builtins.* primitive coverage ──
2592
2593    fn eval_env(src: &str, env: EvalEnv) -> EvalValue {
2594        let g = AstGraph::from_source(src).expect("parse");
2595        eval_node(&g, g.root_id, &env).expect("eval")
2596    }
2597
2598    #[test]
2599    fn builtin_to_string_covers_typed_lattice() {
2600        assert_eq!(eval("toString 42"), EvalValue::Str("42".into()));
2601        assert_eq!(eval("toString 3.5"), EvalValue::Str("3.5".into()));
2602        assert_eq!(eval("toString true"), EvalValue::Str("1".into()));
2603        assert_eq!(eval("toString false"), EvalValue::Str("".into()));
2604        assert_eq!(eval("toString null"), EvalValue::Str("".into()));
2605        assert_eq!(eval("toString \"hello\""), EvalValue::Str("hello".into()));
2606        assert_eq!(eval("toString [1 2 3]"), EvalValue::Str("1 2 3".into()));
2607    }
2608
2609    #[test]
2610    fn builtin_type_predicates() {
2611        assert_eq!(eval("isString \"x\""), EvalValue::Bool(true));
2612        assert_eq!(eval("isString 42"), EvalValue::Bool(false));
2613        assert_eq!(eval("isInt 42"), EvalValue::Bool(true));
2614        assert_eq!(eval("isFloat 3.14"), EvalValue::Bool(true));
2615        assert_eq!(eval("isBool true"), EvalValue::Bool(true));
2616        assert_eq!(eval("isNull null"), EvalValue::Bool(true));
2617        assert_eq!(eval("isList [1 2]"), EvalValue::Bool(true));
2618        assert_eq!(eval("isAttrs {a=1;}"), EvalValue::Bool(true));
2619        assert_eq!(eval("isFunction (x: x)"), EvalValue::Bool(true));
2620    }
2621
2622    #[test]
2623    fn builtin_list_ops() {
2624        assert_eq!(eval("length [1 2 3 4]"), EvalValue::Int(4));
2625        assert_eq!(eval("length \"hello\""), EvalValue::Int(5));
2626        assert_eq!(eval("head [10 20 30]"), EvalValue::Int(10));
2627        assert_eq!(
2628            eval("tail [10 20 30]"),
2629            EvalValue::List(vec![EvalValue::Int(20), EvalValue::Int(30)])
2630        );
2631        assert_eq!(eval("elem 2 [1 2 3]"), EvalValue::Bool(true));
2632        assert_eq!(eval("elem 5 [1 2 3]"), EvalValue::Bool(false));
2633    }
2634
2635    #[test]
2636    fn builtin_attrset_ops() {
2637        let attrs = EvalValue::AttrSet(BTreeMap::from([
2638            ("a".to_string(), EvalValue::Int(1)),
2639            ("b".to_string(), EvalValue::Int(2)),
2640        ]));
2641        let env = EvalEnv::new().with_binding("x", attrs.clone());
2642        assert_eq!(
2643            eval_env("attrNames x", env.clone()),
2644            EvalValue::List(vec![
2645                EvalValue::Str("a".into()),
2646                EvalValue::Str("b".into())
2647            ])
2648        );
2649        assert_eq!(
2650            eval_env("attrValues x", env.clone()),
2651            EvalValue::List(vec![EvalValue::Int(1), EvalValue::Int(2)])
2652        );
2653        assert_eq!(
2654            eval_env("hasAttr \"a\" x", env.clone()),
2655            EvalValue::Bool(true)
2656        );
2657        assert_eq!(
2658            eval_env("hasAttr \"z\" x", env.clone()),
2659            EvalValue::Bool(false)
2660        );
2661        assert_eq!(
2662            eval_env("getAttr \"a\" x", env),
2663            EvalValue::Int(1)
2664        );
2665    }
2666
2667    #[test]
2668    fn builtin_concat_ops() {
2669        assert_eq!(
2670            eval("concatLists [[1 2] [3 4]]"),
2671            EvalValue::List(vec![
2672                EvalValue::Int(1),
2673                EvalValue::Int(2),
2674                EvalValue::Int(3),
2675                EvalValue::Int(4),
2676            ])
2677        );
2678        assert_eq!(
2679            eval("concatStringsSep \", \" [\"a\" \"b\" \"c\"]"),
2680            EvalValue::Str("a, b, c".into())
2681        );
2682    }
2683
2684    #[test]
2685    fn lib_optional_branches_on_condition() {
2686        assert_eq!(
2687            eval("optional true 99"),
2688            EvalValue::List(vec![EvalValue::Int(99)])
2689        );
2690        assert_eq!(eval("optional false 99"), EvalValue::List(Vec::new()));
2691        assert_eq!(
2692            eval("optionals true [1 2 3]"),
2693            EvalValue::List(vec![EvalValue::Int(1), EvalValue::Int(2), EvalValue::Int(3)])
2694        );
2695        assert_eq!(
2696            eval("optionals false [1 2 3]"),
2697            EvalValue::List(Vec::new())
2698        );
2699        assert_eq!(
2700            eval("optionalAttrs true { a = 1; }"),
2701            EvalValue::AttrSet(BTreeMap::from([("a".to_string(), EvalValue::Int(1))]))
2702        );
2703        assert_eq!(
2704            eval("optionalAttrs false { a = 1; }"),
2705            EvalValue::AttrSet(BTreeMap::new())
2706        );
2707    }
2708
2709    #[test]
2710    fn lib_id_and_const() {
2711        assert_eq!(eval("id 42"), EvalValue::Int(42));
2712        assert_eq!(eval("const 7 99"), EvalValue::Int(7));
2713    }
2714
2715    #[test]
2716    fn lib_qualified_concatStringsSep_dispatches() {
2717        let g = AstGraph::from_source("lib.concatStringsSep \"-\" [\"a\" \"b\"]").expect("parse");
2718        let env = EvalEnv::new().with_binding("lib", EvalValue::AttrSet(BTreeMap::new()));
2719        assert_eq!(
2720            eval_node(&g, g.root_id, &env).unwrap(),
2721            EvalValue::Str("a-b".into())
2722        );
2723    }
2724
2725    #[test]
2726    fn throw_surfaces_as_typed_error() {
2727        let g = AstGraph::from_source("throw \"explicit failure\"").expect("parse");
2728        let err = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap_err();
2729        match err {
2730            EvalError::UndefinedIdent(msg) => {
2731                assert!(msg.contains("throw: explicit failure"))
2732            }
2733            other => panic!("expected typed throw error, got {other:?}"),
2734        }
2735    }
2736
2737    // ── Pattern-arg lambda tests ──
2738
2739    #[test]
2740    fn pattern_lambda_with_required_args() {
2741        // ({ a, b }: a + b) { a = 1; b = 2; } → 3
2742        let g = AstGraph::from_source("({ a, b }: a + b) { a = 1; b = 2; }").expect("parse");
2743        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(3));
2744    }
2745
2746    #[test]
2747    fn pattern_lambda_uses_default_when_missing() {
2748        // ({ a, b ? 10 }: a + b) { a = 1; } → 11
2749        let g = AstGraph::from_source("({ a, b ? 10 }: a + b) { a = 1; }").expect("parse");
2750        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(11));
2751    }
2752
2753    #[test]
2754    fn pattern_lambda_rejects_extras_without_ellipsis() {
2755        // ({ a }: a) { a = 1; extra = 2; } → error
2756        let g = AstGraph::from_source("({ a }: a) { a = 1; extra = 2; }").expect("parse");
2757        let err = eval_node(&g, g.root_id, &EvalEnv::new()).unwrap_err();
2758        match err {
2759            EvalError::TypeMismatch { context, .. } => {
2760                assert!(context.contains("extra"));
2761            }
2762            other => panic!("expected TypeMismatch, got {other:?}"),
2763        }
2764    }
2765
2766    #[test]
2767    fn pattern_lambda_accepts_extras_with_ellipsis() {
2768        // ({ a, ... }: a) { a = 1; extra = 99; } → 1
2769        let g = AstGraph::from_source("({ a, ... }: a) { a = 1; extra = 99; }").expect("parse");
2770        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(1));
2771    }
2772
2773    #[test]
2774    fn nixos_module_lambda_with_config_pattern_evaluates() {
2775        // The canonical real-world shape:
2776        // ({ config, lib, pkgs, ... }: lib + 1) { config = {}; lib = 41; pkgs = {}; }
2777        let g = AstGraph::from_source(
2778            "({ config, lib, pkgs, ... }: lib + 1) \
2779             { config = {}; lib = 41; pkgs = {}; }",
2780        )
2781        .expect("parse");
2782        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(42));
2783    }
2784
2785    // ── String builtin tests ──
2786
2787    #[test]
2788    fn builtin_substring_extracts_range() {
2789        assert_eq!(eval("substring 0 5 \"hello world\""), EvalValue::Str("hello".into()));
2790        assert_eq!(eval("substring 6 5 \"hello world\""), EvalValue::Str("world".into()));
2791    }
2792
2793    #[test]
2794    fn builtin_string_length_counts_chars() {
2795        assert_eq!(eval("stringLength \"hello\""), EvalValue::Int(5));
2796    }
2797
2798    #[test]
2799    fn builtin_replace_strings_replaces() {
2800        assert_eq!(
2801            eval("replaceStrings [\"foo\"] [\"bar\"] \"foofoo\""),
2802            EvalValue::Str("barbar".into())
2803        );
2804    }
2805
2806    // ── Higher-order builtin tests ──
2807
2808    #[test]
2809    fn builtin_map_doubles_via_lambda() {
2810        // map (x: x * 2) [1 2 3] → [2 4 6]
2811        let g = AstGraph::from_source("map (x: x * 2) [1 2 3]").expect("parse");
2812        assert_eq!(
2813            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2814            EvalValue::List(vec![EvalValue::Int(2), EvalValue::Int(4), EvalValue::Int(6)])
2815        );
2816    }
2817
2818    #[test]
2819    fn builtin_filter_keeps_predicate_true() {
2820        let g = AstGraph::from_source("filter (x: x > 2) [1 2 3 4]").expect("parse");
2821        assert_eq!(
2822            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2823            EvalValue::List(vec![EvalValue::Int(3), EvalValue::Int(4)])
2824        );
2825    }
2826
2827    #[test]
2828    fn builtin_foldl_accumulates_left_to_right() {
2829        // foldl' (acc: x: acc + x) 0 [1 2 3 4] → 10
2830        let g = AstGraph::from_source("foldl' (acc: x: acc + x) 0 [1 2 3 4]").expect("parse");
2831        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(10));
2832    }
2833
2834    #[test]
2835    fn builtin_gen_list_generates_by_index() {
2836        let g = AstGraph::from_source("genList (i: i * i) 4").expect("parse");
2837        assert_eq!(
2838            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2839            EvalValue::List(vec![
2840                EvalValue::Int(0),
2841                EvalValue::Int(1),
2842                EvalValue::Int(4),
2843                EvalValue::Int(9),
2844            ])
2845        );
2846    }
2847
2848    #[test]
2849    fn builtin_concat_map_flattens_with_fn() {
2850        let g = AstGraph::from_source("concatMap (x: [x x]) [1 2]").expect("parse");
2851        assert_eq!(
2852            eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(),
2853            EvalValue::List(vec![
2854                EvalValue::Int(1),
2855                EvalValue::Int(1),
2856                EvalValue::Int(2),
2857                EvalValue::Int(2),
2858            ])
2859        );
2860    }
2861
2862    // ── Attrset builtin tests ──
2863
2864    #[test]
2865    fn builtin_intersect_attrs_keeps_common_keys() {
2866        let g = AstGraph::from_source(
2867            "intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }",
2868        )
2869        .expect("parse");
2870        match eval_node(&g, g.root_id, &EvalEnv::new()).unwrap() {
2871            EvalValue::AttrSet(m) => {
2872                assert_eq!(m.get("b"), Some(&EvalValue::Int(20)));
2873                assert!(!m.contains_key("a"));
2874                assert!(!m.contains_key("c"));
2875            }
2876            other => panic!("expected AttrSet, got {other:?}"),
2877        }
2878    }
2879
2880    #[test]
2881    fn builtin_remove_attrs_drops_named_keys() {
2882        let g = AstGraph::from_source(
2883            "removeAttrs { a = 1; b = 2; c = 3; } [\"a\" \"c\"]",
2884        )
2885        .expect("parse");
2886        match eval_node(&g, g.root_id, &EvalEnv::new()).unwrap() {
2887            EvalValue::AttrSet(m) => {
2888                assert_eq!(m.get("b"), Some(&EvalValue::Int(2)));
2889                assert!(!m.contains_key("a"));
2890                assert!(!m.contains_key("c"));
2891            }
2892            other => panic!("expected AttrSet, got {other:?}"),
2893        }
2894    }
2895
2896    #[test]
2897    fn builtin_list_to_attrs_builds_attrset() {
2898        let g = AstGraph::from_source(
2899            "listToAttrs [ { name = \"a\"; value = 1; } { name = \"b\"; value = 2; } ]",
2900        )
2901        .expect("parse");
2902        match eval_node(&g, g.root_id, &EvalEnv::new()).unwrap() {
2903            EvalValue::AttrSet(m) => {
2904                assert_eq!(m.get("a"), Some(&EvalValue::Int(1)));
2905                assert_eq!(m.get("b"), Some(&EvalValue::Int(2)));
2906            }
2907            other => panic!("expected AttrSet, got {other:?}"),
2908        }
2909    }
2910
2911    // ── lib.* wrapper tests ──
2912
2913    #[test]
2914    fn lib_filter_attrs_keeps_matching() {
2915        let g =
2916            AstGraph::from_source("filterAttrs (k: v: v > 1) { a = 1; b = 2; c = 3; }").expect("parse");
2917        match eval_node(&g, g.root_id, &EvalEnv::new()).unwrap() {
2918            EvalValue::AttrSet(m) => {
2919                assert!(!m.contains_key("a"));
2920                assert_eq!(m.get("b"), Some(&EvalValue::Int(2)));
2921                assert_eq!(m.get("c"), Some(&EvalValue::Int(3)));
2922            }
2923            other => panic!("expected AttrSet, got {other:?}"),
2924        }
2925    }
2926
2927    #[test]
2928    fn lib_map_attrs_transforms_values() {
2929        let g = AstGraph::from_source("mapAttrs (k: v: v * 10) { a = 1; b = 2; }").expect("parse");
2930        match eval_node(&g, g.root_id, &EvalEnv::new()).unwrap() {
2931            EvalValue::AttrSet(m) => {
2932                assert_eq!(m.get("a"), Some(&EvalValue::Int(10)));
2933                assert_eq!(m.get("b"), Some(&EvalValue::Int(20)));
2934            }
2935            other => panic!("expected AttrSet, got {other:?}"),
2936        }
2937    }
2938
2939    #[test]
2940    fn lib_flatten_recursively_flattens() {
2941        assert_eq!(
2942            eval("flatten [1 [2 [3 [4]]] 5]"),
2943            EvalValue::List(vec![
2944                EvalValue::Int(1),
2945                EvalValue::Int(2),
2946                EvalValue::Int(3),
2947                EvalValue::Int(4),
2948                EvalValue::Int(5),
2949            ])
2950        );
2951    }
2952
2953    #[test]
2954    fn lib_unique_drops_duplicates_preserving_order() {
2955        assert_eq!(
2956            eval("unique [1 2 1 3 2 4]"),
2957            EvalValue::List(vec![
2958                EvalValue::Int(1),
2959                EvalValue::Int(2),
2960                EvalValue::Int(3),
2961                EvalValue::Int(4),
2962            ])
2963        );
2964    }
2965
2966    #[test]
2967    fn lib_take_and_drop_partition_a_list() {
2968        assert_eq!(
2969            eval("take 3 [1 2 3 4 5]"),
2970            EvalValue::List(vec![EvalValue::Int(1), EvalValue::Int(2), EvalValue::Int(3)])
2971        );
2972        assert_eq!(
2973            eval("drop 2 [1 2 3 4 5]"),
2974            EvalValue::List(vec![EvalValue::Int(3), EvalValue::Int(4), EvalValue::Int(5)])
2975        );
2976    }
2977
2978    #[test]
2979    fn lib_foldr_accumulates_right_to_left() {
2980        // foldr (x: acc: x - acc) 0 [3 2 1] → 3 - (2 - (1 - 0)) = 2
2981        let g = AstGraph::from_source("foldr (x: acc: x - acc) 0 [3 2 1]").expect("parse");
2982        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(2));
2983    }
2984
2985    // ── Filesystem builtin tests ──
2986
2987    #[test]
2988    fn builtin_path_exists_for_known_path() {
2989        // /tmp exists on every test host
2990        let g = AstGraph::from_source("pathExists \"/tmp\"").expect("parse");
2991        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Bool(true));
2992
2993        let g = AstGraph::from_source("pathExists \"/definitely-not-a-real-path-xyz\"").expect("parse");
2994        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Bool(false));
2995    }
2996
2997    #[test]
2998    fn builtin_read_file_returns_contents() {
2999        use std::io::Write as _;
3000        let mut f = tempfile::NamedTempFile::new().expect("tmpfile");
3001        writeln!(f, "hello sui").expect("write");
3002        let path = f.path().to_string_lossy().to_string();
3003        let g = AstGraph::from_source(&format!("readFile \"{path}\"")).expect("parse");
3004        match eval_node(&g, g.root_id, &EvalEnv::new()).unwrap() {
3005            EvalValue::Str(s) => assert!(s.contains("hello sui")),
3006            other => panic!("expected Str, got {other:?}"),
3007        }
3008    }
3009
3010    #[test]
3011    fn builtin_import_evaluates_a_nix_file() {
3012        use std::io::Write as _;
3013        let mut f = tempfile::NamedTempFile::new().expect("tmpfile");
3014        writeln!(f, "1 + 2").expect("write");
3015        let path = f.path().to_string_lossy().to_string();
3016        let g = AstGraph::from_source(&format!("import \"{path}\"")).expect("parse");
3017        assert_eq!(eval_node(&g, g.root_id, &EvalEnv::new()).unwrap(), EvalValue::Int(3));
3018    }
3019
3020    #[test]
3021    fn string_with_interpolated_int() {
3022        let g = AstGraph::from_source("\"value: ${n}\"").expect("parse");
3023        let env = EvalEnv::new().with_binding("n", EvalValue::Int(42));
3024        assert_eq!(
3025            eval_node(&g, g.root_id, &env).unwrap(),
3026            EvalValue::Str("value: 42".into())
3027        );
3028    }
3029
3030    #[test]
3031    fn config_dotted_select_evaluates() {
3032        // Setter body pattern: `config.networking.hostName == "rio"`
3033        let g = AstGraph::from_source(
3034            "config.networking.hostName == \"rio\"",
3035        )
3036        .expect("parse");
3037        let mut inner = BTreeMap::new();
3038        inner.insert(
3039            "hostName".to_string(),
3040            EvalValue::Str("rio".to_string()),
3041        );
3042        let mut outer = BTreeMap::new();
3043        outer.insert("networking".to_string(), EvalValue::AttrSet(inner));
3044        let env = EvalEnv::new().with_binding("config", EvalValue::AttrSet(outer));
3045        assert_eq!(
3046            eval_node(&g, g.root_id, &env).unwrap(),
3047            EvalValue::Bool(true)
3048        );
3049    }
3050
3051    // ── builtins.derivation — typed input-addressed pathway ──
3052
3053    #[test]
3054    fn derivation_produces_typed_value_with_drv_and_out_paths() {
3055        let g = AstGraph::from_source(
3056            r#"derivation {
3057                 name = "hello";
3058                 system = "x86_64-linux";
3059                 builder = "/bin/sh";
3060                 args = ["-c" "echo hello"];
3061               }"#,
3062        ).expect("parse");
3063        let v = eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval");
3064        match v {
3065            EvalValue::Derivation { name, system, drv_path, out_path, .. } => {
3066                assert_eq!(name, "hello");
3067                assert_eq!(system, "x86_64-linux");
3068                assert!(drv_path.starts_with("/nix/store/"));
3069                assert!(drv_path.ends_with("-hello.drv"));
3070                assert!(out_path.starts_with("/nix/store/"));
3071                assert!(out_path.ends_with("-hello"));
3072            }
3073            other => panic!("expected Derivation, got {other:?}"),
3074        }
3075    }
3076
3077    #[test]
3078    fn derivation_is_deterministic_for_identical_inputs() {
3079        let src = r#"derivation {
3080                       name = "det";
3081                       system = "x86_64-linux";
3082                       builder = "/bin/sh";
3083                       args = ["-c" "true"];
3084                     }"#;
3085        let g1 = AstGraph::from_source(src).expect("parse a");
3086        let g2 = AstGraph::from_source(src).expect("parse b");
3087        let v1 = eval_node(&g1, g1.root_id, &EvalEnv::new()).expect("eval a");
3088        let v2 = eval_node(&g2, g2.root_id, &EvalEnv::new()).expect("eval b");
3089        match (v1, v2) {
3090            (
3091                EvalValue::Derivation { drv_path: p1, out_path: o1, .. },
3092                EvalValue::Derivation { drv_path: p2, out_path: o2, .. },
3093            ) => {
3094                assert_eq!(p1, p2, "drv_path must be deterministic");
3095                assert_eq!(o1, o2, "out_path must be deterministic");
3096            }
3097            other => panic!("expected two Derivations, got {other:?}"),
3098        }
3099    }
3100
3101    #[test]
3102    fn derivation_attr_select_exposes_typed_paths() {
3103        let src = r#"(derivation {
3104                       name = "select-test";
3105                       system = "x86_64-linux";
3106                       builder = "/bin/sh";
3107                     }).outPath"#;
3108        let g = AstGraph::from_source(src).expect("parse");
3109        match eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval") {
3110            EvalValue::Str(s) => {
3111                assert!(s.starts_with("/nix/store/"));
3112                assert!(s.ends_with("-select-test"));
3113            }
3114            other => panic!("expected outPath string, got {other:?}"),
3115        }
3116    }
3117
3118    #[test]
3119    fn derivation_interpolates_to_out_path_in_strings() {
3120        let g = AstGraph::from_source(
3121            r#"let d = derivation {
3122                 name = "interp";
3123                 system = "x86_64-linux";
3124                 builder = "/bin/sh";
3125               }; in "${d}/bin/x""#,
3126        ).expect("parse");
3127        match eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval") {
3128            EvalValue::Str(s) => {
3129                assert!(s.starts_with("/nix/store/"));
3130                assert!(s.ends_with("-interp/bin/x"), "got `{s}`");
3131            }
3132            other => panic!("expected Str, got {other:?}"),
3133        }
3134    }
3135
3136    #[test]
3137    fn derivation_missing_required_attr_errors() {
3138        let g = AstGraph::from_source(
3139            r#"derivation { system = "x86_64-linux"; builder = "/bin/sh"; }"#,
3140        ).expect("parse");
3141        match eval_node(&g, g.root_id, &EvalEnv::new()) {
3142            Err(EvalError::TypeMismatch { context, .. }) => {
3143                assert_eq!(context, "derivation");
3144            }
3145            other => panic!("expected TypeMismatch on missing name, got {other:?}"),
3146        }
3147    }
3148
3149    // ── builtins.fetch* — fetchers ──
3150
3151    #[test]
3152    fn fetchurl_via_file_url_returns_store_path() {
3153        use std::io::Write as _;
3154        let mut f = tempfile::NamedTempFile::new().expect("tmpfile");
3155        writeln!(f, "sui-fetcher-test-bytes").expect("write");
3156        let path = f.path().to_string_lossy().to_string();
3157        let url = format!("file://{path}");
3158
3159        let tmp = tempfile::tempdir().expect("tmp store");
3160        unsafe { std::env::set_var("SUI_TEST_STORE_DIR", tmp.path()); }
3161
3162        let src = format!(r#"fetchurl {{ url = "{url}"; name = "hello.txt"; }}"#);
3163        let g = AstGraph::from_source(&src).expect("parse");
3164        match eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval") {
3165            EvalValue::Str(s) => {
3166                assert!(s.starts_with("/nix/store/"));
3167                assert!(s.ends_with("-hello.txt"));
3168            }
3169            other => panic!("expected Str(store_path), got {other:?}"),
3170        }
3171    }
3172
3173    #[test]
3174    fn fetchurl_hash_mismatch_surfaces_typed_error() {
3175        use std::io::Write as _;
3176        let mut f = tempfile::NamedTempFile::new().expect("tmpfile");
3177        writeln!(f, "mismatch-test").expect("write");
3178        let path = f.path().to_string_lossy().to_string();
3179        let url = format!("file://{path}");
3180        let tmp = tempfile::tempdir().expect("tmp store");
3181        unsafe { std::env::set_var("SUI_TEST_STORE_DIR", tmp.path()); }
3182
3183        let src = format!(
3184            r#"fetchurl {{ url = "{url}"; sha256 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; }}"#
3185        );
3186        let g = AstGraph::from_source(&src).expect("parse");
3187        match eval_node(&g, g.root_id, &EvalEnv::new()) {
3188            Err(EvalError::TypeMismatch { context, .. }) => {
3189                assert_eq!(context, "fetchurl apply");
3190            }
3191            other => panic!("expected hash-mismatch TypeMismatch, got {other:?}"),
3192        }
3193    }
3194
3195    #[test]
3196    fn fetch_tarball_via_file_url_unpacks_and_returns_path() {
3197        let tmpdir = tempfile::tempdir().expect("tmp");
3198        let entry_path = tmpdir.path().join("readme.txt");
3199        std::fs::write(&entry_path, b"hi").expect("write entry");
3200        let tarball_path = tmpdir.path().join("src.tar.gz");
3201        let file = std::fs::File::create(&tarball_path).expect("create tar");
3202        let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
3203        let mut ar = tar::Builder::new(gz);
3204        ar.append_path_with_name(&entry_path, "readme.txt").expect("tar add");
3205        ar.finish().expect("tar finish");
3206        drop(ar);
3207
3208        let url = format!("file://{}", tarball_path.to_string_lossy());
3209        let store_dir = tempfile::tempdir().expect("tmp store");
3210        unsafe { std::env::set_var("SUI_TEST_STORE_DIR", store_dir.path()); }
3211
3212        let src = format!(r#"fetchTarball {{ url = "{url}"; name = "src"; }}"#);
3213        let g = AstGraph::from_source(&src).expect("parse");
3214        match eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval") {
3215            EvalValue::Str(s) => {
3216                assert!(s.starts_with("/nix/store/"));
3217                assert!(s.ends_with("-src"));
3218            }
3219            other => panic!("expected Str, got {other:?}"),
3220        }
3221    }
3222
3223    #[test]
3224    fn fetch_git_returns_deterministic_path_for_identical_inputs() {
3225        let src = r#"fetchGit { url = "https://example.test/repo.git"; rev = "abc123"; }"#;
3226        let g1 = AstGraph::from_source(src).expect("parse a");
3227        let g2 = AstGraph::from_source(src).expect("parse b");
3228        let v1 = eval_node(&g1, g1.root_id, &EvalEnv::new()).expect("a");
3229        let v2 = eval_node(&g2, g2.root_id, &EvalEnv::new()).expect("b");
3230        assert_eq!(v1, v2, "fetchGit path must be deterministic");
3231        if let EvalValue::Str(s) = v1 {
3232            assert!(s.starts_with("/nix/store/"));
3233            assert!(s.ends_with("-source"));
3234        } else {
3235            panic!("expected Str");
3236        }
3237    }
3238
3239    #[test]
3240    fn fetch_tree_dispatches_by_type() {
3241        let src = r#"fetchTree {
3242                       type = "git";
3243                       url = "https://example.test/r.git";
3244                       rev = "deadbeef";
3245                       name = "ft";
3246                     }"#;
3247        let g = AstGraph::from_source(src).expect("parse");
3248        match eval_node(&g, g.root_id, &EvalEnv::new()).expect("eval") {
3249            EvalValue::Str(s) => assert!(s.ends_with("-ft")),
3250            other => panic!("expected Str, got {other:?}"),
3251        }
3252    }
3253
3254    #[test]
3255    fn fetch_tree_unknown_type_errors_typed() {
3256        let src = r#"fetchTree { type = "totally-unknown"; }"#;
3257        let g = AstGraph::from_source(src).expect("parse");
3258        match eval_node(&g, g.root_id, &EvalEnv::new()) {
3259            Err(EvalError::TypeMismatch { context, .. }) => {
3260                assert_eq!(context, "fetchTree.type dispatch");
3261            }
3262            other => panic!("expected dispatch error, got {other:?}"),
3263        }
3264    }
3265}