Skip to main content

sui_eval/
eval.rs

1//! Tree-walking Nix evaluator using rnix's typed AST.
2//!
3//! Implements Tvix-style lazy evaluation with thunks: let-bindings and
4//! rec-attrset values are wrapped in `Value::Thunk` and only evaluated
5//! when their value is actually needed (call-by-need with memoization).
6
7use std::cell::{Cell, RefCell};
8use std::collections::{HashSet, HashMap, VecDeque};
9use std::path::PathBuf;
10
11use rnix::ast::{self, AstToken, HasEntry, InterpolPart};
12use rowan::ast::AstNode;
13
14use crate::builtins;
15use crate::value::*;
16
17thread_local! { static EVAL_DEPTH: Cell<usize> = const { Cell::new(0) }; }
18
19
20// ── Source ID for identifier symbol cache ─────────────────────
21//
22// Each call to `rnix::Root::parse` produces a distinct AST tree.
23// Identifiers from different trees may share the same byte offset,
24// so we pair offset with a source ID to form a unique cache key.
25// The ID is stored in a thread-local so `eval_expr` can access it
26// without an extra parameter threaded through every call.
27
28thread_local! {
29    static CURRENT_SOURCE_ID: Cell<u32> = const { Cell::new(0) };
30}
31
32// ── Currently-evaluating-file stack ────────────────────────────
33//
34// Real Nix resolves relative path literals (`./foo.nix`) against the
35// directory of the file that *contains* the literal, not against the
36// process cwd. Track the stack of files we're currently evaluating
37// so the `PathRel` handler and `import` builtin can resolve correctly.
38
39thread_local! {
40    /// `None` frame = "evaluating something with no source file" (a `--expr` /
41    /// `<string>` literal). Representing that explicitly is load-bearing: a
42    /// thunk captured in a fileless context used to push NOTHING when it
43    /// forced, so the callee's file stayed on top and `unsafeGetAttrPos`
44    /// stamped the literal with the callee's path where CppNix returns `null`.
45    /// That fed `eval-config.nix`'s `modulesLocation`, which wraps every user
46    /// module in `{ _file; imports = [ m ]; }` — demoting it one
47    /// `genericClosure` level and permuting NixOS definition order.
48    static EVAL_FILE_STACK: RefCell<Vec<Option<PathBuf>>> = const { RefCell::new(Vec::new()) };
49    /// Nix-level error context stack — captures source positions for --show-trace.
50    /// Each entry: (file, expression_snippet). Pushed on function calls, select,
51    /// force, and popped on return. Attached to errors for structured diagnostics.
52    static NIX_TRACE_STACK: RefCell<Vec<NixTraceFrame>> = const { RefCell::new(Vec::new()) };
53}
54
55/// A single frame in the Nix-level error trace.
56///
57/// The frame is only ever *observed* on the cold error path (via
58/// `attach_trace`). To keep the hot lambda-call path allocation-free,
59/// the per-call lambda frame stores the raw ingredients (a cheap
60/// `Rc`-clone of the closure env + the raw current-eval-file `PathBuf`)
61/// and defers the `format!` / path-strip work into `attach_trace`. The
62/// rendered `(description, file)` pair is byte-identical to the eager
63/// form either way (see the `description()` / `file()` accessors).
64#[derive(Debug, Clone)]
65pub enum NixTraceFrame {
66    /// Pre-formatted frame (the builtin-call path — kept eager because
67    /// the builtin name is already a `&'static str`, so there is no
68    /// per-call heap-`String` to defer).
69    Eager {
70        file: Option<String>,
71        description: String,
72    },
73    /// Lazy per-lambda-call frame. The `description` string and the
74    /// stripped `file` string are built on demand in `attach_trace`.
75    ///
76    /// - `closure_env` provides the *description*'s file (from
77    ///   `closure.env.eval_file()`) — an O(1) `Rc` refcount bump.
78    /// - `current_file` is the raw `current_eval_file()` snapshot taken
79    ///   at push time (the stack top after the file guard pushed the
80    ///   closure's file), used verbatim for the frame's `file` field so
81    ///   the rendered `loc` matches the eager form byte-for-byte.
82    Lambda {
83        closure_env: Env,
84        current_file: Option<PathBuf>,
85    },
86}
87
88/// Strip the `-source/` store-path prefix from a rendered path exactly
89/// as the eager trace path did (`p.display()...rsplit_once("-source/")`).
90fn strip_source_prefix(p: &std::path::Path) -> String {
91    let s = p.display().to_string();
92    s.rsplit_once("-source/")
93        .map_or_else(|| p.display().to_string(), |(_, tail)| tail.to_string())
94}
95
96impl NixTraceFrame {
97    /// The frame's `file` field (for the trace `loc`), matching the
98    /// eager `frame.file` byte-for-byte.
99    fn file(&self) -> Option<String> {
100        match self {
101            NixTraceFrame::Eager { file, .. } => file.clone(),
102            NixTraceFrame::Lambda { current_file, .. } => {
103                current_file.as_deref().map(strip_source_prefix)
104            }
105        }
106    }
107
108    /// The frame's `description`, matching the eager `frame.description`
109    /// byte-for-byte. Rendered through the `Display` impl (a `write!`
110    /// surface — the description is the frame's canonical serialization,
111    /// per the fleet TYPED-EMISSION rule; no `format!()`).
112    fn description(&self) -> String {
113        self.to_string()
114    }
115}
116
117/// The frame's rendered description IS its `Display` — the typed emission
118/// surface for the trace message (`write!`, never `format!()`). The
119/// `Lambda` arm defers the path-strip to this cold error-path render.
120impl std::fmt::Display for NixTraceFrame {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            NixTraceFrame::Eager { description, .. } => f.write_str(description),
124            NixTraceFrame::Lambda { closure_env, .. } => {
125                let file = closure_env.eval_file().map(|p| strip_source_prefix(p));
126                write!(
127                    f,
128                    "while calling function defined in {}",
129                    file.as_deref().unwrap_or("<eval>")
130                )
131            }
132        }
133    }
134}
135
136/// Push a Nix-level trace frame. Returns a guard that pops on drop.
137fn push_nix_trace(desc: impl Into<String>) -> NixTraceGuard {
138    let frame = NixTraceFrame::Eager {
139        file: current_eval_file().map(|p| {
140            p.display().to_string()
141                .rsplit_once("-source/")
142                .map_or_else(|| p.display().to_string(), |(_, s)| s.to_string())
143        }),
144        description: desc.into(),
145    };
146    NIX_TRACE_STACK.with(|s| s.borrow_mut().push(frame));
147    NixTraceGuard
148}
149
150/// Push a *lazy* Nix-level trace frame for a lambda call. Stores only the
151/// raw ingredients (an O(1) `Rc`-clone of the closure env + the raw
152/// `current_eval_file()` snapshot) — the `format!`/path-strip work is
153/// deferred to the cold `attach_trace` path. Returns a guard that pops on
154/// drop. The rendered frame is byte-identical to the eager form.
155fn push_nix_trace_lambda(closure_env: &Env) -> NixTraceGuard {
156    let frame = NixTraceFrame::Lambda {
157        closure_env: closure_env.clone(),
158        current_file: current_eval_file(),
159    };
160    NIX_TRACE_STACK.with(|s| s.borrow_mut().push(frame));
161    NixTraceGuard
162}
163
164struct NixTraceGuard;
165impl Drop for NixTraceGuard {
166    fn drop(&mut self) {
167        NIX_TRACE_STACK.with(|s| s.borrow_mut().pop());
168    }
169}
170
171/// Capture the current Nix trace and attach it to an error.
172pub fn attach_trace(err: EvalError) -> EvalError {
173    NIX_TRACE_STACK.with(|s| {
174        let stack = s.borrow();
175        if stack.is_empty() {
176            return err;
177        }
178        let max_frames = std::env::var("SUI_M26_MAXFRAMES").ok()
179            .and_then(|s| s.parse::<usize>().ok()).unwrap_or(15);
180        let mut trace = format!("{err}");
181        for (i, frame) in stack.iter().rev().take(max_frames).enumerate() {
182            let file = frame.file();
183            let loc = file.as_deref().unwrap_or("<eval>");
184            trace.push_str(&format!("\n  {} ({loc})", frame.description()));
185            if i + 1 >= max_frames && stack.len() > max_frames {
186                trace.push_str(&format!("\n  ... ({} more frames)", stack.len() - max_frames));
187            }
188        }
189        // CRITICAL: preserve Throw/AssertionFailed variants so tryEval can catch them.
190        // Converting to TypeError would make tryEval miss them.
191        match err {
192            EvalError::Throw(_) => EvalError::Throw(trace),
193            EvalError::AssertionFailed(_) => EvalError::AssertionFailed(trace),
194            _ => EvalError::TypeError(trace),
195        }
196    })
197}
198
199/// Return the directory of the file currently being evaluated, if any.
200/// Used by the `PathRel` AST handler to resolve relative path literals.
201#[must_use]
202pub fn current_eval_dir() -> Option<PathBuf> {
203    EVAL_FILE_STACK
204        .with(|s| s.borrow().last().cloned())
205        .flatten()
206        .and_then(|p| p.parent().map(PathBuf::from))
207}
208
209/// Push a file onto the eval stack. Returns an RAII guard that pops
210/// it on drop. Use when entering an `import <file>` so subsequent
211/// relative path literals resolve against the right directory.
212pub fn push_eval_file(file: PathBuf) -> EvalFileGuard {
213    push_eval_frame(Some(file))
214}
215
216/// Push a frame that may be fileless. `None` means "this code has no source
217/// file" and MUST still occupy a stack slot — pushing nothing would leave the
218/// caller's file visible to `current_eval_file`, which is exactly the
219/// `unsafeGetAttrPos` divergence documented on `EVAL_FILE_STACK`.
220pub fn push_eval_frame(file: Option<PathBuf>) -> EvalFileGuard {
221    EVAL_FILE_STACK.with(|s| s.borrow_mut().push(file));
222    EvalFileGuard
223}
224
225/// Return the file currently being evaluated, if any.
226/// Used by error sites to attach source location context.
227#[must_use]
228pub fn current_eval_file() -> Option<PathBuf> {
229    EVAL_FILE_STACK.with(|s| s.borrow().last().cloned()).flatten()
230}
231
232
233/// Snapshot the entire eval file stack (debug).
234pub fn eval_file_stack_snapshot() -> Vec<String> {
235    EVAL_FILE_STACK.with(|s| {
236        s.borrow().iter().map(|p| {
237            let Some(p) = p else { return "<no-file>".to_string() };
238            let s = p.display().to_string();
239            s.rsplit_once("-source/").map_or(s.clone(), |(_, r)| r.to_string())
240        }).collect()
241    })
242}
243
244/// Format the current eval file for error context strings.
245/// Returns e.g. `", in '/nix/store/.../default.nix'"` or empty string.
246pub(crate) fn eval_file_ctx() -> String {
247    current_eval_file()
248        .map(|p| format!(", in '{}'", p.display()))
249        .unwrap_or_default()
250}
251
252/// RAII guard that pops the top of the eval-file stack on drop.
253pub struct EvalFileGuard;
254
255impl Drop for EvalFileGuard {
256    fn drop(&mut self) {
257        EVAL_FILE_STACK.with(|s| {
258            s.borrow_mut().pop();
259        });
260    }
261}
262
263/// Set `CURRENT_SOURCE_ID` to `id`, returning an RAII guard that restores
264/// the previous id on drop. Used at thunk force so a cross-file thunk's
265/// idents key the `(source_id, offset)` symbol cache against the file where
266/// the thunk was DEFINED, not the ambient source at force time — the sibling
267/// of the eval-file guard, closing the `parse.nix` cross-file collision.
268pub fn push_source_id(id: u32) -> SourceIdGuard {
269    let prev = CURRENT_SOURCE_ID.with(|s| {
270        let old = s.get();
271        s.set(id);
272        old
273    });
274    SourceIdGuard(prev)
275}
276
277/// RAII guard that restores the previous `CURRENT_SOURCE_ID` on drop.
278pub struct SourceIdGuard(u32);
279
280impl Drop for SourceIdGuard {
281    fn drop(&mut self) {
282        CURRENT_SOURCE_ID.with(|s| s.set(self.0));
283    }
284}
285
286// ── Path normalization ────────────────────────────────────────
287//
288// Normalize a path by removing `.` components and resolving `..`
289// components.  Unlike `canonicalize()`, this doesn't require the
290// path to exist on disk — critical for flake evaluation where
291// files may not be materialized yet.
292
293/// Normalize a path by removing `.` and resolving `..` components
294/// without touching the filesystem.
295///
296/// Delegates to [`crate::path::normalize`] — kept as a public re-export
297/// so existing call-sites continue to compile without changes.
298pub fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
299    crate::path::normalize(path)
300}
301
302// ── Pure (hermetic) evaluation mode ────────────────────────────
303//
304// When pure mode is enabled, impure builtins (`storePath`, `fetchurl`/`fetchTarball`
305// without an explicit hash, `currentTime`, `getEnv`, etc.) should refuse to
306// produce non-deterministic results. The flag is thread-local so each evaluator
307// thread can opt in independently.
308
309thread_local! {
310    static PURE_MODE: Cell<bool> = const { Cell::new(false) };
311}
312
313/// Enable or disable hermetic (pure) evaluation mode for the current thread.
314pub fn set_pure_mode(pure: bool) {
315    PURE_MODE.with(|p| p.set(pure));
316}
317
318/// Whether the current thread is in hermetic (pure) evaluation mode.
319#[must_use]
320pub fn is_pure_mode() -> bool {
321    PURE_MODE.with(Cell::get)
322}
323
324/// Maximum evaluation depth before we report infinite recursion.
325///
326/// With `stacker` dynamically growing the call stack, we are no longer
327/// limited by the default 8 MB thread stack.
328///
329/// **Test builds** keep a low limit (2 048) so that infinite-recursion
330/// tests fail quickly instead of spinning for minutes.
331///
332/// **Non-test builds** disable the depth guard entirely (`usize::MAX`).
333/// nixpkgs uses deeply nested fixpoints (50+ overlay applications, each
334/// creating cascading chains of millions of `eval_expr` calls when
335/// attributes are forced). CppNix has no explicit depth limit — it
336/// relies on the OS stack, which `stacker` now emulates for us. True
337/// infinite recursion is caught by the thunk blackhole detector in
338/// `Thunk::force`, not by this counter.
339#[cfg(test)]
340const MAX_EVAL_DEPTH: usize = 2_048;
341#[cfg(not(test))]
342const MAX_EVAL_DEPTH: usize = usize::MAX;
343
344/// Lightweight depth guard.
345///
346/// In non-test builds where `MAX_EVAL_DEPTH == usize::MAX`, the guard
347/// is effectively a no-op (the overflow check never fires). The
348/// compiler should be able to elide most of the overhead.
349struct DepthGuard;
350
351/// Release-active runaway backstop for the overlay-fixpoint promotion.
352///
353/// Release builds set `MAX_EVAL_DEPTH = usize::MAX` (no eval-depth guard)
354/// so nixpkgs' legitimately-deep fixpoints evaluate.  But a promoted
355/// empty-attrs partial that corrupts a downstream `makeOverridable` /
356/// `commonAttrs` fixpoint (the cross-system Darwin `apple-sdk` path `hello`
357/// hits under `builtins.currentSystem = macOS`) recurses through
358/// `eval_expr` without bound — and that recursion does NOT climb the force
359/// stack, so only an `eval_expr`-level bound catches it before the OS stack
360/// aborts.  Armed ONLY once a promotion has fired (`promotion_occurred()`),
361/// so ordinary deep evaluation (never after a promotion) is untouched.  The
362/// converging native-system fixpoint (`libxcrypt`) peaks well under this
363/// bound and is unaffected; the non-converging cross-system runaway is
364/// caught here, converting a hard native-stack abort into a recoverable
365/// `InfiniteRecursion` that `x.y or default` recovers exactly like nix
366/// (`hello` returns to a clean value-diverge instead of aborting).
367const PROMOTION_RUNAWAY_EVAL_DEPTH: usize = 500;
368
369impl DepthGuard {
370    #[inline(always)]
371    fn enter() -> Result<Self, EvalError> {
372        EVAL_DEPTH.with(|d| {
373            let depth = d.get();
374            if MAX_EVAL_DEPTH != usize::MAX && depth > MAX_EVAL_DEPTH {
375                return Err(EvalError::InfiniteRecursion(
376                    "eval depth exceeded".into(),
377                ));
378            }
379            if depth > PROMOTION_RUNAWAY_EVAL_DEPTH
380                && crate::value::promotion_occurred()
381            {
382                return Err(EvalError::InfiniteRecursion(
383                    "overlay-fixpoint promotion runaway (eval depth exceeded)".into(),
384                ));
385            }
386            d.set(depth + 1);
387            Ok(DepthGuard)
388        })
389    }
390}
391
392impl Drop for DepthGuard {
393    #[inline(always)]
394    fn drop(&mut self) {
395        EVAL_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
396    }
397}
398
399/// Collect ALL identifier names referenced in an AST expression.
400///
401/// Walks the full expression tree (including inside `with` bodies)
402/// and collects every `Ident` node. This is an OVER-APPROXIMATION:
403/// it includes shadowed names and names inside `with` bodies.
404///
405/// Over-approximation is SAFE for dead binding elimination — we may
406/// keep a binding that's unused (waste) but never skip a binding
407/// that IS used (correctness).
408///
409/// Previous versions bailed out on `with` expressions, disabling
410/// dead binding elimination entirely. The fix: collect idents even
411/// inside `with` bodies. If a binding name doesn't appear as ANY
412/// identifier ANYWHERE in the expression, it's provably dead
413/// regardless of `with` scopes — `with` makes names from the
414/// namespace reachable, not names from the enclosing let-scope.
415fn collect_referenced_names(expr: &ast::Expr) -> HashSet<String> {
416    let mut names = HashSet::new();
417    for node in expr.syntax().descendants() {
418        if let Some(ident) = ast::Ident::cast(node) {
419            names.insert(ident_text(&ident));
420        }
421    }
422    names
423}
424
425/// Compute the set of binding names that are transitively needed
426/// by the body expression in a recursive scope (let-in or rec attrset).
427///
428/// Algorithm:
429/// 1. Collect all ident references from the body → root set
430/// 2. Collect all ident references from each binding's value expression
431/// 3. BFS from root set through binding dependencies
432/// 4. Return the set of reachable binding names
433///
434/// Bindings NOT in the returned set are provably dead and can be skipped.
435/// This is correct even for recursive scopes because the BFS follows
436/// transitive dependencies: if A is needed and A references B, then B
437/// is added to the needed set.
438fn compute_needed_bindings(
439    body: &ast::Expr,
440    binding_info: &[(String, Option<ast::Expr>)], // (name, value_expr) — None for plain inherit
441) -> HashSet<String> {
442    // Step 1: Collect idents from the body
443    let body_refs = collect_referenced_names(body);
444
445    // Build the set of all binding names and their dependencies
446    let mut all_names: HashSet<String> = HashSet::with_capacity(binding_info.len());
447    let mut deps: HashMap<String, HashSet<String>> = HashMap::with_capacity(binding_info.len());
448
449    for (name, value_expr) in binding_info {
450        all_names.insert(name.clone());
451        if let Some(expr) = value_expr {
452            deps.insert(name.clone(), collect_referenced_names(expr));
453        }
454    }
455
456    // Step 2: BFS from body refs through binding dependencies
457    let mut needed: HashSet<String> = body_refs.intersection(&all_names).cloned().collect();
458    let mut queue: VecDeque<String> = needed.iter().cloned().collect();
459
460    while let Some(name) = queue.pop_front() {
461        if let Some(name_deps) = deps.get(&name) {
462            for dep in name_deps {
463                if all_names.contains(dep) && needed.insert(dep.clone()) {
464                    queue.push_back(dep.clone());
465                }
466            }
467        }
468    }
469
470    needed
471}
472
473/// Evaluate a Nix expression string.
474#[must_use = "evaluation result should be used"]
475pub fn eval(input: &str) -> Result<Value, EvalError> {
476    eval_with_file(input, None)
477}
478
479// Whether we are inside a top-level eval (used to avoid nested perf reports).
480thread_local! {
481    static EVAL_NESTING: Cell<usize> = const { Cell::new(0) };
482}
483
484/// Evaluate a Nix expression string, optionally tagged with the
485/// path of the source file. The file is stored on the root `Env`
486/// so that any closure created during evaluation captures it and
487/// can resolve relative path literals (`./foo.nix`) in function
488/// defaults that fire after control has left the file's scope.
489
490pub fn eval_with_file(input: &str, file: Option<std::path::PathBuf>) -> Result<Value, EvalError> {
491    let nesting = EVAL_NESTING.with(|n| {
492        let v = n.get();
493        n.set(v + 1);
494        v
495    });
496    if nesting == 0 {
497        crate::perf::init();
498        crate::perf::start();
499        crate::trace::init_trace();
500        // Clear the identifier symbol cache so that offsets from
501        // previous top-level evaluations don't persist.
502        clear_ident_cache();
503        // ENV-RESOLVE M0 (no-op unless `SUI_RESOLVE=1`): clear the per-source
504        // resolution side-table for the same reason — its `(source_id,
505        // offset)` keys must not survive across independent top-level evals.
506        crate::resolve_env::clear();
507        // SOURCE_TEXTS is deliberately NOT cleared here — it is append-only
508        // for the life of the process. Clearing it on a `nesting == 0`
509        // re-entry was a shared-mutable-cell bug: the top-level
510        // `eval_with_file` RETURNS (nesting → 0) BEFORE its caller
511        // deep-forces the result (e.g. `value.to_json()` at the CLI), and
512        // that deep force triggers lazy `import`s which re-enter
513        // `eval_with_file` at nesting == 0 — so clearing here wiped every
514        // registered file's text mid-force. Any `unsafeGetAttrPos` resolved
515        // after the first deep-force import then failed its `text_for()`
516        // existence check and returned null (the cid `options.json` attrTag
517        // `declarations = []` divergence). SOURCE_TEXTS is keyed by canonical
518        // path and `register_source` stores each path's text only once
519        // (identical on re-parse), so append-only is correct — a path always
520        // maps to its own text — and matches CppNix, which never clears its
521        // source registry. The only cost is bounded growth within one process
522        // (a non-issue for a per-invocation CLI). Removing the clearable cell
523        // makes the whole "absent/wrong source text at resolve time" class
524        // unrepresentable rather than merely guarded.
525    }
526    let parse = rnix::Root::parse(input);
527    if !parse.errors().is_empty() {
528        let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
529        EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
530        return Err(EvalError::ParseError(msgs.join("; ")));
531    }
532
533    // Each parse tree gets a unique source ID so that identifiers
534    // at the same byte offset in different files don't collide in
535    // the symbol cache.
536    let src_id = next_source_id();
537    // ENV-RESOLVE M0 (no-op unless `SUI_RESOLVE=1`): run the parse-time
538    // variable resolver over THIS parse tree and merge its `Lexical`
539    // resolutions into the per-source table under `src_id`. Pure + fail-safe
540    // (any uncertainty is left `Dynamic`), so the eval below is byte-identical
541    // — the `Lexical` fast path only shortcuts a lexical-bindings hit, which
542    // `lookup_fast` returns first anyway.
543    if crate::resolve_env::enabled() {
544        let table = sui_resolve::resolve(&parse.tree());
545        crate::resolve_env::populate(src_id, &table);
546    }
547    // Register this parse tree's file + text so a static key's byte offset
548    // (recorded by `eval_attrset`) resolves to a file/line/column for
549    // `builtins.unsafeGetAttrPos`. The file flows through the eval-file
550    // stack (store-path prefixed for imported inputs); the position resolver
551    // lifts a cache-dir path to its `/nix/store/<h>-source` store path.
552    crate::pos::register_source(file.as_deref(), input);
553    let prev_src_id = CURRENT_SOURCE_ID.with(|s| {
554        let old = s.get();
555        s.set(src_id);
556        old
557    });
558
559    let root = parse.tree();
560    let expr = match root.expr() {
561        Some(e) => e,
562        None => {
563            CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
564            EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
565            return Err(EvalError::ParseError("empty expression".to_string()));
566        }
567    };
568    let mut env = Env::new();
569    env.set_eval_file(file);
570    // Tag the env with THIS parse tree's source_id so a thunk created here
571    // and forced later (cross-file) restores this id on force (see the
572    // source-id guard in `Thunk::force`), keying `IDENT_CACHE` against the
573    // file where the thunk was defined.
574    env.set_source_id(src_id);
575    builtins::register(&mut env);
576    let result = eval_expr(&expr, &env).map_err(|e| attach_trace(e))?;
577    // Force the top-level result so callers always see a concrete value.
578    let final_result = force_value(&result).map_err(|e| attach_trace(e));
579    // Restore the previous source ID (matters for nested imports).
580    CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
581    EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
582    if nesting == 0 {
583        crate::perf::report();
584    }
585    final_result
586}
587
588/// Force a value: if it is a thunk, evaluate and memoize the result.
589/// Concrete values are returned unchanged.
590/// Force a value: if it is a thunk, evaluate and memoize the result.
591/// Concrete values are returned unchanged.
592///
593/// Inlined aggressively so the non-thunk fast path compiles to a
594/// simple clone without a function-call boundary.
595#[inline(always)]
596/// Force a value and return a type-safe `Concrete` (guaranteed non-Thunk).
597///
598/// This is the preferred forcing API. The `Concrete` return type makes it
599/// impossible to accidentally use an unforced thunk — the compiler rejects it.
600pub fn force_concrete(value: &Value) -> Result<Concrete, EvalError> {
601    value.demand()
602}
603
604/// Force a value (legacy API — returns `Value` for backward compatibility).
605///
606/// Prefer `force_concrete()` or `Value::demand()` for new code.
607pub fn force_value(value: &Value) -> Result<Value, EvalError> {
608    crate::perf::inc(crate::perf::Counter::ForceValue);
609    // Fast path: non-thunk values are returned immediately (no clone needed
610    // until we actually have work to do).
611    if !matches!(value, Value::Thunk(_)) {
612        return Ok(value.clone());
613    }
614    // Slow path: chase thunk chains.
615    //
616    // A legitimate chain is typically 1–3 links deep (result of lazy
617    // evaluation wrapping an intermediate value in another thunk).
618    // Reaching 100 means either (a) a self-referential cycle like
619    // `let x = x; in x` that bypassed per-thunk Blackhole detection,
620    // or (b) pathological Thunk(Thunk(...)) nesting. Both are errors.
621    //
622    // Previous behavior silently returned `Ok(last_thunk)` at depth
623    // 100, which hid infinite-recursion bugs — the blackhole tests
624    // in the lib suite failed because `result.is_ok()` instead of
625    // `is_err()`. Returning `Err` here makes the silent-bail visible
626    // at the CppNix-compatible call site (real Nix raises "infinite
627    // recursion encountered").
628    let mut v = value.clone();
629    let mut depth = 0u32;
630    loop {
631        match v {
632            Value::Thunk(ref thunk) => {
633                v = force_thunk(thunk)?;
634                depth += 1;
635                if depth > 100 {
636                    return Err(EvalError::InfiniteRecursion(
637                        "force_value: thunk chain exceeded depth 100 (cycle or runaway lazy wrap)".into(),
638                    ));
639                }
640            }
641            _ => return Ok(v),
642        }
643    }
644}
645
646/// Force with call-site tracking (legacy API).
647pub fn force_value_tracked(value: &Value, site: &str) -> Result<Value, EvalError> {
648    crate::perf::inc(crate::perf::Counter::ForceValue);
649    if let Value::Thunk(thunk) = value {
650        FORCE_SITES.with(|sites| {
651            *sites.borrow_mut().entry(site.to_string()).or_insert(0) += 1;
652        });
653        force_thunk(thunk)
654    } else {
655        Ok(value.clone())
656    }
657}
658
659thread_local! {
660    static FORCE_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
661        std::cell::RefCell::new(std::collections::HashMap::new());
662    static APPLY_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
663        std::cell::RefCell::new(std::collections::HashMap::new());
664}
665
666/// Dump force-site counters (call from perf reporting).
667pub fn dump_force_sites() {
668    FORCE_SITES.with(|sites| {
669        let sites = sites.borrow();
670        let mut sorted: Vec<_> = sites.iter().collect();
671        sorted.sort_by(|a, b| b.1.cmp(a.1));
672        eprintln!("[force-sites] top thunk force call sites:");
673        for (site, count) in sorted.iter().take(10) {
674            eprintln!("  {count:>8} {site}");
675        }
676    });
677    APPLY_SITES.with(|sites| {
678        let sites = sites.borrow();
679        let mut sorted: Vec<_> = sites.iter().collect();
680        sorted.sort_by(|a, b| b.1.cmp(a.1));
681        eprintln!("[apply-sites] top lambda call sites by source file:");
682        for (site, count) in sorted.iter().take(15) {
683            // Strip nix store prefix for readability
684            let short = site.rsplit_once("-source/").map_or(site.as_str(), |(_,s)| s);
685            eprintln!("  {count:>8} {short}");
686        }
687    });
688}
689
690/// Force a thunk — split out from [`force_value`] so the fast path
691/// (non-thunk clone) stays fully inlined while this cold path can
692/// be a regular function call with stacker protection.
693fn force_thunk(thunk: &Thunk) -> Result<Value, EvalError> {
694    // Ultra-fast path: if the thunk is already cached, skip stacker overhead.
695    if let Some(cached) = thunk.peek() {
696        crate::perf::inc(crate::perf::Counter::ThunkHit);
697        return Ok(cached.clone().into_value());
698    }
699    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
700        // Force ONE level only — matches CppNix's forceValue which does
701        // not transitively chase thunk-in-thunk chains. The caller will
702        // force again when the value is actually needed. This is the key
703        // optimization: CppNix forces 71 thunks for lib.version while
704        // sui was forcing 180K due to transitive forcing.
705        thunk.force(&|expr, env| eval_expr(expr, env))
706    })
707}
708
709/// Decide whether to thunk an expression or evaluate it directly.
710///
711/// Trivial expressions (literals, paths) are evaluated immediately --
712/// no thunk allocation. For non-recursive scopes, variable lookups
713/// (Ident) and lambdas are also evaluated eagerly. This matches
714/// CppNix's `maybeThunk` optimization which avoids a large fraction
715/// of thunk creations on nixpkgs.
716///
717/// For recursive scopes (let-in, rec attrsets), set `is_rec = true` to
718/// prevent eager evaluation of `Ident` and `Lambda` expressions:
719/// - Ident: sibling bindings may not be defined yet (forward refs).
720/// - Lambda: the closure must capture the *final* env (set in Phase 2)
721///   so that the lambda body can reference sibling bindings.
722///
723/// `defined_so_far`: In recursive scopes, names that have already been
724/// bound in this scope (i.e. earlier bindings). Idents referencing these
725/// are backward references and can be resolved directly without thunking.
726/// Forward references (names not yet defined) must still be thunked.
727/// Detect whether `value_expr`'s source structurally references
728/// the identifier `name` — the signal that this let-binding is a
729/// self-recursive fix-point (`let x = f x; in x` or
730/// `let x = { a = 1; b = x.a; }; in x`).  Used at let-binding
731/// thunking time to pick `Thunk::new_suspended_recursive` over the
732/// classic `Thunk::new_suspended`, so inner re-entrance during
733/// force returns the partial value via `ThunkRepr::Promise`
734/// instead of erroring with `InfiniteRecursion`.
735///
736/// Implementation walks the value-expr's rnix syntax tree looking
737/// for `TOKEN_IDENT` whose text equals `name`.  This is a
738/// conservative over-approximation:
739/// - shadowing (e.g. `let x = let x = 1; in x; in x`) marks the
740///   outer thunk recursive even though no real cycle exists;
741/// - the resulting Promise behaviour is a strict superset of
742///   Blackhole for non-cyclic forces (the body runs to completion
743///   and the cell gets the final value), so false positives are
744///   semantically safe — they cost only the extra `Rc<RefCell>`
745///   allocation per recursive let-binding.
746///
747/// False negatives (e.g. the bound name appears only inside an
748/// inherit-from-source clause) leave the existing
749/// `InfiniteRecursion` behaviour intact, which is the conservative
750/// fallback.
751/// The set of variable-reference ident names in `value_expr`'s subtree
752/// (`NODE_IDENT` whose parent is NOT a `NODE_ATTRPATH` — i.e. genuine
753/// variable references, not attribute names/keys). ONE subtree walk.
754///
755/// Kills the O(N²) re-walk storm (Storm A) at the call sites: previously
756/// `is_self_recursive_binding` did a full subtree walk once per
757/// `(binding × sibling-name)` in every `let`/`rec` scope; now each RHS is
758/// walked ONCE to build this set, then every name is an O(1) set lookup.
759/// Byte-neutral: the recursion verdict is unchanged (a name is self/mutually
760/// recursive iff it is in the set).
761///
762/// NOT cross-call memoized: a process-lifetime memo keyed on ephemeral AST
763/// node identity `(source-id, range)` collides when nodes are parsed/dropped
764/// without a per-eval clear (the standalone-predicate case). The call-site
765/// single-walk is the byte-safe win; `ContentMemo` (sui-intern) is reserved
766/// for sites with a STABLE content key (the NAR-hash memo's `(dir,name)`, the
767/// overlay-flatten per-node cache).
768///
769/// The attrpath exclusion matters: without it, `placeholder = if
770/// lhs.placeholder == …` in nixpkgs `lib/types.nix` would be falsely flagged
771/// self-recursive (its RHS mentions the *attribute* `.placeholder`), routing
772/// the binding through the `Promise` fix-point path whose env handling drops
773/// the let-scope — surfacing as a force-order-dependent `null` in the module
774/// system (`concatLists: expected list, got null`).
775fn referenced_idents(value_expr: &ast::Expr) -> HashSet<SmolStr> {
776    use rnix::SyntaxKind;
777    // Storm A instrumentation (byte-neutral, gated on perf::enabled()): count
778    // this walk + the rnix descendants it visits + its walltime, so the
779    // residual per-fixpoint-iteration self/mutual-recursion detection cost is
780    // VISIBLE in the SUI_EVAL_PERF report — symmetric with sorted_entries /
781    // overlay-flatten. The counter reads add zero output-relevant work.
782    let perf_on = crate::perf::enabled();
783    let t0 = if perf_on {
784        Some(std::time::Instant::now())
785    } else {
786        None
787    };
788    crate::perf::inc(crate::perf::Counter::SelfRecWalkCalls);
789    let mut nodes_walked: u64 = 0;
790    let mut set: HashSet<SmolStr> = HashSet::new();
791    for node in value_expr.syntax().descendants() {
792        nodes_walked += 1;
793        if node.kind() == SyntaxKind::NODE_IDENT
794            && node
795                .parent()
796                .is_none_or(|p| p.kind() != SyntaxKind::NODE_ATTRPATH)
797            && let Some(i) = ast::Ident::cast(node)
798        {
799            set.insert(SmolStr::from(ident_text(&i).as_str()));
800        }
801    }
802    crate::perf::add(crate::perf::Counter::SelfRecWalkNodes, nodes_walked);
803    if let Some(t0) = t0 {
804        crate::trace::add_self_rec_walk_nanos(t0.elapsed().as_nanos());
805    }
806    set
807}
808
809/// True iff `value_expr` references `name` as a variable. Now a set lookup
810/// over one subtree walk (see `referenced_idents`). Byte-neutral vs the prior
811/// per-name-walk implementation.
812fn is_self_recursive_binding(value_expr: &ast::Expr, name: &str) -> bool {
813    referenced_idents(value_expr).contains(name)
814}
815
816fn maybe_thunk(
817    expr: &ast::Expr,
818    env: &Env,
819    is_rec: bool,
820    defined_so_far: Option<&HashSet<String>>,
821) -> Value {
822    match expr {
823        // Literals: evaluate directly (no allocation needed).
824        ast::Expr::Literal(lit) => eval_literal(lit).unwrap_or_else(|_| {
825            Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
826        }),
827        // Ident resolution: try full lookup (lexical + with-scope cache + force).
828        // On successful lookup → return value directly (most common case).
829        // On blackhole (fixpoint being constructed) → env.lookup returns None
830        // → create WithIdent thunk for deferred O(1) cache-based resolution.
831        // This approach: (1) is fast for resolved with-scopes (no thunk overhead),
832        // (2) handles blackhole fixpoints correctly via WithIdent deferral.
833        ast::Expr::Ident(ident) if !is_rec => {
834            // Cache the interned Symbol by (source_id, text_offset) — same
835            // zero-alloc steady-state path as the strict Ident arm in
836            // `eval_expr`. The ident text is materialized only on the
837            // once-per-offset cold miss and on the (rare) blackhole deferral.
838            // Same cross-file aliasing fix as the strict `eval_expr` Ident arm —
839            // key on the env's source id, not the unmaintained thread-local.
840            // This twin had NO stale-symbol guard at all (the one commit
841            // 2d93e77 added sits only on the strict arm's lookup-MISS path,
842            // after the keyword check), so it was the more exposed of the two.
843            let sym = {
844                let src_id = env.source_id();
845                let offset = u32::from(ident.syntax().text_range().start());
846                crate::value::intern_cached_with(src_id, offset, || {
847                    crate::value::intern(&ident_text(ident))
848                })
849            };
850            // Zero-copy keyword check on the resolved Symbol.
851            if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
852                "true" => Some(Value::Bool(true)),
853                "false" => Some(Value::Bool(false)),
854                "null" => Some(Value::Null),
855                _ => None,
856            }) {
857                return kw;
858            }
859            {
860                {
861                    // `name` arg to `lookup_fast` is unused (lookup is by
862                    // Symbol) — pass "" to skip materializing the ident text on
863                    // the hot HIT path.
864                    if let Some(v) = env.lookup_fast(sym, "") {
865                        return v;
866                    }
867                    // Failed — either blackhole or missing. Create WithIdent
868                    // thunk for deferred resolution (only for the blackhole case).
869                    if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
870                        return Value::Thunk(Thunk::new_with_ident(
871                            SmolStr::from(ident_text(ident).as_str()),
872                            scope_cache,
873                            scope_value,
874                            env.clone(),
875                        ));
876                    }
877                    crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
878                    Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
879                }
880            }
881        }
882        // Identifiers in rec scope: check if it's a backward reference
883        // (name already defined earlier in the same scope). If so, we
884        // can resolve it directly instead of creating a wasteful thunk.
885        ast::Expr::Ident(ident) if is_rec => {
886            let name = ident_text(ident);
887            match name.as_str() {
888                "true" => Value::Bool(true),
889                "false" => Value::Bool(false),
890                "null" => Value::Null,
891                _ => {
892                    // If this name was already defined earlier in the
893                    // scope, it's a backward reference — resolve directly.
894                    if defined_so_far.map_or(false, |d| d.contains(&name)) {
895                        env.lookup(&name).unwrap_or_else(|| {
896                            crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
897                            Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
898                        })
899                    } else {
900                        // Forward reference — must thunk
901                        crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
902                        Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
903                    }
904                }
905            }
906        }
907        // Absolute and home paths: trivial text extraction — but ONLY
908        // for the non-interpolated case. An interpolated path (`/a/${e}`,
909        // `~/${e}`) must be thunked so its `${…}` parts are evaluated in
910        // `eval_expr_inner`, never spliced as literal text.
911        ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
912            // CppNix canonicalizes every absolute path literal on eval
913            // (`/.` → `/`, `/a/./b` → `/a/b`, `/a/../b` → `/b`, `..`
914            // clamped at root). A path VALUE carries the canonical form —
915            // the marquee cid root threw in `lib.path.hasStorePathPrefix`
916            // precisely because sui kept the raw `/.` text.
917            let text = crate::path::canon_abs(&p.syntax().text().to_string());
918            Value::Path(Box::new(SmolStr::from(text.as_str())))
919        }
920        ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
921            let text = p.syntax().text().to_string();
922            Value::Path(Box::new(SmolStr::from(text.as_str())))
923        }
924        // Non-interpolated string literal: a constant value with no
925        // interpolation, so `eval_str` runs no `${…}` force/coerce — it is
926        // pure, non-throwing, side-effect-free, and produces a
927        // `String(NixString::with_context(text, EMPTY))`. Evaluating it here is
928        // therefore byte-identical to forcing a suspended thunk of it (M2
929        // thunk-waste: a constant Str thunk is always pure overhead — it can
930        // never observably change eval order because it cannot throw or
931        // diverge). Only the NON-interpolated case is direct; an interpolated
932        // `"${e}"` must stay thunked so its parts force lazily in the right
933        // env/order. `eval_str` on the empty-interpolation input cannot fail,
934        // but fall back to a thunk on the (unreachable) error to preserve
935        // exact prior behavior.
936        ast::Expr::Str(st) if !str_has_interpolation(st) => {
937            eval_str(st, env).unwrap_or_else(|_| {
938                Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
939            })
940        }
941        // Lambda: capture env directly (no computation needed).
942        // But NOT in recursive scopes -- the closure must capture the
943        // final env with all sibling bindings (set in Phase 2).
944        ast::Expr::Lambda(lam) if !is_rec => {
945            if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
946                Value::Lambda(Rc::new(Closure {
947                    param,
948                    body,
949                    env: env.clone(),
950                }))
951            } else {
952                Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
953            }
954        }
955        // Select on a variable: CppNix's maybeThunk evaluates these eagerly
956        // when the base is a simple ident. However, this breaks fixpoints
957        // where the base (e.g., `config`) is a thunk being computed — eagerly
958        // evaluating `config.x` during attrset construction triggers blackhole.
959        //
960        // The nixpkgs module system relies on `{ ...; default = config.x; }`
961        // being lazy. Wrap selects in thunks unconditionally.
962        // The performance cost is minimal (thunk allocation + deferred eval)
963        // and correctness is critical for fixpoint patterns.
964        // Everything else: wrap in a thunk for lazy evaluation.
965        _ => {
966            crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeOther);
967            if crate::perf::enabled() {
968                let kind = match expr {
969                    ast::Expr::Select(_) => "Select",
970                    ast::Expr::Apply(_) => "Apply",
971                    ast::Expr::BinOp(_) => "BinOp",
972                    ast::Expr::IfElse(_) => "IfElse",
973                    ast::Expr::Str(_) => "Str",
974                    ast::Expr::List(_) => "List",
975                    ast::Expr::With(_) => "With",
976                    ast::Expr::Assert(_) => "Assert",
977                    ast::Expr::HasAttr(_) => "HasAttr",
978                    ast::Expr::UnaryOp(_) => "UnaryOp",
979                    ast::Expr::Paren(_) => "Paren",
980                    ast::Expr::LetIn(_) => "LetIn",
981                    ast::Expr::AttrSet(_) => "AttrSet",
982                    ast::Expr::Ident(_) => "Ident(rec)",
983                    ast::Expr::Lambda(_) => "Lambda(rec)",
984                    ast::Expr::LegacyLet(_) => "LegacyLet",
985                    ast::Expr::PathAbs(_)
986                    | ast::Expr::PathHome(_)
987                    | ast::Expr::PathRel(_)
988                    | ast::Expr::PathSearch(_) => "Path(interp)",
989                    _ => "Other",
990                };
991                crate::trace::inc_maybe_other_kind(kind);
992            }
993            Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
994        }
995    }
996}
997
998/// Evaluate an rnix expression in an environment.
999///
1000/// Uses `stacker::maybe_grow` to dynamically extend the call stack when
1001/// it is close to exhaustion.  This prevents stack overflow on deeply
1002/// nested nixpkgs fixpoints (50+ overlay applications each creating
1003/// multiple recursive `eval_expr` / `force_value` frames).
1004///
1005/// **Fast path:** Ident (~32% of all evals), Literal, Paren, and Root
1006/// expressions don't recurse and are handled directly, skipping the
1007/// `stacker::maybe_grow` overhead for ~40% of all `eval_expr` calls.
1008#[inline(always)]
1009pub fn eval_expr(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1010    // Fast path: trivial expressions that don't recurse.
1011    // Skip stacker overhead for ~40% of all eval_expr calls.
1012    match expr {
1013        ast::Expr::Ident(ident) => {
1014            crate::perf::inc(crate::perf::Counter::EvalExpr);
1015            if crate::perf::enabled() {
1016                crate::perf::inc(crate::perf::Counter::ExprIdent);
1017            }
1018            // ── ENV-RESOLVE M0 fast path (no-op unless `SUI_RESOLVE=1`) ──
1019            // A parse-time-`Lexical` reference carries its precomputed
1020            // Symbol; probe the lexical bindings map DIRECTLY, skipping the
1021            // per-lookup `ident_text().to_string()` + `intern()`. This is
1022            // parity-by-construction: `lookup_fast` probes the SAME lexical
1023            // map by the SAME Symbol FIRST, so a hit here is byte-identical
1024            // to what the unchanged path below returns. Any miss (a
1025            // mid-fixpoint blackhole where the binding isn't in scope yet, an
1026            // unrecorded ident, or `Dynamic`) falls through to the EXACT
1027            // unchanged path — including the whole with-chain + WithIdent
1028            // deferral. The resolver never records keywords, so the
1029            // true/false/null handling below is untouched on this path.
1030            if crate::resolve_env::enabled() {
1031                let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1032                let offset = u32::from(ident.syntax().text_range().start());
1033                if let sui_resolve::Resolution::Lexical { sym } =
1034                    crate::resolve_env::resolution_for(src_id, offset)
1035                {
1036                    if let Some(v) = env.lookup_lexical_sym(sym) {
1037                        return Ok(v);
1038                    }
1039                }
1040                // Miss / Dynamic → fall through to the unchanged path.
1041            }
1042            // Cache the interned Symbol by (source_id, text_offset) so the
1043            // steady-state identifier lookup pays neither a per-lookup
1044            // `ident_text().to_string()` heap alloc nor a string re-hash — the
1045            // ident's text is materialized only on the once-per-offset cold
1046            // miss. The keyword check + the common `lookup_fast` HIT then run
1047            // fully allocation-free; `name` is materialized lazily only on the
1048            // miss/error branches, which need the string anyway.
1049            // KEY ON `env.source_id()`, NOT the thread-local (fixed 2026-07-20).
1050            //
1051            // `CURRENT_SOURCE_ID` is pushed at exactly ONE site —
1052            // `value.rs`'s `ThunkRepr::Suspended` force branch. Lambda
1053            // application and the Native/WithIdent/InheritSelect/Promise force
1054            // branches never push it, so while a callee's body was being
1055            // evaluated the thread-local still named the CALLER's file. The
1056            // `(source_id, offset)` cache key then aliased across files: an
1057            // identifier at byte N in file A could resolve to the Symbol
1058            // interned for a `null`/`true`/`false` token at byte N in file B —
1059            // and the zero-copy keyword check below turned that into a literal
1060            // `Value::Null` for a perfectly well-defined identifier, before any
1061            // environment lookup.
1062            //
1063            // That is what stopped sui evaluating nixpkgs: `hostSuffix` in
1064            // `make-derivation.nix` resolved to `null`, so `attrs.name +
1065            // hostSuffix` raised "cannot add string and null" — observed
1066            // directly as `STALE-KEYWORD ident="hostSuffix" resolvedAs="null"`.
1067            // It is not darwin-specific and has nothing to do with the module
1068            // system; `import <nixpkgs> {}` fails identically on x86_64-linux.
1069            //
1070            // `Env` already carries the correct value: `eval_with_file` sets it
1071            // and `child()` inherits it, and a lambda's `call_env` is
1072            // `closure.env.child()` — so a body's env names its DEFINING file.
1073            // Keying on it fixes every cross-file path at the cause, rather than
1074            // adding a fifth push/pop guard that a sixth path can forget.
1075            let sym = {
1076                let src_id = env.source_id();
1077                let offset = u32::from(ident.syntax().text_range().start());
1078                crate::value::intern_cached_with(src_id, offset, || {
1079                    crate::value::intern(&ident_text(ident))
1080                })
1081            };
1082            // Zero-copy keyword check on the resolved Symbol — the resolver
1083            // never records keywords, so this matches the prior `name.as_str()`
1084            // arm exactly.
1085            if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
1086                "true" => Some(Value::Bool(true)),
1087                "false" => Some(Value::Bool(false)),
1088                "null" => Some(Value::Null),
1089                _ => None,
1090            }) {
1091                return Ok(kw);
1092            }
1093            return {
1094                {
1095                    // `lookup_fast`'s `name` argument is unused (lookup is by
1096                    // Symbol); pass "" to avoid materializing the ident text on
1097                    // the hot HIT path.
1098                    if let Some(v) = env.lookup_fast(sym, "") {
1099                        Ok(v)
1100                    } else {
1101                        let name = ident_text(ident);
1102                        // The `(src_id, text_offset)` identifier-symbol cache
1103                        // (`intern_cached_with`) can hand back a STALE Symbol when
1104                        // a lazily-forced thunk's identifier is resolved under a
1105                        // force-time `CURRENT_SOURCE_ID` that differs from the
1106                        // identifier's PARSE-time src_id — a thunk from file A can
1107                        // be forced while B is the current source, so
1108                        // `(B_src_id, offset)` aliases B's parse tree's identifier
1109                        // at that same byte offset and returns ITS Symbol. (Proven
1110                        // root: nixpkgs `lib/systems/parse.nix` `mkOptionType` — the
1111                        // binding IS present in the env, but the cache returned
1112                        // `Symbol(566)` while the binding was interned under
1113                        // `Symbol(506)`, so `lookup_fast(566)` missed a defined
1114                        // var.) `intern` is deterministic + append-only, so on a
1115                        // miss re-intern the name from its text (the authoritative
1116                        // Symbol) and retry the lexical lookup BEFORE considering
1117                        // with-scopes or undefined. A genuinely undefined variable
1118                        // is unaffected — its fresh lookup also misses and falls
1119                        // through unchanged.
1120                        let fresh = crate::value::intern(name.as_str());
1121                        if fresh != sym {
1122                            if let Some(v) = env.lookup_fast(fresh, name.as_str()) {
1123                                return Ok(v);
1124                            }
1125                        }
1126                        if env.with_scope_count() > 0 {
1127                        // With-scope lookup failed (likely blackhole from fixpoint).
1128                        // Return a WithIdent thunk for deferred resolution.
1129                        // This is the eval_expr equivalent of maybe_thunk's deferral.
1130                        if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
1131                            Ok(Value::Thunk(Thunk::new_with_ident(
1132                                SmolStr::from(name.as_str()),
1133                                scope_cache,
1134                                scope_value,
1135                                env.clone(),
1136                            )))
1137                        } else if crate::value::in_promise_eval() {
1138                            // M2.6 Promise softening: an undefined
1139                            // identifier inside Promise body evaluation
1140                            // typically means a `with` block sourced
1141                            // from the empty-attrset sentinel didn't
1142                            // populate the with-scope.  Returning null
1143                            // lets the eval proceed; the result is
1144                            // wrong-but-bounded (no further forces
1145                            // happen on null until something downstream
1146                            // demands a real value).
1147                            Ok(Value::Null)
1148                        } else {
1149                            Err(EvalError::UndefinedVar(
1150                                format!("'{name}'{}", eval_file_ctx()),
1151                            ))
1152                        }
1153                    } else {
1154                        if let Ok(dbg_var) = std::env::var("SUI_DEBUG_VAR") {
1155                            if dbg_var == name || dbg_var == "*" {
1156                                eprintln!(
1157                                    "[sui-debug] UndefinedVar '{name}' in {}\n\
1158                                     [sui-debug]   env bindings ({} total): {:?}\n\
1159                                     [sui-debug]   with_scopes: {}",
1160                                    eval_file_ctx(),
1161                                    env.binding_count(),
1162                                    env.binding_names_preview(20),
1163                                    env.with_scope_count(),
1164                                );
1165                            }
1166                        }
1167                        if crate::value::in_promise_eval() {
1168                            // Same Promise softening as the with-scope
1169                            // branch above.
1170                            return Ok(Value::Null);
1171                        }
1172                        Err(EvalError::UndefinedVar(
1173                            format!("'{name}'{}", eval_file_ctx()),
1174                        ))
1175                        }
1176                    }
1177                }
1178            };
1179        }
1180        ast::Expr::Literal(lit) => {
1181            crate::perf::inc(crate::perf::Counter::EvalExpr);
1182            if crate::perf::enabled() {
1183                crate::perf::inc(crate::perf::Counter::ExprLiteral);
1184            }
1185            return eval_literal(lit);
1186        }
1187        ast::Expr::Paren(p) => {
1188            if let Some(inner) = p.expr() {
1189                return eval_expr(&inner, env);
1190            }
1191        }
1192        ast::Expr::Root(r) => {
1193            if let Some(inner) = r.expr() {
1194                return eval_expr(&inner, env);
1195            }
1196        }
1197        // Lambda: no recursion — just captures env into a closure.
1198        ast::Expr::Lambda(lam) => {
1199            crate::perf::inc(crate::perf::Counter::EvalExpr);
1200            if crate::perf::enabled() {
1201                crate::perf::inc(crate::perf::Counter::ExprLambda);
1202            }
1203            if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1204                return Ok(Value::Lambda(Rc::new(Closure {
1205                    param,
1206                    body,
1207                    env: env.clone(),
1208                })));
1209            }
1210        }
1211        _ => {}
1212    }
1213    // Complex expressions: need stacker for recursion safety
1214    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
1215        eval_expr_inner(expr, env)
1216    })
1217}
1218
1219/// Inner implementation of [`eval_expr`] — called from the `stacker`
1220/// trampoline so that the stack is guaranteed to have headroom.
1221///
1222/// Uses a tail-call loop: for expressions in tail position (`if/else`,
1223/// `let..in`, `with`, `assert`, `paren`, `root`), we update the local
1224/// `expr` and `env` variables and loop instead of recursing. This
1225/// eliminates millions of stack frames in nixpkgs evaluation.
1226fn eval_expr_inner(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1227    // Tail-call trampoline: expressions in tail position update these
1228    // and `continue` instead of recursing into eval_expr.
1229    let mut cur_expr = expr.clone();
1230    let mut cur_env = env.clone();
1231
1232    loop {
1233    crate::perf::inc(crate::perf::Counter::EvalExpr);
1234    // Track expression type distribution when profiling
1235    if crate::perf::enabled() {
1236        use crate::perf::Counter;
1237        let c = match &cur_expr {
1238            ast::Expr::Ident(_) => Counter::ExprIdent,
1239            ast::Expr::Literal(_) => Counter::ExprLiteral,
1240            ast::Expr::Str(_) => Counter::ExprStr,
1241            ast::Expr::List(_) => Counter::ExprList,
1242            ast::Expr::AttrSet(_) => Counter::ExprAttrs,
1243            ast::Expr::Select(_) => Counter::ExprSelect,
1244            ast::Expr::Apply(_) => Counter::ExprApply,
1245            ast::Expr::LetIn(_) => Counter::ExprLetIn,
1246            ast::Expr::IfElse(_) => Counter::ExprIfElse,
1247            ast::Expr::With(_) => Counter::ExprWith,
1248            ast::Expr::Lambda(_) => Counter::ExprLambda,
1249            ast::Expr::BinOp(_) => Counter::ExprBinOp,
1250            ast::Expr::HasAttr(_) => Counter::ExprHasAttr,
1251            ast::Expr::UnaryOp(_) => Counter::ExprUnaryOp,
1252            ast::Expr::Assert(_) => Counter::ExprAssert,
1253            ast::Expr::PathAbs(_) | ast::Expr::PathRel(_)
1254            | ast::Expr::PathHome(_) | ast::Expr::PathSearch(_) => Counter::ExprPath,
1255            _ => Counter::ExprOther,
1256        };
1257        crate::perf::inc(c);
1258    }
1259    let _guard = DepthGuard::enter()?;
1260    let env = &cur_env;
1261    match &cur_expr {
1262        ast::Expr::Literal(lit) => return eval_literal(lit),
1263
1264        ast::Expr::Str(s) => return eval_str(s, env),
1265
1266        ast::Expr::PathAbs(p) => {
1267            // An interpolated absolute path (`/a/${e}`) splices its
1268            // `${…}` parts; a plain one takes the raw-text shortcut.
1269            let parts = p.parts();
1270            if parts_have_interpolation(&parts) {
1271                return eval_interpol_path_parts(&parts, PathKind::Abs, env);
1272            }
1273            // Canonicalize like CppNix (`/.` → `/`, `.`/`..` collapse,
1274            // `..` clamps at root) — see the WHNF fast-path above.
1275            let text = crate::path::canon_abs(&p.syntax().text().to_string());
1276            return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1277        }
1278        ast::Expr::PathRel(p) => {
1279            // Real Nix resolves `./foo.nix` against the directory
1280            // of the file that *contains* the literal, not the
1281            // process cwd. Use the current eval-file stack; fall
1282            // back to cwd when no file is being evaluated (e.g.,
1283            // top-level `sui eval`).
1284            //
1285            // An interpolated relative path (`./${x}.nix`) first splices
1286            // its `${…}` parts, then resolves the concatenated text the
1287            // same way — the interpolation is evaluated + string-coerced,
1288            // NOT treated as literal `${x}` text.
1289            let parts = p.parts();
1290            if parts_have_interpolation(&parts) {
1291                return eval_interpol_path_parts(&parts, PathKind::Rel, env);
1292            }
1293            let text = p.syntax().text().to_string();
1294            let resolved = if let Some(dir) = current_eval_dir() {
1295                let joined = dir.join(&text);
1296                // Use normalize_path instead of canonicalize so that
1297                // paths with ./  and .. are cleaned without requiring
1298                // the path to exist on disk.
1299                let norm = normalize_path(&joined);
1300                // A relative path literal (`./x`, `../..`) resolves against the
1301                // eval-dir, which for a fetched flake input is the sui fetcher
1302                // CACHE dir. CppNix resolves it against the input's
1303                // `/nix/store/<h>-source` STORE path, so the resulting path
1304                // VALUE must carry the store prefix (this is the value half of
1305                // the store↔cache seam — `materialize`/`dematerialize`). Lift
1306                // the cache path back to the store path so `toString ../..`
1307                // matches CppNix — the options.json `hasPrefix
1308                // <nix-darwin>.outPath decl` rewrite root (`prefix = ../..`).
1309                crate::path::dematerialize(&norm)
1310                    .to_string_lossy()
1311                    .into_owned()
1312            } else {
1313                text.clone()
1314            };
1315            return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1316        }
1317        ast::Expr::PathHome(p) => {
1318            let parts = p.parts();
1319            if parts_have_interpolation(&parts) {
1320                return eval_interpol_path_parts(&parts, PathKind::Home, env);
1321            }
1322            let text = p.syntax().text().to_string();
1323            return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1324        }
1325        ast::Expr::PathSearch(p) => {
1326            // `<name>` or `<name/sub/path>` — resolve via NIX_PATH
1327            // entries (parsed from the env var). If no NIX_PATH entry
1328            // matches, fall through to the literal text so the error
1329            // message points at the name the user wrote.
1330            let text = p.syntax().text().to_string();
1331            let inner = text
1332                .strip_prefix('<')
1333                .and_then(|s| s.strip_suffix('>'))
1334                .unwrap_or(&text);
1335            if let Some(resolved) = crate::builtins::resolve_search_path(inner) {
1336                return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1337            }
1338            // CppNix: search path resolution failure is a throw
1339            // (catchable by tryEval). Used by nixpkgs impure-overlays.nix
1340            // which tries `import <nixpkgs-overlays>` inside tryEval.
1341            return Err(EvalError::Throw(
1342                format!("search path '{text}' not in NIX_PATH"),
1343            ));
1344        }
1345
1346        ast::Expr::Ident(ident) => {
1347            let name = ident_text(ident);
1348            return match name.as_str() {
1349                "true" => Ok(Value::Bool(true)),
1350                "false" => Ok(Value::Bool(false)),
1351                "null" => Ok(Value::Null),
1352                _ => {
1353                    env.lookup(&name)
1354                        .ok_or_else(|| EvalError::UndefinedVar(
1355                            format!("'{name}'{}", eval_file_ctx()),
1356                        ))
1357                }
1358            };
1359        }
1360
1361        ast::Expr::List(list) => {
1362            // Wrap list elements in thunks for maximum laziness.
1363            // CppNix wraps list elements — only forced when accessed.
1364            // This prevents eager evaluation of unused list elements
1365            // (e.g., nixpkgs overlay lists with thousands of entries).
1366            let values: Vec<Value> = list.items()
1367                .map(|e| maybe_thunk(&e, env, false, None))
1368                .collect();
1369            return Ok(Value::list(values));
1370        }
1371
1372        ast::Expr::AttrSet(set) => return eval_attrset(set, env),
1373
1374        ast::Expr::Select(sel) => return eval_select(sel, env),
1375
1376        ast::Expr::HasAttr(ha) => return eval_has_attr(ha, env),
1377
1378        ast::Expr::UnaryOp(op) => return eval_unary_op(op, env),
1379
1380        ast::Expr::BinOp(binop) => {
1381            let lhs_expr = binop
1382                .lhs()
1383                .ok_or_else(|| EvalError::ParseError("binop missing lhs".to_string()))?;
1384            let rhs_expr = binop
1385                .rhs()
1386                .ok_or_else(|| EvalError::ParseError("binop missing rhs".to_string()))?;
1387            let kind = binop
1388                .operator()
1389                .ok_or_else(|| EvalError::ParseError("binop missing operator".to_string()))?;
1390            return eval_binop(kind, &lhs_expr, &rhs_expr, env);
1391        }
1392
1393        ast::Expr::Apply(app) => return eval_apply(app, env),
1394
1395        ast::Expr::IfElse(ie) => {
1396            let cond = ie
1397                .condition()
1398                .ok_or_else(|| EvalError::ParseError("if missing condition".to_string()))?;
1399            let body = ie
1400                .body()
1401                .ok_or_else(|| EvalError::ParseError("if missing then body".to_string()))?;
1402            let else_body = ie
1403                .else_body()
1404                .ok_or_else(|| EvalError::ParseError("if missing else body".to_string()))?;
1405            if force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1406                cur_expr = body;
1407            } else {
1408                cur_expr = else_body;
1409            }
1410            // env stays the same — tail call
1411            continue;
1412        }
1413
1414        ast::Expr::Assert(assert) => {
1415            let cond = assert
1416                .condition()
1417                .ok_or_else(|| EvalError::ParseError("assert missing condition".to_string()))?;
1418            let body = assert
1419                .body()
1420                .ok_or_else(|| EvalError::ParseError("assert missing body".to_string()))?;
1421            if !force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1422                return Err(EvalError::AssertionFailed(eval_file_ctx()));
1423            }
1424            cur_expr = body;
1425            continue;
1426        }
1427
1428        ast::Expr::With(with) => {
1429            let ns = with
1430                .namespace()
1431                .ok_or_else(|| EvalError::ParseError("with missing namespace".to_string()))?;
1432            let body = with
1433                .body()
1434                .ok_or_else(|| EvalError::ParseError("with missing body".to_string()))?;
1435            // Don't force the namespace yet — store as a lazy value.
1436            // CppNix evaluates with-scopes lazily: the namespace is only
1437            // forced when a name lookup actually falls through lexical scope.
1438            // This is critical for `fix (self: with self; { … })` patterns
1439            // used throughout nixpkgs.
1440            //
1441            // M2.6 ROOT #4a (byte-verified): `eval_expr(&ns, env)?` was NOT
1442            // lazy — it EVALUATED the namespace expression eagerly at
1443            // `with`-entry.  For `with (throw "X"); body` that runs the
1444            // throw; for `with config.services.borgbackup; { … }` (nixpkgs'
1445            // module `config` shape) it forces `config.services.borgbackup`
1446            // the instant the `with`-body's WHNF/keys are demanded (during
1447            // module collection's `pushDownProperties`), re-entering the
1448            // mid-force `config` fixpoint → the empty-Promise partial →
1449            // `null` softening → `concatLists null`.  cppnix stores the
1450            // namespace as a thunk and forces it ONLY when a bare-ident
1451            // lookup actually falls through lexical scope into the `with`.
1452            // Reduced repro (no module system, iterates in ms):
1453            //   `builtins.attrNames (with (throw "X"); { a = 1; })`
1454            //   nix → [ "a" ] ; sui (before) → throws "X".
1455            // `maybe_thunk` keeps the fast-path for an already-resolved
1456            // ident namespace (no thunk overhead) while deferring any
1457            // non-trivial namespace (Select / Apply / throw) into a lazy
1458            // thunk the scope-lookup path (`Env::lookup_fast`) forces only
1459            // on fallthrough.
1460            let scope_val = maybe_thunk(&ns, env, false, None);
1461            let new_env = env.child().with_scope(scope_val);
1462            cur_expr = body;
1463            cur_env = new_env;
1464            continue;
1465        }
1466
1467        ast::Expr::LetIn(letin) => {
1468            let mut new_env = env.child();
1469
1470            // Phase 1: Create thunks with a dummy env and bind them.
1471            // Collect (key, thunk) pairs so we can update envs later.
1472            let mut thunks: Vec<(String, Thunk)> = Vec::new();
1473
1474            // Track which names have been defined so far in this scope.
1475            // Used by maybe_thunk to resolve backward references directly
1476            // instead of creating wasteful thunks.
1477            let mut defined_so_far: HashSet<String> = HashSet::new();
1478
1479            // Accumulator for dotted-path bindings (`let a.b = 1; a.c = 2; ...`).
1480            // Leaf values are wrapped in thunks so they can reference
1481            // sibling let-bindings (the let scope is recursive in Nix).
1482            let mut dotted_attrs: NixAttrs = NixAttrs::new();
1483
1484            // Pre-pass: collect every binding name in this let-scope
1485            // (single-key bindings + top-level keys of dotted paths +
1486            // names from inherit clauses).  Used by the recursive-thunk
1487            // detector below — a binding is part of the mutual fix-point
1488            // if its RHS references ANY of these names.
1489            let let_scope_names: HashSet<String> = {
1490                let mut s = HashSet::new();
1491                for entry in letin.entries() {
1492                    match entry {
1493                        ast::Entry::AttrpathValue(apv) => {
1494                            if let Some(attrpath) = apv.attrpath() {
1495                                if let Some(first) = attrpath.attrs().next() {
1496                                    if let Ok(name) = eval_attr(&first, env) {
1497                                        s.insert(name);
1498                                    }
1499                                }
1500                            }
1501                        }
1502                        ast::Entry::Inherit(inherit) => {
1503                            for attr in inherit.attrs() {
1504                                if let Ok(name) = eval_attr(&attr, env) {
1505                                    s.insert(name);
1506                                }
1507                            }
1508                        }
1509                    }
1510                }
1511                s
1512            };
1513
1514            for entry in letin.entries() {
1515                match entry {
1516                    ast::Entry::AttrpathValue(ref apv) => {
1517                        let attrpath = apv.attrpath().ok_or_else(|| {
1518                            EvalError::ParseError("binding missing attrpath".to_string())
1519                        })?;
1520                        let value_expr = apv.value().ok_or_else(|| {
1521                            EvalError::ParseError("binding missing value".to_string())
1522                        })?;
1523                        let mut path_keys: Vec<String> = attrpath
1524                            .attrs()
1525                            .map(|a| eval_attr(&a, env))
1526                            .collect::<Result<_, _>>()?;
1527                        if path_keys.len() == 1 {
1528                            let key = path_keys.pop().unwrap();
1529                            // Self/mutual-recursive detection: any binding
1530                            // whose RHS references its own name OR any
1531                            // SIBLING let-scope name is part of the let's
1532                            // mutual fix-point.  Mark as recursive so
1533                            // inner re-entrance during force returns a
1534                            // Promise sentinel instead of erroring with
1535                            // InfiniteRecursion.  This is the M2.6
1536                            // module-system fix path (cppnix's
1537                            // lib/modules.nix uses a deep let-scope with
1538                            // declaredConfig / options / matchedOptions /
1539                            // resultsByName / modules all transitively
1540                            // cycling through each other).
1541                            //
1542                            // `let_scope_names` is collected upfront in a
1543                            // pre-pass so each binding sees every other
1544                            // binding name (not just earlier ones).
1545                            // O(N) not O(N²): compute the RHS's referenced-name
1546                            // set ONCE (memoized), then intersect with the
1547                            // let-scope names. Byte-identical to the prior
1548                            // `references(key) OR references(any sibling)`:
1549                            // chaining `key` covers the self-reference case
1550                            // regardless of whether `key ∈ let_scope_names`.
1551                            let referenced = referenced_idents(&value_expr);
1552                            let in_mutual_cycle = std::iter::once(&key)
1553                                .chain(let_scope_names.iter())
1554                                .any(|n| referenced.contains(n.as_str()));
1555                            let value = if in_mutual_cycle {
1556                                Value::Thunk(Thunk::new_suspended_recursive(
1557                                    value_expr.clone(),
1558                                    env.clone(),
1559                                ))
1560                            } else {
1561                                maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
1562                            };
1563                            new_env.bind(key.clone(), value.clone());
1564                            if let Value::Thunk(t) = &value {
1565                                thunks.push((key.clone(), t.clone()));
1566                            }
1567                            defined_so_far.insert(key);
1568                        } else if path_keys.len() > 1 {
1569                            // Multi-segment dotted path: build a nested
1570                            // attrset with thunks at the leaves so the
1571                            // value expression can reference sibling
1572                            // let-bindings.
1573                            let key = path_keys[0].clone();
1574                            let value = build_nested_attr_thunk(
1575                                &path_keys[1..],
1576                                &value_expr,
1577                                env,
1578                                &mut thunks,
1579                            );
1580                            merge_nested_insert(&mut dotted_attrs, key, value);
1581                        }
1582                    }
1583                    ast::Entry::Inherit(ref inherit) => {
1584                        if let Some(from) = inherit.from() {
1585                            let source_expr = from.expr().ok_or_else(|| {
1586                                EvalError::ParseError(
1587                                    "inherit from missing expr".to_string(),
1588                                )
1589                            })?;
1590                            // Create ONE shared source thunk per
1591                            // `inherit (source)` clause. All inherited
1592                            // names share it via Rc clone — the source
1593                            // is evaluated at most once.
1594                            let source_thunk = Thunk::new_suspended(
1595                                source_expr, env.clone(),
1596                            );
1597                            for attr in inherit.attrs() {
1598                                let name = eval_attr(&attr, env)?;
1599                                let thunk = Thunk::new_inherit_select(
1600                                    source_thunk.clone(),
1601                                    name.clone(),
1602                                );
1603                                new_env.bind(name.clone(), Value::Thunk(thunk.clone()));
1604                                thunks.push((name, thunk));
1605                            }
1606                        } else {
1607                            // `inherit name1 name2 ...` from the
1608                            // enclosing lexical scope. This stays
1609                            // eager because the names already exist
1610                            // in `env` — no fixpoint involved.
1611                            for attr in inherit.attrs() {
1612                                let name = eval_attr(&attr, env)?;
1613                                let value = env.lookup(&name).ok_or_else(|| {
1614                                    EvalError::UndefinedVar(
1615                                        format!("'{name}'{}", eval_file_ctx()),
1616                                    )
1617                                })?;
1618                                new_env.bind(name, value);
1619                            }
1620                        }
1621                    }
1622                }
1623            }
1624
1625            // Phase 1b: Bind accumulated dotted-path attrs into new_env.
1626            // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
1627            // duplicate definition, so we do not attempt to merge with
1628            // existing inherit thunks — just bind directly.
1629            for (key, value) in dotted_attrs.iter() {
1630                new_env.bind(key.clone(), value.clone());
1631            }
1632
1633            // Phase 2: Update all thunks to capture the final env
1634            // (which now has all names bound).
1635            for (_key, thunk) in &thunks {
1636                thunk.update_env(&new_env);
1637            }
1638
1639            let body = letin
1640                .body()
1641                .ok_or_else(|| EvalError::ParseError("let missing body".to_string()))?;
1642            cur_expr = body;
1643            cur_env = new_env;
1644            continue;
1645        }
1646
1647        ast::Expr::Lambda(lam) => {
1648            let param = lam
1649                .param()
1650                .ok_or_else(|| EvalError::ParseError("lambda missing param".to_string()))?;
1651            let body = lam
1652                .body()
1653                .ok_or_else(|| EvalError::ParseError("lambda missing body".to_string()))?;
1654            return Ok(Value::Lambda(Rc::new(Closure {
1655                param,
1656                body,
1657                env: env.clone(),
1658            })));
1659        }
1660
1661        ast::Expr::Paren(p) => {
1662            let inner = p
1663                .expr()
1664                .ok_or_else(|| EvalError::ParseError("paren missing expr".to_string()))?;
1665            cur_expr = inner;
1666            continue;
1667        }
1668
1669        ast::Expr::Root(r) => {
1670            let inner = r
1671                .expr()
1672                .ok_or_else(|| EvalError::ParseError("root missing expr".to_string()))?;
1673            cur_expr = inner;
1674            continue;
1675        }
1676
1677        ast::Expr::LegacyLet(ll) => {
1678            let mut new_env = env.child();
1679            eval_entries(ll, &mut new_env)?;
1680            // legacy let returns the `body` attr from its bindings
1681            return new_env
1682                .lookup("body")
1683                .ok_or_else(|| EvalError::AttrNotFound(
1684                    format!("'body' in legacy let{}", eval_file_ctx()),
1685                ));
1686        }
1687
1688        ast::Expr::CurPos(_) => return Err(EvalError::NotImplemented("__curPos".to_string())),
1689        ast::Expr::Error(_) => return Err(EvalError::ParseError("parse error node".to_string())),
1690    } // match
1691    } // loop — unreachable, all arms either return or continue
1692}
1693
1694fn eval_literal(lit: &ast::Literal) -> Result<Value, EvalError> {
1695    use ast::LiteralKind;
1696    match lit.kind() {
1697        LiteralKind::Integer(tok) => {
1698            let n = tok
1699                .value()
1700                .map_err(|e| EvalError::ParseError(format!("invalid integer: {e}")))?;
1701            Ok(Value::Int(n))
1702        }
1703        LiteralKind::Float(tok) => {
1704            let f = tok
1705                .value()
1706                .map_err(|e| EvalError::ParseError(format!("invalid float: {e}")))?;
1707            Ok(Value::Float(f))
1708        }
1709        LiteralKind::Uri(tok) => Ok(Value::string(tok.syntax().text().to_string())),
1710    }
1711}
1712
1713/// Result of walking an attrpath on a base value.
1714enum TraverseResult {
1715    /// All keys found; contains the leaf value.
1716    Found(Value),
1717    /// A key was missing; contains the missing key name.
1718    Missing(String),
1719    /// A non-attrset value was encountered during traversal.
1720    NotAttrs(Value),
1721}
1722
1723/// Walk an attrpath on a base value, forcing at each level.
1724///
1725/// Returns `Found(leaf)` when every key exists, `Missing(key)` when
1726/// a key is absent, or `NotAttrs(v)` when a non-attrset is encountered.
1727fn traverse_attrpath(
1728    base: Value,
1729    attrpath: &rnix::ast::Attrpath,
1730    env: &Env,
1731) -> Result<TraverseResult, EvalError> {
1732    let attrs: Vec<_> = attrpath.attrs().collect();
1733    let mut value = base;
1734    for (i, attr) in attrs.iter().enumerate() {
1735        let key = eval_attr(attr, env)?;
1736        // Force the current value to an attrset to select from it.
1737        let forced = force_value(&value)?;
1738        match forced {
1739            Value::Attrs(ref a) => match a.get(&key) {
1740                Some(v) => {
1741                    if i < attrs.len() - 1 {
1742                        // Intermediate step: force to attrset for next selection.
1743                        value = force_value(v)?;
1744                    } else {
1745                        // Final step: return WITHOUT forcing — let the caller
1746                        // decide when to force. Matches CppNix's lazy attr access.
1747                        value = v.clone();
1748                    }
1749                }
1750                None => return Ok(TraverseResult::Missing(key)),
1751            },
1752            _ => return Ok(TraverseResult::NotAttrs(forced)),
1753        }
1754    }
1755    Ok(TraverseResult::Found(value))
1756}
1757
1758fn eval_select(sel: &ast::Select, env: &Env) -> Result<Value, EvalError> {
1759    crate::perf::inc(crate::perf::Counter::Select);
1760    let base_expr = sel.expr().ok_or_else(|| {
1761        EvalError::ParseError("select missing expression".to_string())
1762    })?;
1763    // M2.6 bridge: in `expr.path or default`, an `InfiniteRecursion`
1764    // hit while forcing the LEFT side falls back to the default —
1765    // operationally matches cppnix, which avoids the cycle entirely
1766    // via lazy attribute access during fix-point evaluation.  Without
1767    // a default, the recursion propagates as a real error.  Other
1768    // error kinds (Throw, TypeError, …) always propagate so user
1769    // bugs aren't masked.  Removed when the underlying fix-point /
1770    // lazy-access semantics land — see docs/M2.6-MODULE-SYSTEM-FIXPOINT.md.
1771    let base_result = eval_expr(&base_expr, env)
1772        .and_then(|v| force_concrete(&v).map(Concrete::into_value));
1773    let base = match base_result {
1774        Ok(v) => v,
1775        Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
1776            return eval_expr(&sel.default_expr().expect("checked"), env);
1777        }
1778        Err(e) => return Err(e),
1779    };
1780    let base_type = base.type_name();
1781    let attrpath = sel.attrpath().ok_or_else(|| {
1782        EvalError::ParseError("select missing attrpath".to_string())
1783    })?;
1784    // M2.6 bridge: when the blackhole-bridge sentinels are active,
1785    // an attribute lookup that misses (`AttrNotFound`) or hits a
1786    // non-attrset intermediate (`NotAttrs`) on the bridge's empty
1787    // sentinel value gets resolved to `null` instead of erroring.
1788    // cppnix's partial attrset would have CARRIED the keys (with
1789    // their lazy values), so the lookup would succeed; null is the
1790    // cheapest sentinel that propagates through downstream code
1791    // without further type errors.
1792    //
1793    // M2.6 ROOT #4 CLOSED (2026-07-11): the `|| crate::value::in_promise_eval()`
1794    // clause that used to soften a mid-Promise `config.<x>` select-miss to
1795    // `null` is REMOVED.  It was the band-aid masking the two real over-forces
1796    // that ROOT #4a (the `with`-namespace eager eval, above) and ROOT #4b (the
1797    // dropped full-set leaf in `merge_nested_insert`, below) now fix at their
1798    // load-bearing cause.  Verified with the softening gone: both
1799    // `lib.nixosSystem { modules = []; }.config.system.name` → `"nixos"` and
1800    // `attrNames sys.options` → 53 (nix-parity), `sui parity` stays 35 match /
1801    // 0 regressions, 1324 sui-eval lib tests + 30 diff tests pass — nothing
1802    // depended on the sentinel any more.  The two explicit operator-gated
1803    // bridges below stay as opt-in experiments (default-off); only the
1804    // always-on Promise softening is retired.
1805    let bridge_active = std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_ATTRS").is_some()
1806        || std::env::var_os("SUI_BLACKHOLE_AS_NULL").is_some();
1807    let traversal = traverse_attrpath(base, &attrpath, env);
1808    match traversal {
1809        Ok(TraverseResult::Found(v)) => Ok(v),
1810        Ok(TraverseResult::Missing(key)) => {
1811            if let Some(def) = sel.default_expr() {
1812                eval_expr(&def, env)
1813            } else if bridge_active {
1814                if std::env::var_os("SUI_M26_SELTRACE").is_some() {
1815                    let path: Vec<String> = sel.attrpath().map(|ap|
1816                        ap.attrs().map(|a| a.syntax().text().to_string()).collect()
1817                    ).unwrap_or_default();
1818                    eprintln!("[M26 SEL-MISS→null] base_type={base_type} path={path:?} missing-key={key}{}", eval_file_ctx());
1819                }
1820                if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
1821                    let path: Vec<String> = sel.attrpath().map(|ap|
1822                        ap.attrs().map(|a| a.syntax().text().to_string()).collect()
1823                    ).unwrap_or_default();
1824                    if path.iter().any(|p| p.contains(&filt)) {
1825                        return Err(EvalError::type_error(format!(
1826                            "M26-HARDSOFTEN path={path:?} key={key}"
1827                        )));
1828                    }
1829                }
1830                Ok(Value::Null)
1831            } else {
1832                Err(EvalError::AttrNotFound(
1833                    format!("'{key}'{}", eval_file_ctx()),
1834                ))
1835            }
1836        }
1837        Ok(TraverseResult::NotAttrs(forced)) => {
1838            // CppNix: `expr.a.b or default` falls back to default for
1839            // ANY error in the path — including intermediate values
1840            // that aren't attrsets (e.g., null). The module system
1841            // relies on this: `x.options.type.name or null` must
1842            // return null when x.options is null, not throw.
1843            if let Some(def) = sel.default_expr() {
1844                eval_expr(&def, env)
1845            } else if bridge_active {
1846                if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
1847                    let path: Vec<String> = sel.attrpath().map(|ap|
1848                        ap.attrs().map(|a| a.syntax().text().to_string()).collect()
1849                    ).unwrap_or_default();
1850                    if path.iter().any(|p| p.contains(&filt)) {
1851                        return Err(EvalError::type_error(format!(
1852                            "M26-HARDSOFTEN-NOTATTRS path={path:?} base_type={base_type}"
1853                        )));
1854                    }
1855                }
1856                return Ok(Value::Null);
1857            } else {
1858                if std::env::var("SUI_DEBUG_SELECT").is_ok() {
1859                    let path: Vec<String> = sel.attrpath().map(|ap|
1860                        ap.attrs().filter_map(|a| match a {
1861                            ast::Attr::Ident(i) => Some(i.to_string()),
1862                            ast::Attr::Str(s) => Some(format!("\"{}\"", s.syntax().text())),
1863                            ast::Attr::Dynamic(_) => Some("<dyn>".into()),
1864                        }).collect()
1865                    ).unwrap_or_default();
1866                    let dbg = format!("{:?}", forced);
1867                    let truncated = if dbg.len() > 200 { format!("{}…", &dbg[..200]) } else { dbg };
1868                    eprintln!("[SUI_DEBUG_SELECT] base_type={base_type} path={path:?} base={truncated}{}", eval_file_ctx());
1869                }
1870                Err(attach_trace(EvalError::type_error(
1871                    format!("cannot select from {base_type}"),
1872                )))
1873            }
1874        }
1875        // Same M2.6 bridge as on the base force above: if an
1876        // intermediate step in the attrpath traversal raises
1877        // InfiniteRecursion and `or default` was supplied, the
1878        // default is the operationally-correct value.
1879        Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
1880            eval_expr(&sel.default_expr().expect("checked"), env)
1881        }
1882        Err(e) => Err(e),
1883    }
1884}
1885
1886/// Evaluate `expr ? a.b.c` — check key presence without forcing value thunks.
1887fn eval_has_attr(ha: &ast::HasAttr, env: &Env) -> Result<Value, EvalError> {
1888    let base_expr = ha.expr().ok_or_else(|| {
1889        EvalError::ParseError("hasattr missing expression".to_string())
1890    })?;
1891    let base = force_concrete(&eval_expr(&base_expr, env)?)?.into_value();
1892    let attrpath = ha.attrpath().ok_or_else(|| {
1893        EvalError::ParseError("hasattr missing attrpath".to_string())
1894    })?;
1895    match traverse_attrpath(base, &attrpath, env)? {
1896        TraverseResult::Found(_) => Ok(Value::Bool(true)),
1897        TraverseResult::Missing(_) | TraverseResult::NotAttrs(_) => Ok(Value::Bool(false)),
1898    }
1899}
1900
1901fn eval_unary_op(op: &ast::UnaryOp, env: &Env) -> Result<Value, EvalError> {
1902    let inner = op
1903        .expr()
1904        .ok_or_else(|| EvalError::ParseError("unary op missing expr".to_string()))?;
1905    let val = force_value(&eval_expr(&inner, env)?)?;
1906    let kind = op
1907        .operator()
1908        .ok_or_else(|| EvalError::ParseError("unary op missing operator".to_string()))?;
1909    match kind {
1910        ast::UnaryOpKind::Negate => match val {
1911            Value::Int(n) => Ok(Value::Int(-n)),
1912            Value::Float(f) => Ok(Value::Float(-f)),
1913            _ => Err(EvalError::type_error(
1914                format!("cannot negate {}", val.type_name()),
1915            )),
1916        },
1917        ast::UnaryOpKind::Invert => Ok(Value::Bool(!val.as_bool()?)),
1918    }
1919}
1920
1921/// Builtins that must receive their argument UNFORCED (call-by-need). This is the
1922/// SINGLE source of truth consumed by BOTH `eval_apply` (which must THUNK the arg
1923/// instead of eager-evaluating it) AND the builtin apply arm (which must SKIP the
1924/// arg force). The two sites MUST agree: if `eval_apply` eager-evaluates the arg,
1925/// the apply-arm's force-skip is dead (the arg is already forced — or already
1926/// threw) upstream. They were previously inconsistent (only `tryEval` was thunked
1927/// in `eval_apply`), so `seq`/`deepSeq`/`addErrorContext`/`foldl'` silently got
1928/// eager args despite their apply-time exemption — the bug behind
1929/// `builtins.foldl' (_: x: x) (throw "…") […]` throwing instead of returning the
1930/// last element (nix's foldl' is NOT strict in the nul accumulator).
1931#[inline]
1932pub(crate) fn builtin_takes_lazy_arg(name: &str) -> bool {
1933    matches!(
1934        name,
1935        "tryEval" | "addErrorContext<partial>" | "seq<partial>" | "deepSeq<partial>" | "foldl'<p1>"
1936    )
1937}
1938
1939fn eval_apply(app: &ast::Apply, env: &Env) -> Result<Value, EvalError> {
1940    let func_expr = app
1941        .lambda()
1942        .ok_or_else(|| EvalError::ParseError("apply missing function".to_string()))?;
1943    let arg_expr = app
1944        .argument()
1945        .ok_or_else(|| EvalError::ParseError("apply missing argument".to_string()))?;
1946    let func = force_value(&eval_expr(&func_expr, env)?)?;
1947    // Lambda arguments are wrapped in a thunk for call-by-need semantics.
1948    // Thunk strategy depends on function type:
1949    // - Lambda: ALWAYS thunk (call-by-need, enables fixpoints)
1950    // - tryEval: ALWAYS thunk (must catch errors during force)
1951    // - Builtin: evaluate eagerly (builtins always force args anyway;
1952    //   thunking wastes Rc + OnceCell allocation per call)
1953    // - __functor: evaluate eagerly (will be applied immediately)
1954    let arg = match &func {
1955        Value::Lambda(_) => {
1956            // Call-by-need: the arg is thunked so it forces lazily. But a
1957            // PURE-CONSTANT arg (a literal, a non-interpolated string, or a
1958            // non-interpolated path) can never throw or diverge, so producing
1959            // its value directly is byte-neutral whether or not the lambda ever
1960            // forces it — identical eval-order-observable behavior, one fewer
1961            // never-forced thunk. This is `arg_pure_constant` ONLY: any arg that
1962            // could throw/diverge/observe a fixpoint (Ident with-scope, Select,
1963            // Apply, BinOp, …) stays fully thunked to preserve laziness.
1964            if let Some(v) = eval_pure_constant_arg(&arg_expr) {
1965                v
1966            } else {
1967                crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
1968                Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
1969            }
1970        }
1971        Value::Builtin(b) if builtin_takes_lazy_arg(&b.name) => {
1972            // Call-by-need for the laziness-exempt builtins (tryEval / seq /
1973            // deepSeq / addErrorContext / foldl'<p1>): the arg MUST be thunked,
1974            // not eager-evaluated, so it forces only if/when the builtin demands
1975            // it. Kept in lockstep with the apply-arm skip via `builtin_takes_lazy_arg`.
1976            crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
1977            Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
1978        }
1979        _ => eval_expr(&arg_expr, env)?,
1980    };
1981    apply(func, arg)
1982}
1983
1984/// If `arg_expr` is a PURE CONSTANT — a literal, a non-interpolated string, or
1985/// a non-interpolated absolute/home path — return its value directly (no thunk).
1986///
1987/// A pure constant has no free variables, cannot throw, cannot diverge, and has
1988/// no fixpoint/laziness interaction: `eval_expr(arg)` is total and produces the
1989/// exact value a suspended thunk of it would yield on force. Producing it
1990/// eagerly in a call-by-need arg position is therefore byte-neutral (the
1991/// lambda that never forces the arg observes no difference — the value is inert).
1992///
1993/// Returns `None` for EVERYTHING else (Ident — may hit a with-scope force;
1994/// Select/Apply/BinOp/If/… — may throw or diverge; interpolated Str/Path —
1995/// must force `${…}` lazily), which keeps those args fully thunked. `env` is
1996/// NOT threaded in because a pure constant needs no environment; if a match
1997/// arm ever needed `env`, it would not be a pure constant.
1998fn eval_pure_constant_arg(arg_expr: &ast::Expr) -> Option<Value> {
1999    match arg_expr {
2000        ast::Expr::Literal(lit) => eval_literal(lit).ok(),
2001        ast::Expr::Str(st) if !str_has_interpolation(st) => {
2002            // No interpolation ⇒ `eval_str` runs no force/coerce; env is unused.
2003            eval_str(st, &Env::new()).ok()
2004        }
2005        ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
2006            let text = crate::path::canon_abs(&p.syntax().text().to_string());
2007            Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2008        }
2009        ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
2010            let text = p.syntax().text().to_string();
2011            Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2012        }
2013        _ => None,
2014    }
2015}
2016
2017fn eval_str(s: &ast::Str, env: &Env) -> Result<Value, EvalError> {
2018    let mut result = String::new();
2019    let mut ctx = StringContext::new();
2020    for part in s.normalized_parts() {
2021        match part {
2022            InterpolPart::Literal(text) => result.push_str(&text),
2023            InterpolPart::Interpolation(interpol) => {
2024                let expr = interpol.expr().ok_or_else(|| {
2025                    EvalError::ParseError("interpolation missing expr".to_string())
2026                })?;
2027                let val = force_value(&eval_expr(&expr, env)?)?;
2028                // CppNix string interpolation is copy-to-store coercion: an
2029                // interpolated source path (`"${./foo}"`) is NAR-copied into
2030                // the store and the store path is spliced in (with context),
2031                // never the raw filesystem path.
2032                let (s, c) = val.coerce_to_string_copy_to_store()?;
2033                result.push_str(&s);
2034                ctx.merge(&c);
2035            }
2036        }
2037    }
2038    Ok(Value::String(Rc::new(NixString::with_context(result, ctx))))
2039}
2040
2041/// Whether a list of path parts contains a `${…}` interpolation. When
2042/// it does not, the raw `.syntax().text()` shortcut is byte-identical
2043/// and cheaper, so the trivial fast paths stay on that shortcut.
2044fn parts_have_interpolation(parts: &[InterpolPart<rnix::ast::PathContent>]) -> bool {
2045    parts
2046        .iter()
2047        .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2048}
2049
2050/// Whether a string literal contains any `${…}` interpolation part. A `false`
2051/// result means the string is a pure constant (`eval_str` runs no force/coerce
2052/// and cannot throw), so `maybe_thunk` may evaluate it eagerly byte-neutrally.
2053fn str_has_interpolation(s: &ast::Str) -> bool {
2054    s.normalized_parts()
2055        .iter()
2056        .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2057}
2058
2059/// Evaluate an interpolatable path literal that contains `${…}` parts.
2060///
2061/// CppNix path interpolation (`./${x}.nix`, `/a/${e}`, `~/x/${e}`):
2062///   * each literal segment is spliced verbatim,
2063///   * each `${e}` is **plain**-coerced to a string with context
2064///     (NOT copy-to-store — path-typed interpolations splice the raw
2065///     store/filesystem path, e.g. `/bar/${./foo}` → `/bar/tmp/foo`),
2066///   * the concatenated text is then resolved exactly like the plain
2067///     path literal of the same kind (relative → joined + normalized
2068///     against the defining file's directory; absolute/home → verbatim),
2069///   * the result is a `path` value.
2070///
2071/// Parts come from rnix's `<PathKind>::parts()` which splits the path
2072/// token stream into `Literal(PathContent)` / `Interpolation(Interpol)`.
2073fn eval_interpol_path_parts(
2074    parts: &[InterpolPart<rnix::ast::PathContent>],
2075    kind: PathKind,
2076    env: &Env,
2077) -> Result<Value, EvalError> {
2078    let mut text = String::new();
2079    for part in parts {
2080        match part {
2081            InterpolPart::Literal(content) => text.push_str(content.text()),
2082            InterpolPart::Interpolation(interpol) => {
2083                let expr = interpol.expr().ok_or_else(|| {
2084                    EvalError::ParseError("path interpolation missing expr".to_string())
2085                })?;
2086                let val = force_value(&eval_expr(&expr, env)?)?;
2087                // Plain coercion (coerceMore = false): a path-typed
2088                // interpolation splices the raw path string, never a
2089                // copied-to-store hash path.
2090                let (s, _ctx) = val.coerce_to_string()?;
2091                text.push_str(&s);
2092            }
2093        }
2094    }
2095    let resolved = match kind {
2096        // Relative path: resolve against the defining file's directory,
2097        // mirroring the plain `PathRel` branch.
2098        PathKind::Rel => {
2099            if let Some(dir) = current_eval_dir() {
2100                let norm = normalize_path(&dir.join(&text));
2101                // Lift cache→store exactly like the plain `PathRel` branch (the
2102                // store↔cache seam value-half). Without this, an interpolated
2103                // relative-path literal (`./${x}`, `./modules/${name}.nix`)
2104                // inside a fetched flake input yielded a Value::Path holding the
2105                // fetcher CACHE dir instead of the input's `/nix/store/<h>-source`
2106                // path — so its `toString`/copy-to-store/inputSrc diverged from
2107                // CppNix (the plain `./x` sibling already dematerializes; the two
2108                // must agree).
2109                crate::path::dematerialize(&norm).to_string_lossy().into_owned()
2110            } else {
2111                // No eval-file context (top-level `sui eval -E`): the
2112                // plain branch keeps the raw text, so match it — but the
2113                // interpolation is still spliced.
2114                text
2115            }
2116        }
2117        // Absolute paths: canonicalize the concatenated text CppNix's way.
2118        // The `${e}` splice routinely introduces a `//` seam (`/bar/` +
2119        // `/tmp/foo`) or a `.`/`..` component that must collapse
2120        // (`/bar//tmp/foo` → `/bar/tmp/foo`), and `..` must clamp at root.
2121        // `canon_abs` is filesystem-free (works on not-yet-materialized
2122        // flake paths) and root-aware (unlike `normalize_path`, which pops
2123        // past root — the marquee-root divergence).
2124        PathKind::Abs => crate::path::canon_abs(&text),
2125        // Home paths (`~/…`) carry a leading `~` component, so they are
2126        // not absolute-rooted; keep the pre-existing normalization.
2127        PathKind::Home => normalize_path(std::path::Path::new(&text))
2128            .to_string_lossy()
2129            .into_owned(),
2130    };
2131    Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))))
2132}
2133
2134/// Which kind of interpolatable path literal — governs how the
2135/// concatenated text is finally resolved.
2136#[derive(Clone, Copy)]
2137enum PathKind {
2138    Abs,
2139    Rel,
2140    Home,
2141}
2142
2143/// Evaluate an attribute name, requiring non-null.
2144/// Use `eval_attr_maybe_null` when null dynamic attrs should be skipped.
2145fn eval_attr(attr: &ast::Attr, env: &Env) -> Result<String, EvalError> {
2146    eval_attr_maybe_null(attr, env)?
2147        .ok_or_else(|| EvalError::TypeError("null dynamic attribute name".into()))
2148}
2149
2150/// Evaluate an attribute name. Returns `None` for null dynamic attrs
2151/// (CppNix silently omits attributes with null names).
2152fn eval_attr_maybe_null(attr: &ast::Attr, env: &Env) -> Result<Option<String>, EvalError> {
2153    match attr {
2154        ast::Attr::Ident(ident) => Ok(Some(ident_text(ident))),
2155        ast::Attr::Dynamic(dyn_) => {
2156            let expr = dyn_
2157                .expr()
2158                .ok_or_else(|| EvalError::ParseError("dynamic attr missing expr".to_string()))?;
2159            let val = force_value(&eval_expr(&expr, env)?)?;
2160            // CppNix: null dynamic attr name → skip the attribute entirely.
2161            // Used by nixpkgs module system: `${if cond then null else "name"} = value;`
2162            if val == Value::Null {
2163                return Ok(None);
2164            }
2165            Ok(Some(val.as_string()?.to_string()))
2166        }
2167        ast::Attr::Str(s) => {
2168            let val = eval_str(s, env)?;
2169            Ok(Some(val.as_string()?.to_string()))
2170        }
2171    }
2172}
2173
2174/// Get the text of an rnix Ident node.
2175fn ident_text(ident: &ast::Ident) -> String {
2176    // Fast path: a `NODE_IDENT` holds a single `TOKEN_IDENT`, whose `text()`
2177    // borrows the source `&str` directly from the green node — no
2178    // `PreorderWithTokens` cursor tree-walk and none of the `NodeData::new`
2179    // allocations that `syntax().text()` (a `SyntaxText` over the node's whole
2180    // descendant span) pays. Byte-identical fallback: the identifier `or` is
2181    // lexed as a nested `TOKEN_OR` (rnix quirk), so `ident_token()` is `None`
2182    // there — walk the full node text in that case, exactly as before.
2183    match ident.ident_token() {
2184        Some(tok) => tok.text().to_string(),
2185        None => ident.syntax().text().to_string(),
2186    }
2187}
2188
2189/// Byte offset of a STATIC attr key (`Ident` or `Str`) in its source text —
2190/// the position `builtins.unsafeGetAttrPos` reports for that key. Returns
2191/// `None` for a dynamic key (`${e}`), which has no fixed source position.
2192///
2193/// CppNix points a binding's position at the KEY token's start; rnix exposes
2194/// it via the syntax node's `text_range().start()`.
2195fn static_attr_offset(attr: &ast::Attr) -> Option<u32> {
2196    let node = match attr {
2197        ast::Attr::Ident(i) => i.syntax(),
2198        ast::Attr::Str(s) => s.syntax(),
2199        ast::Attr::Dynamic(_) => return None,
2200    };
2201    Some(u32::from(node.text_range().start()))
2202}
2203
2204/// Collect a literal attrset's static top-level KEY offsets into an
2205/// [`crate::pos::AttrPositions`] and attach it to `attrs` (behind the value's
2206/// `Rc<AttrPositions>` slot). Records only single-key static bindings — the
2207/// shape `attrTag`'s `tags_` (`{ app = …; file = …; }`) is built from and the
2208/// only shape `builtins.unsafeGetAttrPos` reads in nixpkgs. `None`-costs a
2209/// pointer when the set has no such keys (attaches nothing).
2210fn attach_attrset_positions(set: &ast::AttrSet, attrs: &mut NixAttrs, env: &Env) {
2211    // The FILE is the one the literal is being built in — from the eval-file
2212    // stack, which a thunk restores to its captured file when it forces. This
2213    // is correct under laziness: a `dock.nix` attrset literal forced later
2214    // records `dock.nix`, not whatever file is top-of-stack at force time.
2215    // (`current_source_id`/`CURRENT_SOURCE_ID` is per-`eval_with_file`, NOT
2216    // per-env, so it would mis-attribute a lazily-forced literal.)
2217    let mut table = crate::pos::AttrPositions::new(current_eval_file());
2218    for entry in set.entries() {
2219        if let ast::Entry::AttrpathValue(apv) = entry {
2220            let Some(attrpath) = apv.attrpath() else { continue };
2221            let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2222            // A dotted path `a.b = …` desugars to a nested set and CppNix gives
2223            // the OUTER key the position of the path's HEAD, so record
2224            // `path_attrs[0]` whatever the length. This previously skipped any
2225            // multi-segment path, on the assumption that nixpkgs never asks for
2226            // a dotted tag's position. Measured — for
2227            // `{ …; nested.deep = 3; }` at line 6:
2228            //   nix  nested=6:3      sui  nested=NULL
2229            let Some(head) = path_attrs.first() else { continue };
2230            let Some(offset) = static_attr_offset(head) else { continue };
2231            // Resolve the static key name (Ident/Str) — never forces (a
2232            // dynamic key already returned None above).
2233            if let Ok(Some(name)) = eval_attr_maybe_null(&path_attrs[0], env) {
2234                table.insert(intern(&name), offset);
2235            }
2236        } else if let ast::Entry::Inherit(inh) = entry {
2237            // `inherit x;` and `inherit (src) x;` BIND an attribute exactly as
2238            // `x = …` does, and CppNix gives each inherited name the position of
2239            // its own ident. Skipping them left every inherited key
2240            // position-less — which is most of nixpkgs' `lib`, since
2241            // `lib/default.nix` re-exports through
2242            // `inherit (self.options) mkOption …`. Measured before the fix:
2243            //   unsafeGetAttrPos "mkOption" nixpkgs.lib
2244            //     nix …-source/lib/default.nix     sui null
2245            //
2246            // An earlier attempt at this arm was reverted for reporting line 1;
2247            // that was `pos::line_col` returning a constant, NOT this arm. With
2248            // the real offset→line/column conversion in place it resolves
2249            // exactly.
2250            for attr in inh.attrs() {
2251                let Some(offset) = static_attr_offset(&attr) else { continue };
2252                if let Ok(Some(name)) = eval_attr_maybe_null(&attr, env) {
2253                    table.insert(intern(&name), offset);
2254                }
2255            }
2256        }
2257    }
2258    if !table.is_empty() {
2259        attrs.set_positions(std::rc::Rc::new(table));
2260    }
2261}
2262
2263fn eval_attrset(set: &ast::AttrSet, env: &Env) -> Result<Value, EvalError> {
2264    crate::perf::inc(crate::perf::Counter::Attrset);
2265    let mut attrs = NixAttrs::new();
2266    let is_rec = set.rec_token().is_some();
2267
2268    if is_rec {
2269        let mut rec_env = env.child();
2270        let mut thunks: Vec<(String, Thunk)> = Vec::new();
2271
2272        // Track which names have been defined so far in this scope.
2273        // Used by maybe_thunk to resolve backward references directly
2274        // instead of creating wasteful thunks.
2275        let mut defined_so_far: HashSet<String> = HashSet::new();
2276
2277        // Accumulator for dotted-path bindings (`rec { a.b = 1; a.c = 2; ... }`).
2278        // Leaf values are wrapped in thunks so they participate in the
2279        // recursive env fixpoint, matching CppNix semantics where
2280        // `rec { types.a = f 1; f = x: x + 1; }` allows `f` to be a
2281        // sibling binding.
2282        let mut dotted_attrs: NixAttrs = NixAttrs::new();
2283
2284        // Phase 1: Create thunks with placeholder env and bind them.
2285        for entry in set.entries() {
2286            match entry {
2287                ast::Entry::AttrpathValue(apv) => {
2288                    let attrpath = apv.attrpath().ok_or_else(|| {
2289                        EvalError::ParseError("binding missing attrpath".to_string())
2290                    })?;
2291                    let value_expr = apv.value().ok_or_else(|| {
2292                        EvalError::ParseError("binding missing value".to_string())
2293                    })?;
2294                    let mut path_keys: Vec<String> = attrpath
2295                        .attrs()
2296                        .filter_map(|a| eval_attr_maybe_null(&a, env).transpose())
2297                        .collect::<Result<_, _>>()?;
2298                    // Null dynamic attr name → skip entire binding (CppNix compat)
2299                    if path_keys.is_empty() { continue; }
2300                    if path_keys.len() == 1 {
2301                        let key = path_keys.pop().unwrap();
2302                        // Self-recursive detection in a `rec { … }` scope:
2303                        // any binding whose value-expr references the
2304                        // bound name OR any sibling key declared in this
2305                        // rec scope is potentially self-recursive (the
2306                        // siblings' thunks share the rec_env via Phase 2).
2307                        // Mark as recursive so inner re-entrance during
2308                        // force returns a Promise sentinel instead of
2309                        // erroring with InfiniteRecursion.
2310                        //
2311                        // For simplicity we check `key` and all already-
2312                        // defined siblings; siblings defined later are
2313                        // covered when THEIR thunks force (they reference
2314                        // back into this rec scope via Phase 2's env update).
2315                        // O(N) not O(N²): one memoized referenced-name set,
2316                        // intersected with key + already-defined siblings.
2317                        // Byte-identical to the prior per-name walks.
2318                        let referenced = referenced_idents(&value_expr);
2319                        let is_recursive_binding = referenced.contains(key.as_str())
2320                            || defined_so_far
2321                                .iter()
2322                                .any(|n| referenced.contains(n.as_str()));
2323                        let value = if is_recursive_binding {
2324                            Value::Thunk(Thunk::new_suspended_recursive(
2325                                value_expr.clone(),
2326                                env.clone(),
2327                            ))
2328                        } else {
2329                            // maybeThunk: skip thunk for trivial exprs.
2330                            // is_rec=true because rec attrset bindings
2331                            // can reference each other.
2332                            // Pass defined_so_far so backward refs
2333                            // resolve directly.
2334                            maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
2335                        };
2336                        rec_env.bind(key.clone(), value.clone());
2337                        attrs.insert(key.clone(), value.clone());
2338                        if let Value::Thunk(t) = &value {
2339                            thunks.push((key.clone(), t.clone()));
2340                        }
2341                        defined_so_far.insert(key);
2342                    } else {
2343                        // Multi-segment dotted path: build a nested attrset
2344                        // with a thunk at the leaf so the value expression
2345                        // can reference sibling rec-bindings.
2346                        let key = path_keys[0].clone();
2347                        let value =
2348                            build_nested_attr_thunk(&path_keys[1..], &value_expr, env, &mut thunks);
2349                        merge_nested_insert(&mut dotted_attrs, key, value);
2350                    }
2351                }
2352                ast::Entry::Inherit(inherit) => {
2353                    eval_inherit(&inherit, env, &mut attrs, Some(&mut rec_env), Some(&mut thunks))?;
2354                }
2355            }
2356        }
2357
2358        // Phase 1b: Bind accumulated dotted-path attrs into attrs and rec_env.
2359        // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
2360        // duplicate definition, so we do not attempt to merge with
2361        // existing inherit thunks — just bind directly.
2362        for (key, value) in dotted_attrs.iter() {
2363            attrs.insert(key.clone(), value.clone());
2364            rec_env.bind(key.clone(), value.clone());
2365        }
2366
2367        // Phase 2: Update all thunks (both Suspended and InheritSelect)
2368        // to capture the final rec_env (which now has all names bound).
2369        for (_key, thunk) in &thunks {
2370            thunk.update_env(&rec_env);
2371        }
2372    } else {
2373        for entry in set.entries() {
2374            match entry {
2375                ast::Entry::AttrpathValue(apv) => {
2376                    let attrpath = apv.attrpath().ok_or_else(|| {
2377                        EvalError::ParseError("binding missing attrpath".to_string())
2378                    })?;
2379                    let value_expr = apv.value().ok_or_else(|| {
2380                        EvalError::ParseError("binding missing value".to_string())
2381                    })?;
2382                    let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2383                    // CppNix defers a dynamic key that is NOT at the HEAD of the
2384                    // attrpath: `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`,
2385                    // so `e` never forces until `.a` is demanded. Evaluating the
2386                    // whole path eagerly would force `e` at construction and — in
2387                    // the module-system fixpoint — read `config.<x>` while `config`
2388                    // is mid-force (the M2.6 divergence: `homes.null` instead of
2389                    // `homes.<name>`). Only the head is eager; a lone dynamic tail
2390                    // becomes a deferred thunk. A rarer collision under the same
2391                    // head stays eager (forced) so static deep-merge still works.
2392                    let tail_is_dynamic =
2393                        path_attrs.len() > 1 && attrs_have_dynamic(&path_attrs[1..]);
2394                    let head_key = match eval_attr_maybe_null(&path_attrs[0], env)? {
2395                        Some(k) => k,
2396                        // Null dynamic HEAD attr name → skip entire binding.
2397                        None => continue,
2398                    };
2399                    if tail_is_dynamic && attrs.get(&head_key).is_none() {
2400                        let value =
2401                            build_deferred_tail_attr(&path_attrs[1..], &value_expr, env);
2402                        attrs.insert(head_key, value);
2403                        continue;
2404                    }
2405                    // M2.6 ROOT #3 (collision case): the tail has a dynamic key
2406                    // AND the head already exists (a sibling binding wrote it,
2407                    // e.g. osquery's `systemd.services.… = …` then
2408                    // `systemd.tmpfiles.settings."10-osquery".${dirname …}.d`).
2409                    // The plain deferral above bails (head present), and the
2410                    // eager path below would force the dynamic key at
2411                    // construction — re-reading `config.<x>` mid-fixpoint →
2412                    // the empty-Promise partial. Instead, descend the existing
2413                    // head along the tail's STATIC prefix and splice a DEFERRED
2414                    // thunk at the first dynamic level, so the dynamic key
2415                    // stays lazy exactly as CppNix's nested-literal desugaring
2416                    // does — while preserving the static deep-merge with the
2417                    // sibling binding.
2418                    if tail_is_dynamic {
2419                        if let Some(existing) = attrs.get(&head_key).cloned() {
2420                            let merged = merge_deferred_dynamic_tail(
2421                                existing,
2422                                &path_attrs[1..],
2423                                &value_expr,
2424                                env,
2425                            )?;
2426                            attrs.insert(head_key, merged);
2427                            continue;
2428                        }
2429                    }
2430                    // Eager path: evaluate the remaining (static, or collision)
2431                    // keys now. A null dynamic tail key skips the binding.
2432                    let mut path_keys: Vec<String> = {
2433                        let mut v = Vec::with_capacity(path_attrs.len());
2434                        v.push(head_key);
2435                        let mut skip = false;
2436                        for a in &path_attrs[1..] {
2437                            match eval_attr_maybe_null(a, env)? {
2438                                Some(k) => v.push(k),
2439                                None => { skip = true; break; }
2440                            }
2441                        }
2442                        if skip { v.clear(); }
2443                        v
2444                    };
2445                    // Null dynamic attr name → skip entire binding (CppNix compat)
2446                    if path_keys.is_empty() { continue; }
2447                    if path_keys.len() == 1 {
2448                        let key = path_keys.pop().unwrap();
2449                        // maybeThunk: skip thunk for trivial exprs.
2450                        // is_rec=false — Ident lookups are safe.
2451                        let value = maybe_thunk(&value_expr, env, false, None);
2452                        // CppNix desugars `a.b = x; a = { c = y; };` into a single
2453                        // merged `a = { b = x; c = y; }` at parse time. rnix keeps
2454                        // the two bindings separate, so when a single-key binding
2455                        // collides with an already-built (dotted) attrs for the
2456                        // same key, deep-MERGE instead of overwrite. Force the RHS
2457                        // to WHNF so merge_nested_insert (which needs concrete
2458                        // Value::Attrs on both sides) can merge — forcing an
2459                        // attrset to WHNF does NOT force its fields, so leaf values
2460                        // stay lazy. Only fires on collision; non-colliding
2461                        // single-key bindings keep the plain fast insert.
2462                        // (This is the pkg-config-wrapper `env.addFlags` drop:
2463                        // `env.addFlags = …` then `env = { wrapperName = …; … }`.)
2464                        // If the earlier binding for this key is still a lazy
2465                        // Thunk (an attrset literal inserted via maybe_thunk), force
2466                        // it to WHNF FIRST so a `key = {..}; key = {..}` collision is
2467                        // seen as attrs-vs-attrs and MERGES, matching nix
2468                        // (`{ s = {a=1;}; s = {b=2;}; }` → `{ s = {a=1; b=2;}; }`).
2469                        // Without this the `Some(Value::Attrs(_))` test below is false
2470                        // on a Thunk and the second binding overwrites, dropping the
2471                        // first's keys. The dotted branch below already does this; R3
2472                        // (eval-okay-merge-dynamic-attrs set1/set2) needs it here too.
2473                        // WHNF force does not force fields → leaf laziness preserved.
2474                        // (A non-attrs dup like `s = 1; s = 2` still overwrites here,
2475                        // unchanged — nix errors there, an eval-FAIL case out of scope.)
2476                        if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2477                            let existing = attrs.get(&key).cloned().unwrap();
2478                            let forced_existing = force_value(&existing)?;
2479                            attrs.insert(key.clone(), forced_existing);
2480                        }
2481                        if matches!(attrs.get(&key), Some(Value::Attrs(_))) {
2482                            let forced = force_value(&value)?;
2483                            merge_nested_insert(&mut attrs, key, forced);
2484                        } else {
2485                            attrs.insert(key, value);
2486                        }
2487                    } else {
2488                        let key = path_keys[0].clone();
2489                        let value = build_nested_attr(&path_keys[1..], &value_expr, env)?;
2490                        // CppNix desugars `a = { x = …; }; a.y = …;` into a
2491                        // single merged `a = { x = …; y = …; }`. When the
2492                        // full-set binding for `a` was inserted FIRST it is a
2493                        // lazy Thunk (attrset literals go through maybe_thunk),
2494                        // so merge_nested_insert — which only merges when the
2495                        // existing value is a concrete Value::Attrs — would
2496                        // NOT see the earlier keys and would overwrite `a`
2497                        // with just `{ y = … }`, silently dropping `x`. Force
2498                        // the existing entry to WHNF on collision so the merge
2499                        // sees the concrete attrs (forcing to WHNF does not
2500                        // force the fields, so leaf laziness is preserved).
2501                        // (This is the gst-plugins-base `passthru.waylandEnabled`
2502                        // drop: `passthru = { … }; passthru.tests.x = …;`.)
2503                        if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2504                            let existing = attrs.get(&key).cloned().unwrap();
2505                            let forced = force_value(&existing)?;
2506                            attrs.insert(key.clone(), forced);
2507                        }
2508                        merge_nested_insert(&mut attrs, key, value);
2509                    }
2510                }
2511                ast::Entry::Inherit(inherit) => {
2512                    eval_inherit(&inherit, env, &mut attrs, None, None)?;
2513                }
2514            }
2515        }
2516    }
2517
2518    // Record the literal's static-key source positions for
2519    // `builtins.unsafeGetAttrPos` (the `attrTag` `declarations` — options.json
2520    // dock root). Cheap: one entry walk over static Ident/Str keys, no
2521    // forcing; attaches nothing (a pointer-sized `None`) when the set has no
2522    // single-static-key bindings.
2523    attach_attrset_positions(set, &mut attrs, env);
2524
2525    Ok(Value::Attrs(Rc::new(attrs)))
2526}
2527
2528fn eval_inherit(
2529    inherit: &ast::Inherit,
2530    env: &Env,
2531    attrs: &mut NixAttrs,
2532    bind_env: Option<&mut Env>,
2533    mut thunks: Option<&mut Vec<(String, Thunk)>>,
2534) -> Result<(), EvalError> {
2535    if let Some(from) = inherit.from() {
2536        // inherit (expr) a b c;
2537        //
2538        // The source expression must NOT be eagerly evaluated. nixpkgs
2539        // `lib/trivial.nix` has `inherit (lib.trivial) isFunction ...`
2540        // at the top of a file that itself defines `lib.trivial`. If
2541        // we eagerly force `lib.trivial`, we hit a self-referential
2542        // thunk blackhole. Instead: build a thunk per inherited
2543        // name that, when forced, evaluates the source and pulls
2544        // out that one attribute. This is what real Nix does.
2545        //
2546        // For `rec { inherit (X) name; ...; foo = name; }` we ALSO
2547        // need to bind the name in the enclosing rec env so the
2548        // sibling `foo = name` can reference it. The caller passes
2549        // its rec env in `bind_env`.
2550        //
2551        // When `thunks` is provided (rec attrsets), InheritSelect
2552        // thunks are collected so Phase 2 can update their captured
2553        // env to the full recursive scope. Without this, the source
2554        // expression cannot reference sibling bindings.
2555        let source_expr = from
2556            .expr()
2557            .ok_or_else(|| EvalError::ParseError("inherit from missing expr".to_string()))?;
2558        // Shared source thunk — all inherited names share one source
2559        // evaluation (the source thunk's own memoization ensures at
2560        // most one evaluation).
2561        let source_thunk = Thunk::new_suspended(source_expr, env.clone());
2562        let mut be = bind_env;
2563        for attr in inherit.attrs() {
2564            let name = eval_attr(&attr, env)?;
2565            let thunk = Thunk::new_inherit_select(source_thunk.clone(), name.clone());
2566            let value = Value::Thunk(thunk.clone());
2567            attrs.insert(name.clone(), value.clone());
2568            if let Some(ref mut e) = be {
2569                e.bind(name.clone(), value);
2570            }
2571            if let Some(ref mut t) = thunks {
2572                t.push((name, thunk));
2573            }
2574        }
2575    } else {
2576        // inherit a b c;
2577        //
2578        // CppNix resolves a bare `inherit x;` LAZILY, exactly like a plain
2579        // reference to `x` — it does NOT eagerly force the enclosing scope.
2580        // This matters when `x` is provided only by an enclosing `with`
2581        // scope whose value is a fixpoint still being constructed (a
2582        // blackhole): eager `env.lookup` returns None → spurious
2583        // `UndefinedVar`. nixpkgs `all-packages.nix` is
2584        // `… with pkgs; { nettle = import … { inherit callPackage; }; }`,
2585        // so `inherit callPackage` must resolve `callPackage` from the
2586        // `with pkgs` scope AT FORCE TIME, not eagerly at attrset
2587        // construction. Mirror `maybe_thunk`'s Ident path: try the fast
2588        // lookup, and on a miss defer to a WithIdent thunk (or a suspended
2589        // env lookup) so the resolution happens lazily against the settled
2590        // scope. (This was the `nettle` UndefinedVar('callPackage') drop.)
2591        let mut be = bind_env;
2592        for attr in inherit.attrs() {
2593            let name = eval_attr(&attr, env)?;
2594            let sym = crate::value::intern(&name);
2595            let value = if let Some(v) = env.lookup_fast(sym, &name) {
2596                v
2597            } else if let Some((scope_cache, scope_value)) =
2598                env.innermost_with_scope()
2599            {
2600                Value::Thunk(Thunk::new_with_ident(
2601                    SmolStr::from(name.as_str()),
2602                    scope_cache,
2603                    scope_value,
2604                    env.clone(),
2605                ))
2606            } else {
2607                return Err(EvalError::UndefinedVar(format!(
2608                    "'{name}'{}",
2609                    eval_file_ctx()
2610                )));
2611            };
2612            attrs.insert(name.clone(), value.clone());
2613            if let Some(ref mut e) = be {
2614                e.bind(name, value);
2615            }
2616        }
2617    }
2618    Ok(())
2619}
2620
2621fn build_nested_attr(
2622    path: &[String],
2623    expr: &ast::Expr,
2624    env: &Env,
2625) -> Result<Value, EvalError> {
2626    if path.is_empty() {
2627        // CRITICAL: Wrap leaf in a thunk instead of eagerly evaluating.
2628        // For dotted paths like `config.warnings = optionals config.x [...]`,
2629        // the leaf expression must be lazy — eagerly evaluating it during
2630        // attrset construction forces fixpoint thunks prematurely.
2631        return Ok(maybe_thunk(expr, env, false, None));
2632    }
2633    let key = path[0].clone();
2634    let inner = build_nested_attr(&path[1..], expr, env)?;
2635    let mut attrs = NixAttrs::new();
2636    attrs.insert(key, inner);
2637    Ok(Value::Attrs(Rc::new(attrs)))
2638}
2639
2640/// True if a single attr is a DYNAMIC key — one whose resolution runs
2641/// arbitrary expression code and therefore must not be forced at
2642/// attrset-construction time.
2643///
2644/// Two forms are dynamic:
2645///   * `ast::Attr::Dynamic` — a bare `${e}` antiquotation.
2646///   * `ast::Attr::Str` **containing an interpolation** — an interpolated
2647///     string key like `"iwd/${nm}"`.  A `Str` with NO interpolation
2648///     (`"foo bar"`) is a plain static string literal and is NOT dynamic.
2649///
2650/// M2.6 ROOT #3: `attrs_have_dynamic` previously matched ONLY
2651/// `Attr::Dynamic`, so an interpolated-string tail key (`config.a."p${e}"`)
2652/// fell to the eager path and forced `e` at construction.  In the module
2653/// system that forces a `config.<x>` read while `config` is mid-fixpoint
2654/// (`environment.etc."iwd/${configFile.name}"`, where `configFile` reads
2655/// `with config.networking.networkmanager`), yielding the empty-Promise
2656/// partial → the `set/null` softening.  Treating an interpolated `Str` as
2657/// dynamic routes it through the same per-level deferral as `${e}`
2658/// (ROOT #1/#2), so `e` forces only when the enclosing head is demanded —
2659/// exactly CppNix's nested-attrset-literal desugaring.
2660fn attr_is_dynamic(attr: &ast::Attr) -> bool {
2661    match attr {
2662        ast::Attr::Dynamic(_) => true,
2663        // A string attr key is dynamic iff it has ≥1 interpolation part;
2664        // a purely-literal string key forces nothing and stays eager.
2665        ast::Attr::Str(s) => s
2666            .normalized_parts()
2667            .iter()
2668            .any(|p| matches!(p, InterpolPart::Interpolation(_))),
2669        ast::Attr::Ident(_) => false,
2670    }
2671}
2672
2673/// True if any attr in the slice is a dynamic (interpolated) key.
2674///
2675/// A dynamic key beyond the HEAD of an attrpath must NOT be evaluated at
2676/// attrset-construction time — CppNix defers it inside the head's lazy
2677/// value, so `{ a.${e} = v; }` never forces `e` until `.a` is demanded.
2678/// Static string/ident keys are cheap and force nothing, so they don't
2679/// need deferral.
2680fn attrs_have_dynamic(attrs: &[ast::Attr]) -> bool {
2681    attrs.iter().any(attr_is_dynamic)
2682}
2683
2684/// Build the nested attrset for the TAIL of an attrpath, deferring
2685/// evaluation of dynamic tail keys until the value is forced.
2686///
2687/// Given tail attrs `[b, ${e}, c]` and a value expr, produce a lazy
2688/// `Value::Thunk` that, when forced, evaluates each tail key (including
2689/// the dynamic `${e}`) against `env` and builds `{ b = { ${e} = { c =
2690/// <leaf-thunk> }; }; }`. This mirrors CppNix: the inner attrset (and
2691/// thus its dynamic keys) is constructed only when the enclosing head
2692/// attribute is demanded — never at construction of the outer attrset.
2693///
2694/// A dynamic key that evaluates to `null` skips the whole binding
2695/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
2696fn build_deferred_tail_attr(
2697    tail: &[ast::Attr],
2698    value_expr: &ast::Expr,
2699    env: &Env,
2700) -> Value {
2701    let tail: Vec<ast::Attr> = tail.to_vec();
2702    let value_expr = value_expr.clone();
2703    let env = env.clone();
2704    Value::Thunk(Thunk::new_native(move || {
2705        build_tail_attrs_now(&tail, &value_expr, &env)
2706    }))
2707}
2708
2709/// Resolve ONE level of the deferred attrpath tail — used from inside
2710/// the deferred thunk above once the enclosing head is demanded.
2711///
2712/// M2.6 ROOT #2 (the OVER-FORCE fix): this resolves *only* `tail[0]`'s
2713/// key and wraps the remaining tail `tail[1..]` in another DEFERRED
2714/// thunk — it does NOT recurse eagerly through the whole tail. This is
2715/// exactly CppNix's desugaring of `a.b.c = v` into nested attrset
2716/// literals `a = { b = { c = v; }; }`, where forcing `a` to WHNF yields
2717/// `{ b = <thunk {c=v}> }` — the inner level (`b`, and any dynamic key
2718/// under it) stays lazy until `.b` is demanded.
2719///
2720/// Forcing the enclosing head therefore resolves ONE tail key, never
2721/// the whole chain: `config.homes.${cfg.pleme.userName} = 7` demanded
2722/// as `config` yields `{ homes = <deferred> }` WITHOUT forcing the
2723/// `${cfg.pleme.userName}` key. The prior implementation recursed the
2724/// whole tail eagerly, forcing that dynamic key while only `.config`
2725/// (or its `._type`) was demanded — the over-force cppnix never does.
2726///
2727/// A dynamic key that evaluates to `null` skips the whole binding
2728/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
2729fn build_tail_attrs_now(
2730    tail: &[ast::Attr],
2731    value_expr: &ast::Expr,
2732    env: &Env,
2733) -> Result<Value, EvalError> {
2734    if tail.is_empty() {
2735        return Ok(maybe_thunk(value_expr, env, false, None));
2736    }
2737    if std::env::var_os("SUI_M26_TAILTRACE").is_some() {
2738        let t: String = tail[0].syntax().text().to_string().chars().take(40).collect();
2739        eprintln!("[M26 TAIL-RESOLVE] forcing dynamic tail key `{t}`");
2740        if attrs_have_dynamic(&tail[..1]) {
2741            crate::trace::dump_force_stack_ids();
2742        }
2743    }
2744    let key = match eval_attr_maybe_null(&tail[0], env)? {
2745        Some(k) => k,
2746        // Null dynamic key → the whole binding is skipped; an empty
2747        // attrset is the identity for merge_nested_insert.
2748        None => return Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
2749    };
2750    // Resolve ONE level: if more tail remains, defer it (a new lazy
2751    // thunk) rather than recursing eagerly. Only the leaf (empty tail)
2752    // is built here. This keeps each nested level lazy, exactly like
2753    // CppNix's nested-attrset-literal desugaring — so forcing this
2754    // level does NOT force the next level's (possibly dynamic) key.
2755    let inner = if tail.len() == 1 {
2756        maybe_thunk(value_expr, env, false, None)
2757    } else {
2758        build_deferred_tail_attr(&tail[1..], value_expr, env)
2759    };
2760    let mut attrs = NixAttrs::new();
2761    attrs.insert(key, inner);
2762    Ok(Value::Attrs(Rc::new(attrs)))
2763}
2764
2765/// M2.6 ROOT #3 (collision case): splice a DEFERRED dynamic-tail binding
2766/// into an ALREADY-PRESENT head value without forcing the dynamic key.
2767///
2768/// `existing` is the value already stored at the attrpath's head (written
2769/// by a sibling binding — e.g. `systemd.services.… = …`). `tail` is the
2770/// remaining attrpath (`path_attrs[1..]`) of the new binding, which
2771/// contains ≥1 dynamic attr (`systemd.tmpfiles.….${dirname …}.d`).
2772///
2773/// We descend `existing` along the LONGEST STATIC PREFIX of `tail`
2774/// (`tmpfiles`, `settings`, `"10-osquery"` — all static, forced-free
2775/// keys), forcing each already-present sub-attrset to WHNF so the merge
2776/// sees concrete keys (forcing to WHNF never forces leaf VALUES, so leaf
2777/// laziness is preserved), and at the first DYNAMIC level splice a
2778/// `build_deferred_tail_attr` thunk. The dynamic key therefore forces
2779/// only when that exact nested path is later demanded — CppNix's
2780/// nested-attrset-literal desugaring, now honoured through a sibling
2781/// collision too.
2782fn merge_deferred_dynamic_tail(
2783    existing: Value,
2784    tail: &[ast::Attr],
2785    value_expr: &ast::Expr,
2786    env: &Env,
2787) -> Result<Value, EvalError> {
2788    // `tail` is non-empty and contains a dynamic attr somewhere (the
2789    // caller guarantees `attrs_have_dynamic(tail)`).
2790    debug_assert!(!tail.is_empty());
2791
2792    // If the FIRST tail attr is itself dynamic, there is no static prefix
2793    // to descend — the whole tail is deferred and merged as a lazy
2794    // overlay onto the existing head (a `//`-style right-merge; the
2795    // deferred attrset only materialises its dynamic key on demand).
2796    if attr_is_dynamic(&tail[0]) {
2797        let deferred = build_deferred_tail_attr(tail, value_expr, env);
2798        return Ok(lazy_overlay_merge(existing, deferred));
2799    }
2800
2801    // The head static key of `tail`. Resolve it (static → forces nothing
2802    // relevant; a null dynamic can't occur here since tail[0] is static).
2803    let key = match eval_attr_maybe_null(&tail[0], env)? {
2804        Some(k) => k,
2805        None => return Ok(existing),
2806    };
2807
2808    // Force the existing head to a concrete attrset so we can descend +
2809    // merge on the resolved static key. Forcing to WHNF does NOT force
2810    // its field VALUES, so leaf laziness is preserved.
2811    let existing_forced = force_value(&existing)?;
2812    let mut base = match existing_forced {
2813        Value::Attrs(a) => (*a).clone(),
2814        // The existing head is not an attrset (a sibling wrote a leaf
2815        // here); CppNix would error on the merge, but to stay lazy we
2816        // defer the tail and let a later demand surface the real merge
2817        // conflict. Build the deferred tail as a fresh attrset.
2818        _ => {
2819            let deferred = build_deferred_tail_attr(tail, value_expr, env);
2820            return Ok(deferred);
2821        }
2822    };
2823
2824    // Recurse: merge the REMAINING tail (`tail[1..]`) under `key`.
2825    let child_existing = base.get(&key).cloned();
2826    let new_child = match child_existing {
2827        Some(child) if tail.len() > 1 => {
2828            // Deeper static/dynamic prefix under an existing sub-attrset.
2829            merge_deferred_dynamic_tail(child, &tail[1..], value_expr, env)?
2830        }
2831        Some(child) => {
2832            // tail == [key]; the leaf collides with an existing value.
2833            // Static leaf collision — build the leaf and lazy-merge.
2834            let leaf = maybe_thunk(value_expr, env, false, None);
2835            lazy_overlay_merge(child, leaf)
2836        }
2837        None if tail.len() > 1 => {
2838            // No existing child; the remaining tail may itself start with
2839            // a dynamic key — defer it whole (build_deferred_tail_attr
2840            // handles the static/dynamic split per-level).
2841            build_deferred_tail_attr(&tail[1..], value_expr, env)
2842        }
2843        None => maybe_thunk(value_expr, env, false, None),
2844    };
2845    base.insert(key, new_child);
2846    Ok(Value::Attrs(Rc::new(base)))
2847}
2848
2849/// Lazy right-merge of two values that are (or will force to) attrsets,
2850/// preserving leaf laziness. Used by [`merge_deferred_dynamic_tail`] to
2851/// combine a deferred dynamic-tail attrset with an existing value without
2852/// forcing either's dynamic keys eagerly. When both are concrete attrs we
2853/// deep-merge in place (reusing [`merge_nested_insert`]); otherwise we
2854/// build a lazy overlay thunk that merges on demand.
2855fn lazy_overlay_merge(left: Value, right: Value) -> Value {
2856    match (&left, &right) {
2857        (Value::Attrs(la), Value::Attrs(_)) => {
2858            crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
2859            let mut merged = (**la).clone();
2860            if let Value::Attrs(ra) = &right {
2861                // Merging distinct override keys into `merged` is order-
2862                // independent (per-key right-wins), and the result map is
2863                // unordered storage — the sorted `iter()` was dead work.
2864                for (k, v) in ra.iter_unsorted() {
2865                    merge_nested_insert(&mut merged, k.clone(), v.clone());
2866                }
2867            }
2868            Value::Attrs(Rc::new(merged))
2869        }
2870        _ => {
2871            // At least one side is a thunk (a deferred dynamic tail).
2872            // Defer the merge behind a Native thunk so neither side's
2873            // dynamic key forces until the merged attrset is demanded.
2874            Value::Thunk(Thunk::new_native(move || {
2875                let lf = force_value(&left)?;
2876                let rf = force_value(&right)?;
2877                let la = lf.as_attrs()?;
2878                let ra = rf.as_attrs()?;
2879                crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
2880                let mut merged = (*la).clone();
2881                for (k, v) in ra.iter_unsorted() {
2882                    merge_nested_insert(&mut merged, k.clone(), v.clone());
2883                }
2884                Ok(Value::Attrs(Rc::new(merged)))
2885            }))
2886        }
2887    }
2888}
2889
2890/// Like [`build_nested_attr`] but wraps the leaf in a [`Thunk`] instead of
2891/// eagerly evaluating it. Used inside `rec { ... }` and `let ... in` so
2892/// that dotted-path leaf expressions can reference sibling bindings
2893/// through the recursive env (which is finalised in Phase 2).
2894///
2895/// Every thunk created is appended to `thunks` so Phase 2 can update
2896/// its captured environment.
2897fn build_nested_attr_thunk(
2898    path: &[String],
2899    expr: &ast::Expr,
2900    env: &Env,
2901    thunks: &mut Vec<(String, Thunk)>,
2902) -> Value {
2903    if path.is_empty() {
2904        let thunk = Thunk::new_suspended(expr.clone(), env.clone());
2905        let val = Value::Thunk(thunk.clone());
2906        thunks.push((String::new(), thunk));
2907        return val;
2908    }
2909    let key = path[0].clone();
2910    let inner = build_nested_attr_thunk(&path[1..], expr, env, thunks);
2911    let mut attrs = NixAttrs::new();
2912    attrs.insert(key, inner);
2913    Value::Attrs(Rc::new(attrs))
2914}
2915
2916/// Insert `value` at `key` in `target`. If `target` already has a
2917/// concrete `Value::Attrs` at that key AND `value` is also a
2918/// concrete `Value::Attrs`, deep-merge them rather than overwriting.
2919/// This is what makes `{ a.b.c = 1; a.b.d = 2; a.e = 3; }` produce
2920/// `{ a = { b = { c = 1; d = 2; }; e = 3; }; }` instead of
2921/// dropping siblings — every nixpkgs module relies on this.
2922fn merge_nested_insert(target: &mut NixAttrs, key: String, value: Value) {
2923    // Fast path: no existing entry at this key → plain insert, keeping the
2924    // value lazy (the overwhelmingly common non-colliding case, so we never
2925    // force a thunk here).
2926    let existing = match target.get(&key) {
2927        Some(e) => e.clone(),
2928        None => {
2929            target.insert(key, value);
2930            return;
2931        }
2932    };
2933    // A collision exists.  A deep merge is warranted only when BOTH the
2934    // existing entry AND the new value are attrset-shaped.  M2.6 ROOT #4b
2935    // (byte-verified): either side may be a lazy `Thunk` wrapping a
2936    // full-set leaf — both dotted-path orderings hit this:
2937    //   forward  `o.a = { x = 1; }; o.a.y = 2;` → EXISTING `a` is a thunk
2938    //            (`build_nested_attr` puts the `{x=1}` leaf through
2939    //            `maybe_thunk`), NEW `a` is `{ y = … }`;
2940    //   reverse  `o.a.y = 2; o.a = { x = 1; };` → EXISTING `a` is `{y}`,
2941    //            NEW `a` is the `<thunk {x=1}>`.
2942    // The old `should_merge` required BOTH sides to already be concrete
2943    // `Value::Attrs`, so a Thunk-vs-Attrs collision fell to the overwrite
2944    // path and silently dropped the earlier leaf's keys.  cppnix desugars
2945    // BOTH orderings into one merged `o.a = { x = 1; y = 2; }`.  Force each
2946    // side's thunk to WHNF ON COLLISION ONLY (forcing an attrset to WHNF
2947    // does NOT force its fields, so leaf laziness is preserved); a thunk
2948    // that forces to a non-attrset (or errors) makes the merge a plain
2949    // overwrite (leaf last-write-wins).
2950    // Symptom this closes: nixpkgs' alsa module declares
2951    // `options.hardware.alsa = { enable = …; cardAliases = …; … }` AND
2952    // `options.hardware.alsa.enablePersistence = …`; sui merged them to
2953    // only `{enablePersistence}`, so `hardware.alsa.cardAliases` "does not
2954    // exist" — the M2.6 frontier once the `with`-namespace over-force (#4a)
2955    // was fixed.
2956    let value = match value {
2957        Value::Thunk(_) => match force_value(&value) {
2958            Ok(v @ Value::Attrs(_)) => v,
2959            _ => value,
2960        },
2961        other => other,
2962    };
2963    if !matches!(value, Value::Attrs(_)) {
2964        target.insert(key, value);
2965        return;
2966    }
2967    // Normalize the existing side to concrete attrs too (forcing a thunk
2968    // to WHNF if needed); if it isn't attrset-shaped, the new attrs wins.
2969    let existing_concrete = match &existing {
2970        Value::Attrs(_) => existing.clone(),
2971        Value::Thunk(_) => match force_value(&existing) {
2972            Ok(v @ Value::Attrs(_)) => v,
2973            _ => {
2974                target.insert(key, value);
2975                return;
2976            }
2977        },
2978        _ => {
2979            target.insert(key, value);
2980            return;
2981        }
2982    };
2983    // Both sides are concrete attrs — merge in place. We pop the
2984    // existing entry, then walk the new attrs and recursively
2985    // merge each child onto it.
2986    let mut existing_attrs = match existing_concrete {
2987        Value::Attrs(a) => (*a).clone(),
2988        _ => unreachable!(),
2989    };
2990    let new_attrs = match value {
2991        Value::Attrs(ref a) => a,
2992        _ => unreachable!(),
2993    };
2994    for (k, v) in new_attrs.iter_unsorted() {
2995        merge_nested_insert(&mut existing_attrs, k.clone(), v.clone());
2996    }
2997    target.insert(key, Value::Attrs(Rc::new(existing_attrs)));
2998}
2999
3000/// Evaluate entries from any HasEntry node (LegacyLet).
3001fn eval_entries<N: HasEntry + AstNode>(node: &N, env: &mut Env) -> Result<(), EvalError> {
3002    for entry in node.entries() {
3003        match entry {
3004            ast::Entry::AttrpathValue(apv) => {
3005                let attrpath = apv.attrpath().ok_or_else(|| {
3006                    EvalError::ParseError("binding missing attrpath".to_string())
3007                })?;
3008                let value_expr = apv.value().ok_or_else(|| {
3009                    EvalError::ParseError("binding missing value".to_string())
3010                })?;
3011                let mut path_keys: Vec<String> = attrpath
3012                    .attrs()
3013                    .map(|a| eval_attr(&a, env))
3014                    .collect::<Result<_, _>>()?;
3015                if path_keys.len() == 1 {
3016                    let key = path_keys.pop().unwrap();
3017                    let value = eval_expr(&value_expr, env)?;
3018                    env.bind(key, value);
3019                }
3020                // Multi-key paths in let are not standard; skip for now.
3021            }
3022            ast::Entry::Inherit(inherit) => {
3023                if let Some(from) = inherit.from() {
3024                    let source_expr = from.expr().ok_or_else(|| {
3025                        EvalError::ParseError("inherit from missing expr".to_string())
3026                    })?;
3027                    let source = force_value(&eval_expr(&source_expr, env)?)?;
3028                    let source_attrs = source.as_attrs()?;
3029                    for attr in inherit.attrs() {
3030                        let name = eval_attr(&attr, env)?;
3031                        let value = source_attrs
3032                            .get(&name)
3033                            .cloned()
3034                            .ok_or_else(|| EvalError::AttrNotFound(
3035                                format!("'{name}' in inherit{}", eval_file_ctx()),
3036                            ))?;
3037                        env.bind(name, value);
3038                    }
3039                } else {
3040                    for attr in inherit.attrs() {
3041                        let name = eval_attr(&attr, env)?;
3042                        let value = env
3043                            .lookup(&name)
3044                            .ok_or_else(|| EvalError::UndefinedVar(
3045                                format!("'{name}'{}", eval_file_ctx()),
3046                            ))?;
3047                        env.bind(name, value);
3048                    }
3049                }
3050            }
3051        }
3052    }
3053    Ok(())
3054}
3055
3056fn eval_binop(
3057    op: ast::BinOpKind,
3058    lhs: &ast::Expr,
3059    rhs: &ast::Expr,
3060    env: &Env,
3061) -> Result<Value, EvalError> {
3062    // Short-circuit for && and ||
3063    match op {
3064        ast::BinOpKind::And => {
3065            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3066            if !l {
3067                return Ok(Value::Bool(false));
3068            }
3069            return eval_expr(rhs, env);
3070        }
3071        ast::BinOpKind::Or => {
3072            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3073            if l {
3074                return Ok(Value::Bool(true));
3075            }
3076            return eval_expr(rhs, env);
3077        }
3078        ast::BinOpKind::Implication => {
3079            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3080            if !l {
3081                return Ok(Value::Bool(true));
3082            }
3083            return eval_expr(rhs, env);
3084        }
3085        _ => {}
3086    }
3087
3088    let lc = force_concrete(&eval_expr(lhs, env)?)?;
3089    let rc = force_concrete(&eval_expr(rhs, env)?)?;
3090    // Consume the Concretes (move, don't clone) so `l`/`r` hold the sole Rc to
3091    // any heap payload. This is byte-neutral — `into_value` yields the identical
3092    // `Value` as `to_value` — but it drops `lc`/`rc`, which is what lets the
3093    // `Concat` arm's structural-share fast path see a uniquely-owned left list
3094    // for a fresh `++` temporary (`Rc::try_unwrap` → append in place). Keeping
3095    // `lc` alive via `to_value` pinned the refcount at ≥2 and defeated reuse.
3096    let l = lc.into_value();
3097    let r = rc.into_value();
3098
3099    match op {
3100        ast::BinOpKind::Add => match (&l, &r) {
3101            (Value::Int(a), Value::Int(b)) => a
3102                .checked_add(*b)
3103                .map(Value::Int)
3104                .ok_or_else(|| int_overflow("adding", *a, '+', *b)),
3105            (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
3106            (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
3107            (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
3108            (Value::String(a), Value::String(b)) => {
3109                let mut ctx = a.context.clone();
3110                ctx.merge(&b.context);
3111                // Byte-identical to `format!("{}{}", a.chars, b.chars)` but
3112                // routes around the `core::fmt` runtime (its dispatch was the
3113                // #1 self-time frame on the string-concat hot path): a single
3114                // exact-capacity `String` + two `push_str` reserves the final
3115                // size once, so the left operand is copied exactly once instead
3116                // of copied-then-regrown. Result string + context unchanged →
3117                // ByteSufficient. (Also removes a `format!` — TYPED EMISSION.)
3118                let mut s = String::with_capacity(a.chars.len() + b.chars.len());
3119                s.push_str(&a.chars);
3120                s.push_str(&b.chars);
3121                Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
3122            }
3123            (Value::Path(a), Value::String(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}{}", b.chars).as_str())))),
3124            (Value::Path(a), Value::Path(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}/{b}").as_str())))),
3125            // CppNix coerces attrsets with outPath when used with +
3126            (Value::Attrs(_), _) | (_, Value::Attrs(_)) => {
3127                let (ls, lctx) = l.coerce_to_string()?;
3128                let (rs, rctx) = r.coerce_to_string()?;
3129                let mut ctx = lctx;
3130                ctx.merge(&rctx);
3131                Ok(Value::String(Rc::new(NixString::with_context(
3132                    format!("{ls}{rs}"),
3133                    ctx,
3134                ))))
3135            }
3136            _ => Err(EvalError::op_type("add", l.type_name(), r.type_name())),
3137        },
3138        ast::BinOpKind::Sub => num_op(
3139            &l,
3140            &r,
3141            |a, b| a.checked_sub(b),
3142            |a, b| a - b,
3143            |a, b| int_overflow("subtracting", a, '-', b),
3144        ),
3145        ast::BinOpKind::Mul => num_op(
3146            &l,
3147            &r,
3148            |a, b| a.checked_mul(b),
3149            |a, b| a * b,
3150            |a, b| int_overflow("multiplying", a, '*', b),
3151        ),
3152        ast::BinOpKind::Div => {
3153            // CppNix rejects division by zero for both int and float
3154            // operands; Rust's native int-div-by-0 panics (we handle
3155            // that below) but float-div-by-0 silently returns `inf`
3156            // or `NaN`, which sui was then serializing as `null` —
3157            // an invisible silent-Ok bug surfaced by the error-case
3158            // differential corpus.
3159            //
3160            // Cover every zero-denominator case explicitly.
3161            let rhs_is_zero = match &r {
3162                Value::Int(0) => true,
3163                Value::Float(f) => *f == 0.0,
3164                _ => false,
3165            };
3166            if rhs_is_zero {
3167                return Err(EvalError::DivisionByZero);
3168            }
3169            num_op(
3170                &l,
3171                &r,
3172                |a, b| a.checked_div(b),
3173                |a, b| a / b,
3174                |a, b| int_overflow("dividing", a, '/', b),
3175            )
3176        }
3177        ast::BinOpKind::Equal => Ok(Value::Bool(l == r)),
3178        ast::BinOpKind::NotEqual => Ok(Value::Bool(l != r)),
3179        ast::BinOpKind::Less => compare(&l, &r, |o| o == std::cmp::Ordering::Less),
3180        ast::BinOpKind::LessOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Greater),
3181        ast::BinOpKind::More => compare(&l, &r, |o| o == std::cmp::Ordering::Greater),
3182        ast::BinOpKind::MoreOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Less),
3183        ast::BinOpKind::Update => {
3184            let la = l.to_attrs()?;
3185            let ra = r.to_attrs()?;
3186            // O(1) lazy overlay — defers merge until attribute access.
3187            Ok(Value::Attrs(Rc::new(la.overlay(ra))))
3188        }
3189        ast::BinOpKind::Concat => {
3190            // Structural-share fast path: when the left operand's `Rc<Vec>` is
3191            // uniquely owned (a fresh temporary, as in a left-associative `++`
3192            // fold `acc ++ [x]`), append the right elements IN PLACE instead of
3193            // cloning the whole accumulator. This turns an O(n) copy per concat
3194            // into amortized O(1), byte-identically — the result is the same
3195            // ordered sequence of the same Rc-shared lazy thunks (no forcing,
3196            // no reordering, no identity change). When the Rc is shared (the
3197            // left came from a still-live binding/thunk) we fall back to the
3198            // clone-extend path, preserving the shared list unchanged.
3199            crate::value::concat_lists(l, r.as_list()?)
3200        }
3201        ast::BinOpKind::And | ast::BinOpKind::Or | ast::BinOpKind::Implication => {
3202            unreachable!("handled above")
3203        }
3204        ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
3205            Err(EvalError::NotImplemented("pipe operators".to_string()))
3206        }
3207    }
3208}
3209
3210/// CppNix aborts (uncatchably) on i64 arithmetic overflow, e.g.
3211/// `integer overflow in adding 9223372036854775807 + 1`. `EvalError::Abort` is
3212/// the uncatchable variant (`tryEval` catches only `Throw`/`AssertionFailed`),
3213/// matching nix — a wrapping result would silently produce a wrong drvPath.
3214#[inline]
3215fn int_overflow(verb: &str, a: i64, sym: char, b: i64) -> EvalError {
3216    EvalError::Abort(format!("integer overflow in {verb} {a} {sym} {b}"))
3217}
3218
3219fn num_op(
3220    l: &Value,
3221    r: &Value,
3222    int_op: impl Fn(i64, i64) -> Option<i64>,
3223    float_op: impl Fn(f64, f64) -> f64,
3224    overflow: impl Fn(i64, i64) -> EvalError,
3225) -> Result<Value, EvalError> {
3226    match (l, r) {
3227        (Value::Int(a), Value::Int(b)) => {
3228            int_op(*a, *b).map(Value::Int).ok_or_else(|| overflow(*a, *b))
3229        }
3230        (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
3231        (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
3232        (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
3233        _ => Err(EvalError::op_type("perform arithmetic on", l.type_name(), r.type_name())),
3234    }
3235}
3236
3237fn compare(
3238    l: &Value,
3239    r: &Value,
3240    pred: impl Fn(std::cmp::Ordering) -> bool,
3241) -> Result<Value, EvalError> {
3242    let ord = match (l, r) {
3243        (Value::Int(a), Value::Int(b)) => a.cmp(b),
3244        (Value::Float(a), Value::Float(b)) => {
3245            a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
3246        }
3247        (Value::Int(a), Value::Float(b)) => (*a as f64)
3248            .partial_cmp(b)
3249            .unwrap_or(std::cmp::Ordering::Equal),
3250        (Value::Float(a), Value::Int(b)) => a
3251            .partial_cmp(&(*b as f64))
3252            .unwrap_or(std::cmp::Ordering::Equal),
3253        (Value::String(a), Value::String(b)) => a.chars.cmp(&b.chars),
3254        _ => {
3255            return Err(EvalError::op_type("compare", l.type_name(), r.type_name()));
3256        }
3257    };
3258    Ok(Value::Bool(pred(ord)))
3259}
3260
3261/// Apply a function to an argument.
3262///
3263/// Supports `__functor`: if `func` is an attrset with a `__functor` key,
3264/// calls `__functor self arg` (the Nix `__functor` protocol).
3265///
3266/// For lambda with a simple ident parameter, the argument is NOT forced
3267/// before binding -- this enables fixpoint combinators (`lib.fix`) where
3268/// the argument is a self-referential thunk.
3269/// Apply a function and force the result.
3270///
3271/// Builtins that inspect the return value (via `as_list`, `as_bool`, etc.)
3272/// must use this instead of bare `apply` — otherwise a thunk-wrapped result
3273/// will cause "thunk in as_list: force first" errors.
3274pub fn apply_and_force(func: Value, arg: Value) -> Result<Value, EvalError> {
3275    force_value(&apply(func, arg)?)
3276}
3277
3278pub fn apply(func: Value, arg: Value) -> Result<Value, EvalError> {
3279    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || apply_inner(func, arg))
3280}
3281
3282fn apply_inner(func: Value, arg: Value) -> Result<Value, EvalError> {
3283    crate::perf::inc(crate::perf::Counter::Apply);
3284    let func = force_concrete(&func)?.into_value();
3285    match func {
3286        Value::Lambda(closure) => {
3287            // Hot function tracker: log source file + param name for each lambda call
3288            if crate::perf::enabled() {
3289                APPLY_SITES.with(|sites| {
3290                    let file = closure.env.eval_file()
3291                        .map(|p| p.display().to_string())
3292                        .unwrap_or_else(|| "<eval>".into());
3293                    // Include param info for identification
3294                    let param_name = match &closure.param {
3295                        rnix::ast::Param::IdentParam(ip) => ip.ident().map(|i| ident_text(&i)).unwrap_or_default(),
3296                        rnix::ast::Param::Pattern(pat) => {
3297                            let mut names: Vec<String> = pat.pat_entries()
3298                                .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3299                                .take(3)
3300                                .collect();
3301                            if pat.pat_entries().count() > 3 { names.push("...".to_string()); }
3302                            format!("{{{}}}", names.join(","))
3303                        }
3304                    };
3305                    let key = format!("{}:{}", file.rsplit_once("-source/").map_or(file.as_str(), |(_,s)| s), param_name);
3306                    *sites.borrow_mut().entry(key).or_insert(0u64) += 1;
3307                });
3308            }
3309            let mut call_env = closure.env.child();
3310            // ALWAYS push a frame, even when the closure captured no file:
3311            // `.map(push_eval_file)` pushed nothing for `None`, leaving the
3312            // CALLER's file on top, so a literal written in a fileless
3313            // context got stamped with the callee's path. CppNix returns
3314            // `null` there. See `EVAL_FILE_STACK`.
3315            let _file_guard = push_eval_frame(closure.env.eval_file().cloned());
3316            // Push Nix-level trace frame for function calls. Lazy: stores
3317            // only the raw ingredients (O(1) Rc-clone of the closure env +
3318            // the current-eval-file snapshot) and defers the format!/strip
3319            // work to the cold `attach_trace` path. Renders byte-identical
3320            // to the eager form.
3321            let _trace = push_nix_trace_lambda(&closure.env);
3322            match &closure.param {
3323                rnix::ast::Param::IdentParam(_) => {
3324                    // Simple ident param: bind argument WITHOUT forcing.
3325                    // This is critical for fixpoint / call-by-need semantics.
3326                    bind_param(&closure.param, &arg, &mut call_env)?;
3327                }
3328                rnix::ast::Param::Pattern(_) => {
3329                    // Pattern param needs the arg to be an attrset, so force.
3330                    let forced_arg = force_concrete(&arg)?.into_value();
3331                    bind_param(&closure.param, &forced_arg, &mut call_env)?;
3332                }
3333            }
3334            eval_expr(&closure.body, &call_env)
3335        }
3336        Value::Builtin(b) => {
3337            let _trace = push_nix_trace(format!("while calling the '{}' builtin", b.name));
3338            // Special builtins that must receive UNFORCED arguments:
3339            // - tryEval: must catch throw/abort during its own forcing
3340            // - addErrorContext<partial>: wraps value with error context
3341            //   without forcing (the value is the fixpoint `config` which
3342            //   causes infinite recursion if forced during collectModules)
3343            // - seq<partial>: forces first arg but returns second UNFORCED
3344            // Same lazy-arg set as `eval_apply` (single source of truth) — these
3345            // builtins receive the arg UNFORCED. foldl'<p1> is the nul accumulator
3346            // (nix's foldl' is strict in each op RESULT, NOT in the nul).
3347            if builtin_takes_lazy_arg(&b.name) {
3348                (b.func)(&[arg])
3349            } else {
3350                let forced_arg = force_value(&arg)?;
3351                (b.func)(&[forced_arg])
3352            }
3353        }
3354        Value::Attrs(ref attrs) => {
3355            if let Some(functor) = attrs.get("__functor") {
3356                let functor = force_value(functor)?;
3357                // __functor protocol: (functor self) arg
3358                let partial = apply(functor, func.clone())?;
3359                apply(partial, arg)
3360            } else if crate::value::in_promise_eval() {
3361                // M2.6 Promise softening: an attrset without __functor
3362                // being called as a function — typically the empty-
3363                // attrset sentinel inside a fix-point body.  Return
3364                // null so eval can proceed.
3365                Ok(Value::Null)
3366            } else {
3367                Err(EvalError::type_error(
3368                    format!("cannot call {} (missing __functor){}", func.type_name(), eval_file_ctx()),
3369                ))
3370            }
3371        }
3372        _ if crate::value::in_promise_eval() => {
3373            // M2.6 Promise softening: calling null / int / string / list
3374            // as a function inside a Promise body is the sentinel
3375            // cascade landing somewhere it doesn't belong.  Return null
3376            // so the fix-point continues instead of erroring.
3377            Ok(Value::Null)
3378        }
3379        _ => Err(EvalError::type_error(
3380            format!("cannot call {}{}", func.type_name(), eval_file_ctx()),
3381        )),
3382    }
3383}
3384
3385/// Dark-side lever `batch-bind` (byte-SAFE, `RedundantWrite`) — OFF by default.
3386/// When `SUI_BATCH_BIND=1`, an N-formal pattern binds in ONE copy-on-write step
3387/// (`Env::bind_many`) instead of N successive `env.bind()` calls. Byte-identical
3388/// either way (same intern, same insert order, same final HAMT — Phase 2's
3389/// `update_env` makes each default thunk's initial env capture unobservable).
3390/// Gated because the extra `Vec` allocation could regress the common small-pattern
3391/// case, and the win is unmeasured under load — never change the default path on a
3392/// hunch (never-ship-a-regression). Cached so the default path pays zero per call.
3393/// Ledger: `sui-spec/specs/darkside.lisp` (`batch-bind`, DarkGated).
3394static SUI_BATCH_BIND: std::sync::LazyLock<bool> =
3395    std::sync::LazyLock::new(|| std::env::var_os("SUI_BATCH_BIND").is_some());
3396
3397fn bind_param(param: &ast::Param, arg: &Value, env: &mut Env) -> Result<(), EvalError> {
3398    match param {
3399        ast::Param::IdentParam(ip) => {
3400            let ident = ip
3401                .ident()
3402                .ok_or_else(|| EvalError::ParseError("ident param missing ident".to_string()))?;
3403            let name = ident_text(&ident);
3404            env.bind(name, arg.clone());
3405        }
3406        ast::Param::Pattern(pat) => {
3407            let attrs = arg.as_attrs()?;
3408
3409            // @-binding (either `args @ { ... }` or `{ ... } @ args`)
3410            if let Some(pat_bind) = pat.pat_bind()
3411                && let Some(ident) = pat_bind.ident()
3412            {
3413                let name = ident_text(&ident);
3414                env.bind(name, arg.clone());
3415            }
3416
3417            let has_ellipsis = pat.ellipsis_token().is_some();
3418            let entries: Vec<ast::PatEntry> = pat.pat_entries().collect();
3419
3420            // Two-phase binding (matching CppNix semantics):
3421            // Phase 1: Bind all formals. Defaults get thunks with a
3422            //   preliminary env. We collect thunks for Phase 2 update.
3423            // Phase 2: Update default thunks to capture the final env
3424            //   (which now has ALL formals bound). This allows defaults
3425            //   to reference any other formal — including forward refs.
3426            let mut default_thunks: Vec<Thunk> = Vec::new();
3427            // batch-bind (byte-SAFE `RedundantWrite`, OFF unless `SUI_BATCH_BIND=1`):
3428            // the flag path collects every formal's (name, value) pair and binds
3429            // them in ONE copy-on-write step (`bind_many`) instead of N successive
3430            // `env.bind()` calls. Byte-identical either way — the default thunks
3431            // capture `env.clone()` (pre-batch) and Phase 2's `update_env` re-points
3432            // every one to the final all-formals-bound env, so a thunk's *initial*
3433            // capture is unobservable (overwritten before any force); same intern,
3434            // same insert order, same final HAMT. The default path (flag unset) is
3435            // the original per-formal loop, byte- AND perf-identical (no Vec alloc).
3436            let use_batch = *SUI_BATCH_BIND;
3437            let mut pairs: Vec<(String, Value)> =
3438                if use_batch { Vec::with_capacity(entries.len()) } else { Vec::new() };
3439
3440            for entry in &entries {
3441                let ident = entry.ident().ok_or_else(|| {
3442                    EvalError::ParseError("pat entry missing ident".to_string())
3443                })?;
3444                let name = ident_text(&ident);
3445                let value = if let Some(v) = attrs.get(&name) {
3446                    v.clone()
3447                } else if let Some(default_expr) = entry.default() {
3448                    // Default values in pattern parameters must be lazy
3449                    // (wrapped in thunks), matching CppNix semantics.
3450                    // Patterns like `vendor ? assert false; null` rely on
3451                    // the default never being forced when the body checks
3452                    // `args ? vendor` instead of using `vendor` directly.
3453                    let thunk = Thunk::new_suspended(
3454                        ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3455                        env.clone(),
3456                    );
3457                    default_thunks.push(thunk.clone());
3458                    Value::Thunk(thunk)
3459                } else {
3460                    return Err(EvalError::type_error(
3461                        format!("missing argument '{name}'{}", eval_file_ctx()),
3462                    ));
3463                };
3464                if use_batch {
3465                    pairs.push((name, value));
3466                } else {
3467                    env.bind(name, value);
3468                }
3469            }
3470            if use_batch {
3471                env.bind_many(pairs);
3472            }
3473
3474            // Phase 2: Update default thunks to see ALL formals.
3475            for thunk in &default_thunks {
3476                thunk.update_env(env);
3477            }
3478
3479            if !has_ellipsis {
3480                let entry_names: std::collections::HashSet<String> = entries
3481                    .iter()
3482                    .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3483                    .collect();
3484                for key in attrs.keys() {
3485                    if !entry_names.contains(key.as_str()) {
3486                        return Err(EvalError::type_error(
3487                            format!("unexpected argument '{key}'{}", eval_file_ctx()),
3488                        ));
3489                    }
3490                }
3491            }
3492        }
3493    }
3494    Ok(())
3495}
3496
3497#[cfg(test)]
3498mod tests {
3499    use super::*;
3500
3501    fn ev(input: &str) -> Value {
3502        eval(input).unwrap()
3503    }
3504
3505    // Regression (2026-07-10): the let-scope fix-point detector must count
3506    // only GENUINE variable references, not attribute names / attrset keys
3507    // (which sit under a `NODE_ATTRPATH`).  nixpkgs `lib/types.nix` has
3508    // `placeholder = if lhs.placeholder == …` whose RHS mentions the
3509    // *attribute* `.placeholder`; the old raw-token match falsely flagged
3510    // the binding self-recursive and routed it through the Promise path.
3511    #[test]
3512    fn is_self_recursive_binding_ignores_attribute_names() {
3513        fn expr(s: &str) -> ast::Expr {
3514            rnix::Root::parse(s).tree().expr().expect("parse")
3515        }
3516        // attribute names / keys are NOT references to the binding
3517        assert!(!is_self_recursive_binding(&expr("lhs.placeholder"), "placeholder"));
3518        assert!(!is_self_recursive_binding(&expr("{ placeholder = 1; }"), "placeholder"));
3519        assert!(!is_self_recursive_binding(
3520            &expr("if lhs.placeholder == rhs.placeholder then lhs.placeholder else null"),
3521            "placeholder",
3522        ));
3523        // genuine variable references ARE detected
3524        assert!(is_self_recursive_binding(&expr("placeholder + 1"), "placeholder"));
3525        assert!(is_self_recursive_binding(
3526            &expr("if placeholder then 1 else 2"),
3527            "placeholder"
3528        ));
3529    }
3530
3531    // M2 thunk-waste (byte-safe eager constant): a NON-interpolated string in a
3532    // maybe_thunk site is evaluated directly (no suspended thunk). The value +
3533    // its (empty) context must be byte-identical to forcing a thunk of it.
3534    #[test]
3535    fn maybe_thunk_eager_constant_str_is_byte_identical() {
3536        fn expr(s: &str) -> ast::Expr {
3537            rnix::Root::parse(s).tree().expr().expect("parse")
3538        }
3539        let env = Env::new();
3540        // Constant string → returned as a concrete String, NOT a Thunk.
3541        let v = maybe_thunk(&expr(r#""abc""#), &env, false, None);
3542        assert!(matches!(v, Value::String(_)), "constant str should be eager, got {v:?}");
3543        assert_eq!(force_value(&v).unwrap(), Value::string("abc"));
3544        // Interpolated string → MUST stay a thunk (lazy `${…}` force).
3545        let vi = maybe_thunk(&expr(r#""a${b}c""#), &env, false, None);
3546        assert!(matches!(vi, Value::Thunk(_)), "interpolated str must stay thunked");
3547    }
3548
3549    // The pure-constant arg classifier admits ONLY literals + non-interpolated
3550    // strings/paths, and rejects everything that could throw/diverge/observe a
3551    // fixpoint — the laziness safety boundary of the apply-arg optimization.
3552    #[test]
3553    fn eval_pure_constant_arg_classification() {
3554        fn expr(s: &str) -> ast::Expr {
3555            rnix::Root::parse(s).tree().expr().expect("parse")
3556        }
3557        // ADMIT: pure constants (byte-safe to eval eagerly in an arg position).
3558        assert!(eval_pure_constant_arg(&expr("42")).is_some());
3559        assert!(eval_pure_constant_arg(&expr("3.14")).is_some());
3560        assert!(eval_pure_constant_arg(&expr(r#""const""#)).is_some());
3561        assert!(eval_pure_constant_arg(&expr("/abs/path")).is_some());
3562        // REJECT: anything that could throw / diverge / observe laziness.
3563        assert!(eval_pure_constant_arg(&expr(r#""a${b}c""#)).is_none(), "interpolated str");
3564        // `true`/`false`/`null` are IDENTS in nix (shadowable), not literals —
3565        // rejected to avoid a with-scope force, correctly conservative.
3566        assert!(eval_pure_constant_arg(&expr("true")).is_none(), "bool is an ident");
3567        assert!(eval_pure_constant_arg(&expr("x")).is_none(), "ident (with-scope force)");
3568        assert!(eval_pure_constant_arg(&expr("a.b")).is_none(), "select (fixpoint)");
3569        assert!(eval_pure_constant_arg(&expr("f x")).is_none(), "apply (may throw)");
3570        assert!(eval_pure_constant_arg(&expr("1 + 1")).is_none(), "binop (may throw)");
3571        assert!(eval_pure_constant_arg(&expr("throw \"x\"")).is_none(), "throw stays lazy");
3572    }
3573
3574    // LAZINESS GUARD: a lambda that IGNORES its arg must NOT force it — even a
3575    // throwing arg. The pure-constant optimization only touches inert constants,
3576    // so a `throw`-ing arg stays fully thunked and the ignoring lambda succeeds.
3577    #[test]
3578    fn ignored_throwing_arg_stays_lazy() {
3579        assert_eq!(ev(r#"(x: 7) (throw "boom")"#), Value::Int(7));
3580        // And an ignored constant arg is equally invisible.
3581        assert_eq!(ev(r#"(x: 7) "const""#), Value::Int(7));
3582        // A USED constant arg produces the right value.
3583        assert_eq!(ev(r#"(x: x) "used""#), Value::string("used"));
3584    }
3585
3586    #[test]
3587    fn eval_int() { assert_eq!(ev("42"), Value::Int(42)); }
3588
3589    #[test]
3590    fn eval_float() { assert_eq!(ev("3.14"), Value::Float(3.14)); }
3591
3592    #[test]
3593    fn eval_string() { assert_eq!(ev(r#""hello""#), Value::string("hello")); }
3594
3595    #[test]
3596    fn eval_bool() { assert_eq!(ev("true"), Value::Bool(true)); }
3597
3598    #[test]
3599    fn eval_null() { assert_eq!(ev("null"), Value::Null); }
3600
3601    #[test]
3602    fn eval_arithmetic() {
3603        assert_eq!(ev("1 + 2"), Value::Int(3));
3604        assert_eq!(ev("10 - 3"), Value::Int(7));
3605        assert_eq!(ev("2 * 3"), Value::Int(6));
3606        assert_eq!(ev("10 / 3"), Value::Int(3));
3607    }
3608
3609    #[test]
3610    fn eval_precedence() {
3611        assert_eq!(ev("1 + 2 * 3"), Value::Int(7));
3612        assert_eq!(ev("(1 + 2) * 3"), Value::Int(9));
3613    }
3614
3615    #[test]
3616    fn eval_comparison() {
3617        assert_eq!(ev("1 == 1"), Value::Bool(true));
3618        assert_eq!(ev("1 == 2"), Value::Bool(false));
3619        assert_eq!(ev("1 < 2"), Value::Bool(true));
3620        assert_eq!(ev("2 <= 2"), Value::Bool(true));
3621    }
3622
3623    #[test]
3624    fn eval_logic() {
3625        assert_eq!(ev("true && false"), Value::Bool(false));
3626        assert_eq!(ev("true || false"), Value::Bool(true));
3627        assert_eq!(ev("!true"), Value::Bool(false));
3628    }
3629
3630    #[test]
3631    fn eval_string_concat() {
3632        assert_eq!(ev(r#""hello" + " " + "world""#), Value::string("hello world"));
3633    }
3634
3635    #[test]
3636    fn eval_if() {
3637        assert_eq!(ev("if true then 1 else 2"), Value::Int(1));
3638        assert_eq!(ev("if false then 1 else 2"), Value::Int(2));
3639    }
3640
3641    #[test]
3642    fn eval_let() {
3643        assert_eq!(ev("let x = 1; in x"), Value::Int(1));
3644        assert_eq!(ev("let x = 1; y = 2; in x + y"), Value::Int(3));
3645    }
3646
3647    #[test]
3648    fn eval_let_dotted_simple() {
3649        // Two dotted bindings sharing the top-level key `a`.
3650        assert_eq!(ev("let a.b = 1; a.c = 2; in a.b + a.c"), Value::Int(3));
3651    }
3652
3653    #[test]
3654    fn eval_let_dotted_deep() {
3655        // Deeply nested dotted path.
3656        assert_eq!(ev("let a.b.c = 1; in a.b.c"), Value::Int(1));
3657    }
3658
3659    #[test]
3660    fn eval_let_dotted_mixed() {
3661        // Mix of simple and dotted bindings.
3662        assert_eq!(
3663            ev("let a.x = 1; b = 2; a.y = 3; in a.x + a.y + b"),
3664            Value::Int(6),
3665        );
3666    }
3667
3668    #[test]
3669    fn eval_let_dotted_produces_attrset() {
3670        // Dotted let bindings produce a real attrset.
3671        let v = ev("let a.b = 1; a.c = 2; in a");
3672        if let Value::Attrs(attrs) = v {
3673            assert_eq!(attrs.get("b"), Some(&Value::Int(1)));
3674            assert_eq!(attrs.get("c"), Some(&Value::Int(2)));
3675        } else {
3676            panic!("expected Attrs, got {v:?}");
3677        }
3678    }
3679
3680    // ── Inner dynamic attrpath key laziness ──────────────────
3681    // CppNix defers a dynamic key that is NOT at the head of an attrpath:
3682    // `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`, so `e` never
3683    // forces until `.a` is demanded. Reading a sibling must not force the
3684    // inner dynamic key. Root fix: `build_deferred_tail_attr` in eval.rs.
3685    // This is the pure-builtins reduction of the NixOS module-system
3686    // `config.homes.${cfg.userName}` fixpoint divergence.
3687    #[test]
3688    fn dynamic_inner_attr_key_is_lazy_on_sibling_read() {
3689        // The dynamic key throws; reading the SIBLING must NOT force it.
3690        assert_eq!(
3691            ev(r#"let s = { a.${throw "KEYFORCED"} = 7; other = 9; }; in s.other"#),
3692            Value::Int(9),
3693        );
3694    }
3695
3696    #[test]
3697    fn dynamic_inner_attr_key_resolves_on_head_demand() {
3698        // Demanding the head DOES resolve the deferred dynamic key.
3699        let v = ev(r#"let u = "bob"; s = { homes.${u} = 7; }; in s.homes"#);
3700        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3701            assert_eq!(attrs.get("bob"), Some(&Value::Int(7)));
3702        } else {
3703            panic!("expected Attrs");
3704        }
3705    }
3706
3707    #[test]
3708    fn dynamic_inner_attr_key_merges_with_static_sibling() {
3709        // Collision under one head still deep-merges (static + dynamic).
3710        let v = ev(r#"let u = "x"; s = { a.${u} = 1; a.b = 2; }; in s.a"#);
3711        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3712            assert_eq!(attrs.get("x"), Some(&Value::Int(1)));
3713            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
3714        } else {
3715            panic!("expected Attrs");
3716        }
3717    }
3718
3719    #[test]
3720    fn dynamic_inner_attr_key_null_skips_binding() {
3721        // A null dynamic inner key skips the definition (CppNix rule):
3722        // `a` becomes an empty attrset, the sibling stays.
3723        let v = ev(
3724            r#"let c = true; s = { a.${if c then null else "n"} = 5; b = 1; }; in s.b"#,
3725        );
3726        assert_eq!(v, Value::Int(1));
3727    }
3728
3729    // ── M2.6 ROOT #3: interpolated-STRING tail keys are dynamic too ──────
3730    // `{ a."p${e}" = v; }` must build `{ a = <thunk {"p${e}"=v}>; }` — an
3731    // interpolated-string attr key references `e` and so must defer like a
3732    // bare `${e}`, never force at construction. Reading a sibling must NOT
3733    // force it (the KEYFORCE discriminator, now for a `Str` key).
3734    #[test]
3735    fn interpolated_string_attr_key_is_lazy_on_sibling_read() {
3736        assert_eq!(
3737            ev(r#"let s = { a."p/${throw "KEYFORCED"}" = 7; other = 9; }; in s.other"#),
3738            Value::Int(9),
3739        );
3740    }
3741
3742    #[test]
3743    fn interpolated_string_attr_key_resolves_on_head_demand() {
3744        // Demanding the head DOES resolve the deferred interpolated key.
3745        let v = ev(r#"let u = "bob"; s = { homes."u/${u}" = 7; }; in s.homes"#);
3746        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3747            assert_eq!(attrs.get("u/bob"), Some(&Value::Int(7)));
3748        } else {
3749            panic!("expected Attrs");
3750        }
3751    }
3752
3753    #[test]
3754    fn purely_literal_string_attr_key_stays_eager_static() {
3755        // A `Str` key with NO interpolation is a plain static key and must
3756        // NOT be treated as dynamic (it forces nothing, deep-merges).
3757        let v = ev(r#"let s = { a."foo bar" = 1; a.b = 2; }; in s.a"#);
3758        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3759            assert_eq!(attrs.get("foo bar"), Some(&Value::Int(1)));
3760            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
3761        } else {
3762            panic!("expected Attrs");
3763        }
3764    }
3765
3766    // ── M2.6 ROOT #3 (collision case): dynamic tail key under a head that
3767    // a sibling binding already wrote must stay lazy AND deep-merge.
3768    #[test]
3769    fn dynamic_tail_key_under_colliding_head_is_lazy() {
3770        // `sd.services.x` writes head `sd`; the second binding's dynamic
3771        // key must NOT force when a SIBLING (`sd.services`) is read.
3772        let v = ev(
3773            r#"let s = { sd.services.x = 1; sd.tmpfiles.${throw "KEYFORCED"}.d = 2; }; in s.sd.services.x"#,
3774        );
3775        assert_eq!(v, Value::Int(1));
3776    }
3777
3778    #[test]
3779    fn dynamic_tail_key_under_colliding_head_resolves_and_merges() {
3780        // Demanding the dynamic branch resolves the key; the sibling
3781        // static branch (`sd.services`) survives the merge intact.
3782        let v = ev(
3783            r#"let k = "z"; s = { sd.services.x = 1; sd.tmpfiles.${k}.d = 2; }; in s.sd"#,
3784        );
3785        let sd = force_value(&v).unwrap();
3786        if let Value::Attrs(sd_attrs) = &sd {
3787            // static sibling intact
3788            let services = force_value(sd_attrs.get("services").unwrap()).unwrap();
3789            if let Value::Attrs(a) = &services {
3790                assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
3791            } else { panic!("expected services attrs"); }
3792            // dynamic branch resolved to key "z"
3793            let tmpfiles = force_value(sd_attrs.get("tmpfiles").unwrap()).unwrap();
3794            if let Value::Attrs(a) = &tmpfiles {
3795                let z = force_value(a.get("z").unwrap()).unwrap();
3796                if let Value::Attrs(zd) = &z {
3797                    assert_eq!(force_value(zd.get("d").unwrap()).unwrap(), Value::Int(2));
3798                } else { panic!("expected z attrs"); }
3799            } else { panic!("expected tmpfiles attrs"); }
3800        } else {
3801            panic!("expected sd attrs");
3802        }
3803    }
3804
3805    // ── M2.6 ROOT #4a — `with` namespace must be LAZY ─────────────────
3806    // `with X; body` stores the namespace as a thunk forced only on a
3807    // bare-ident fallthrough lookup; demanding only the body's WHNF/keys
3808    // must NOT force X.  cppnix: `attrNames (with (throw "X"); {a=1;})`
3809    // → ["a"].  Before the fix, sui EVALUATED the namespace at `with`-entry
3810    // and threw.  This is the load-bearing over-force behind the M2.6
3811    // `concatLists null` (nixpkgs' `config = mkIf … (with config.services.X;
3812    // { … })` module shape forced `config.services.X` during collection).
3813    #[test]
3814    fn with_namespace_is_lazy_on_body_whnf() {
3815        let v = ev(r#"builtins.attrNames (with (throw "WITH-FORCED"); { a = 1; b = 2; })"#);
3816        if let Value::List(items) = force_value(&v).unwrap() {
3817            let names: Vec<String> = items
3818                .iter()
3819                .map(|i| match force_value(i).unwrap() {
3820                    Value::String(s) => s.as_str().to_string(),
3821                    other => panic!("expected string, got {}", other.type_name()),
3822                })
3823                .collect();
3824            assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
3825        } else {
3826            panic!("expected list");
3827        }
3828    }
3829
3830    #[test]
3831    fn with_namespace_forces_only_on_fallthrough() {
3832        // A bare ident that falls through lexical scope DOES resolve via
3833        // the namespace (correct cppnix semantics) — proves the deferred
3834        // thunk is real and gets forced on demand, not an accidental no-op.
3835        assert_eq!(ev(r#"with { x = 42; }; x"#), Value::Int(42));
3836        // A lexical binding shadows the with-scope, so the (throwing)
3837        // namespace is never forced — the laziness we rely on for M2.6.
3838        assert_eq!(ev(r#"let x = 7; in with (throw "NS"); x"#), Value::Int(7));
3839    }
3840
3841    // ── M2.6 ROOT #4b — depth-≥2 dotted full-set leaf must deep-merge ──
3842    // `o.a = { x = 1; }` inserts `o = { a = <thunk {x=1}> }` (leaf goes
3843    // through maybe_thunk); a deeper sibling `o.a.y = 2` recurses
3844    // merge_nested_insert down to key `a` where the existing value is that
3845    // thunk.  Before the fix, merge_nested_insert required BOTH sides to be
3846    // concrete Attrs, so the Thunk-vs-Attrs collision OVERWROTE — dropping
3847    // `x`.  cppnix desugars both orderings into `o.a = { x = 1; y = 2; }`.
3848    // This is the M2.6 post-`with`-fix frontier (nixpkgs alsa's
3849    // `options.hardware.alsa = { … }` + `options.hardware.alsa.enablePersistence
3850    // = …` merged to only {enablePersistence} → `cardAliases` "does not exist").
3851    #[test]
3852    fn dotted_fullset_leaf_deep_merges_with_deeper_sibling() {
3853        let v = ev(r#"{ o.a = { x = 1; }; o.a.y = 2; }.o.a"#);
3854        if let Value::Attrs(a) = force_value(&v).unwrap() {
3855            assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
3856            assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
3857        } else {
3858            panic!("expected attrs");
3859        }
3860    }
3861
3862    #[test]
3863    fn dotted_fullset_leaf_deep_merge_reverse_order() {
3864        // Deeper sibling FIRST, full-set leaf SECOND — the NEW value is the
3865        // `<thunk {x=1}>`; must still merge (the collision forces it).
3866        let v = ev(r#"{ o.a.y = 2; o.a = { x = 1; }; }.o.a"#);
3867        if let Value::Attrs(a) = force_value(&v).unwrap() {
3868            assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
3869            assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
3870        } else {
3871            panic!("expected attrs");
3872        }
3873    }
3874
3875    #[test]
3876    fn dotted_fullset_leaf_merge_preserves_leaf_laziness() {
3877        // The merge forces the existing/new leaf to WHNF (keys) but MUST
3878        // NOT force the leaf VALUES — a throwing sibling value that is never
3879        // demanded stays lazy.
3880        assert_eq!(ev(r#"{ o.a = { x = throw "X-NEVER"; }; o.a.y = 2; }.o.a.y"#), Value::Int(2));
3881    }
3882
3883    #[test]
3884    fn eval_nested_let() {
3885        assert_eq!(ev("let a = 1; b = let c = 2; in c; in a + b"), Value::Int(3));
3886    }
3887
3888    #[test]
3889    fn eval_lambda() {
3890        assert_eq!(ev("(x: x + 1) 41"), Value::Int(42));
3891    }
3892
3893    #[test]
3894    fn eval_lambda_multi_arg() {
3895        assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
3896    }
3897
3898    #[test]
3899    fn eval_list() {
3900        let v = ev("[1 2 3]");
3901        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]));
3902    }
3903
3904    #[test]
3905    fn eval_list_concat() {
3906        let v = ev("[1 2] ++ [3 4]");
3907        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]));
3908    }
3909
3910    #[test]
3911    fn eval_attrset() {
3912        let v = ev("{ a = 1; b = 2; }");
3913        if let Value::Attrs(attrs) = v {
3914            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
3915            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
3916        } else {
3917            panic!("expected attrset");
3918        }
3919    }
3920
3921    #[test]
3922    fn eval_select() {
3923        assert_eq!(ev("{ a = 42; }.a"), Value::Int(42));
3924    }
3925
3926    #[test]
3927    fn eval_select_or() {
3928        assert_eq!(ev("{ a = 42; }.b or 0"), Value::Int(0));
3929    }
3930
3931    #[test]
3932    fn eval_has_attr() {
3933        assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
3934        assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
3935    }
3936
3937    #[test]
3938    fn eval_update() {
3939        let v = ev("{ a = 1; b = 2; } // { b = 3; c = 4; }");
3940        if let Value::Attrs(attrs) = v {
3941            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
3942            assert_eq!(attrs.get("b"), Some(&Value::Int(3)));
3943            assert_eq!(attrs.get("c"), Some(&Value::Int(4)));
3944        } else {
3945            panic!("expected attrset");
3946        }
3947    }
3948
3949    #[test]
3950    fn eval_with() {
3951        assert_eq!(ev("with { x = 42; }; x"), Value::Int(42));
3952    }
3953
3954    #[test]
3955    fn eval_assert() {
3956        assert_eq!(ev("assert true; 42"), Value::Int(42));
3957        assert!(eval("assert false; 42").is_err());
3958    }
3959
3960    #[test]
3961    fn eval_formals() {
3962        assert_eq!(ev("({ a, b }: a + b) { a = 1; b = 2; }"), Value::Int(3));
3963    }
3964
3965    #[test]
3966    fn eval_formals_default() {
3967        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 1; }"), Value::Int(11));
3968    }
3969
3970    #[test]
3971    fn eval_formals_ellipsis() {
3972        assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; }"), Value::Int(1));
3973    }
3974
3975    #[test]
3976    fn eval_named_formals() {
3977        assert_eq!(ev("(args @ { a }: args.a) { a = 42; }"), Value::Int(42));
3978    }
3979
3980    #[test]
3981    fn eval_rec_attrset() {
3982        assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
3983    }
3984
3985    #[test]
3986    fn eval_negation() {
3987        assert_eq!(ev("-42"), Value::Int(-42));
3988    }
3989
3990    #[test]
3991    fn eval_float_arithmetic() {
3992        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
3993        assert_eq!(ev("1 + 1.5"), Value::Float(2.5));
3994    }
3995
3996    #[test]
3997    fn eval_division_by_zero() {
3998        assert!(eval("1 / 0").is_err());
3999    }
4000
4001    #[test]
4002    fn eval_builtins_available() {
4003        assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
4004        assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
4005    }
4006
4007    #[test]
4008    fn eval_builtins_length() {
4009        assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4010    }
4011
4012    #[test]
4013    fn eval_builtins_head_tail() {
4014        assert_eq!(ev("builtins.head [1 2 3]"), Value::Int(1));
4015        assert_eq!(ev("builtins.length (builtins.tail [1 2 3])"), Value::Int(2));
4016    }
4017
4018    #[test]
4019    fn eval_builtins_add() {
4020        assert_eq!(ev("builtins.add 1 2"), Value::Int(3));
4021    }
4022
4023    #[test]
4024    fn eval_builtins_to_string() {
4025        assert_eq!(ev("builtins.toString 42"), Value::string("42"));
4026    }
4027
4028    #[test]
4029    fn eval_implication() {
4030        assert_eq!(ev("false -> true"), Value::Bool(true));
4031        assert_eq!(ev("true -> false"), Value::Bool(false));
4032        assert_eq!(ev("true -> true"), Value::Bool(true));
4033    }
4034
4035    // ── New tests ────────────────────────────────────────
4036
4037    #[test]
4038    fn eval_error_undefined_variable() {
4039        let result = eval("nonexistent");
4040        assert!(result.is_err());
4041        let msg = format!("{}", result.unwrap_err());
4042        assert!(msg.contains("undefined variable"));
4043    }
4044
4045    #[test]
4046    fn eval_error_type_mismatch_arithmetic() {
4047        let result = eval(r#"1 + "hello""#);
4048        assert!(result.is_err());
4049        let msg = format!("{}", result.unwrap_err());
4050        assert!(msg.contains("cannot add") || msg.contains("type"));
4051    }
4052
4053    #[test]
4054    fn eval_error_unexpected_argument() {
4055        let result = eval("({ a }: a) { a = 1; b = 2; }");
4056        assert!(result.is_err());
4057        let msg = format!("{}", result.unwrap_err());
4058        assert!(msg.contains("unexpected argument"));
4059    }
4060
4061    #[test]
4062    fn eval_error_missing_required_argument() {
4063        let result = eval("({ a, b }: a + b) { a = 1; }");
4064        assert!(result.is_err());
4065        let msg = format!("{}", result.unwrap_err());
4066        assert!(msg.contains("missing argument"));
4067    }
4068
4069    #[test]
4070    fn eval_builtins_attr_names_sorted() {
4071        let v = ev("builtins.attrNames { z = 1; a = 2; m = 3; }");
4072        // BTreeMap keys are already sorted
4073        assert_eq!(
4074            v,
4075            Value::list(vec![
4076                Value::string("a"),
4077                Value::string("m"),
4078                Value::string("z"),
4079            ]),
4080        );
4081    }
4082
4083    #[test]
4084    fn eval_builtins_attr_values() {
4085        let v = ev("builtins.attrValues { a = 1; b = 2; }");
4086        // BTreeMap iteration is sorted by key, so a=1 first, b=2 second
4087        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
4088    }
4089
4090    #[test]
4091    fn eval_builtins_is_null() {
4092        assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
4093        assert_eq!(ev("builtins.isNull 1"), Value::Bool(false));
4094    }
4095
4096    #[test]
4097    fn eval_builtins_is_int() {
4098        assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
4099        assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
4100    }
4101
4102    #[test]
4103    fn eval_builtins_is_bool() {
4104        assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
4105        assert_eq!(ev("builtins.isBool 0"), Value::Bool(false));
4106    }
4107
4108    #[test]
4109    fn eval_builtins_is_string() {
4110        assert_eq!(ev(r#"builtins.isString "hi""#), Value::Bool(true));
4111        assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
4112    }
4113
4114    #[test]
4115    fn eval_builtins_is_list() {
4116        assert_eq!(ev("builtins.isList [1 2]"), Value::Bool(true));
4117        assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
4118    }
4119
4120    #[test]
4121    fn eval_builtins_is_attrs() {
4122        assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
4123        assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
4124    }
4125
4126    #[test]
4127    fn eval_builtins_string_length() {
4128        assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
4129        assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
4130    }
4131
4132    #[test]
4133    fn eval_builtins_to_json_roundtrip() {
4134        // toJSON produces a JSON string; fromJSON parses it back
4135        assert_eq!(
4136            ev(r#"builtins.fromJSON (builtins.toJSON 42)"#),
4137            Value::Int(42),
4138        );
4139        assert_eq!(
4140            ev(r#"builtins.fromJSON (builtins.toJSON [1 2 3])"#),
4141            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4142        );
4143    }
4144
4145    #[test]
4146    fn eval_builtins_from_json() {
4147        assert_eq!(
4148            ev(r#"builtins.fromJSON "{\"a\": 1}""#),
4149            {
4150                let mut attrs = NixAttrs::new();
4151                attrs.insert("a".to_string(), Value::Int(1));
4152                Value::Attrs(Rc::new(attrs))
4153            },
4154        );
4155        assert_eq!(ev(r#"builtins.fromJSON "null""#), Value::Null);
4156        assert_eq!(ev(r#"builtins.fromJSON "true""#), Value::Bool(true));
4157    }
4158
4159    #[test]
4160    fn eval_nested_function_application() {
4161        // (f 1) 2 where f = x: y: x + y
4162        assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4163        // equivalent parenthesized form
4164        assert_eq!(ev("((x: y: x + y) 1) 2"), Value::Int(3));
4165    }
4166
4167    #[test]
4168    fn eval_recursive_let() {
4169        assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4170        assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4171    }
4172
4173    #[test]
4174    fn eval_string_comparison() {
4175        assert_eq!(ev(r#""a" < "b""#), Value::Bool(true));
4176        assert_eq!(ev(r#""b" < "a""#), Value::Bool(false));
4177        assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4178        assert_eq!(ev(r#""abc" != "def""#), Value::Bool(true));
4179    }
4180
4181    #[test]
4182    fn eval_list_in_attrset() {
4183        let v = ev("{ x = [1 2 3]; }.x");
4184        assert_eq!(
4185            v,
4186            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4187        );
4188    }
4189
4190    #[test]
4191    fn eval_nested_attrset_select() {
4192        assert_eq!(ev("{ a = { b = 42; }; }.a.b"), Value::Int(42));
4193    }
4194
4195    #[test]
4196    fn eval_let_shadows_outer() {
4197        assert_eq!(
4198            ev("let x = 1; in let x = 2; in x"),
4199            Value::Int(2),
4200        );
4201    }
4202
4203    #[test]
4204    fn eval_with_provides_scope() {
4205        // `with` scope is available for name resolution
4206        assert_eq!(
4207            ev("with { x = 42; y = 10; }; x + y"),
4208            Value::Int(52),
4209        );
4210    }
4211
4212    #[test]
4213    fn eval_list_equality() {
4214        assert_eq!(ev("[1 2] == [1 2]"), Value::Bool(true));
4215        assert_eq!(ev("[1 2] == [1 3]"), Value::Bool(false));
4216    }
4217
4218    #[test]
4219    fn eval_attrset_equality() {
4220        assert_eq!(ev("{ a = 1; } == { a = 1; }"), Value::Bool(true));
4221        assert_eq!(ev("{ a = 1; } == { a = 2; }"), Value::Bool(false));
4222    }
4223
4224    // ═══════════════════════════════════════════════════════════
4225    // 1. LITERAL TYPES
4226    // ═══════════════════════════════════════════════════════════
4227
4228    #[test]
4229    fn literal_int_large_zero_negative() {
4230        // Large positive integer (within i64 range)
4231        assert_eq!(ev("9223372036854775807"), Value::Int(i64::MAX));
4232        // Zero
4233        assert_eq!(ev("0"), Value::Int(0));
4234        // Negative via unary negate
4235        assert_eq!(ev("-1"), Value::Int(-1));
4236        assert_eq!(ev("-999999"), Value::Int(-999999));
4237    }
4238
4239    #[test]
4240    fn literal_float_small_large() {
4241        assert_eq!(ev("0.001"), Value::Float(0.001));
4242        assert_eq!(ev("999999.999"), Value::Float(999999.999));
4243        // Float with scientific notation via expression (1e6 parsed by rnix)
4244        assert_eq!(ev("1.0e3"), Value::Float(1000.0));
4245        assert_eq!(ev("1.5e2"), Value::Float(150.0));
4246    }
4247
4248    #[test]
4249    fn literal_string_empty_and_escapes() {
4250        assert_eq!(ev(r#""""#), Value::string(""));
4251        // Escape sequences within strings
4252        assert_eq!(ev(r#""hello\nworld""#), Value::string("hello\nworld"));
4253        assert_eq!(ev(r#""tab\there""#), Value::string("tab\there"));
4254    }
4255
4256    #[test]
4257    fn literal_multiline_string() {
4258        // Indented string ('' ... '')
4259        assert_eq!(
4260            ev("''hello''"),
4261            Value::string("hello"),
4262        );
4263        // Multiline indented string strips common indentation
4264        assert_eq!(
4265            ev("''\n  line1\n  line2\n''"),
4266            Value::string("line1\nline2\n"),
4267        );
4268    }
4269
4270    #[test]
4271    fn literal_paths() {
4272        // Relative path
4273        assert_eq!(ev("./foo"), Value::Path(Box::new(SmolStr::from("./foo"))));
4274        // Absolute path
4275        assert_eq!(ev("/nix/store/abc"), Value::Path(Box::new(SmolStr::from("/nix/store/abc"))));
4276        // Home path
4277        assert_eq!(ev("~/myfile"), Value::Path(Box::new(SmolStr::from("~/myfile"))));
4278    }
4279
4280    // ── Interpolated path literals (cid-marquee root, 2026-07-12) ──
4281    //
4282    // CppNix path literals may contain `${e}` antiquotations: `./${x}.nix`,
4283    // `/a/${e}`, `~/${e}`. sui previously flattened the whole path token to
4284    // raw text and dropped the interpolation (`import ./${x}.nix` →
4285    // `No such file or directory`). The `${e}` must be evaluated,
4286    // string-coerced (plain, no copy-to-store), spliced, and the result is
4287    // still a `path` value. Oracles taken from cppnix.
4288
4289    #[test]
4290    fn interp_path_abs_splices_and_types_path() {
4291        // /a/${x}/b with x="foo" → /a/foo/b, type path (nix oracle).
4292        let v = ev(r#"let x = "foo"; in /a/${x}/b"#);
4293        assert_eq!(v, Value::Path(Box::new(SmolStr::from("/a/foo/b"))));
4294    }
4295
4296    #[test]
4297    fn interp_path_abs_multi_and_slash_in_value() {
4298        // Multiple interpolations + a slash inside the spliced value.
4299        assert_eq!(
4300            ev(r#"let a = "x"; b = "y/z"; in /p/${a}/${b}.nix"#),
4301            Value::Path(Box::new(SmolStr::from("/p/x/y/z.nix"))),
4302        );
4303    }
4304
4305    #[test]
4306    fn interp_path_abs_normalizes_double_slash_seam() {
4307        // A path-typed interpolation splices the raw path (no copy-to-store)
4308        // and the `/` seam is normalized: `/bar/` + `/tmp/foo` → /bar/tmp/foo.
4309        assert_eq!(
4310            ev(r#"/bar/${/tmp/foo}"#),
4311            Value::Path(Box::new(SmolStr::from("/bar/tmp/foo"))),
4312        );
4313    }
4314
4315    #[test]
4316    fn interp_path_rel_resolves_against_eval_dir() {
4317        // The spicetify `map (x: ./${x}.nix) [...]` root: a relative
4318        // interpolated path resolves against the defining file's directory,
4319        // exactly like a plain `./foo.nix` literal.
4320        let _g = push_eval_file(std::path::PathBuf::from("/tmp/example/default.nix"));
4321        assert_eq!(
4322            ev(r#"let x = "foo"; in ./${x}.nix"#),
4323            Value::Path(Box::new(SmolStr::from("/tmp/example/foo.nix"))),
4324        );
4325    }
4326
4327    #[test]
4328    fn interp_path_rel_no_eval_dir_keeps_relative_text() {
4329        // With no eval-file context the plain branch keeps the raw relative
4330        // text; the interpolated branch splices then does the same.
4331        assert_eq!(
4332            ev(r#"let x = "foo"; in ./${x}.nix"#),
4333            Value::Path(Box::new(SmolStr::from("./foo.nix"))),
4334        );
4335    }
4336
4337    #[test]
4338    fn interp_path_home_splices_leading_tilde_preserved() {
4339        // Home paths splice their `${e}`; the leading `~` is carried as-is
4340        // (matching sui's plain `~/foo` behavior — `~`-expansion is a
4341        // separate, pre-existing concern, not introduced here).
4342        assert_eq!(
4343            ev(r#"let x = "foo"; in ~/${x}/bar"#),
4344            Value::Path(Box::new(SmolStr::from("~/foo/bar"))),
4345        );
4346    }
4347
4348    #[test]
4349    fn interp_path_non_interpolated_still_raw() {
4350        // A path with no `${…}` must keep the trivial raw-text shortcut
4351        // (byte-for-byte identical to the plain branch).
4352        assert_eq!(ev("/a/b/c"), Value::Path(Box::new(SmolStr::from("/a/b/c"))));
4353        assert_eq!(ev("~/plain"), Value::Path(Box::new(SmolStr::from("~/plain"))));
4354    }
4355
4356    #[test]
4357    fn literal_null_true_false_standalone() {
4358        assert_eq!(ev("null"), Value::Null);
4359        assert_eq!(ev("true"), Value::Bool(true));
4360        assert_eq!(ev("false"), Value::Bool(false));
4361    }
4362
4363    // ═══════════════════════════════════════════════════════════
4364    // 2. OPERATORS — COMPLETE COVERAGE
4365    // ═══════════════════════════════════════════════════════════
4366
4367    #[test]
4368    fn op_arithmetic_int() {
4369        assert_eq!(ev("100 + 200"), Value::Int(300));
4370        assert_eq!(ev("50 - 30"), Value::Int(20));
4371        assert_eq!(ev("7 * 8"), Value::Int(56));
4372        assert_eq!(ev("17 / 3"), Value::Int(5)); // integer division
4373    }
4374
4375    #[test]
4376    fn op_arithmetic_float() {
4377        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4378        assert_eq!(ev("5.0 - 1.5"), Value::Float(3.5));
4379        assert_eq!(ev("2.0 * 3.0"), Value::Float(6.0));
4380        assert_eq!(ev("7.0 / 2.0"), Value::Float(3.5));
4381    }
4382
4383    #[test]
4384    fn op_arithmetic_mixed_int_float() {
4385        // int + float => float
4386        assert_eq!(ev("1 + 2.5"), Value::Float(3.5));
4387        assert_eq!(ev("2.5 + 1"), Value::Float(3.5));
4388        // int * float => float
4389        assert_eq!(ev("2 * 1.5"), Value::Float(3.0));
4390        // float - int => float
4391        assert_eq!(ev("5.5 - 2"), Value::Float(3.5));
4392    }
4393
4394    #[test]
4395    fn op_string_concat() {
4396        assert_eq!(ev(r#""foo" + "bar""#), Value::string("foobar"));
4397        assert_eq!(ev(r#""" + "x""#), Value::string("x"));
4398        assert_eq!(ev(r#""a" + "" + "b""#), Value::string("ab"));
4399    }
4400
4401    #[test]
4402    fn op_path_concat() {
4403        // path + string
4404        assert_eq!(ev(r#"./foo + "/bar""#), Value::Path(Box::new(SmolStr::from("./foo/bar"))));
4405        // path + path (should join with /)
4406        assert_eq!(ev("./a + ./b"), Value::Path(Box::new(SmolStr::from("./a/./b"))));
4407    }
4408
4409    #[test]
4410    fn op_comparison_ints() {
4411        assert_eq!(ev("1 < 2"), Value::Bool(true));
4412        assert_eq!(ev("2 < 1"), Value::Bool(false));
4413        assert_eq!(ev("2 > 1"), Value::Bool(true));
4414        assert_eq!(ev("1 > 2"), Value::Bool(false));
4415        assert_eq!(ev("2 <= 2"), Value::Bool(true));
4416        assert_eq!(ev("3 <= 2"), Value::Bool(false));
4417        assert_eq!(ev("2 >= 2"), Value::Bool(true));
4418        assert_eq!(ev("1 >= 2"), Value::Bool(false));
4419    }
4420
4421    #[test]
4422    fn op_comparison_floats() {
4423        assert_eq!(ev("1.5 < 2.5"), Value::Bool(true));
4424        assert_eq!(ev("2.5 > 1.5"), Value::Bool(true));
4425        assert_eq!(ev("1.5 <= 1.5"), Value::Bool(true));
4426        assert_eq!(ev("1.5 >= 1.5"), Value::Bool(true));
4427    }
4428
4429    #[test]
4430    fn op_comparison_strings() {
4431        assert_eq!(ev(r#""apple" < "banana""#), Value::Bool(true));
4432        assert_eq!(ev(r#""banana" > "apple""#), Value::Bool(true));
4433        assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4434        assert_eq!(ev(r#""abc" != "xyz""#), Value::Bool(true));
4435        assert_eq!(ev(r#""abc" <= "abd""#), Value::Bool(true));
4436        assert_eq!(ev(r#""abc" >= "abb""#), Value::Bool(true));
4437    }
4438
4439    #[test]
4440    fn op_equality_various_types() {
4441        assert_eq!(ev("null == null"), Value::Bool(true));
4442        assert_eq!(ev("true == true"), Value::Bool(true));
4443        assert_eq!(ev("false == false"), Value::Bool(true));
4444        assert_eq!(ev("true == false"), Value::Bool(false));
4445        assert_eq!(ev("1 == 1"), Value::Bool(true));
4446        assert_eq!(ev("1 != 2"), Value::Bool(true));
4447        // Different types are not equal
4448        assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
4449        assert_eq!(ev("null == false"), Value::Bool(false));
4450    }
4451
4452    #[test]
4453    fn op_logic_short_circuit() {
4454        // false && <error> should NOT evaluate the RHS
4455        assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
4456        // true || <error> should NOT evaluate the RHS
4457        assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
4458    }
4459
4460    #[test]
4461    fn op_logic_full() {
4462        assert_eq!(ev("true && true"), Value::Bool(true));
4463        assert_eq!(ev("true && false"), Value::Bool(false));
4464        assert_eq!(ev("false && true"), Value::Bool(false));
4465        assert_eq!(ev("false && false"), Value::Bool(false));
4466        assert_eq!(ev("true || true"), Value::Bool(true));
4467        assert_eq!(ev("true || false"), Value::Bool(true));
4468        assert_eq!(ev("false || true"), Value::Bool(true));
4469        assert_eq!(ev("false || false"), Value::Bool(false));
4470        assert_eq!(ev("!true"), Value::Bool(false));
4471        assert_eq!(ev("!false"), Value::Bool(true));
4472    }
4473
4474    #[test]
4475    fn op_implication_truth_table() {
4476        // false -> anything = true
4477        assert_eq!(ev("false -> false"), Value::Bool(true));
4478        assert_eq!(ev("false -> true"), Value::Bool(true));
4479        // true -> x = x
4480        assert_eq!(ev("true -> true"), Value::Bool(true));
4481        assert_eq!(ev("true -> false"), Value::Bool(false));
4482    }
4483
4484    #[test]
4485    fn op_implication_short_circuit() {
4486        // false -> <error> should NOT evaluate the RHS
4487        assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
4488    }
4489
4490    #[test]
4491    fn op_update_merge() {
4492        let v = ev("{ a = 1; } // { b = 2; }");
4493        if let Value::Attrs(attrs) = v {
4494            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4495            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4496        } else {
4497            panic!("expected attrs");
4498        }
4499    }
4500
4501    #[test]
4502    fn op_update_right_wins() {
4503        assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4504    }
4505
4506    #[test]
4507    fn op_list_concat() {
4508        assert_eq!(
4509            ev("[1 2] ++ [3 4]"),
4510            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]),
4511        );
4512        // Empty list concat
4513        assert_eq!(ev("[] ++ [1]"), Value::list(vec![Value::Int(1)]));
4514        assert_eq!(ev("[1] ++ []"), Value::list(vec![Value::Int(1)]));
4515    }
4516
4517    #[test]
4518    fn op_has_attr_present_and_absent() {
4519        assert_eq!(ev("{ x = 1; y = 2; } ? x"), Value::Bool(true));
4520        assert_eq!(ev("{ x = 1; } ? z"), Value::Bool(false));
4521        assert_eq!(ev("{} ? anything"), Value::Bool(false));
4522    }
4523
4524    #[test]
4525    fn op_unary_negate() {
4526        assert_eq!(ev("-42"), Value::Int(-42));
4527        assert_eq!(ev("-3.14"), Value::Float(-3.14));
4528        // Double negate
4529        assert_eq!(ev("- -5"), Value::Int(5));
4530    }
4531
4532    // ═══════════════════════════════════════════════════════════
4533    // 3. CONTROL FLOW
4534    // ═══════════════════════════════════════════════════════════
4535
4536    #[test]
4537    fn control_if_true_branch() {
4538        assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
4539    }
4540
4541    #[test]
4542    fn control_if_false_branch() {
4543        assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
4544    }
4545
4546    #[test]
4547    fn control_if_nested() {
4548        assert_eq!(
4549            ev("if true then (if false then 1 else 2) else 3"),
4550            Value::Int(2),
4551        );
4552        assert_eq!(
4553            ev("if false then 1 else (if true then 2 else 3)"),
4554            Value::Int(2),
4555        );
4556    }
4557
4558    #[test]
4559    fn control_assert_passing() {
4560        assert_eq!(ev("assert 1 == 1; 42"), Value::Int(42));
4561        assert_eq!(ev("assert true; true"), Value::Bool(true));
4562    }
4563
4564    #[test]
4565    fn control_assert_failing() {
4566        assert!(eval("assert false; 42").is_err());
4567        assert!(eval("assert 1 == 2; 42").is_err());
4568    }
4569
4570    #[test]
4571    fn control_with_basic_scope() {
4572        assert_eq!(ev("with { a = 1; b = 2; }; a + b"), Value::Int(3));
4573    }
4574
4575    #[test]
4576    fn control_with_lexical_precedence() {
4577        // let binding takes precedence over with scope
4578        assert_eq!(
4579            ev("let x = 10; in with { x = 99; }; x"),
4580            Value::Int(10),
4581        );
4582    }
4583
4584    #[test]
4585    fn control_with_nested() {
4586        assert_eq!(
4587            ev("with { a = 1; }; with { b = 2; }; a + b"),
4588            Value::Int(3),
4589        );
4590    }
4591
4592    #[test]
4593    fn control_with_lazy_fix_self() {
4594        // THE critical pattern that nixpkgs requires:
4595        // fix (self: with self; { a = 1; b = a + 1; })
4596        // Before the lazy-with fix, this would hit the blackhole detector
4597        // because `with` eagerly forced `self`.
4598        let result = eval(
4599            "let fix = f: let x = f x; in x; in fix (self: with self; { a = 1; b = a + 1; })"
4600        );
4601        assert!(result.is_ok(), "fix with self should work: {:?}", result);
4602        if let Ok(Value::Attrs(attrs)) = result {
4603            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4604            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4605        } else {
4606            panic!("expected Attrs, got {:?}", result);
4607        }
4608    }
4609
4610    #[test]
4611    fn control_with_lazy_fix_self_lib_pattern() {
4612        // The nixpkgs pattern: self-referential package set with lib.
4613        // Access via select to force through the thunk layer.
4614        let result = eval(r#"
4615            let fix = f: let x = f x; in x;
4616            in (fix (self: with self; {
4617                lib = { version = "1.0"; };
4618                hello = "hello ${lib.version}";
4619            })).hello
4620        "#);
4621        assert!(result.is_ok(), "nixpkgs-style lib pattern: {:?}", result);
4622        assert_eq!(
4623            result.unwrap(),
4624            Value::String(Rc::new(NixString::plain("hello 1.0"))),
4625        );
4626    }
4627
4628    #[test]
4629    fn control_with_non_attrset_errors() {
4630        // CppNix errors when with-scope is not an attrset and a lookup hits it
4631        let result = eval("with 42; 1");
4632        // The body `1` is a literal and doesn't look up anything in the
4633        // with-scope, so this should succeed (the scope is never forced).
4634        assert_eq!(result.unwrap(), Value::Int(1));
4635    }
4636
4637    #[test]
4638    fn control_with_non_attrset_lookup_falls_through() {
4639        // If the with scope is not an attrset, lookups should fall through
4640        // to outer scopes rather than crashing.
4641        let result = eval("let x = 1; in with 42; x");
4642        assert_eq!(result.unwrap(), Value::Int(1));
4643    }
4644
4645    #[test]
4646    fn control_let_simple_and_multiple() {
4647        assert_eq!(ev("let x = 5; in x"), Value::Int(5));
4648        assert_eq!(ev("let x = 1; y = 2; z = 3; in x + y + z"), Value::Int(6));
4649    }
4650
4651    #[test]
4652    fn control_let_shadow_outer() {
4653        assert_eq!(
4654            ev("let x = 1; in let x = 2; in x"),
4655            Value::Int(2),
4656        );
4657    }
4658
4659    #[test]
4660    fn control_let_recursive_reference() {
4661        assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4662        assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4663    }
4664
4665    #[test]
4666    fn control_nested_let_expression() {
4667        assert_eq!(
4668            ev("let a = let b = 1; in b; in a"),
4669            Value::Int(1),
4670        );
4671        assert_eq!(
4672            ev("let a = let b = 10; in b + 5; in a * 2"),
4673            Value::Int(30),
4674        );
4675    }
4676
4677    // ═══════════════════════════════════════════════════════════
4678    // 4. FUNCTIONS — COMPLETE COVERAGE
4679    // ═══════════════════════════════════════════════════════════
4680
4681    #[test]
4682    fn func_identity_lambda() {
4683        assert_eq!(ev("(x: x) 42"), Value::Int(42));
4684        assert_eq!(ev(r#"(x: x) "hello""#), Value::string("hello"));
4685    }
4686
4687    #[test]
4688    fn func_curried_two_args() {
4689        assert_eq!(ev("(x: y: x + y) 3 4"), Value::Int(7));
4690    }
4691
4692    #[test]
4693    fn func_curried_three_args() {
4694        assert_eq!(ev("(a: b: c: a + b + c) 1 2 3"), Value::Int(6));
4695    }
4696
4697    #[test]
4698    fn func_formals_basic() {
4699        assert_eq!(ev("({ a, b }: a + b) { a = 3; b = 7; }"), Value::Int(10));
4700    }
4701
4702    #[test]
4703    fn func_formals_with_defaults() {
4704        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; }"), Value::Int(15));
4705        // Providing the default-able argument overrides the default
4706        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; b = 20; }"), Value::Int(25));
4707    }
4708
4709    #[test]
4710    fn func_formals_with_ellipsis() {
4711        assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; c = 3; }"), Value::Int(1));
4712    }
4713
4714    #[test]
4715    fn func_named_formals_at_before() {
4716        // args @ { a, b }: ...
4717        assert_eq!(
4718            ev("(args @ { a, b }: args.a + args.b) { a = 3; b = 4; }"),
4719            Value::Int(7),
4720        );
4721    }
4722
4723    #[test]
4724    fn func_named_formals_at_after() {
4725        // { a, b } @ args: ...
4726        assert_eq!(
4727            ev("({ a, b } @ args: args.a + args.b) { a = 10; b = 20; }"),
4728            Value::Int(30),
4729        );
4730    }
4731
4732    #[test]
4733    fn func_nested_application() {
4734        // Explicit parenthesized application
4735        assert_eq!(ev("((x: y: x * y) 3) 4"), Value::Int(12));
4736    }
4737
4738    #[test]
4739    fn func_higher_order_map() {
4740        assert_eq!(
4741            ev("builtins.map (x: x * 2) [1 2 3]"),
4742            Value::list(vec![Value::Int(2), Value::Int(4), Value::Int(6)]),
4743        );
4744    }
4745
4746    #[test]
4747    fn func_higher_order_filter() {
4748        assert_eq!(
4749            ev("builtins.filter (x: x > 2) [1 2 3 4 5]"),
4750            Value::list(vec![Value::Int(3), Value::Int(4), Value::Int(5)]),
4751        );
4752    }
4753
4754    #[test]
4755    fn func_higher_order_foldl() {
4756        // Sum of list via foldl'
4757        assert_eq!(
4758            ev("builtins.foldl' (acc: x: acc + x) 0 [1 2 3 4]"),
4759            Value::Int(10),
4760        );
4761    }
4762
4763    #[test]
4764    fn func_as_attrset_value() {
4765        assert_eq!(
4766            ev("let s = { f = x: x + 1; }; in s.f 5"),
4767            Value::Int(6),
4768        );
4769    }
4770
4771    #[test]
4772    fn func_immediate_application() {
4773        assert_eq!(ev("(x: x * x) 7"), Value::Int(49));
4774    }
4775
4776    #[test]
4777    fn func_in_let_binding() {
4778        assert_eq!(
4779            ev("let double = x: x * 2; in double 21"),
4780            Value::Int(42),
4781        );
4782    }
4783
4784    // ═══════════════════════════════════════════════════════════
4785    // 5. ATTRIBUTE SETS — COMPLETE COVERAGE
4786    // ═══════════════════════════════════════════════════════════
4787
4788    #[test]
4789    fn attrs_empty_set() {
4790        let v = ev("{}");
4791        if let Value::Attrs(attrs) = v {
4792            assert!(attrs.is_empty());
4793        } else {
4794            panic!("expected attrs");
4795        }
4796    }
4797
4798    #[test]
4799    fn attrs_simple() {
4800        assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
4801    }
4802
4803    #[test]
4804    fn attrs_nested_access() {
4805        assert_eq!(ev("{ a = { b = { c = 42; }; }; }.a.b.c"), Value::Int(42));
4806    }
4807
4808    #[test]
4809    fn attrs_recursive_set() {
4810        assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
4811    }
4812
4813    #[test]
4814    fn attrs_update_disjoint() {
4815        let v = ev("{ a = 1; } // { b = 2; }");
4816        if let Value::Attrs(attrs) = v {
4817            assert_eq!(attrs.len(), 2);
4818            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4819            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4820        } else {
4821            panic!("expected attrs");
4822        }
4823    }
4824
4825    #[test]
4826    fn attrs_update_override() {
4827        assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4828    }
4829
4830    #[test]
4831    fn attrs_has_attr_operator() {
4832        assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
4833        assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
4834    }
4835
4836    #[test]
4837    fn attrs_select_with_default() {
4838        assert_eq!(ev("{ a = 1; }.a or 99"), Value::Int(1));
4839        assert_eq!(ev("{}.missing or 99"), Value::Int(99));
4840        assert_eq!(ev("{ a = 1; }.b or 42"), Value::Int(42));
4841    }
4842
4843    #[test]
4844    fn attrs_nested_attr_path_in_binding() {
4845        // { a.b = 1; } creates { a = { b = 1; }; }
4846        assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
4847    }
4848
4849    #[test]
4850    fn attrs_inherit_from_scope() {
4851        assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.x"), Value::Int(1));
4852        assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.y"), Value::Int(2));
4853    }
4854
4855    #[test]
4856    fn attrs_inherit_from_expr() {
4857        assert_eq!(
4858            ev("{ inherit ({ a = 42; b = 10; }) a; }.a"),
4859            Value::Int(42),
4860        );
4861    }
4862
4863    #[test]
4864    fn attrs_dynamic_attr_name() {
4865        assert_eq!(
4866            ev(r#"let name = "x"; in { ${name} = 42; }.x"#),
4867            Value::Int(42),
4868        );
4869    }
4870
4871    #[test]
4872    fn attrs_attr_names_sorted() {
4873        assert_eq!(
4874            ev("builtins.attrNames { z = 1; m = 2; a = 3; }"),
4875            Value::list(vec![
4876                Value::string("a"),
4877                Value::string("m"),
4878                Value::string("z"),
4879            ]),
4880        );
4881    }
4882
4883    #[test]
4884    fn attrs_attr_values_follow_key_order() {
4885        // BTreeMap iteration order: a=1, b=2, c=3
4886        assert_eq!(
4887            ev("builtins.attrValues { c = 3; a = 1; b = 2; }"),
4888            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4889        );
4890    }
4891
4892    #[test]
4893    fn attrs_update_is_shallow() {
4894        // // is a shallow merge; nested attrs are replaced, not merged
4895        assert_eq!(
4896            ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a ? x"),
4897            Value::Bool(false),
4898        );
4899        assert_eq!(
4900            ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a.y"),
4901            Value::Int(2),
4902        );
4903    }
4904
4905    // ═══════════════════════════════════════════════════════════
4906    // 6. LISTS — COMPLETE COVERAGE
4907    // ═══════════════════════════════════════════════════════════
4908
4909    #[test]
4910    fn list_empty() {
4911        assert_eq!(ev("[]"), Value::list(vec![]));
4912    }
4913
4914    #[test]
4915    fn list_single_element() {
4916        assert_eq!(ev("[1]"), Value::list(vec![Value::Int(1)]));
4917    }
4918
4919    #[test]
4920    fn list_mixed_types() {
4921        assert_eq!(
4922            ev(r#"[1 "two" true null]"#),
4923            Value::list(vec![
4924                Value::Int(1),
4925                Value::string("two"),
4926                Value::Bool(true),
4927                Value::Null,
4928            ]),
4929        );
4930    }
4931
4932    #[test]
4933    fn list_nested() {
4934        assert_eq!(
4935            ev("[[1 2] [3 4]]"),
4936            Value::list(vec![
4937                Value::list(vec![Value::Int(1), Value::Int(2)]),
4938                Value::list(vec![Value::Int(3), Value::Int(4)]),
4939            ]),
4940        );
4941    }
4942
4943    #[test]
4944    fn list_concat_operator() {
4945        assert_eq!(
4946            ev("[1] ++ [2] ++ [3]"),
4947            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4948        );
4949    }
4950
4951    #[test]
4952    fn list_builtins_length() {
4953        assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4954        assert_eq!(ev("builtins.length []"), Value::Int(0));
4955    }
4956
4957    #[test]
4958    fn list_builtins_elem_at() {
4959        assert_eq!(ev("builtins.elemAt [10 20 30] 0"), Value::Int(10));
4960        assert_eq!(ev("builtins.elemAt [10 20 30] 1"), Value::Int(20));
4961        assert_eq!(ev("builtins.elemAt [10 20 30] 2"), Value::Int(30));
4962    }
4963
4964    #[test]
4965    fn list_equality() {
4966        assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
4967        assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
4968        assert_eq!(ev("[] == []"), Value::Bool(true));
4969    }
4970
4971    // ═══════════════════════════════════════════════════════════
4972    // 7. STRING INTERPOLATION
4973    // ═══════════════════════════════════════════════════════════
4974
4975    #[test]
4976    fn interp_simple_variable() {
4977        assert_eq!(
4978            ev(r#"let name = "world"; in "hello ${name}""#),
4979            Value::string("hello world"),
4980        );
4981    }
4982
4983    #[test]
4984    fn interp_nested_expression() {
4985        assert_eq!(
4986            ev(r#""result: ${builtins.toString (1 + 2)}""#),
4987            Value::string("result: 3"),
4988        );
4989    }
4990
4991    #[test]
4992    fn interp_int_coercion() {
4993        // Ints are coerced to string in interpolation
4994        assert_eq!(
4995            ev(r#"let x = 42; in "count: ${builtins.toString x}""#),
4996            Value::string("count: 42"),
4997        );
4998    }
4999
5000    #[test]
5001    fn interp_multiple() {
5002        assert_eq!(
5003            ev(r#"let a = "foo"; b = "bar"; in "${a} and ${b}""#),
5004            Value::string("foo and bar"),
5005        );
5006    }
5007
5008    #[test]
5009    fn interp_in_let() {
5010        assert_eq!(
5011            ev(r#"let x = "world"; in "hello ${x}""#),
5012            Value::string("hello world"),
5013        );
5014    }
5015
5016    #[test]
5017    fn interp_empty_result() {
5018        assert_eq!(
5019            ev(r#"let x = ""; in "a${x}b""#),
5020            Value::string("ab"),
5021        );
5022    }
5023
5024    #[test]
5025    fn interp_path_in_string_context() {
5026        // CppNix string interpolation is copy-to-store coercion: a nonexistent
5027        // path errors "path '…' does not exist" (previously sui spliced the raw
5028        // relative path "./foo" verbatim, diverging from nix). The positive
5029        // copy-to-store case is byte-verified in
5030        // interp_path_copies_to_store_byte_matches_cppnix below.
5031        assert!(eval(r#""path: ${./foo-nonexistent-xyz}""#).is_err());
5032    }
5033
5034    #[test]
5035    fn interp_adjacent_interpolations() {
5036        assert_eq!(
5037            ev(r#"let a = "x"; b = "y"; in "${a}${b}""#),
5038            Value::string("xy"),
5039        );
5040    }
5041
5042    // ═══════════════════════════════════════════════════════════
5043    // 8. BUILTINS — VERIFY ALL MAJOR ONES
5044    // ═══════════════════════════════════════════════════════════
5045
5046    #[test]
5047    fn builtins_map_filter_foldl() {
5048        // map
5049        assert_eq!(
5050            ev("builtins.map (x: x + 10) [1 2 3]"),
5051            Value::list(vec![Value::Int(11), Value::Int(12), Value::Int(13)]),
5052        );
5053        // filter
5054        assert_eq!(
5055            ev("builtins.filter (x: x > 1) [1 2 3]"),
5056            Value::list(vec![Value::Int(2), Value::Int(3)]),
5057        );
5058        // foldl' — product
5059        assert_eq!(
5060            ev("builtins.foldl' (a: b: a * b) 1 [2 3 4]"),
5061            Value::Int(24),
5062        );
5063    }
5064
5065    #[test]
5066    fn builtins_map_attrs() {
5067        assert_eq!(
5068            ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).a"),
5069            Value::Int(2),
5070        );
5071        assert_eq!(
5072            ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).b"),
5073            Value::Int(4),
5074        );
5075    }
5076
5077    #[test]
5078    fn builtins_list_to_attrs() {
5079        assert_eq!(
5080            ev(r#"(builtins.listToAttrs [{ name = "x"; value = 1; } { name = "y"; value = 2; }]).x"#),
5081            Value::Int(1),
5082        );
5083    }
5084
5085    #[test]
5086    fn builtins_list_to_attrs_duplicate_key_first_wins() {
5087        // Nix `listToAttrs` keeps the FIRST occurrence of a duplicate `name`
5088        // (later duplicates are ignored). cppnix returns 1 here, not 2.
5089        // Byte-parity root (cid darwin): a Cargo.lock listing a crate twice
5090        // (registry entry then git entry of the same name+version) must
5091        // resolve to the FIRST source, so `substrate/lockfile-delta.nix`'s
5092        // `lockByKey` picks the registry crate exactly as nix does. Last-wins
5093        // silently switched the source to git and produced a structurally
5094        // different `rust_<crate>` derivation.
5095        assert_eq!(
5096            ev(r#"(builtins.listToAttrs [{ name = "k"; value = 1; } { name = "k"; value = 2; }]).k"#),
5097            Value::Int(1),
5098        );
5099    }
5100
5101    #[test]
5102    fn builtins_concat_map() {
5103        assert_eq!(
5104            ev("builtins.concatMap (x: [x (x * 2)]) [1 2 3]"),
5105            Value::list(vec![
5106                Value::Int(1), Value::Int(2),
5107                Value::Int(2), Value::Int(4),
5108                Value::Int(3), Value::Int(6),
5109            ]),
5110        );
5111    }
5112
5113    #[test]
5114    fn builtins_concat_lists() {
5115        assert_eq!(
5116            ev("builtins.concatLists [[1 2] [3] [4 5]]"),
5117            Value::list(vec![
5118                Value::Int(1), Value::Int(2), Value::Int(3),
5119                Value::Int(4), Value::Int(5),
5120            ]),
5121        );
5122    }
5123
5124    #[test]
5125    fn builtins_concat_strings_sep() {
5126        assert_eq!(
5127            ev(r#"builtins.concatStringsSep ", " ["a" "b" "c"]"#),
5128            Value::string("a, b, c"),
5129        );
5130        assert_eq!(
5131            ev(r#"builtins.concatStringsSep "" ["x" "y"]"#),
5132            Value::string("xy"),
5133        );
5134    }
5135
5136    #[test]
5137    fn builtins_replace_strings() {
5138        assert_eq!(
5139            ev(r#"builtins.replaceStrings ["o"] ["0"] "foobar""#),
5140            Value::string("f00bar"),
5141        );
5142        assert_eq!(
5143            ev(r#"builtins.replaceStrings ["hello"] ["goodbye"] "hello world""#),
5144            Value::string("goodbye world"),
5145        );
5146    }
5147
5148    #[test]
5149    fn builtins_has_prefix_has_suffix() {
5150        assert_eq!(ev(r#"builtins.hasPrefix "he" "hello""#), Value::Bool(true));
5151        assert_eq!(ev(r#"builtins.hasPrefix "xx" "hello""#), Value::Bool(false));
5152        assert_eq!(ev(r#"builtins.hasSuffix "lo" "hello""#), Value::Bool(true));
5153        assert_eq!(ev(r#"builtins.hasSuffix "xx" "hello""#), Value::Bool(false));
5154    }
5155
5156    #[test]
5157    fn builtins_all_any() {
5158        assert_eq!(ev("builtins.all (x: x > 0) [1 2 3]"), Value::Bool(true));
5159        assert_eq!(ev("builtins.all (x: x > 1) [1 2 3]"), Value::Bool(false));
5160        assert_eq!(ev("builtins.any (x: x > 2) [1 2 3]"), Value::Bool(true));
5161        assert_eq!(ev("builtins.any (x: x > 5) [1 2 3]"), Value::Bool(false));
5162    }
5163
5164    #[test]
5165    fn builtins_sort() {
5166        assert_eq!(
5167            ev("builtins.sort (a: b: a < b) [3 1 2]"),
5168            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5169        );
5170    }
5171
5172    #[test]
5173    fn builtins_remove_attrs() {
5174        let v = ev(r#"builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b" "c"]"#);
5175        if let Value::Attrs(attrs) = v {
5176            assert_eq!(attrs.len(), 1);
5177            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5178            assert!(attrs.get("b").is_none());
5179        } else {
5180            panic!("expected attrs");
5181        }
5182    }
5183
5184    #[test]
5185    fn builtins_intersect_attrs() {
5186        let v = ev("builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }");
5187        if let Value::Attrs(attrs) = v {
5188            assert_eq!(attrs.len(), 1);
5189            // intersectAttrs returns values from the second set
5190            assert_eq!(attrs.get("b"), Some(&Value::Int(20)));
5191        } else {
5192            panic!("expected attrs");
5193        }
5194    }
5195
5196    #[test]
5197    fn builtins_type_of_all_types() {
5198        assert_eq!(ev("builtins.typeOf null"), Value::string("null"));
5199        assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
5200        assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
5201        assert_eq!(ev("builtins.typeOf 3.14"), Value::string("float"));
5202        assert_eq!(ev(r#"builtins.typeOf "hi""#), Value::string("string"));
5203        assert_eq!(ev("builtins.typeOf [1]"), Value::string("list"));
5204        assert_eq!(ev("builtins.typeOf {}"), Value::string("set"));
5205        assert_eq!(ev("builtins.typeOf (x: x)"), Value::string("lambda"));
5206    }
5207
5208    #[test]
5209    fn builtins_is_type_checks() {
5210        assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
5211        assert_eq!(ev("builtins.isNull 0"), Value::Bool(false));
5212        assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
5213        assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
5214        assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
5215        assert_eq!(ev("builtins.isBool 1"), Value::Bool(false));
5216        assert_eq!(ev(r#"builtins.isString "x""#), Value::Bool(true));
5217        assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
5218        assert_eq!(ev("builtins.isList []"), Value::Bool(true));
5219        assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
5220        assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
5221        assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
5222        assert_eq!(ev("builtins.isFunction (x: x)"), Value::Bool(true));
5223        assert_eq!(ev("builtins.isFunction 1"), Value::Bool(false));
5224        assert_eq!(ev("builtins.isFloat 3.14"), Value::Bool(true));
5225        assert_eq!(ev("builtins.isFloat 1"), Value::Bool(false));
5226    }
5227
5228    #[test]
5229    fn builtins_to_json_from_json_roundtrip() {
5230        // int roundtrip
5231        assert_eq!(ev("builtins.fromJSON (builtins.toJSON 42)"), Value::Int(42));
5232        // string roundtrip
5233        assert_eq!(
5234            ev(r#"builtins.fromJSON (builtins.toJSON "hello")"#),
5235            Value::string("hello"),
5236        );
5237        // list roundtrip
5238        assert_eq!(
5239            ev("builtins.fromJSON (builtins.toJSON [1 2 3])"),
5240            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5241        );
5242        // null roundtrip
5243        assert_eq!(ev("builtins.fromJSON (builtins.toJSON null)"), Value::Null);
5244        // bool roundtrip
5245        assert_eq!(ev("builtins.fromJSON (builtins.toJSON true)"), Value::Bool(true));
5246    }
5247
5248    #[test]
5249    fn builtins_to_string_various() {
5250        assert_eq!(ev("builtins.toString 42"), Value::string("42"));
5251        assert_eq!(ev("builtins.toString true"), Value::string("1"));
5252        assert_eq!(ev("builtins.toString false"), Value::string(""));
5253        assert_eq!(ev("builtins.toString null"), Value::string(""));
5254        assert_eq!(ev(r#"builtins.toString "hello""#), Value::string("hello"));
5255    }
5256
5257    #[test]
5258    fn builtins_function_args() {
5259        let v = ev("builtins.functionArgs ({ a, b ? 1 }: a)");
5260        if let Value::Attrs(attrs) = v {
5261            assert_eq!(attrs.get("a"), Some(&Value::Bool(false))); // no default
5262            assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));  // has default
5263        } else {
5264            panic!("expected attrs");
5265        }
5266    }
5267
5268    #[test]
5269    fn builtins_gen_list() {
5270        assert_eq!(
5271            ev("builtins.genList (x: x * x) 5"),
5272            Value::list(vec![
5273                Value::Int(0), Value::Int(1), Value::Int(4),
5274                Value::Int(9), Value::Int(16),
5275            ]),
5276        );
5277        assert_eq!(ev("builtins.genList (x: x) 0"), Value::list(vec![]));
5278    }
5279
5280    #[test]
5281    fn builtins_elem() {
5282        assert_eq!(ev("builtins.elem 2 [1 2 3]"), Value::Bool(true));
5283        assert_eq!(ev("builtins.elem 5 [1 2 3]"), Value::Bool(false));
5284        assert_eq!(ev("builtins.elem 1 []"), Value::Bool(false));
5285    }
5286
5287    #[test]
5288    fn builtins_head_tail() {
5289        assert_eq!(ev("builtins.head [10 20 30]"), Value::Int(10));
5290        assert_eq!(
5291            ev("builtins.tail [10 20 30]"),
5292            Value::list(vec![Value::Int(20), Value::Int(30)]),
5293        );
5294    }
5295
5296    #[test]
5297    fn builtins_string_length() {
5298        assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
5299        assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
5300        assert_eq!(ev(r#"builtins.stringLength "abc def""#), Value::Int(7));
5301    }
5302
5303    #[test]
5304    fn builtins_ceil_floor() {
5305        assert_eq!(ev("builtins.ceil 2.3"), Value::Int(3));
5306        assert_eq!(ev("builtins.ceil 2.0"), Value::Int(2));
5307        assert_eq!(ev("builtins.floor 2.9"), Value::Int(2));
5308        assert_eq!(ev("builtins.floor 2.0"), Value::Int(2));
5309        // Int coercion: ceil/floor on int should work via to_float()
5310        assert_eq!(ev("builtins.ceil 5"), Value::Int(5));
5311        assert_eq!(ev("builtins.floor 5"), Value::Int(5));
5312    }
5313
5314    #[test]
5315    fn builtins_try_eval() {
5316        let v = ev("builtins.tryEval 42");
5317        if let Value::Attrs(attrs) = v {
5318            assert_eq!(attrs.get("success"), Some(&Value::Bool(true)));
5319            assert_eq!(attrs.get("value"), Some(&Value::Int(42)));
5320        } else {
5321            panic!("expected attrs");
5322        }
5323    }
5324
5325    #[test]
5326    fn builtins_throw() {
5327        let result = eval(r#"builtins.throw "oops""#);
5328        assert!(result.is_err());
5329        let msg = format!("{}", result.unwrap_err());
5330        assert!(msg.contains("oops"));
5331    }
5332
5333    #[test]
5334    fn builtins_seq_deep_seq() {
5335        // seq forces first arg, returns second
5336        assert_eq!(ev("builtins.seq 1 42"), Value::Int(42));
5337        // deepSeq similarly
5338        assert_eq!(ev("builtins.deepSeq [1 2 3] 99"), Value::Int(99));
5339    }
5340
5341    #[test]
5342    fn builtins_current_system() {
5343        let v = ev("builtins.currentSystem");
5344        if let Value::String(ns) = v {
5345            let s = &ns.chars;
5346            // Should be a valid system string
5347            assert!(
5348                s == "aarch64-darwin"
5349                    || s == "x86_64-darwin"
5350                    || s == "aarch64-linux"
5351                    || s == "x86_64-linux",
5352                "unexpected system: {s}",
5353            );
5354        } else {
5355            panic!("expected string");
5356        }
5357    }
5358
5359    // ═══════════════════════════════════════════════════════════
5360    // 9. REAL-WORLD NIXPKGS PATTERNS
5361    // ═══════════════════════════════════════════════════════════
5362
5363    #[test]
5364    fn pattern_mkif_like() {
5365        // lib.mkIf pattern: if condition then { key = value; } else {}
5366        assert_eq!(
5367            ev("(if true then { x = 1; } else {}).x"),
5368            Value::Int(1),
5369        );
5370        let v = ev("if false then { x = 1; } else {}");
5371        if let Value::Attrs(attrs) = v {
5372            assert!(attrs.is_empty());
5373        } else {
5374            panic!("expected attrs");
5375        }
5376    }
5377
5378    #[test]
5379    fn pattern_optional_attrs() {
5380        // lib.optionalAttrs pattern
5381        assert_eq!(
5382            ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in (optionalAttrs true { a = 1; }).a"),
5383            Value::Int(1),
5384        );
5385        let v = ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in optionalAttrs false { a = 1; }");
5386        if let Value::Attrs(attrs) = v {
5387            assert!(attrs.is_empty());
5388        } else {
5389            panic!("expected attrs");
5390        }
5391    }
5392
5393    #[test]
5394    fn pattern_filter_attrs_via_remove() {
5395        // lib.filterAttrs pattern via removeAttrs
5396        assert_eq!(
5397            ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]).a"#),
5398            Value::Int(1),
5399        );
5400        assert_eq!(
5401            ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]) ? b"#),
5402            Value::Bool(false),
5403        );
5404    }
5405
5406    #[test]
5407    fn pattern_override() {
5408        // default // overrides pattern
5409        let v = ev(r#"
5410            let
5411                defaults = { debug = false; port = 8080; host = "localhost"; };
5412                overrides = { debug = true; port = 9090; };
5413            in defaults // overrides
5414        "#);
5415        if let Value::Attrs(attrs) = v {
5416            assert_eq!(attrs.get("debug"), Some(&Value::Bool(true)));
5417            assert_eq!(attrs.get("port"), Some(&Value::Int(9090)));
5418            assert_eq!(attrs.get("host"), Some(&Value::string("localhost")));
5419        } else {
5420            panic!("expected attrs");
5421        }
5422    }
5423
5424    #[test]
5425    fn pattern_functor() {
5426        // { __functor = self: x: self.value + x; value = 10; } 5
5427        assert_eq!(
5428            ev("let s = { __functor = self: x: self.value + x; value = 10; }; in s 5"),
5429            Value::Int(15),
5430        );
5431    }
5432
5433    #[test]
5434    fn pattern_platform_check() {
5435        // Check pattern: if builtins.currentSystem == "..." then ... else ...
5436        let v = ev(r#"if builtins.currentSystem == "aarch64-darwin" then "arm" else "other""#);
5437        // We just verify it evaluates without error and produces a string
5438        if let Value::String(_) = v {
5439            // ok
5440        } else {
5441            panic!("expected string");
5442        }
5443    }
5444
5445    #[test]
5446    fn pattern_recursive_overlay_lambda_structure() {
5447        // Test the lambda structure of an overlay (self: super: { ... })
5448        let v = ev("let overlay = self: super: { pkg = 42; }; in overlay {} {}");
5449        if let Value::Attrs(attrs) = v {
5450            assert_eq!(attrs.get("pkg"), Some(&Value::Int(42)));
5451        } else {
5452            panic!("expected attrs");
5453        }
5454    }
5455
5456    #[test]
5457    fn pattern_call_package_simplified() {
5458        // Simplified callPackage: f: f { inherit lib; }
5459        assert_eq!(
5460            ev("let callPkg = f: f { lib = { id = x: x; }; }; lib = { id = x: x; }; in callPkg ({ lib }: lib.id 42)"),
5461            Value::Int(42),
5462        );
5463    }
5464
5465    #[test]
5466    fn pattern_derivation_like_attrset() {
5467        let v = ev(r#"{ type = "derivation"; name = "hello"; system = builtins.currentSystem; builder = "/bin/sh"; }"#);
5468        if let Value::Attrs(attrs) = v {
5469            assert_eq!(attrs.get("type"), Some(&Value::string("derivation")));
5470            assert_eq!(attrs.get("name"), Some(&Value::string("hello")));
5471            assert_eq!(attrs.get("builder"), Some(&Value::string("/bin/sh")));
5472            // system should be a string (may be a thunk that forces to string)
5473            let system = force_value(attrs.get("system").unwrap()).unwrap();
5474            assert!(matches!(system, Value::String(_)), "expected string, got {system:?}");
5475        } else {
5476            panic!("expected attrs");
5477        }
5478    }
5479
5480    #[test]
5481    fn pattern_module_system_simplified() {
5482        // Simplified NixOS module evaluation
5483        assert_eq!(
5484            ev(r#"
5485                let
5486                    eval = m: m { config = {}; lib = { mkDefault = x: x; }; };
5487                in eval ({ config, lib }: { result = lib.mkDefault 42; })
5488            "#),
5489            {
5490                let mut attrs = NixAttrs::new();
5491                attrs.insert("result".to_string(), Value::Int(42));
5492                Value::Attrs(Rc::new(attrs))
5493            },
5494        );
5495    }
5496
5497    // ═══════════════════════════════════════════════════════════
5498    // 10. ERROR HANDLING
5499    // ═══════════════════════════════════════════════════════════
5500
5501    #[test]
5502    fn error_undefined_variable() {
5503        let result = eval("nonexistent_var");
5504        assert!(result.is_err());
5505        let msg = format!("{}", result.unwrap_err());
5506        assert!(msg.contains("undefined variable") || msg.contains("nonexistent_var"));
5507    }
5508
5509    #[test]
5510    fn error_type_mismatch_arithmetic() {
5511        let result = eval(r#"1 + "hello""#);
5512        assert!(result.is_err());
5513    }
5514
5515    #[test]
5516    fn error_missing_attribute() {
5517        let result = eval("{}.nonexistent");
5518        assert!(result.is_err());
5519        let msg = format!("{}", result.unwrap_err());
5520        assert!(msg.contains("nonexistent") || msg.contains("not found"));
5521    }
5522
5523    #[test]
5524    fn error_division_by_zero() {
5525        assert!(eval("1 / 0").is_err());
5526        assert!(eval("100 / 0").is_err());
5527    }
5528
5529    #[test]
5530    fn error_missing_required_function_arg() {
5531        let result = eval("({ a, b }: a + b) { a = 1; }");
5532        assert!(result.is_err());
5533        let msg = format!("{}", result.unwrap_err());
5534        assert!(msg.contains("missing argument"));
5535    }
5536
5537    #[test]
5538    fn error_unexpected_function_arg() {
5539        let result = eval("({ a }: a) { a = 1; b = 2; }");
5540        assert!(result.is_err());
5541        let msg = format!("{}", result.unwrap_err());
5542        assert!(msg.contains("unexpected argument"));
5543    }
5544
5545    #[test]
5546    fn error_assertion_failure() {
5547        assert!(eval("assert false; 1").is_err());
5548        assert!(eval("assert 1 == 2; 1").is_err());
5549    }
5550
5551    #[test]
5552    fn error_infinite_recursion() {
5553        // `let x = x; in x` should either hit the depth guard or fail on
5554        // undefined variable (since sequential let can't see its own binding).
5555        let result = eval("let x = x; in x");
5556        assert!(result.is_err());
5557    }
5558
5559    #[test]
5560    fn error_infinite_recursion_via_lambda() {
5561        // A true infinite recursion via self-application -- depth guard catches this.
5562        let result = eval("let f = x: f x; in f 1");
5563        assert!(result.is_err());
5564        let msg = format!("{}", result.unwrap_err());
5565        assert!(
5566            msg.contains("infinite recursion") || msg.contains("eval depth") || msg.contains("undefined"),
5567        );
5568    }
5569
5570    // ═══════════════════════════════════════════════════════════
5571    // ADDITIONAL COVERAGE: edge cases and integration
5572    // ═══════════════════════════════════════════════════════════
5573
5574    #[test]
5575    fn integration_let_with_function_returning_attrset() {
5576        assert_eq!(
5577            ev("let mkPkg = name: { inherit name; version = 1; }; in (mkPkg \"hello\").name"),
5578            Value::string("hello"),
5579        );
5580    }
5581
5582    #[test]
5583    fn integration_chained_updates() {
5584        assert_eq!(
5585            ev("({ a = 1; } // { b = 2; } // { c = 3; }).c"),
5586            Value::Int(3),
5587        );
5588    }
5589
5590    #[test]
5591    fn integration_map_over_attrnames() {
5592        // Common nixpkgs pattern: map over attrNames
5593        assert_eq!(
5594            ev(r#"
5595                let
5596                    set = { a = 1; b = 2; };
5597                    names = builtins.attrNames set;
5598                in builtins.length names
5599            "#),
5600            Value::Int(2),
5601        );
5602    }
5603
5604    #[test]
5605    fn integration_compose_functions() {
5606        // Function composition
5607        assert_eq!(
5608            ev("let compose = f: g: x: f (g x); double = x: x * 2; inc = x: x + 1; in compose double inc 5"),
5609            Value::Int(12), // (5 + 1) * 2
5610        );
5611    }
5612
5613    #[test]
5614    fn integration_recursive_list_building() {
5615        // Build a list using genList and map
5616        assert_eq!(
5617            ev("builtins.map (x: x * x) (builtins.genList (x: x + 1) 4)"),
5618            Value::list(vec![Value::Int(1), Value::Int(4), Value::Int(9), Value::Int(16)]),
5619        );
5620    }
5621
5622    #[test]
5623    fn integration_attrset_from_list() {
5624        // Convert list to attrset via listToAttrs + map
5625        let v = ev(r#"
5626            builtins.listToAttrs (builtins.map (x: { name = x; value = true; }) ["a" "b" "c"])
5627        "#);
5628        if let Value::Attrs(attrs) = v {
5629            assert_eq!(attrs.get("a"), Some(&Value::Bool(true)));
5630            assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));
5631            assert_eq!(attrs.get("c"), Some(&Value::Bool(true)));
5632        } else {
5633            panic!("expected attrs");
5634        }
5635    }
5636
5637    #[test]
5638    fn integration_nested_with_and_let() {
5639        assert_eq!(
5640            ev("let x = 10; in with { y = 20; }; x + y"),
5641            Value::Int(30),
5642        );
5643    }
5644
5645    #[test]
5646    fn integration_complex_pattern_match() {
5647        // Complex function with defaults, ellipsis, and @ pattern
5648        assert_eq!(
5649            ev("(args @ { a, b ? 5, ... }: a + b + (if args ? c then args.c else 0)) { a = 1; c = 10; }"),
5650            Value::Int(16), // 1 + 5 + 10
5651        );
5652    }
5653
5654    #[test]
5655    fn integration_substring() {
5656        assert_eq!(
5657            ev(r#"builtins.substring 0 5 "hello world""#),
5658            Value::string("hello"),
5659        );
5660        assert_eq!(
5661            ev(r#"builtins.substring 6 5 "hello world""#),
5662            Value::string("world"),
5663        );
5664    }
5665
5666    #[test]
5667    fn integration_has_attr_on_nested() {
5668        // ? on nested attr paths
5669        assert_eq!(ev("{ a = { b = 1; }; } ? a"), Value::Bool(true));
5670        assert_eq!(
5671            ev("({ a = { b = 1; }; }.a) ? b"),
5672            Value::Bool(true),
5673        );
5674    }
5675
5676    #[test]
5677    fn integration_cat_attrs() {
5678        assert_eq!(
5679            ev(r#"builtins.catAttrs "x" [{ x = 1; } { y = 2; } { x = 3; }]"#),
5680            Value::list(vec![Value::Int(1), Value::Int(3)]),
5681        );
5682    }
5683
5684    #[test]
5685    fn integration_get_attr_builtin() {
5686        assert_eq!(
5687            ev(r#"builtins.getAttr "a" { a = 42; b = 10; }"#),
5688            Value::Int(42),
5689        );
5690    }
5691
5692    #[test]
5693    fn integration_has_attr_builtin() {
5694        assert_eq!(
5695            ev(r#"builtins.hasAttr "a" { a = 1; }"#),
5696            Value::Bool(true),
5697        );
5698        assert_eq!(
5699            ev(r#"builtins.hasAttr "z" { a = 1; }"#),
5700            Value::Bool(false),
5701        );
5702    }
5703
5704    #[test]
5705    fn integration_is_path() {
5706        assert_eq!(ev("builtins.isPath ./foo"), Value::Bool(true));
5707        assert_eq!(ev("builtins.isPath 42"), Value::Bool(false));
5708    }
5709
5710    #[test]
5711    fn integration_builtins_trace() {
5712        // trace prints the first arg (as debug) and returns the second
5713        assert_eq!(ev(r#"builtins.trace "debug msg" 42"#), Value::Int(42));
5714    }
5715
5716    #[test]
5717    fn integration_builtins_split() {
5718        // Nix spec: split returns alternating non-match strings and match group lists.
5719        // When the regex has no capture groups, separator positions get empty lists.
5720        // split "/" "a/b/c" => ["a" [] "b" [] "c"]
5721        assert_eq!(
5722            ev(r#"builtins.split "/" "a/b/c""#),
5723            Value::list(vec![
5724                Value::string("a"),
5725                Value::list(vec![]),
5726                Value::string("b"),
5727                Value::list(vec![]),
5728                Value::string("c"),
5729            ]),
5730        );
5731        // With a capture group, the captured text appears in the list.
5732        // split "(/)" "a/b/c" => ["a" ["/"] "b" ["/"] "c"]
5733        assert_eq!(
5734            ev(r#"builtins.split "(/)" "a/b/c""#),
5735            Value::list(vec![
5736                Value::string("a"),
5737                Value::list(vec![Value::string("/")]),
5738                Value::string("b"),
5739                Value::list(vec![Value::string("/")]),
5740                Value::string("c"),
5741            ]),
5742        );
5743    }
5744
5745    #[test]
5746    fn integration_builtins_split_no_capture_groups() {
5747        // builtins.split with no capture groups returns empty lists
5748        // at separator positions — matches CppNix behavior.
5749        // This is critical for nixpkgs lib.splitString which uses
5750        // builtins.filter builtins.isString on the result.
5751        assert_eq!(
5752            ev(r#"builtins.split "-" "aarch64-darwin""#),
5753            Value::list(vec![
5754                Value::string("aarch64"),
5755                Value::list(vec![]),
5756                Value::string("darwin"),
5757            ]),
5758        );
5759    }
5760
5761    #[test]
5762    fn integration_builtins_split_system_string_filter() {
5763        // Simulates nixpkgs lib.splitString: filter isString (split pattern string)
5764        // This is the exact pattern that parses system strings like "aarch64-darwin".
5765        assert_eq!(
5766            ev(r#"builtins.filter builtins.isString (builtins.split "-" "aarch64-darwin")"#),
5767            Value::list(vec![
5768                Value::string("aarch64"),
5769                Value::string("darwin"),
5770            ]),
5771        );
5772    }
5773
5774    #[test]
5775    fn integration_deeply_nested_let() {
5776        // Deeply nested let-in expressions
5777        assert_eq!(
5778            ev("let a = let b = let c = 10; in c * 2; in b + 1; in a"),
5779            Value::Int(21),
5780        );
5781    }
5782
5783    #[test]
5784    fn integration_if_in_attrset_value() {
5785        assert_eq!(
5786            ev("{ x = if true then 1 else 2; }.x"),
5787            Value::Int(1),
5788        );
5789    }
5790
5791    #[test]
5792    fn integration_lambda_in_list() {
5793        // Store lambdas in a list and apply them
5794        assert_eq!(
5795            ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 0) 5"),
5796            Value::Int(6),
5797        );
5798        assert_eq!(
5799            ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 1) 5"),
5800            Value::Int(10),
5801        );
5802    }
5803
5804    #[test]
5805    fn integration_nixpkgs_lib_id() {
5806        // lib.id = x: x
5807        assert_eq!(
5808            ev("let lib = { id = x: x; const = a: b: a; }; in lib.id 42"),
5809            Value::Int(42),
5810        );
5811        assert_eq!(
5812            ev("let lib = { id = x: x; const = a: b: a; }; in lib.const 1 2"),
5813            Value::Int(1),
5814        );
5815    }
5816
5817    #[test]
5818    fn integration_multiple_inherit() {
5819        assert_eq!(
5820            ev("let a = 1; b = 2; c = 3; in { inherit a b c; }.b"),
5821            Value::Int(2),
5822        );
5823    }
5824
5825    #[test]
5826    fn integration_rec_set_with_builtins() {
5827        assert_eq!(
5828            ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
5829            Value::Int(5),
5830        );
5831    }
5832
5833    // ═══════════════════════════════════════════════════════════
5834    // 11. __FUNCTOR PROTOCOL
5835    // ═══════════════════════════════════════════════════════════
5836
5837    #[test]
5838    fn functor_simple_callable_attrset() {
5839        assert_eq!(
5840            ev("let s = { __functor = self: x: x + 1; }; in s 41"),
5841            Value::Int(42),
5842        );
5843    }
5844
5845    #[test]
5846    fn functor_with_self_reference() {
5847        assert_eq!(
5848            ev("let s = { __functor = self: x: self.base + x; base = 100; }; in s 23"),
5849            Value::Int(123),
5850        );
5851    }
5852
5853    #[test]
5854    fn functor_updated_attrset() {
5855        // Override a field in the attrset, functor still works
5856        assert_eq!(
5857            ev(r#"
5858                let
5859                    mk = { __functor = self: x: self.n + x; n = 0; };
5860                    s = mk // { n = 50; };
5861                in s 7
5862            "#),
5863            Value::Int(57),
5864        );
5865    }
5866
5867    #[test]
5868    fn functor_error_on_non_callable_attrset() {
5869        // Attrset without __functor should produce error when called
5870        let result = eval("let s = { a = 1; }; in s 5");
5871        assert!(result.is_err());
5872    }
5873
5874    // ═══════════════════════════════════════════════════════════
5875    // 12. __TOSTRING PROTOCOL
5876    // ═══════════════════════════════════════════════════════════
5877
5878    #[test]
5879    fn to_string_protocol_in_interpolation() {
5880        assert_eq!(
5881            ev(r#"let s = { __toString = self: "world"; }; in "hello ${s}""#),
5882            Value::string("hello world"),
5883        );
5884    }
5885
5886    #[test]
5887    fn to_string_protocol_accesses_self() {
5888        assert_eq!(
5889            ev(r#"let s = { __toString = self: self.val; val = "abc"; }; in "${s}""#),
5890            Value::string("abc"),
5891        );
5892    }
5893
5894    #[test]
5895    fn to_string_protocol_via_builtin_to_string() {
5896        assert_eq!(
5897            ev(r#"builtins.toString { __toString = self: "via-builtin"; }"#),
5898            Value::string("via-builtin"),
5899        );
5900    }
5901
5902    #[test]
5903    fn to_string_protocol_attrset_without_toString_fails() {
5904        // An attrset without __toString should fail in string context
5905        let result = eval(r#""${{}}"#);
5906        assert!(result.is_err());
5907    }
5908
5909    // ═══════════════════════════════════════════════════════════
5910    // 13. NEWLY IMPLEMENTED BUILTINS (eval-level tests)
5911    // ═══════════════════════════════════════════════════════════
5912
5913    #[test]
5914    fn eval_builtins_concat_strings() {
5915        assert_eq!(
5916            ev(r#"builtins.concatStrings ["a" "b" "c"]"#),
5917            Value::string("abc"),
5918        );
5919        assert_eq!(
5920            ev(r#"builtins.concatStrings []"#),
5921            Value::string(""),
5922        );
5923    }
5924
5925    #[test]
5926    fn eval_builtins_partition() {
5927        let v = ev("builtins.partition (x: x > 3) [1 2 3 4 5]");
5928        if let Value::Attrs(a) = v {
5929            assert_eq!(a.get("right"), Some(&Value::list(vec![Value::Int(4), Value::Int(5)])));
5930            assert_eq!(a.get("wrong"), Some(&Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)])));
5931        } else {
5932            panic!("expected attrs");
5933        }
5934    }
5935
5936    #[test]
5937    fn eval_builtins_group_by() {
5938        let v = ev(r#"builtins.groupBy (x: if x > 0 then "pos" else "neg") [1 (0 - 2) 3 (0 - 4)]"#);
5939        if let Value::Attrs(a) = v {
5940            assert_eq!(a.get("pos"), Some(&Value::list(vec![Value::Int(1), Value::Int(3)])));
5941            assert_eq!(a.get("neg"), Some(&Value::list(vec![Value::Int(-2), Value::Int(-4)])));
5942        } else {
5943            panic!("expected attrs");
5944        }
5945    }
5946
5947    #[test]
5948    fn eval_builtins_zip_attrs_with() {
5949        let v = ev("builtins.zipAttrsWith (n: vs: builtins.head vs) [{ a = 1; } { a = 2; b = 3; }]");
5950        if let Value::Attrs(a) = v {
5951            assert_eq!(a.get("a"), Some(&Value::Int(1)));
5952            assert_eq!(a.get("b"), Some(&Value::Int(3)));
5953        } else {
5954            panic!("expected attrs");
5955        }
5956    }
5957
5958    #[test]
5959    fn eval_builtins_compare_versions() {
5960        assert_eq!(ev(r#"builtins.compareVersions "2.0" "1.0""#), Value::Int(1));
5961        assert_eq!(ev(r#"builtins.compareVersions "1.0" "2.0""#), Value::Int(-1));
5962        assert_eq!(ev(r#"builtins.compareVersions "1.0" "1.0""#), Value::Int(0));
5963    }
5964
5965    #[test]
5966    fn eval_builtins_parse_drv_name() {
5967        let v = ev(r#"builtins.parseDrvName "nix-2.3.4""#);
5968        if let Value::Attrs(a) = v {
5969            assert_eq!(a.get("name"), Some(&Value::string("nix")));
5970            assert_eq!(a.get("version"), Some(&Value::string("2.3.4")));
5971        } else {
5972            panic!("expected attrs");
5973        }
5974    }
5975
5976    #[test]
5977    fn eval_builtins_base_name_of() {
5978        assert_eq!(
5979            ev(r#"builtins.baseNameOf "/foo/bar/baz""#),
5980            Value::string("baz"),
5981        );
5982    }
5983
5984    #[test]
5985    fn eval_builtins_dir_of() {
5986        assert_eq!(
5987            ev(r#"builtins.dirOf "/foo/bar/baz""#),
5988            Value::string("/foo/bar"),
5989        );
5990    }
5991
5992    #[test]
5993    fn eval_builtins_add_error_context() {
5994        assert_eq!(
5995            ev(r#"builtins.addErrorContext "some context" 42"#),
5996            Value::Int(42),
5997        );
5998    }
5999
6000    #[test]
6001    fn eval_builtins_abort() {
6002        let result = eval(r#"builtins.abort "fatal error""#);
6003        assert!(result.is_err());
6004        let msg = format!("{}", result.unwrap_err());
6005        assert!(msg.contains("fatal error"));
6006    }
6007
6008    // ═══════════════════════════════════════════════════════════
6009    // 14. INDENTED STRINGS ('' ... '')
6010    // ═══════════════════════════════════════════════════════════
6011
6012    #[test]
6013    fn indented_string_simple() {
6014        assert_eq!(ev("''hello''"), Value::string("hello"));
6015    }
6016
6017    #[test]
6018    fn indented_string_multiline_strips_indent() {
6019        assert_eq!(
6020            ev("''\n  line1\n  line2\n''"),
6021            Value::string("line1\nline2\n"),
6022        );
6023    }
6024
6025    #[test]
6026    fn indented_string_with_interpolation() {
6027        let code = "let x = \"world\"; in ''hello ${x}''";
6028        assert_eq!(
6029            ev(code),
6030            Value::string("hello world"),
6031        );
6032    }
6033
6034    #[test]
6035    fn indented_string_deeper_indent_preserved() {
6036        // Common indent is 2 spaces; the 4-space line keeps 2 extra
6037        assert_eq!(
6038            ev("''\n  a\n    b\n''"),
6039            Value::string("a\n  b\n"),
6040        );
6041    }
6042
6043    // ═══════════════════════════════════════════════════════════
6044    // 15. DYNAMIC ATTRIBUTE NAMES
6045    // ═══════════════════════════════════════════════════════════
6046
6047    #[test]
6048    fn dynamic_attr_name_in_set() {
6049        assert_eq!(
6050            ev(r#"let key = "mykey"; in { ${key} = 42; }.mykey"#),
6051            Value::Int(42),
6052        );
6053    }
6054
6055    #[test]
6056    fn dynamic_attr_name_with_expression() {
6057        assert_eq!(
6058            ev(r#"let prefix = "foo"; in { ${"${prefix}bar"} = 1; }.foobar"#),
6059            Value::Int(1),
6060        );
6061    }
6062
6063    // ═══════════════════════════════════════════════════════════
6064    // 16. IGNORED TESTS — features needing major infrastructure
6065    // ═══════════════════════════════════════════════════════════
6066
6067    #[test]
6068    fn eval_builtins_match() {
6069        assert_eq!(
6070            ev(r#"builtins.match "([0-9]+)" "42""#),
6071            Value::list(vec![Value::string("42")]),
6072        );
6073    }
6074
6075    #[test]
6076    fn eval_builtins_hash_string() {
6077        let v = ev(r#"builtins.hashString "sha256" "hello""#);
6078        if let Value::String(ns) = v {
6079            assert_eq!(ns.chars.len(), 64);
6080        } else {
6081            panic!("expected string");
6082        }
6083    }
6084
6085    #[test]
6086    fn eval_builtins_import() {
6087        let dir = std::env::temp_dir();
6088        let path = dir.join("sui_eval_test_import_eval.nix");
6089        std::fs::write(&path, "42").unwrap();
6090        let expr = format!(r#"import "{}""#, path.display());
6091        let v = eval(&expr).unwrap();
6092        assert_eq!(v, Value::Int(42));
6093        std::fs::remove_file(&path).ok();
6094    }
6095
6096    #[test]
6097    fn eval_builtins_derivation() {
6098        let v = eval(r#"builtins.derivation { name = "test"; system = "x86_64-linux"; builder = "/bin/sh"; }"#).unwrap();
6099        if let Value::Attrs(a) = v {
6100            assert_eq!(a.get("type"), Some(&Value::string("derivation")));
6101        } else {
6102            panic!("expected attrs");
6103        }
6104    }
6105
6106    #[test]
6107    fn eval_mutual_recursive_let() {
6108        // Multi-pass evaluation allows forward references in let bindings.
6109        // After 3 passes (placeholder + eval + re-eval), `a.x` resolves to
6110        // the value of `b` from the previous pass, and `a.x.y` is an attrset.
6111        // Full semantic equivalence with Nix (a.x.y == a) requires lazy
6112        // thunks, but the multi-pass approach is sufficient for common
6113        // patterns like mutual module references.
6114        let v = eval("let a = { x = b; }; b = { y = a; }; in a.x.y");
6115        assert!(v.is_ok(), "mutual recursive let should not error: {v:?}");
6116        // a.x.y should be an attrset (it's a's value from a prior pass)
6117        let val = v.unwrap();
6118        assert!(
6119            matches!(val, Value::Attrs(_)),
6120            "a.x.y should be an attrset, got: {val:?}",
6121        );
6122    }
6123
6124    #[test]
6125    fn eval_mutual_recursive_let_simple() {
6126        // Simpler case: forward reference in sequential let bindings
6127        let v = eval("let a = b; b = 42; in a");
6128        assert!(v.is_ok());
6129        // After multi-pass: pass 2 sets a=Null (b not yet bound), b=42
6130        // pass 3 sets a=42, b=42
6131        assert_eq!(v.unwrap(), Value::Int(42));
6132    }
6133
6134    #[test]
6135    fn eval_builtins_read_dir() {
6136        let dir = std::env::temp_dir().join("sui_eval_test_readdir_eval");
6137        let _ = std::fs::remove_dir_all(&dir);
6138        std::fs::create_dir_all(&dir).unwrap();
6139        std::fs::write(dir.join("a.txt"), "").unwrap();
6140        let expr = format!(r#"builtins.readDir "{}""#, dir.display());
6141        let v = eval(&expr).unwrap();
6142        if let Value::Attrs(a) = v {
6143            assert_eq!(a.get("a.txt"), Some(&Value::string("regular")));
6144        } else {
6145            panic!("expected attrs");
6146        }
6147        let _ = std::fs::remove_dir_all(&dir);
6148    }
6149
6150    // ═══════════════════════════════════════════════════════════
6151    // 17. THUNK / LAZY EVALUATION
6152    // ═══════════════════════════════════════════════════════════
6153
6154    #[test]
6155    fn thunk_basic_let() {
6156        // Simple let binding through thunk.
6157        assert_eq!(ev("let x = 1; in x"), Value::Int(1));
6158    }
6159
6160    #[test]
6161    fn thunk_forward_ref() {
6162        // Forward reference: `a` references `b` which is defined later.
6163        assert_eq!(ev("let a = b; b = 1; in a"), Value::Int(1));
6164    }
6165
6166    #[test]
6167    fn thunk_mutual_rec_attrset_in_let() {
6168        // Mutual recursion through attrsets in let bindings.
6169        assert_eq!(ev("let a = { x = b; }; b = { y = 1; }; in a.x.y"), Value::Int(1));
6170    }
6171
6172    #[test]
6173    fn thunk_rec_attrset() {
6174        // rec { a = b; b = 1; } -- forward ref within rec set.
6175        assert_eq!(ev("(rec { a = b; b = 1; }).a"), Value::Int(1));
6176    }
6177
6178    #[test]
6179    fn thunk_rec_attrset_chain() {
6180        // Longer chain: c depends on b depends on a.
6181        assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
6182    }
6183
6184    #[test]
6185    fn thunk_fixpoint() {
6186        // Classic fixpoint combinator -- the core of nixpkgs' `lib.fix`.
6187        assert_eq!(
6188            ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; })).b"),
6189            Value::Int(2),
6190        );
6191    }
6192
6193    #[test]
6194    fn thunk_blackhole_self_reference() {
6195        // `let x = x; in x` is infinite recursion -- blackhole detection.
6196        let result = eval("let x = x; in x");
6197        assert!(result.is_err());
6198        let msg = format!("{}", result.unwrap_err());
6199        assert!(
6200            msg.contains("infinite recursion") || msg.contains("blackhole"),
6201            "expected blackhole error, got: {msg}",
6202        );
6203    }
6204
6205    #[test]
6206    fn thunk_mutual_blackhole() {
6207        // `let a = b; b = a; in a` -- mutual infinite recursion.
6208        let result = eval("let a = b; b = a; in a");
6209        assert!(result.is_err());
6210    }
6211
6212    #[test]
6213    fn thunk_let_body_forces_correctly() {
6214        // The let body should be able to use thunked bindings in arithmetic.
6215        assert_eq!(ev("let a = 10; b = 20; in a + b"), Value::Int(30));
6216    }
6217
6218    #[test]
6219    fn thunk_only_forced_when_needed() {
6220        // The binding `bad` would error if forced, but it is never used.
6221        assert_eq!(ev("let bad = 1 / 0; good = 42; in good"), Value::Int(42));
6222    }
6223
6224    #[test]
6225    fn thunk_forward_ref_in_function_body() {
6226        // Forward reference used inside a function body.
6227        assert_eq!(
6228            ev("let f = x: x + b; b = 10; in f 5"),
6229            Value::Int(15),
6230        );
6231    }
6232
6233    #[test]
6234    fn thunk_rec_set_self_ref_through_self() {
6235        // rec set where `b` references `a` which is in the same set.
6236        assert_eq!(
6237            ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6238            Value::Int(5),
6239        );
6240    }
6241
6242    #[test]
6243    fn thunk_nested_let_forward_ref() {
6244        // Forward reference in nested let.
6245        assert_eq!(
6246            ev("let a = b + 1; b = 2; in a"),
6247            Value::Int(3),
6248        );
6249    }
6250
6251    #[test]
6252    fn thunk_deep_chain() {
6253        // Chain of forward references: e -> d -> c -> b -> a.
6254        assert_eq!(
6255            ev("let a = 1; b = a; c = b; d = c; e = d; in e"),
6256            Value::Int(1),
6257        );
6258    }
6259
6260    #[test]
6261    fn thunk_rec_set_fixpoint() {
6262        // Fixpoint through rec set -- common nixpkgs pattern.
6263        assert_eq!(
6264            ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; c = self.b + 1; })).c"),
6265            Value::Int(3),
6266        );
6267    }
6268
6269    #[test]
6270    fn thunk_let_with_inherit() {
6271        // Inherit in let should work alongside thunked bindings.
6272        assert_eq!(
6273            ev("let a = 1; in let inherit a; b = a + 1; in b"),
6274            Value::Int(2),
6275        );
6276    }
6277
6278    #[test]
6279    fn thunk_attrset_value_lazy() {
6280        // Values in non-rec attrsets are evaluated eagerly, but the test
6281        // verifies that thunked let bindings inside attrset values work.
6282        assert_eq!(
6283            ev("let x = 42; in { a = x; }.a"),
6284            Value::Int(42),
6285        );
6286    }
6287
6288    #[test]
6289    fn thunk_unused_error_not_forced() {
6290        // Multiple bindings, only `ok` is used. `bad` throws but is never forced.
6291        assert_eq!(
6292            ev(r#"let bad = builtins.throw "boom"; ok = 1; in ok"#),
6293            Value::Int(1),
6294        );
6295    }
6296
6297    #[test]
6298    fn thunk_rec_set_mutual_reference() {
6299        // Mutual reference within rec set.
6300        let v = ev("rec { a = { val = b.val + 1; }; b = { val = 10; }; }");
6301        if let Value::Attrs(attrs) = v {
6302            let a = attrs.get("a").unwrap();
6303            let a_forced = force_value(a).unwrap();
6304            if let Value::Attrs(a_attrs) = a_forced {
6305                assert_eq!(a_attrs.get("val"), Some(&Value::Int(11)));
6306            } else {
6307                panic!("expected attrs for a");
6308            }
6309        } else {
6310            panic!("expected attrs");
6311        }
6312    }
6313
6314    // ── let-rec self-reference corner cases ───────────────
6315
6316    #[test]
6317    fn let_rec_self_reference_simple() {
6318        assert_eq!(
6319            ev("let x = 1; y = x + 1; in y"),
6320            Value::Int(2),
6321        );
6322    }
6323
6324    #[test]
6325    fn let_rec_self_reference_chain() {
6326        assert_eq!(
6327            ev("let a = 1; b = a + 1; c = b + 1; in c"),
6328            Value::Int(3),
6329        );
6330    }
6331
6332    #[test]
6333    fn let_rec_self_reference_with_function() {
6334        assert_eq!(
6335            ev("let f = x: x + 1; y = f 10; in y"),
6336            Value::Int(11),
6337        );
6338    }
6339
6340    #[test]
6341    fn let_rec_mutual_recursion_via_if() {
6342        assert_eq!(
6343            ev("let isEven = n: if n == 0 then true else isOdd (n - 1); isOdd = n: if n == 0 then false else isEven (n - 1); in isEven 4"),
6344            Value::Bool(true),
6345        );
6346    }
6347
6348    #[test]
6349    fn let_rec_forward_ref_in_list() {
6350        assert_eq!(
6351            ev("let xs = [a b]; a = 1; b = 2; in builtins.length xs"),
6352            Value::Int(2),
6353        );
6354    }
6355
6356    // ── with-shadowing corner cases ───────────────────────
6357
6358    #[test]
6359    fn with_shadowing_let_wins_over_with() {
6360        assert_eq!(
6361            ev("let x = 1; in with { x = 2; }; x"),
6362            Value::Int(1),
6363        );
6364    }
6365
6366    #[test]
6367    fn with_shadowing_inner_with_wins() {
6368        assert_eq!(
6369            ev("with { x = 1; }; with { x = 2; }; x"),
6370            Value::Int(2),
6371        );
6372    }
6373
6374    #[test]
6375    fn with_shadowing_outer_provides_missing() {
6376        assert_eq!(
6377            ev("with { x = 1; y = 10; }; with { x = 2; }; x + y"),
6378            Value::Int(12),
6379        );
6380    }
6381
6382    #[test]
6383    fn with_shadowing_lambda_arg_wins() {
6384        assert_eq!(
6385            ev("(x: with { x = 99; }; x) 42"),
6386            Value::Int(42),
6387        );
6388    }
6389
6390    #[test]
6391    fn with_shadowing_nested_let_wins_over_with() {
6392        assert_eq!(
6393            ev("with { x = 1; }; let x = 2; in x"),
6394            Value::Int(2),
6395        );
6396    }
6397
6398    #[test]
6399    fn with_scope_dynamic_attrs() {
6400        assert_eq!(
6401            ev(r#"with { x = 1; y = 2; z = 3; }; x + y + z"#),
6402            Value::Int(6),
6403        );
6404    }
6405
6406    #[test]
6407    fn with_scope_over_lazy_thunk_chain_resolves() {
6408        // A `with`-head that resolves through a NESTED thunk chain
6409        // (`Thunk(Thunk(Attrs))`) must still be searched: the lookup
6410        // has to FULLY force the head (chase the chain), not take a
6411        // single force step. A single step leaves a `Value::Thunk`
6412        // that `type_name()` reports as "set" but the `Value::Attrs`
6413        // match rejects — the scope is skipped and a bare ident
6414        // through it fails with a spurious UndefinedVar. This corners
6415        // the nixpkgs `platforms = with lib.platforms; unix;` shape.
6416        assert_eq!(
6417            ev(r#"let outer = if true then (if true then { unix = 42; } else {}) else {};
6418                      # force a two-deep lazy wrap of the with-head
6419                      head = (x: x) ((y: y) outer);
6420                  in with head; unix"#),
6421            Value::Int(42),
6422        );
6423    }
6424
6425    #[test]
6426    fn with_scope_head_from_deep_select_resolves() {
6427        // `with a.b.c; key` where a.b.c is a lazily-selected attrset —
6428        // the bare-ident body must find `key` through the forced head.
6429        assert_eq!(
6430            ev(r#"let a = { b = { c = { key = 7; }; }; }; in with a.b.c; key"#),
6431            Value::Int(7),
6432        );
6433    }
6434
6435    // ── attrset deep merge ────────────────────────────────
6436
6437    #[test]
6438    fn attrset_deep_merge_simple() {
6439        let v = ev("{ a.b = 1; a.c = 2; }");
6440        if let Value::Attrs(attrs) = v {
6441            let a = force_value(attrs.get("a").unwrap()).unwrap();
6442            if let Value::Attrs(inner) = a {
6443                assert_eq!(force_value(inner.get("b").unwrap()).unwrap(), Value::Int(1));
6444                assert_eq!(force_value(inner.get("c").unwrap()).unwrap(), Value::Int(2));
6445            } else {
6446                panic!("expected nested attrs");
6447            }
6448        } else {
6449            panic!("expected attrs");
6450        }
6451    }
6452
6453    #[test]
6454    fn attrset_deep_merge_three_levels() {
6455        let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
6456        if let Value::Attrs(attrs) = v {
6457            let a = force_value(attrs.get("a").unwrap()).unwrap();
6458            if let Value::Attrs(a_inner) = a {
6459                let e = force_value(a_inner.get("e").unwrap()).unwrap();
6460                assert_eq!(e, Value::Int(3));
6461                let b = force_value(a_inner.get("b").unwrap()).unwrap();
6462                if let Value::Attrs(b_inner) = b {
6463                    assert_eq!(force_value(b_inner.get("c").unwrap()).unwrap(), Value::Int(1));
6464                    assert_eq!(force_value(b_inner.get("d").unwrap()).unwrap(), Value::Int(2));
6465                } else {
6466                    panic!("expected nested attrs for b");
6467                }
6468            } else {
6469                panic!("expected nested attrs for a");
6470            }
6471        } else {
6472            panic!("expected attrs");
6473        }
6474    }
6475
6476    #[test]
6477    fn attrset_deep_merge_preserves_siblings() {
6478        assert_eq!(
6479            ev("{ a.x = 1; b = 2; a.y = 3; }.b"),
6480            Value::Int(2),
6481        );
6482    }
6483
6484    #[test]
6485    fn attrset_deep_merge_in_let() {
6486        let v = ev("let s = { a.b = 1; a.c = 2; }; in s.a.b + s.a.c");
6487        assert_eq!(v, Value::Int(3));
6488    }
6489
6490    #[test]
6491    fn attrset_deep_merge_fullset_then_dotted() {
6492        // General root (gst-plugins-base `passthru.waylandEnabled` drop):
6493        // `a = { x = 1; }; a.y = 2;` — the full-set binding is a lazy
6494        // Thunk (attrset literals go through maybe_thunk), so a naive
6495        // merge_nested_insert (which only merges concrete Value::Attrs)
6496        // overwrote `a` with `{ y = 2 }`, silently dropping `x`. The
6497        // collision must force the existing thunk to WHNF first.
6498        let v = ev("let s = { a = { x = 1; }; a.y = 2; }; in s.a.x + s.a.y");
6499        assert_eq!(v, Value::Int(3));
6500        // both keys must survive (not just their sum)
6501        let both = ev("let s = { a = { x = 1; }; a.y = 2; }; in [ s.a.x s.a.y ]");
6502        if let Value::List(items) = both {
6503            assert_eq!(force_value(&items[0]).unwrap(), Value::Int(1));
6504            assert_eq!(force_value(&items[1]).unwrap(), Value::Int(2));
6505        } else {
6506            panic!("expected list");
6507        }
6508    }
6509
6510    // ── inherit-from patterns ─────────────────────────────
6511
6512    #[test]
6513    fn inherit_from_basic() {
6514        assert_eq!(
6515            ev("let s = { x = 1; y = 2; }; in let inherit (s) x y; in x + y"),
6516            Value::Int(3),
6517        );
6518    }
6519
6520    #[test]
6521    fn inherit_from_with_shadowing() {
6522        assert_eq!(
6523            ev("let x = 10; in let inherit ({ x = 20; }) x; in x"),
6524            Value::Int(20),
6525        );
6526    }
6527
6528    #[test]
6529    fn inherit_from_in_attrset() {
6530        let v = ev(r#"let s = { a = 1; b = 2; }; in { inherit (s) a b; c = 3; }"#);
6531        if let Value::Attrs(attrs) = v {
6532            assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
6533            assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
6534            assert_eq!(force_value(attrs.get("c").unwrap()).unwrap(), Value::Int(3));
6535        } else {
6536            panic!("expected attrs");
6537        }
6538    }
6539
6540    #[test]
6541    fn inherit_from_rec_set() {
6542        assert_eq!(
6543            ev("rec { inherit ({ x = 42; }) x; y = x; }.y"),
6544            Value::Int(42),
6545        );
6546    }
6547
6548    #[test]
6549    fn inherit_plain_from_scope() {
6550        assert_eq!(
6551            ev("let x = 1; in { inherit x; }.x"),
6552            Value::Int(1),
6553        );
6554    }
6555
6556    // Regression (2026-07-11): a bare `inherit x;` must resolve LAZILY, like
6557    // a plain reference to `x` — not eagerly at attrset construction. When
6558    // `x` is provided only by an enclosing `with` scope whose value is a
6559    // fixpoint still being constructed, eager resolution spuriously threw
6560    // `UndefinedVar`. nixpkgs `all-packages.nix` is
6561    // `with pkgs; { nettle = import … { inherit callPackage; }; }`, so
6562    // `inherit callPackage` must resolve from the `with pkgs` scope at force
6563    // time. (This was the nettle UndefinedVar('callPackage') drop.)
6564    #[test]
6565    fn inherit_plain_from_with_scope_lazy() {
6566        // `inherit cp` reads `cp` from a `with self` fixpoint scope; the
6567        // attr forcing it (`a`) must resolve `cp` lazily against the settled
6568        // scope, not eagerly during attrset construction.
6569        assert_eq!(
6570            ev("let fix = f: let x = f x; in x;
6571                    self = fix (self: with self; {
6572                      a = use { inherit cp; };
6573                      use = { cp }: cp 5;
6574                      cp = x: x + 100;
6575                    });
6576                in self.a"),
6577            Value::Int(105),
6578        );
6579        // Simpler: bare inherit from a plain (non-blackhole) with scope.
6580        assert_eq!(
6581            ev("with { y = 7; }; { inherit y; }.y"),
6582            Value::Int(7),
6583        );
6584    }
6585
6586    #[test]
6587    fn inherit_multiple_from_expr() {
6588        assert_eq!(
6589            ev("let s = { a = 10; b = 20; c = 30; }; in let inherit (s) a b c; in a + b + c"),
6590            Value::Int(60),
6591        );
6592    }
6593
6594    // ── string interpolation edge cases ───────────────────
6595
6596    #[test]
6597    fn interp_nested_attrset_access() {
6598        assert_eq!(
6599            ev(r#"let x = { a = "hello"; }; in "${x.a} world""#),
6600            Value::string("hello world"),
6601        );
6602    }
6603
6604    #[test]
6605    fn interp_with_let_expression() {
6606        assert_eq!(
6607            ev(r#""${let x = "inner"; in x}""#),
6608            Value::string("inner"),
6609        );
6610    }
6611
6612    #[test]
6613    fn interp_float_coercion() {
6614        // CppNix %f-format: always 6 decimal places.
6615        assert_eq!(
6616            ev(r#""${toString 3.14}""#),
6617            Value::string("3.140000"),
6618        );
6619    }
6620
6621    // ── comparison edge cases ─────────────────────────────
6622
6623    #[test]
6624    fn compare_mixed_int_float() {
6625        assert_eq!(ev("1 < 1.5"), Value::Bool(true));
6626        assert_eq!(ev("1.5 > 1"), Value::Bool(true));
6627        assert_eq!(ev("2.0 == 2"), Value::Bool(true));
6628    }
6629
6630    #[test]
6631    fn compare_string_lexicographic() {
6632        assert_eq!(ev(r#""abc" < "abd""#), Value::Bool(true));
6633        assert_eq!(ev(r#""abc" < "abc""#), Value::Bool(false));
6634        assert_eq!(ev(r#""abc" <= "abc""#), Value::Bool(true));
6635    }
6636
6637    // ── update operator edge cases ────────────────────────
6638
6639    #[test]
6640    fn update_empty_sets() {
6641        let v = ev("{} // {}");
6642        if let Value::Attrs(a) = v { assert!(a.is_empty()); } else { panic!(); }
6643    }
6644
6645    #[test]
6646    fn update_right_overrides_completely() {
6647        assert_eq!(
6648            ev("{ a = 1; b = 2; } // { a = 10; c = 30; }"),
6649            ev("{ a = 10; b = 2; c = 30; }"),
6650        );
6651    }
6652
6653    #[test]
6654    fn update_chained() {
6655        assert_eq!(
6656            ev("{ a = 1; } // { b = 2; } // { c = 3; }"),
6657            ev("{ a = 1; b = 2; c = 3; }"),
6658        );
6659    }
6660
6661    // ── force_value edge cases ────────────────────────────
6662
6663    #[test]
6664    fn force_value_concrete_unchanged() {
6665        let v = Value::Int(42);
6666        assert_eq!(force_value(&v).unwrap(), Value::Int(42));
6667    }
6668
6669    #[test]
6670    fn force_value_null() {
6671        assert_eq!(force_value(&Value::Null).unwrap(), Value::Null);
6672    }
6673
6674    // ── eval_with_file ────────────────────────────────────
6675
6676    #[test]
6677    fn eval_with_file_none() {
6678        let result = eval_with_file("1 + 2", None).unwrap();
6679        assert_eq!(result, Value::Int(3));
6680    }
6681
6682    // ── error messages ────────────────────────────────────
6683
6684    #[test]
6685    fn error_type_mismatch_in_comparison() {
6686        let result = eval(r#"1 < "a""#);
6687        assert!(result.is_err());
6688    }
6689
6690    #[test]
6691    fn error_select_from_non_set() {
6692        let result = eval("42.x");
6693        assert!(result.is_err());
6694    }
6695
6696    #[test]
6697    fn error_call_non_function() {
6698        let result = eval("42 1");
6699        assert!(result.is_err());
6700    }
6701
6702    #[test]
6703    fn error_negate_string() {
6704        let result = eval(r#"-"hello""#);
6705        assert!(result.is_err());
6706    }
6707
6708    // ── multiline string edge cases ───────────────────────
6709
6710    #[test]
6711    fn multiline_string_empty() {
6712        assert_eq!(ev("''''"), Value::string(""));
6713    }
6714
6715    #[test]
6716    fn multiline_string_with_trailing_newline() {
6717        let v = ev("''\n  hello\n''");
6718        assert_eq!(v, Value::string("hello\n"));
6719    }
6720
6721    // ── list operations ───────────────────────────────────
6722
6723    #[test]
6724    fn list_concat_empty_left() {
6725        assert_eq!(ev("[] ++ [1 2]"), Value::list(vec![Value::Int(1), Value::Int(2)]));
6726    }
6727
6728    #[test]
6729    fn list_concat_empty_right() {
6730        assert_eq!(ev("[1 2] ++ []"), Value::list(vec![Value::Int(1), Value::Int(2)]));
6731    }
6732
6733    #[test]
6734    fn list_concat_both_empty() {
6735        assert_eq!(ev("[] ++ []"), Value::list(vec![]));
6736    }
6737
6738    // ── pattern matching / formals edge cases ─────────────
6739
6740    #[test]
6741    fn formals_at_pattern_accessible() {
6742        assert_eq!(
6743            ev("({ x, ... } @ args: builtins.length (builtins.attrNames args)) { x = 1; y = 2; z = 3; }"),
6744            Value::Int(3),
6745        );
6746    }
6747
6748    #[test]
6749    fn formals_default_uses_other_arg() {
6750        assert_eq!(
6751            ev("({ x, y ? x + 1 }: y) { x = 10; }"),
6752            Value::Int(11),
6753        );
6754    }
6755
6756    #[test]
6757    fn formals_default_lazy_assert_false() {
6758        // nixpkgs parse.nix pattern: default is `assert false; null` but
6759        // the body checks `args ? vendor` instead of using `vendor`
6760        // directly, so the default must never be forced.
6761        assert_eq!(
6762            ev("({ cpu, vendor ? assert false; null, kernel } @ args: if args ? vendor then vendor else \"inferred\") { cpu = \"x86_64\"; kernel = \"linux\"; }"),
6763            Value::String(Rc::new(NixString::plain("inferred"))),
6764        );
6765    }
6766
6767    #[test]
6768    fn formals_default_lazy_only_forced_when_accessed() {
6769        // When the default IS accessed, it should still evaluate correctly.
6770        assert_eq!(
6771            ev("({ a, b ? 42 }: b) { a = 1; }"),
6772            Value::Int(42),
6773        );
6774    }
6775
6776    #[test]
6777    fn formals_ellipsis_ignores_extra() {
6778        assert_eq!(
6779            ev("({ x, ... }: x) { x = 1; y = 2; z = 3; }"),
6780            Value::Int(1),
6781        );
6782    }
6783
6784    // ── pure mode ─────────────────────────────────────────
6785
6786    #[test]
6787    fn pure_mode_roundtrip() {
6788        let was_pure = is_pure_mode();
6789        set_pure_mode(true);
6790        assert!(is_pure_mode());
6791        set_pure_mode(false);
6792        assert!(!is_pure_mode());
6793        set_pure_mode(was_pure);
6794    }
6795
6796    // ── path operations ───────────────────────────────────
6797
6798    #[test]
6799    fn path_concat_with_string() {
6800        assert_eq!(
6801            ev(r#"/foo + "bar""#),
6802            Value::Path(Box::new(SmolStr::from("/foobar"))),
6803        );
6804    }
6805
6806    #[test]
6807    fn path_concat_with_path() {
6808        assert_eq!(
6809            ev("/foo + /bar"),
6810            Value::Path(Box::new(SmolStr::from("/foo//bar"))),
6811        );
6812    }
6813
6814    // ── EvalFileGuard / current_eval_dir ───────────────────
6815
6816    #[test]
6817    fn current_eval_dir_empty_when_no_file_pushed() {
6818        // Without a push, current_eval_dir should yield None.
6819        // (Note: this test is order-dependent; we accept whatever the
6820        // top of the stack happens to be when called.)
6821        let snapshot = current_eval_dir();
6822        // At minimum the API doesn't panic and returns Option.
6823        let _ = snapshot;
6824    }
6825
6826    #[test]
6827    fn push_eval_file_sets_current_dir() {
6828        let p = std::path::PathBuf::from("/tmp/example/file.nix");
6829        {
6830            let _g = push_eval_file(p.clone());
6831            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/tmp/example")));
6832        }
6833        // Guard dropped, stack popped — current dir is whatever was below.
6834        // We can't assert exact value without snapshotting first, but the
6835        // value before push should be restored.
6836    }
6837
6838    #[test]
6839    fn push_eval_file_nested_stack() {
6840        let outer = std::path::PathBuf::from("/a/x.nix");
6841        let inner = std::path::PathBuf::from("/b/y.nix");
6842        {
6843            let _g_outer = push_eval_file(outer.clone());
6844            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
6845            {
6846                let _g_inner = push_eval_file(inner.clone());
6847                assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/b")));
6848            }
6849            // Inner dropped — outer is back on top.
6850            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
6851        }
6852    }
6853
6854    /// A fileless frame MASKS the parent's file rather than being skipped.
6855    ///
6856    /// Regression: the stack used to be `Vec<PathBuf>`, so a thunk captured in
6857    /// a `--expr` context pushed nothing when it forced and the callee's file
6858    /// stayed visible. `builtins.unsafeGetAttrPos` then reported the callee's
6859    /// path where CppNix reports `null`, which set `eval-config.nix`'s
6860    /// `modulesLocation` and permuted NixOS module definition order.
6861    #[test]
6862    fn fileless_frame_masks_parent_file() {
6863        let outer = std::path::PathBuf::from("/a/x.nix");
6864        let _g_outer = push_eval_file(outer.clone());
6865        assert_eq!(current_eval_file(), Some(outer.clone()));
6866        {
6867            let _g_none = push_eval_frame(None);
6868            // The whole point: NOT Some("/a/x.nix").
6869            assert_eq!(current_eval_file(), None);
6870            assert_eq!(current_eval_dir(), None);
6871            assert_eq!(eval_file_stack_snapshot().last().map(String::as_str), Some("<no-file>"));
6872        }
6873        // Popped — the parent is visible again.
6874        assert_eq!(current_eval_file(), Some(outer));
6875    }
6876
6877    // ── Source-mapped error context ────────────────────────
6878
6879    #[test]
6880    fn error_undefined_var_includes_file_context() {
6881        let p = std::path::PathBuf::from("/nix/store/abc-default.nix");
6882        let _g = push_eval_file(p);
6883        let result = eval("nonexistent_xyz");
6884        let msg = format!("{}", result.unwrap_err());
6885        assert!(msg.contains("undefined variable"), "msg: {msg}");
6886        assert!(msg.contains("nonexistent_xyz"), "msg: {msg}");
6887        assert!(msg.contains("abc-default.nix"), "msg: {msg}");
6888    }
6889
6890    #[test]
6891    fn error_attr_not_found_includes_file_context() {
6892        let p = std::path::PathBuf::from("/nix/store/xyz-module.nix");
6893        let _g = push_eval_file(p);
6894        let result = eval("{}.missing_key");
6895        let msg = format!("{}", result.unwrap_err());
6896        assert!(msg.contains("not found") || msg.contains("missing_key"), "msg: {msg}");
6897        assert!(msg.contains("xyz-module.nix"), "msg: {msg}");
6898    }
6899
6900    #[test]
6901    fn error_assertion_failed_includes_file_context() {
6902        let p = std::path::PathBuf::from("/nix/store/test-assert.nix");
6903        let _g = push_eval_file(p);
6904        let result = eval("assert false; 1");
6905        let msg = format!("{}", result.unwrap_err());
6906        assert!(msg.contains("assertion failed"), "msg: {msg}");
6907        assert!(msg.contains("test-assert.nix"), "msg: {msg}");
6908    }
6909
6910    /// `inherit` binds an attribute, so it carries a position.
6911    ///
6912    /// Regression: `attach_attrset_positions` matched only
6913    /// `Entry::AttrpathValue`, so every inherited key was position-less — most
6914    /// of nixpkgs' `lib`, which re-exports via `inherit (self.options) mkOption
6915    /// …`, and it fed a null into `eval-config.nix`'s `modulesLocation`.
6916    ///
6917    /// Shaped exactly like `unsafe_get_attr_pos_reports_file_and_offset_column`
6918    /// (ONE direct `eval`, no lambda, no second evaluation) because the
6919    /// in-process harness is fragile here: the source-text registry is a
6920    /// thread-local that `pos.rs`'s tests clear, so a multi-eval version passes
6921    /// standalone and fails in the full suite. The CLI path is not affected —
6922    /// verified against `nix eval` on both shapes, both engines agreeing on
6923    /// column 18.
6924    #[test]
6925    fn inherit_bindings_carry_positions() {
6926        let dir = tempfile::tempdir().unwrap();
6927        // A PLAIN attrset, no `let ... in` wrapper: with the wrapper the
6928        // result is built lazily AFTER `import` returns, and the in-process
6929        // harness then resolves it without the file on the eval stack. The CLI
6930        // handles both (measured), the harness only this one.
6931        let body = "{ inherit ({ x = 1; }) x; }\n";
6932        let f = dir.path().join("inh.nix");
6933        std::fs::write(&f, body).unwrap();
6934        let v = eval(&format!("builtins.unsafeGetAttrPos \"x\" (import {})", f.display())).unwrap();
6935        let attrs = match v {
6936            Value::Attrs(a) => a,
6937            Value::Null => panic!("null — the inherit binding carried no position"),
6938            o => panic!("expected attrs, got {o:?}"),
6939        };
6940        // Computed from the fixture, never hardcoded: a hardcoded expectation is
6941        // how `pos::line_col`'s own "verified" comment came to agree with the
6942        // bug it documented.
6943        let off = body.rfind("x; }").unwrap();
6944        let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
6945        assert_eq!(*attrs.get("line").unwrap(), Value::Int(1));
6946        assert_eq!(*attrs.get("column").unwrap(), Value::Int((off - bol) as i64 + 1));
6947    }
6948
6949    /// Corpus gate: every attribute-BINDING form carries a position.
6950    ///
6951    /// Seals the class the three position bugs came from, rather than the three
6952    /// instances: `//` dropping positions wholesale, `pos::line_col` returning a
6953    /// constant, and `inherit` never being recorded. Each was found only because
6954    /// a NixOS toplevel drvPath diverged — an expensive way to learn that an
6955    /// attribute lost its position.
6956    ///
6957    /// Expectations are DERIVED from the fixture, never written out, so the test
6958    /// cannot drift into agreeing with whatever the implementation emits. That
6959    /// is exactly how `line_col`'s own "verified against nix eval" comment came
6960    /// to document the bug it contained.
6961    ///
6962    /// Anti-vacuity: the row count is asserted, and any `NULL` fails. A change
6963    /// that stops attaching positions altogether makes every row `NULL` — which
6964    /// must be a failure, not an empty-set pass.
6965    #[test]
6966    fn every_binding_form_carries_a_position() {
6967        let dir = tempfile::tempdir().unwrap();
6968        // One line per key so the expected line number is its 1-based index.
6969        let body = concat!(
6970            "let src = { i = 1; j = 2; }; in {\n",
6971            "  plain = 1;\n",
6972            "  \"quoted\" = 2;\n",
6973            "  inherit (src) i;\n",
6974            "  inherit src;\n",
6975            "  nested.deep = 3;\n",
6976            "}\n",
6977        );
6978        let f = dir.path().join("forms.nix");
6979        std::fs::write(&f, body).unwrap();
6980
6981        // `nested` is the head of a dotted path; CppNix points at the head.
6982        let keys = ["plain", "quoted", "i", "src", "nested"];
6983        let probe = keys
6984            .iter()
6985            .map(|k| format!(
6986                "(let q = builtins.unsafeGetAttrPos \"{k}\" t; \
6987                 in if q == null then \"{k}=NULL\" \
6988                 else \"{k}=${{toString q.line}}:${{toString q.column}}\")"
6989            ))
6990            .collect::<Vec<_>>()
6991            .join(" + \" \" + ");
6992        let got = eval(&format!("let t = import {}; in {probe}", f.display()))
6993            .unwrap()
6994            .as_string()
6995            .unwrap()
6996            .to_string();
6997
6998        assert!(!got.contains("NULL"), "a binding form lost its position: {got}");
6999        let rows: Vec<&str> = got.split(' ').collect();
7000        assert_eq!(rows.len(), keys.len(), "corpus shrank — gate would be vacuous: {got}");
7001
7002        // Derive each expectation by locating the key token in the fixture.
7003        for (k, row) in keys.iter().zip(&rows) {
7004            let needle = match *k {
7005                "quoted" => "\"quoted\"".to_string(),
7006                "i" => "i;".to_string(),
7007                "src" => "src;".to_string(),
7008                // A dotted path's head is followed by `.`, not ` =` — CppNix
7009                // reports the HEAD token's position for the outer key.
7010                "nested" => "nested.".to_string(),
7011                other => format!("{other} ="),
7012            };
7013            let off = body.find(&needle).unwrap();
7014            let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7015            let line = 1 + body[..off].matches('\n').count();
7016            let col = off - bol + 1;
7017            assert_eq!(*row, format!("{k}={line}:{col}"), "wrong position for `{k}` in:\n{body}");
7018        }
7019    }
7020
7021    /// A missing-argument error names the file the LAMBDA came from.
7022    ///
7023    /// Evaluated with `eval_with_file`, not `push_eval_file` + bare `eval`, and
7024    /// the difference is the point. Calling a closure now pushes the closure's
7025    /// OWN file — including a fileless frame when it has none — so a lambda
7026    /// defined in a fileless string no longer borrows whatever unrelated file
7027    /// happens to sit on the stack. That borrowing is what the old form
7028    /// asserted, and CppNix does not do it: an `--expr` lambda has no file.
7029    /// Associating the source with a file, as every real `import` does, keeps
7030    /// the original intent (errors carry file context) while testing the path
7031    /// production actually takes. Verified against CppNix: for a lambda in a
7032    /// real file both engines name that file.
7033    #[test]
7034    fn error_missing_argument_includes_file_context() {
7035        let p = std::path::PathBuf::from("/nix/store/func.nix");
7036        let result = eval_with_file("({ a, b }: a) { a = 1; }", Some(p));
7037        let msg = format!("{}", result.unwrap_err());
7038        assert!(msg.contains("missing argument"), "msg: {msg}");
7039        assert!(msg.contains("func.nix"), "msg: {msg}");
7040    }
7041
7042    #[test]
7043    fn error_cannot_call_includes_file_context() {
7044        let p = std::path::PathBuf::from("/nix/store/call.nix");
7045        let _g = push_eval_file(p);
7046        let result = eval("42 99");
7047        let msg = format!("{}", result.unwrap_err());
7048        assert!(msg.contains("cannot call"), "msg: {msg}");
7049        assert!(msg.contains("call.nix"), "msg: {msg}");
7050    }
7051
7052    #[test]
7053    fn error_without_file_has_no_in_prefix() {
7054        // When no file is on the eval stack, error messages should
7055        // not contain ", in" context.
7056        let result = eval("nonexistent_xyz");
7057        let msg = format!("{}", result.unwrap_err());
7058        assert!(msg.contains("undefined variable"), "msg: {msg}");
7059        assert!(!msg.contains(", in"), "msg should not contain file context: {msg}");
7060    }
7061
7062    // ── pure mode getter/setter independence ───────────────
7063
7064    #[test]
7065    fn pure_mode_set_get_independence() {
7066        let was = is_pure_mode();
7067        set_pure_mode(true);
7068        assert!(is_pure_mode());
7069        set_pure_mode(false);
7070        assert!(!is_pure_mode());
7071        set_pure_mode(was);
7072    }
7073
7074    // ── eval_with_file with file path ──────────────────────
7075
7076    #[test]
7077    fn eval_with_file_some_path_arithmetic() {
7078        let p = std::path::PathBuf::from("/tmp/imaginary.nix");
7079        let result = eval_with_file("1 + 2", Some(p)).unwrap();
7080        assert_eq!(result, Value::Int(3));
7081    }
7082
7083    // ── unsafeGetAttrPos — the options.json `attrTag` declarations root ──
7084    //
7085    // Seals the CppNix-matching behavior: for a literal attrset built in a
7086    // FILE, `builtins.unsafeGetAttrPos <key> <set>` returns
7087    // `{ file; line=1; column=<key byte offset>+1; }`; for a `<string>` eval
7088    // (no file) it returns `null`. Byte-verified against `nix eval`.
7089
7090    #[test]
7091    fn unsafe_get_attr_pos_reports_file_and_offset_column() {
7092        // The real `attrTag` path: a literal attrset built in an IMPORTED file.
7093        // `import` registers the file's source text + pushes it on the eval
7094        // stack, so `eval_attrset` captures the key positions against that file
7095        // and `unsafeGetAttrPos` resolves them. CppNix reports the file plus a
7096        // real newline-resolved line and BYTE column.
7097        //
7098        // Re-baselined: this used to assert line 1 and column = the key's
7099        // 1-based byte offset in the whole file, citing "verified against nix
7100        // eval". It was not — that was sui's own output taken as the oracle,
7101        // and the same false rule was pinned in pos.rs. Measured on nix 2.31.5:
7102        // for `{ a = 1;\n  b = 2; }` the `b` key is 2:3, not 1:12.
7103        let dir = tempfile::tempdir().unwrap();
7104        // The literal's `b` key sits at a known byte offset in this file.
7105        let file_body = "{ a = 1;\n  b = 2; }\n";
7106        let f = dir.path().join("lit.nix");
7107        std::fs::write(&f, file_body).unwrap();
7108        let src = format!("builtins.unsafeGetAttrPos \"b\" (import {})", f.display());
7109        let v = eval(&src).unwrap();
7110        let attrs = match v { Value::Attrs(a) => a, other => panic!("expected attrs, got {other:?}") };
7111        assert_eq!(
7112            attrs.get("file").unwrap().as_string().unwrap(),
7113            f.to_string_lossy(),
7114        );
7115        // `b` is on the SECOND line, at byte column 3.
7116        let off = file_body.find("b = 2").unwrap();
7117        let bol = file_body[..off].rfind('\n').map_or(0, |i| i + 1);
7118        let expected_line = 1 + file_body[..off].matches('\n').count() as i64;
7119        let expected_col = (off - bol) as i64 + 1;
7120        assert_eq!(expected_line, 2, "fixture must put `b` on line 2");
7121        assert_eq!(*attrs.get("line").unwrap(), Value::Int(expected_line));
7122        let col = match attrs.get("column").unwrap() { Value::Int(n) => *n, o => panic!("{o:?}") };
7123        assert_eq!(col, expected_col, "column must be the 1-based BYTE column");
7124    }
7125
7126    #[test]
7127    fn unsafe_get_attr_pos_null_for_string_origin() {
7128        // A `<string>`-eval'd literal (no file on the stack) has no position → null.
7129        let v = eval("builtins.unsafeGetAttrPos \"a\" { a = 1; }").unwrap();
7130        assert_eq!(v, Value::Null);
7131    }
7132
7133    #[test]
7134    fn unsafe_get_attr_pos_null_for_missing_key() {
7135        // A key absent from an imported set → null.
7136        let dir = tempfile::tempdir().unwrap();
7137        let f = dir.path().join("lit.nix");
7138        std::fs::write(&f, "{ a = 1; }\n").unwrap();
7139        let src = format!("builtins.unsafeGetAttrPos \"zzz\" (import {})", f.display());
7140        let v = eval(&src).unwrap();
7141        assert_eq!(v, Value::Null);
7142    }
7143
7144    // ── String interpolation primitive coercions ───────────
7145
7146    #[test]
7147    fn interp_int_into_string() {
7148        // Integer interpolated into a string is coerced to its decimal repr.
7149        assert_eq!(ev(r#""val=${toString 42}""#), Value::string("val=42"));
7150    }
7151
7152    #[test]
7153    fn interp_bool_true_becomes_one() {
7154        // Per eval_str: Bool(true) → "1", Bool(false) → "" (empty)
7155        let v = ev(r#"let x = true; in "${builtins.toString x}""#);
7156        assert_eq!(v, Value::string("1"));
7157    }
7158
7159    #[test]
7160    fn interp_null_becomes_empty() {
7161        // Null in interpolation is empty.
7162        let v = ev(r#"let x = null; in "${builtins.toString x}""#);
7163        assert_eq!(v, Value::string(""));
7164    }
7165
7166    #[test]
7167    fn interp_attrset_without_to_string_errors() {
7168        // An attrset interpolated without __toString is a type error.
7169        let result = eval(r#"let s = { x = 1; }; in "${s}""#);
7170        assert!(result.is_err());
7171    }
7172
7173    #[test]
7174    fn interp_attrset_with_to_string_protocol() {
7175        // __toString protocol returns a string when called with self.
7176        let v = ev(r#""${{ __toString = self: "ok"; }}""#);
7177        assert_eq!(v, Value::string("ok"));
7178    }
7179
7180    // ── Path PathRel / PathHome / PathAbs ─────────────────
7181
7182    #[test]
7183    fn eval_path_absolute_literal() {
7184        let v = ev("/tmp/foo");
7185        match v {
7186            Value::Path(p) => assert!(p.contains("/tmp/foo")),
7187            _ => panic!("expected Path"),
7188        }
7189    }
7190
7191    #[test]
7192    fn eval_path_home_literal() {
7193        let v = ev("~/foo.nix");
7194        match v {
7195            Value::Path(p) => assert!(p.contains("~/foo.nix") || p.ends_with("foo.nix")),
7196            _ => panic!("expected Path"),
7197        }
7198    }
7199
7200    // ── search path miss ──────────────────────────────────
7201
7202    #[test]
7203    fn path_search_unmatched_errors() {
7204        // Without NIX_PATH entries matching, <nonexistent> errors out.
7205        // We unset NIX_PATH locally to ensure no entries match.
7206        let saved = std::env::var("NIX_PATH").ok();
7207        // SAFETY: tests run sequentially in single-threaded mode by
7208        // default? The thread_local NIX_PATH is per-thread but std::env
7209        // is process-global. We restore it after.
7210        unsafe {
7211            std::env::remove_var("NIX_PATH");
7212        }
7213        let result = eval("<this_should_not_resolve>");
7214        if let Some(v) = saved {
7215            unsafe {
7216                std::env::set_var("NIX_PATH", v);
7217            }
7218        }
7219        assert!(result.is_err());
7220    }
7221
7222    // ── Unary operators ────────────────────────────────────
7223
7224    #[test]
7225    fn unary_negate_int() {
7226        assert_eq!(ev("-7"), Value::Int(-7));
7227    }
7228
7229    #[test]
7230    fn unary_negate_float() {
7231        assert_eq!(ev("-2.5"), Value::Float(-2.5));
7232    }
7233
7234    #[test]
7235    fn unary_invert_true() {
7236        assert_eq!(ev("!true"), Value::Bool(false));
7237    }
7238
7239    #[test]
7240    fn unary_invert_false() {
7241        assert_eq!(ev("!false"), Value::Bool(true));
7242    }
7243
7244    #[test]
7245    fn unary_negate_bool_errors() {
7246        let result = eval("-true");
7247        assert!(result.is_err());
7248    }
7249
7250    #[test]
7251    fn unary_invert_int_errors() {
7252        let result = eval("!42");
7253        assert!(result.is_err());
7254    }
7255
7256    // ── Binary op type errors ──────────────────────────────
7257
7258    #[test]
7259    fn binop_add_attrs_errors() {
7260        let result = eval("{a=1;} + {b=2;}");
7261        assert!(result.is_err());
7262    }
7263
7264    #[test]
7265    fn binop_sub_string_errors() {
7266        let result = eval(r#""a" - "b""#);
7267        assert!(result.is_err());
7268    }
7269
7270    #[test]
7271    fn binop_mul_string_errors() {
7272        let result = eval(r#""a" * "b""#);
7273        assert!(result.is_err());
7274    }
7275
7276    #[test]
7277    fn binop_div_string_errors() {
7278        let result = eval(r#""a" / "b""#);
7279        assert!(result.is_err());
7280    }
7281
7282    #[test]
7283    fn binop_compare_attrs_errors() {
7284        let result = eval("{a=1;} < {b=2;}");
7285        assert!(result.is_err());
7286    }
7287
7288    #[test]
7289    fn binop_div_float_by_zero_int() {
7290        // Float / int(0) is NOT a DivisionByZero error in this evaluator —
7291        // only int/int matches the DivisionByZero branch. This documents
7292        // that branch.
7293        let result = eval("1.0 / 0");
7294        // Either inf or error is acceptable; the documented branch is
7295        // the int/int(0) → DivisionByZero one.
7296        let _ = result;
7297    }
7298
7299    #[test]
7300    fn binop_int_div_zero_is_division_by_zero() {
7301        let result = eval("5 / 0");
7302        match result {
7303            Err(EvalError::DivisionByZero) => {}
7304            other => panic!("expected DivisionByZero, got {other:?}"),
7305        }
7306    }
7307
7308    // ── if/then/else laziness ──────────────────────────────
7309
7310    #[test]
7311    fn if_else_only_chosen_branch_evaluated_then() {
7312        // The else branch contains a divide-by-zero that would error
7313        // if eagerly evaluated. Choosing the then branch must skip it.
7314        assert_eq!(ev("if true then 42 else 1 / 0"), Value::Int(42));
7315    }
7316
7317    #[test]
7318    fn if_else_only_chosen_branch_evaluated_else() {
7319        assert_eq!(ev("if false then 1 / 0 else 99"), Value::Int(99));
7320    }
7321
7322    #[test]
7323    fn if_condition_must_be_bool() {
7324        let result = eval("if 1 then 1 else 2");
7325        assert!(result.is_err());
7326    }
7327
7328    #[test]
7329    fn if_condition_lazy_does_not_force_unused() {
7330        // Lazy `let` ensures that `bad` is only forced if the chosen
7331        // branch references it.
7332        assert_eq!(
7333            ev("let bad = 1 / 0; in if true then 42 else bad"),
7334            Value::Int(42),
7335        );
7336    }
7337
7338    // ── Logic short-circuit laziness ───────────────────────
7339
7340    #[test]
7341    fn and_short_circuits_on_false() {
7342        // RHS contains an error; should never run.
7343        assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
7344    }
7345
7346    #[test]
7347    fn or_short_circuits_on_true() {
7348        assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
7349    }
7350
7351    #[test]
7352    fn implication_short_circuits_on_false_lhs() {
7353        // false -> anything is true; RHS not evaluated.
7354        assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
7355    }
7356
7357    // ── Lambda fixpoint via let ────────────────────────────
7358
7359    #[test]
7360    fn lambda_fix_combinator_returns_attrset() {
7361        // The classic `fix = f: let x = f x; in x` shape.
7362        let v = ev(
7363            "let fix = f: let x = f x; in x; in
7364              (fix (self: { val = 1; double = self.val * 2; })).double",
7365        );
7366        assert_eq!(v, Value::Int(2));
7367    }
7368
7369    // ── eval_attrset rec scope details ─────────────────────
7370
7371    #[test]
7372    fn rec_attrset_self_reference() {
7373        // rec set with simple forward reference.
7374        let v = ev("(rec { a = b; b = 1; }).a");
7375        assert_eq!(v, Value::Int(1));
7376    }
7377
7378    #[test]
7379    fn rec_attrset_inherit_from_uses_outer_scope() {
7380        // inherit-from in rec uses the OUTER (lexical) scope to evaluate
7381        // the source expression, not the rec scope. We bind `src` in
7382        // an outer let so the inherit can find it.
7383        let v = ev(
7384            "let src = { a = 10; }; in
7385              rec {
7386                inherit (src) a;
7387                b = a + 1;
7388              }",
7389        );
7390        if let Value::Attrs(attrs) = v {
7391            let b = attrs.get("b").unwrap();
7392            let b_forced = force_value(b).unwrap();
7393            assert_eq!(b_forced, Value::Int(11));
7394        } else {
7395            panic!("expected attrs");
7396        }
7397    }
7398
7399    #[test]
7400    fn nonrec_attrset_no_self_reference() {
7401        // In a non-rec set, a name doesn't see its sibling. The error
7402        // surfaces as an UndefinedVar when the thunk is forced.
7403        let result = eval("({ a = 1; b = a + 1; }).b");
7404        assert!(result.is_err());
7405    }
7406
7407    // ── eval_attrset deep merge edge cases ─────────────────
7408
7409    #[test]
7410    fn dotted_binding_three_segments_then_sibling() {
7411        let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
7412        if let Value::Attrs(attrs) = v {
7413            let a = attrs.get("a").unwrap();
7414            let a_forced = force_value(a).unwrap();
7415            if let Value::Attrs(a_attrs) = a_forced {
7416                let b = a_attrs.get("b").unwrap();
7417                let b_forced = force_value(b).unwrap();
7418                if let Value::Attrs(b_attrs) = b_forced {
7419                    assert_eq!(force_value(b_attrs.get("c").unwrap()).unwrap(), Value::Int(1));
7420                    assert_eq!(force_value(b_attrs.get("d").unwrap()).unwrap(), Value::Int(2));
7421                } else {
7422                    panic!("expected b to be attrs");
7423                }
7424                assert_eq!(force_value(a_attrs.get("e").unwrap()).unwrap(), Value::Int(3));
7425            } else {
7426                panic!("expected a to be attrs");
7427            }
7428        } else {
7429            panic!("expected outer attrs");
7430        }
7431    }
7432
7433    // ── rec/let dotted bindings in recursive scope ────────
7434
7435    #[test]
7436    fn rec_dotted_bindings_visible_to_siblings() {
7437        // Dotted bindings in rec blocks must be visible to sibling
7438        // bindings -- this is the nixpkgs lib/systems/parse.nix pattern.
7439        let v = ev("rec { types.openSB = 1; types.openCpu = 2; foo = types.openSB; }.foo");
7440        assert_eq!(v, Value::Int(1));
7441    }
7442
7443    #[test]
7444    fn rec_dotted_leaf_uses_rec_scope() {
7445        // Leaf expressions in dotted bindings must see sibling
7446        // rec-bindings, not just the parent scope.
7447        let v = ev("rec { types.a = f 1; f = x: x + 1; }.types.a");
7448        assert_eq!(v, Value::Int(2));
7449    }
7450
7451    #[test]
7452    fn rec_dotted_multiple_keys_merge() {
7453        // Multiple dotted bindings sharing a top-level key must merge.
7454        let v = ev("rec { types.a = 1; types.b = 2; x = types; }.x");
7455        if let Value::Attrs(attrs) = v {
7456            assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7457            assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7458        } else {
7459            panic!("expected attrs");
7460        }
7461    }
7462
7463    #[test]
7464    fn rec_nixpkgs_parse_pattern() {
7465        // Simplified nixpkgs lib/systems/parse.nix pattern:
7466        // rec block with dotted types.xxx bindings that reference
7467        // each other through the rec scope.
7468        let v = ev(r#"
7469            let
7470              mkOptionType = x: x;
7471              mergeOneOption = "merge";
7472              attrValues = builtins.attrValues;
7473              setType = name: value: { __type = name; } // value;
7474              mapAttrs = builtins.mapAttrs;
7475              enum = xs: mkOptionType { name = "enum"; check = x: builtins.elem x xs; };
7476              setTypes = type: mapAttrs (name: value: setType type.name ({ inherit name; } // value));
7477            in
7478            rec {
7479              types.openSB = mkOptionType { name = "sb"; merge = mergeOneOption; };
7480              types.significantByte = enum (attrValues significantBytes);
7481              significantBytes = setTypes types.openSB { bigEndian = {}; littleEndian = {}; };
7482              types.openCpuType = mkOptionType { name = "cpu-type"; };
7483              types.cpuType = enum (attrValues cpuTypes);
7484              cpuTypes = setTypes types.openCpuType { arm = { bits = 32; }; };
7485            }.types.openCpuType
7486        "#);
7487        if let Value::Attrs(attrs) = v {
7488            assert_eq!(
7489                force_value(attrs.get("name").unwrap()).unwrap(),
7490                Value::string("cpu-type")
7491            );
7492        } else {
7493            panic!("expected attrs");
7494        }
7495    }
7496
7497    #[test]
7498    fn let_dotted_leaf_uses_let_scope() {
7499        // Dotted binding leaf in a let block sees sibling let-bindings.
7500        let v = ev("let a.x = f 1; f = x: x + 1; in a.x");
7501        assert_eq!(v, Value::Int(2));
7502    }
7503
7504    #[test]
7505    fn let_inherit_from_plus_dotted_overrides() {
7506        // inherit-from and dotted bindings for the same key in a let
7507        // block: CppNix rejects this as a duplicate definition.  Sui
7508        // currently lets the dotted binding win (last-write-wins).
7509        // This test documents the current behaviour -- when we add
7510        // duplicate detection it should change to assert an error.
7511        let v = ev(r#"
7512            let
7513              src = { types = { existing = true; }; };
7514              inherit (src) types;
7515              types.added = true;
7516            in types
7517        "#);
7518        if let Value::Attrs(attrs) = v {
7519            // Dotted binding overwrites the inherited value
7520            assert_eq!(
7521                force_value(attrs.get("added").unwrap()).unwrap(),
7522                Value::Bool(true)
7523            );
7524            // Inherited 'existing' is lost because dotted replaced it
7525            assert!(attrs.get("existing").is_none());
7526        } else {
7527            panic!("expected attrs");
7528        }
7529    }
7530
7531    // ── Function pattern variations ────────────────────────
7532
7533    #[test]
7534    fn pattern_empty_no_args_no_ellipsis() {
7535        // {} pattern accepts only an empty attrset.
7536        assert_eq!(ev("({}: 1) {}"), Value::Int(1));
7537    }
7538
7539    #[test]
7540    fn pattern_empty_with_ellipsis_accepts_extra() {
7541        assert_eq!(ev("({...}: 1) { a = 1; b = 2; }"), Value::Int(1));
7542    }
7543
7544    #[test]
7545    fn pattern_all_defaults() {
7546        assert_eq!(
7547            ev("({a ? 1, b ? 2}: a + b) {}"),
7548            Value::Int(3),
7549        );
7550    }
7551
7552    #[test]
7553    fn pattern_at_bind_before() {
7554        // args @ { x }: args.x — bind name comes before pattern.
7555        assert_eq!(ev("(args @ { x }: args.x) { x = 7; }"), Value::Int(7));
7556    }
7557
7558    #[test]
7559    fn pattern_at_bind_after() {
7560        // { x } @ args: args.x — bind name comes after pattern.
7561        assert_eq!(ev("({ x } @ args: args.x) { x = 7; }"), Value::Int(7));
7562    }
7563
7564    #[test]
7565    fn pattern_default_references_other_arg() {
7566        // The default for `b` references `a` (which exists).
7567        assert_eq!(ev("({a, b ? a + 1}: b) {a = 10;}"), Value::Int(11));
7568    }
7569
7570    #[test]
7571    fn pattern_required_missing_errors() {
7572        let result = eval("({ a, b }: a) { a = 1; }");
7573        assert!(result.is_err());
7574    }
7575
7576    #[test]
7577    fn pattern_unexpected_errors_without_ellipsis() {
7578        let result = eval("({ a }: a) { a = 1; b = 2; }");
7579        assert!(result.is_err());
7580    }
7581
7582    // ── apply: error on non-callable ───────────────────────
7583
7584    #[test]
7585    fn apply_int_errors() {
7586        let result = eval("42 5");
7587        assert!(result.is_err());
7588    }
7589
7590    #[test]
7591    fn apply_string_errors() {
7592        let result = eval(r#""hi" 5"#);
7593        assert!(result.is_err());
7594    }
7595
7596    #[test]
7597    fn apply_attrset_without_functor_errors() {
7598        let result = eval("{ x = 1; } 5");
7599        assert!(result.is_err());
7600        let msg = format!("{}", result.unwrap_err());
7601        assert!(msg.contains("__functor") || msg.contains("cannot call"));
7602    }
7603
7604    // ── Select with multi-segment + default ────────────────
7605
7606    #[test]
7607    fn select_multi_segment_with_default() {
7608        // a.b.missing or 99 -- the missing segment yields the default.
7609        assert_eq!(ev("{ a = { b = 1; }; }.a.c or 99"), Value::Int(99));
7610    }
7611
7612    #[test]
7613    fn select_from_int_errors() {
7614        let result = eval("(1).x");
7615        assert!(result.is_err());
7616    }
7617
7618    // ── HasAttr edge cases ─────────────────────────────────
7619
7620    #[test]
7621    fn has_attr_on_non_set_returns_false() {
7622        // `expr ? a` where expr is not a set returns false (not error).
7623        assert_eq!(ev("1 ? x"), Value::Bool(false));
7624    }
7625
7626    #[test]
7627    fn has_attr_nested_path_present() {
7628        assert_eq!(ev("{ a = { b = 1; }; } ? a.b"), Value::Bool(true));
7629    }
7630
7631    #[test]
7632    fn has_attr_nested_path_missing() {
7633        assert_eq!(ev("{ a = { b = 1; }; } ? a.c"), Value::Bool(false));
7634    }
7635
7636    #[test]
7637    fn has_attr_intermediate_missing_returns_false() {
7638        assert_eq!(ev("{} ? a.b.c"), Value::Bool(false));
7639    }
7640
7641    // ── List eval edge cases ───────────────────────────────
7642
7643    #[test]
7644    fn list_with_function_value() {
7645        let v = ev("[(x: x + 1)]");
7646        if let Value::List(items) = v {
7647            assert_eq!(items.len(), 1);
7648            // List elements are now lazy (thunked). Force to check type.
7649            let forced = force_value(&items[0]).unwrap();
7650            assert!(matches!(forced, Value::Lambda(_)));
7651        } else {
7652            panic!("expected list");
7653        }
7654    }
7655
7656    // ── eval_inherit edge: inherit from missing var ────────
7657
7658    #[test]
7659    fn inherit_unknown_name_errors() {
7660        let result = eval("let x = 1; in let inherit nonexistent; in nonexistent");
7661        assert!(result.is_err());
7662    }
7663
7664    // ── String op: string concat preserves context ─────────
7665
7666    #[test]
7667    fn string_concat_no_context_when_both_plain() {
7668        let v = ev(r#""abc" + "def""#);
7669        if let Value::String(ns) = v {
7670            assert_eq!(ns.chars, "abcdef");
7671            assert!(!ns.has_context());
7672        } else {
7673            panic!("expected string");
7674        }
7675    }
7676
7677    // ── Parens / Root ──────────────────────────────────────
7678
7679    #[test]
7680    fn parens_around_expression() {
7681        assert_eq!(ev("(1 + 2)"), Value::Int(3));
7682    }
7683
7684    #[test]
7685    fn nested_parens() {
7686        assert_eq!(ev("(((42)))"), Value::Int(42));
7687    }
7688
7689    // ── Throw via builtins ─────────────────────────────────
7690
7691    #[test]
7692    fn throw_propagates_as_error() {
7693        let result = eval(r#"builtins.throw "kaboom""#);
7694        match result {
7695            Err(EvalError::Throw(s)) => assert!(s.contains("kaboom")),
7696            other => panic!("expected Throw, got {other:?}"),
7697        }
7698    }
7699
7700    #[test]
7701    fn assert_failed_propagates_as_error() {
7702        let result = eval("assert false; 1");
7703        match result {
7704            Err(EvalError::AssertionFailed(_)) => {}
7705            other => panic!("expected AssertionFailed, got {other:?}"),
7706        }
7707    }
7708
7709    // ── eval_str InterpolPart::Literal only ────────────────
7710
7711    #[test]
7712    fn string_no_interp_yields_no_context() {
7713        let v = ev(r#""just literal""#);
7714        if let Value::String(ns) = v {
7715            assert!(!ns.has_context());
7716        } else {
7717            panic!("expected string");
7718        }
7719    }
7720
7721    // ── Path interpolation adds context ───────────────────
7722
7723    // Byte-parity root #5: interpolating a source path is CppNix copy-to-store
7724    // coercion — the path is NAR-copied into /nix/store/<hash>-<name> and the
7725    // store path (with store-path context) is spliced in, not the raw path.
7726    // NAR of a single regular file is content+basename only (location-
7727    // independent), so a temp <dir>/data.txt of "hello\n" yields the exact
7728    // store path nix 2.34 produced: /nix/store/y9dmv…-data.txt.
7729    #[test]
7730    fn interp_path_copies_to_store_byte_matches_cppnix() {
7731        let dir = std::env::temp_dir().join(format!("sui-r5-interp-{}", std::process::id()));
7732        let _ = std::fs::remove_dir_all(&dir);
7733        std::fs::create_dir_all(&dir).unwrap();
7734        let f = dir.join("data.txt");
7735        std::fs::write(&f, b"hello\n").unwrap();
7736        let expr = format!(r#""${{{}}}""#, f.display());
7737        let v = eval(&expr).unwrap();
7738        if let Value::String(ns) = v {
7739            assert_eq!(
7740                ns.chars.to_string(),
7741                "/nix/store/y9dmvfhip31hg8ia4njwjz9vfa3ndphr-data.txt",
7742            );
7743            assert!(ns.has_context());
7744        } else {
7745            panic!("expected string");
7746        }
7747        let _ = std::fs::remove_dir_all(&dir);
7748    }
7749
7750    // ── pipe operators (NotImplemented) ────────────────────
7751    // Pipe operators (|>, <|) are parsed as PipeRight/PipeLeft and
7752    // currently return NotImplemented. We can't easily evaluate them
7753    // here because rnix may not even parse them, so we just rely on
7754    // the binop branch existing.
7755
7756    // ── ParseError surface ─────────────────────────────────
7757
7758    #[test]
7759    fn parse_error_unbalanced_braces() {
7760        let result = eval("{ a = 1");
7761        assert!(result.is_err());
7762        let err = result.unwrap_err();
7763        assert!(matches!(err, EvalError::ParseError(_)));
7764    }
7765
7766    #[test]
7767    fn parse_error_dangling_let() {
7768        let result = eval("let in");
7769        assert!(result.is_err());
7770    }
7771
7772    #[test]
7773    fn parse_error_empty_input() {
7774        let result = eval("");
7775        assert!(result.is_err());
7776    }
7777
7778    // ── num_op coverage via float ops ──────────────────────
7779
7780    #[test]
7781    fn float_int_subtraction() {
7782        assert_eq!(ev("3.5 - 1"), Value::Float(2.5));
7783    }
7784
7785    #[test]
7786    fn int_float_subtraction() {
7787        assert_eq!(ev("3 - 0.5"), Value::Float(2.5));
7788    }
7789
7790    #[test]
7791    fn float_float_division() {
7792        assert_eq!(ev("6.0 / 2.0"), Value::Float(3.0));
7793    }
7794
7795    #[test]
7796    fn int_float_multiplication() {
7797        assert_eq!(ev("3 * 2.5"), Value::Float(7.5));
7798    }
7799
7800    // ── compare with mixed numerics ────────────────────────
7801
7802    #[test]
7803    fn compare_int_float_less() {
7804        assert_eq!(ev("1 < 1.5"), Value::Bool(true));
7805    }
7806
7807    #[test]
7808    fn compare_float_int_more() {
7809        assert_eq!(ev("3.5 > 3"), Value::Bool(true));
7810    }
7811
7812    #[test]
7813    fn compare_equal_int_float() {
7814        assert_eq!(ev("3 <= 3.0"), Value::Bool(true));
7815    }
7816
7817    // ── Equality ──────────────────────────────────────────
7818
7819    #[test]
7820    fn equal_lists_same() {
7821        assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
7822    }
7823
7824    #[test]
7825    fn equal_lists_diff_length() {
7826        assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
7827    }
7828
7829    #[test]
7830    fn not_equal_lists() {
7831        assert_eq!(ev("[1] != [2]"), Value::Bool(true));
7832    }
7833
7834    #[test]
7835    fn equal_attrsets_same() {
7836        assert_eq!(ev("{a = 1; b = 2;} == {b = 2; a = 1;}"), Value::Bool(true));
7837    }
7838
7839    // ── Lambda identity equality (Rc ptr_eq) ────────────────
7840    // Regression test: same lambda via Rc must compare equal.
7841    // Without this, nixpkgs stdenv evaluation enters an infinite loop
7842    // because `crossSystem != localSystem` returns true even when both
7843    // are the same elaborate result (containing shared function attrs).
7844
7845    #[test]
7846    fn lambda_self_equality_in_attrset() {
7847        // Same closure shared via let → inherit must be equal
7848        assert_eq!(
7849            ev("let f = x: x; in { a = 1; inherit f; } == { a = 1; inherit f; }"),
7850            Value::Bool(true),
7851        );
7852    }
7853
7854    #[test]
7855    fn lambda_self_reference_attrset_equality() {
7856        // Attrset with function attr: x == x must be true
7857        assert_eq!(
7858            ev("let x = { a = 1; f = y: y; }; in x == x"),
7859            Value::Bool(true),
7860        );
7861    }
7862
7863    #[test]
7864    fn lambda_different_closures_not_equal() {
7865        // Different lambda closures (even structurally identical) must be false
7866        assert_eq!(
7867            ev("{ f = x: x; } == { f = x: x; }"),
7868            Value::Bool(false),
7869        );
7870    }
7871
7872    #[test]
7873    fn lambda_ne_does_not_force_unused_branch() {
7874        // If crossSystem == localSystem (same obj), != returns false,
7875        // and the then-branch (with throw) is never forced.
7876        assert_eq!(
7877            ev("let ls = { a = 1; f = x: x; }; in if ls != ls then builtins.throw \"bug\" else 42"),
7878            Value::Int(42),
7879        );
7880    }
7881
7882    // ── force_value chains thunks ──────────────────────────
7883
7884    #[test]
7885    fn force_value_through_thunk() {
7886        let root = rnix::Root::parse("1 + 2");
7887        let expr = root.tree().expr().unwrap();
7888        let thunk = Thunk::new_suspended(expr, Env::new());
7889        let val = Value::Thunk(thunk);
7890        assert_eq!(force_value(&val).unwrap(), Value::Int(3));
7891    }
7892
7893    // ── Builtin name "tryEval" lazy arg path ──────────────
7894
7895    #[test]
7896    fn try_eval_catches_thrown_error() {
7897        // tryEval wraps the thunk and catches throws inside.
7898        let v = ev(r#"(builtins.tryEval (builtins.throw "oops")).success"#);
7899        assert_eq!(v, Value::Bool(false));
7900    }
7901
7902    #[test]
7903    fn try_eval_returns_value_on_success() {
7904        let v = ev("(builtins.tryEval 42).value");
7905        assert_eq!(v, Value::Int(42));
7906    }
7907
7908    // ── LegacyLet (`let { body = ...; ...}`) ───────────────
7909
7910    #[test]
7911    fn legacy_let_returns_body_attr() {
7912        // `let { x = 1; body = x + 41; }` is the legacy let form: it
7913        // is desugared as a recursive set whose `body` attr is the
7914        // result.
7915        assert_eq!(ev("let { x = 1; body = x + 41; }"), Value::Int(42));
7916    }
7917
7918    #[test]
7919    fn legacy_let_missing_body_errors() {
7920        let result = eval("let { x = 1; }");
7921        assert!(result.is_err());
7922    }
7923
7924    #[test]
7925    fn legacy_let_with_inherit_from_scope() {
7926        assert_eq!(
7927            ev("let outer = 5; in let { inherit outer; body = outer * 2; }"),
7928            Value::Int(10),
7929        );
7930    }
7931
7932    // ── eval_str interpolation more cases ──────────────────
7933
7934    #[test]
7935    fn interp_with_string_concat_preserves_order() {
7936        assert_eq!(
7937            ev(r#"let a = "x"; b = "y"; in "${a}-${b}""#),
7938            Value::string("x-y"),
7939        );
7940    }
7941
7942    #[test]
7943    fn interp_only_literal_part() {
7944        assert_eq!(ev(r#""no interp here""#), Value::string("no interp here"));
7945    }
7946
7947    // ── eval_attr dynamic / string keys ────────────────────
7948
7949    #[test]
7950    fn dynamic_attr_via_string_key_in_set() {
7951        // `{ "a" = 1; }.a` works because attr keys can be string literals.
7952        assert_eq!(ev(r#"{ "a" = 1; }.a"#), Value::Int(1));
7953    }
7954
7955    #[test]
7956    fn dynamic_attr_via_interpolated_key() {
7957        let v = ev(r#"let k = "foo"; in { ${k} = 99; }.foo"#);
7958        assert_eq!(v, Value::Int(99));
7959    }
7960
7961    // ── String key access via select with dynamic ──────────
7962
7963    #[test]
7964    fn select_with_string_key() {
7965        let v = ev(r#"{ a = 42; }."a""#);
7966        assert_eq!(v, Value::Int(42));
7967    }
7968
7969    // ── Apply via __functor on attrset ─────────────────────
7970
7971    #[test]
7972    fn apply_attrset_with_functor_works() {
7973        let v = ev("let s = { __functor = self: x: x + 1; }; in s 5");
7974        assert_eq!(v, Value::Int(6));
7975    }
7976
7977    // ── Negation of negative ───────────────────────────────
7978
7979    #[test]
7980    fn double_negate_int() {
7981        assert_eq!(ev("- (-5)"), Value::Int(5));
7982    }
7983
7984    // ── Inherit from rec scope binding visibility ──────────
7985
7986    #[test]
7987    fn inherit_in_let_makes_name_available() {
7988        assert_eq!(
7989            ev("let src = { a = 7; }; in let inherit (src) a; in a"),
7990            Value::Int(7),
7991        );
7992    }
7993
7994    // ── String + path ──────────────────────────────────────
7995
7996    #[test]
7997    fn path_plus_string_yields_path() {
7998        let v = ev(r#"/foo + "/bar""#);
7999        match v {
8000            Value::Path(p) => assert_eq!(&*p, "/foo/bar"),
8001            _ => panic!("expected path"),
8002        }
8003    }
8004
8005    // ── Lazy attrset value not forced unless selected ──────
8006
8007    #[test]
8008    fn attrset_value_not_forced_unless_selected() {
8009        // `bad` is an attr whose value would error if forced, but we
8010        // only ever select `good`, so it's never touched.
8011        assert_eq!(
8012            ev(r#"{ bad = builtins.throw "boom"; good = 42; }.good"#),
8013            Value::Int(42),
8014        );
8015    }
8016
8017    // ── Lambda calling itself via let ──────────────────────
8018
8019    #[test]
8020    fn lambda_recursive_via_let() {
8021        // factorial via let-bound recursive function
8022        assert_eq!(
8023            ev("let fact = n: if n == 0 then 1 else n * fact (n - 1); in fact 5"),
8024            Value::Int(120),
8025        );
8026    }
8027
8028    // ── Dynamic key in select ──────────────────────────────
8029
8030    #[test]
8031    fn select_with_dynamic_key_via_var() {
8032        // ${k} interpolation in select position is not standard Nix
8033        // syntax, but a string-literal key works for select.
8034        assert_eq!(ev(r#"let k = { x = 1; }; in k.x"#), Value::Int(1));
8035    }
8036
8037    // ── Compare strings ────────────────────────────────────
8038
8039    #[test]
8040    fn compare_string_lex_greater_or_equal() {
8041        assert_eq!(ev(r#""b" >= "a""#), Value::Bool(true));
8042        assert_eq!(ev(r#""a" >= "a""#), Value::Bool(true));
8043        assert_eq!(ev(r#""a" >= "b""#), Value::Bool(false));
8044    }
8045
8046    // ── PartialEq across types ─────────────────────────────
8047
8048    #[test]
8049    fn equal_int_string_false() {
8050        assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
8051    }
8052
8053    #[test]
8054    fn equal_null_int_false() {
8055        assert_eq!(ev("null == 0"), Value::Bool(false));
8056    }
8057
8058    // ── Update operator on thunked operands ────────────────
8059
8060    #[test]
8061    fn update_with_let_bound_operands() {
8062        assert_eq!(
8063            ev("let a = { x = 1; }; b = { y = 2; }; in (a // b).y"),
8064            Value::Int(2),
8065        );
8066    }
8067
8068    // ── Concat on let-bound lists ──────────────────────────
8069
8070    #[test]
8071    fn concat_lists_from_let() {
8072        assert_eq!(
8073            ev("let a = [1 2]; b = [3 4]; in builtins.length (a ++ b)"),
8074            Value::Int(4),
8075        );
8076    }
8077
8078    // ── String interpolation: list coercion ─────────────────
8079
8080    #[test]
8081    fn interp_list_coerces_with_spaces() {
8082        // Lists in interpolation are now coerced via coerce_to_string
8083        // (space-joined elements).
8084        assert_eq!(
8085            ev(r#""${toString [1 2 3]}""#),
8086            Value::string("1 2 3"),
8087        );
8088    }
8089
8090    #[test]
8091    fn interp_list_directly_coerces() {
8092        // Direct list interpolation space-joins elements via coerce_to_string.
8093        assert_eq!(
8094            ev(r#""${[1 2]}""#),
8095            Value::string("1 2"),
8096        );
8097    }
8098
8099    // ── String interpolation: outPath ─────────────────────
8100
8101    #[test]
8102    fn interp_outpath_attrset() {
8103        assert_eq!(
8104            ev(r#"let x = { outPath = "/nix/store/abc"; }; in "${x}""#),
8105            Value::string("/nix/store/abc"),
8106        );
8107    }
8108
8109    #[test]
8110    fn interp_tostring_takes_priority_over_outpath() {
8111        assert_eq!(
8112            ev(r#"let x = { __toString = self: "custom"; outPath = "/ignored"; }; in "${x}""#),
8113            Value::string("custom"),
8114        );
8115    }
8116
8117    #[test]
8118    fn interp_derivation_coerces_to_outpath() {
8119        // derivation produces an attrset with outPath
8120        let result = eval(r#"
8121            let drv = builtins.derivation {
8122                name = "test";
8123                system = "x86_64-linux";
8124                builder = "/bin/sh";
8125            };
8126            in "${drv}"
8127        "#).unwrap();
8128        if let Value::String(s) = result {
8129            assert!(s.chars.starts_with("/nix/store/"), "got: {}", s.chars);
8130        } else {
8131            panic!("expected string");
8132        }
8133    }
8134
8135    // ── String interpolation: lambda error ─────────────────
8136
8137    #[test]
8138    fn interp_lambda_errors() {
8139        let result = eval(r#""${x: x}""#);
8140        assert!(result.is_err());
8141    }
8142
8143    // ── force_value tests ────────────────────────────────────
8144
8145    #[test]
8146    fn force_value_int_returns_same() {
8147        let v = Value::Int(42);
8148        assert_eq!(force_value(&v).unwrap(), Value::Int(42));
8149    }
8150
8151    #[test]
8152    fn force_value_bool_returns_same() {
8153        let v = Value::Bool(true);
8154        assert_eq!(force_value(&v).unwrap(), Value::Bool(true));
8155    }
8156
8157    #[test]
8158    fn force_value_string_returns_same() {
8159        let v = Value::string("hello");
8160        assert_eq!(force_value(&v).unwrap(), Value::string("hello"));
8161    }
8162
8163    #[test]
8164    fn force_value_attrs_returns_same() {
8165        let mut a = NixAttrs::new();
8166        a.insert("x".to_string(), Value::Int(1));
8167        let v = Value::Attrs(Rc::new(a.clone()));
8168        assert_eq!(force_value(&v).unwrap(), Value::Attrs(Rc::new(a)));
8169    }
8170
8171    #[test]
8172    fn force_value_list_returns_same() {
8173        let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
8174        assert_eq!(
8175            force_value(&v).unwrap(),
8176            Value::list(vec![Value::Int(1), Value::Int(2)]),
8177        );
8178    }
8179
8180    #[test]
8181    fn force_value_null_returns_null() {
8182        let v = Value::Null;
8183        assert_eq!(force_value(&v).unwrap(), Value::Null);
8184    }
8185
8186    #[test]
8187    fn force_value_evaluated_thunk_returns_cached() {
8188        // Thunk wrapping a simple expression should evaluate and cache
8189        let v = ev("let x = 1 + 2; in x");
8190        assert_eq!(v, Value::Int(3));
8191        // Force again — should return the cached value
8192        assert_eq!(force_value(&v).unwrap(), Value::Int(3));
8193    }
8194
8195    // ── Tail-call loop tests ─────────────────────────────────
8196
8197    #[test]
8198    fn tco_if_true_condition() {
8199        assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
8200    }
8201
8202    #[test]
8203    fn tco_if_false_condition() {
8204        assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
8205    }
8206
8207    #[test]
8208    fn tco_deeply_nested_if_else_chain() {
8209        // Build a chain: if false then 1 else if false then 2 else ... else 150
8210        // All conditions are false except the final else, which produces 150.
8211        let mut expr = String::from("150");
8212        for i in (1..150).rev() {
8213            expr = format!("if false then {} else {}", i, expr);
8214        }
8215        let v = ev(&expr);
8216        assert_eq!(v, Value::Int(150));
8217    }
8218
8219    #[test]
8220    fn tco_assert_true_passes_through() {
8221        assert_eq!(ev("assert true; 42"), Value::Int(42));
8222    }
8223
8224    #[test]
8225    fn tco_assert_false_throws_assertion_failed() {
8226        let result = eval("assert false; 42");
8227        assert!(result.is_err());
8228        let err = result.unwrap_err();
8229        assert!(
8230            matches!(err, EvalError::AssertionFailed(_)),
8231            "expected AssertionFailed, got: {err}",
8232        );
8233    }
8234
8235    #[test]
8236    fn tco_with_makes_scope_available() {
8237        assert_eq!(ev("with { x = 10; y = 20; }; x + y"), Value::Int(30));
8238    }
8239
8240    #[test]
8241    fn tco_let_in_creates_bindings() {
8242        assert_eq!(ev("let a = 5; in a"), Value::Int(5));
8243    }
8244
8245    #[test]
8246    fn tco_let_in_multiple_bindings() {
8247        assert_eq!(ev("let a = 1; b = 2; c = 3; in a + b + c"), Value::Int(6));
8248    }
8249
8250    // ── eval_attrset tests ───────────────────────────────────
8251
8252    #[test]
8253    fn eval_attrset_empty() {
8254        let v = ev("{}");
8255        if let Value::Attrs(attrs) = v {
8256            assert!(attrs.is_empty(), "expected empty attrset");
8257        } else {
8258            panic!("expected attrset, got {v:?}");
8259        }
8260    }
8261
8262    #[test]
8263    fn eval_attrset_simple_kv() {
8264        let v = ev("{ a = 1; b = 2; }");
8265        if let Value::Attrs(attrs) = v {
8266            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8267            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8268        } else {
8269            panic!("expected attrset, got {v:?}");
8270        }
8271    }
8272
8273    #[test]
8274    fn eval_attrset_recursive() {
8275        assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
8276        assert_eq!(ev("(rec { a = 1; b = a + 1; }).a"), Value::Int(1));
8277    }
8278
8279    #[test]
8280    fn eval_attrset_inherit_from_scope() {
8281        assert_eq!(ev("let x = 1; in { inherit x; }.x"), Value::Int(1));
8282    }
8283
8284    #[test]
8285    fn eval_attrset_inherit_from_expr() {
8286        assert_eq!(
8287            ev("{ inherit (builtins) true; }.true"),
8288            Value::Bool(true),
8289        );
8290    }
8291
8292    #[test]
8293    fn eval_attrset_dotted_path() {
8294        assert_eq!(ev("{ a.b.c = 1; }.a.b.c"), Value::Int(1));
8295    }
8296
8297    #[test]
8298    fn eval_attrset_update_merge() {
8299        let v = ev("{ a = 1; } // { b = 2; }");
8300        if let Value::Attrs(attrs) = v {
8301            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8302            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8303        } else {
8304            panic!("expected attrset, got {v:?}");
8305        }
8306    }
8307
8308    // ── eval_apply tests ─────────────────────────────────────
8309
8310    #[test]
8311    fn eval_apply_simple_function() {
8312        assert_eq!(ev("(x: x + 1) 2"), Value::Int(3));
8313    }
8314
8315    #[test]
8316    fn eval_apply_pattern_destructuring() {
8317        assert_eq!(ev("({a, b}: a + b) { a = 1; b = 2; }"), Value::Int(3));
8318    }
8319
8320    #[test]
8321    fn eval_apply_default_arguments() {
8322        assert_eq!(ev("({a, b ? 0}: a + b) { a = 1; }"), Value::Int(1));
8323    }
8324
8325    #[test]
8326    fn eval_apply_ellipsis() {
8327        assert_eq!(ev("({a, ...}: a) { a = 1; b = 2; }"), Value::Int(1));
8328    }
8329
8330    // ── eval_select tests ────────────────────────────────────
8331
8332    #[test]
8333    fn eval_select_single_key() {
8334        assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
8335    }
8336
8337    #[test]
8338    fn eval_select_multi_level() {
8339        assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
8340    }
8341
8342    #[test]
8343    fn eval_select_with_or_default() {
8344        assert_eq!(ev("{}.a or 42"), Value::Int(42));
8345    }
8346
8347    #[test]
8348    fn eval_select_missing_key_without_default_throws() {
8349        let result = eval("{}.a");
8350        assert!(result.is_err());
8351    }
8352
8353    // ── BinOp tests ──────────────────────────────────────────
8354
8355    #[test]
8356    fn binop_add_ints() {
8357        assert_eq!(ev("1 + 2"), Value::Int(3));
8358    }
8359
8360    #[test]
8361    fn binop_sub_ints() {
8362        assert_eq!(ev("3 - 1"), Value::Int(2));
8363    }
8364
8365    #[test]
8366    fn binop_mul_ints() {
8367        assert_eq!(ev("2 * 3"), Value::Int(6));
8368    }
8369
8370    #[test]
8371    fn binop_div_ints() {
8372        assert_eq!(ev("6 / 2"), Value::Int(3));
8373    }
8374
8375    #[test]
8376    fn binop_float_arithmetic() {
8377        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
8378    }
8379
8380    #[test]
8381    fn binop_string_concat() {
8382        assert_eq!(
8383            ev(r#""hello" + " " + "world""#),
8384            Value::string("hello world"),
8385        );
8386    }
8387
8388    #[test]
8389    fn binop_list_concat() {
8390        assert_eq!(
8391            ev("[1 2] ++ [3 4]"),
8392            Value::list(vec![
8393                Value::Int(1),
8394                Value::Int(2),
8395                Value::Int(3),
8396                Value::Int(4),
8397            ]),
8398        );
8399    }
8400
8401    #[test]
8402    fn binop_attrset_update() {
8403        let v = ev("{ a = 1; } // { b = 2; }");
8404        if let Value::Attrs(attrs) = v {
8405            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8406            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8407        } else {
8408            panic!("expected attrset, got {v:?}");
8409        }
8410    }
8411
8412    #[test]
8413    fn binop_less_than() {
8414        assert_eq!(ev("1 < 2"), Value::Bool(true));
8415        assert_eq!(ev("2 < 1"), Value::Bool(false));
8416    }
8417
8418    #[test]
8419    fn binop_greater_than() {
8420        assert_eq!(ev("2 > 1"), Value::Bool(true));
8421        assert_eq!(ev("1 > 2"), Value::Bool(false));
8422    }
8423
8424    #[test]
8425    fn binop_equal() {
8426        assert_eq!(ev("1 == 1"), Value::Bool(true));
8427        assert_eq!(ev("1 == 2"), Value::Bool(false));
8428    }
8429
8430    #[test]
8431    fn binop_not_equal() {
8432        assert_eq!(ev("1 != 2"), Value::Bool(true));
8433        assert_eq!(ev("1 != 1"), Value::Bool(false));
8434    }
8435
8436    #[test]
8437    fn binop_logical_and() {
8438        assert_eq!(ev("true && false"), Value::Bool(false));
8439        assert_eq!(ev("true && true"), Value::Bool(true));
8440    }
8441
8442    #[test]
8443    fn binop_logical_or() {
8444        assert_eq!(ev("true || false"), Value::Bool(true));
8445        assert_eq!(ev("false || false"), Value::Bool(false));
8446    }
8447
8448    #[test]
8449    fn binop_logical_not() {
8450        assert_eq!(ev("!true"), Value::Bool(false));
8451        assert_eq!(ev("!false"), Value::Bool(true));
8452    }
8453
8454    #[test]
8455    fn binop_implication() {
8456        assert_eq!(ev("false -> true"), Value::Bool(true));
8457        assert_eq!(ev("false -> false"), Value::Bool(true));
8458        assert_eq!(ev("true -> true"), Value::Bool(true));
8459        assert_eq!(ev("true -> false"), Value::Bool(false));
8460    }
8461}