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            // Only a single-segment static key gets a position (a dotted path
2223            // `a.b = …` desugars to a nested set; CppNix points the position
2224            // at the head, and nixpkgs never `unsafeGetAttrPos`es a dotted
2225            // tag). Skip anything else.
2226            if path_attrs.len() != 1 {
2227                continue;
2228            }
2229            let Some(offset) = static_attr_offset(&path_attrs[0]) else { continue };
2230            // Resolve the static key name (Ident/Str) — never forces (a
2231            // dynamic key already returned None above).
2232            if let Ok(Some(name)) = eval_attr_maybe_null(&path_attrs[0], env) {
2233                table.insert(intern(&name), offset);
2234            }
2235        }
2236    }
2237    if !table.is_empty() {
2238        attrs.set_positions(std::rc::Rc::new(table));
2239    }
2240}
2241
2242fn eval_attrset(set: &ast::AttrSet, env: &Env) -> Result<Value, EvalError> {
2243    crate::perf::inc(crate::perf::Counter::Attrset);
2244    let mut attrs = NixAttrs::new();
2245    let is_rec = set.rec_token().is_some();
2246
2247    if is_rec {
2248        let mut rec_env = env.child();
2249        let mut thunks: Vec<(String, Thunk)> = Vec::new();
2250
2251        // Track which names have been defined so far in this scope.
2252        // Used by maybe_thunk to resolve backward references directly
2253        // instead of creating wasteful thunks.
2254        let mut defined_so_far: HashSet<String> = HashSet::new();
2255
2256        // Accumulator for dotted-path bindings (`rec { a.b = 1; a.c = 2; ... }`).
2257        // Leaf values are wrapped in thunks so they participate in the
2258        // recursive env fixpoint, matching CppNix semantics where
2259        // `rec { types.a = f 1; f = x: x + 1; }` allows `f` to be a
2260        // sibling binding.
2261        let mut dotted_attrs: NixAttrs = NixAttrs::new();
2262
2263        // Phase 1: Create thunks with placeholder env and bind them.
2264        for entry in set.entries() {
2265            match entry {
2266                ast::Entry::AttrpathValue(apv) => {
2267                    let attrpath = apv.attrpath().ok_or_else(|| {
2268                        EvalError::ParseError("binding missing attrpath".to_string())
2269                    })?;
2270                    let value_expr = apv.value().ok_or_else(|| {
2271                        EvalError::ParseError("binding missing value".to_string())
2272                    })?;
2273                    let mut path_keys: Vec<String> = attrpath
2274                        .attrs()
2275                        .filter_map(|a| eval_attr_maybe_null(&a, env).transpose())
2276                        .collect::<Result<_, _>>()?;
2277                    // Null dynamic attr name → skip entire binding (CppNix compat)
2278                    if path_keys.is_empty() { continue; }
2279                    if path_keys.len() == 1 {
2280                        let key = path_keys.pop().unwrap();
2281                        // Self-recursive detection in a `rec { … }` scope:
2282                        // any binding whose value-expr references the
2283                        // bound name OR any sibling key declared in this
2284                        // rec scope is potentially self-recursive (the
2285                        // siblings' thunks share the rec_env via Phase 2).
2286                        // Mark as recursive so inner re-entrance during
2287                        // force returns a Promise sentinel instead of
2288                        // erroring with InfiniteRecursion.
2289                        //
2290                        // For simplicity we check `key` and all already-
2291                        // defined siblings; siblings defined later are
2292                        // covered when THEIR thunks force (they reference
2293                        // back into this rec scope via Phase 2's env update).
2294                        // O(N) not O(N²): one memoized referenced-name set,
2295                        // intersected with key + already-defined siblings.
2296                        // Byte-identical to the prior per-name walks.
2297                        let referenced = referenced_idents(&value_expr);
2298                        let is_recursive_binding = referenced.contains(key.as_str())
2299                            || defined_so_far
2300                                .iter()
2301                                .any(|n| referenced.contains(n.as_str()));
2302                        let value = if is_recursive_binding {
2303                            Value::Thunk(Thunk::new_suspended_recursive(
2304                                value_expr.clone(),
2305                                env.clone(),
2306                            ))
2307                        } else {
2308                            // maybeThunk: skip thunk for trivial exprs.
2309                            // is_rec=true because rec attrset bindings
2310                            // can reference each other.
2311                            // Pass defined_so_far so backward refs
2312                            // resolve directly.
2313                            maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
2314                        };
2315                        rec_env.bind(key.clone(), value.clone());
2316                        attrs.insert(key.clone(), value.clone());
2317                        if let Value::Thunk(t) = &value {
2318                            thunks.push((key.clone(), t.clone()));
2319                        }
2320                        defined_so_far.insert(key);
2321                    } else {
2322                        // Multi-segment dotted path: build a nested attrset
2323                        // with a thunk at the leaf so the value expression
2324                        // can reference sibling rec-bindings.
2325                        let key = path_keys[0].clone();
2326                        let value =
2327                            build_nested_attr_thunk(&path_keys[1..], &value_expr, env, &mut thunks);
2328                        merge_nested_insert(&mut dotted_attrs, key, value);
2329                    }
2330                }
2331                ast::Entry::Inherit(inherit) => {
2332                    eval_inherit(&inherit, env, &mut attrs, Some(&mut rec_env), Some(&mut thunks))?;
2333                }
2334            }
2335        }
2336
2337        // Phase 1b: Bind accumulated dotted-path attrs into attrs and rec_env.
2338        // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
2339        // duplicate definition, so we do not attempt to merge with
2340        // existing inherit thunks — just bind directly.
2341        for (key, value) in dotted_attrs.iter() {
2342            attrs.insert(key.clone(), value.clone());
2343            rec_env.bind(key.clone(), value.clone());
2344        }
2345
2346        // Phase 2: Update all thunks (both Suspended and InheritSelect)
2347        // to capture the final rec_env (which now has all names bound).
2348        for (_key, thunk) in &thunks {
2349            thunk.update_env(&rec_env);
2350        }
2351    } else {
2352        for entry in set.entries() {
2353            match entry {
2354                ast::Entry::AttrpathValue(apv) => {
2355                    let attrpath = apv.attrpath().ok_or_else(|| {
2356                        EvalError::ParseError("binding missing attrpath".to_string())
2357                    })?;
2358                    let value_expr = apv.value().ok_or_else(|| {
2359                        EvalError::ParseError("binding missing value".to_string())
2360                    })?;
2361                    let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2362                    // CppNix defers a dynamic key that is NOT at the HEAD of the
2363                    // attrpath: `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`,
2364                    // so `e` never forces until `.a` is demanded. Evaluating the
2365                    // whole path eagerly would force `e` at construction and — in
2366                    // the module-system fixpoint — read `config.<x>` while `config`
2367                    // is mid-force (the M2.6 divergence: `homes.null` instead of
2368                    // `homes.<name>`). Only the head is eager; a lone dynamic tail
2369                    // becomes a deferred thunk. A rarer collision under the same
2370                    // head stays eager (forced) so static deep-merge still works.
2371                    let tail_is_dynamic =
2372                        path_attrs.len() > 1 && attrs_have_dynamic(&path_attrs[1..]);
2373                    let head_key = match eval_attr_maybe_null(&path_attrs[0], env)? {
2374                        Some(k) => k,
2375                        // Null dynamic HEAD attr name → skip entire binding.
2376                        None => continue,
2377                    };
2378                    if tail_is_dynamic && attrs.get(&head_key).is_none() {
2379                        let value =
2380                            build_deferred_tail_attr(&path_attrs[1..], &value_expr, env);
2381                        attrs.insert(head_key, value);
2382                        continue;
2383                    }
2384                    // M2.6 ROOT #3 (collision case): the tail has a dynamic key
2385                    // AND the head already exists (a sibling binding wrote it,
2386                    // e.g. osquery's `systemd.services.… = …` then
2387                    // `systemd.tmpfiles.settings."10-osquery".${dirname …}.d`).
2388                    // The plain deferral above bails (head present), and the
2389                    // eager path below would force the dynamic key at
2390                    // construction — re-reading `config.<x>` mid-fixpoint →
2391                    // the empty-Promise partial. Instead, descend the existing
2392                    // head along the tail's STATIC prefix and splice a DEFERRED
2393                    // thunk at the first dynamic level, so the dynamic key
2394                    // stays lazy exactly as CppNix's nested-literal desugaring
2395                    // does — while preserving the static deep-merge with the
2396                    // sibling binding.
2397                    if tail_is_dynamic {
2398                        if let Some(existing) = attrs.get(&head_key).cloned() {
2399                            let merged = merge_deferred_dynamic_tail(
2400                                existing,
2401                                &path_attrs[1..],
2402                                &value_expr,
2403                                env,
2404                            )?;
2405                            attrs.insert(head_key, merged);
2406                            continue;
2407                        }
2408                    }
2409                    // Eager path: evaluate the remaining (static, or collision)
2410                    // keys now. A null dynamic tail key skips the binding.
2411                    let mut path_keys: Vec<String> = {
2412                        let mut v = Vec::with_capacity(path_attrs.len());
2413                        v.push(head_key);
2414                        let mut skip = false;
2415                        for a in &path_attrs[1..] {
2416                            match eval_attr_maybe_null(a, env)? {
2417                                Some(k) => v.push(k),
2418                                None => { skip = true; break; }
2419                            }
2420                        }
2421                        if skip { v.clear(); }
2422                        v
2423                    };
2424                    // Null dynamic attr name → skip entire binding (CppNix compat)
2425                    if path_keys.is_empty() { continue; }
2426                    if path_keys.len() == 1 {
2427                        let key = path_keys.pop().unwrap();
2428                        // maybeThunk: skip thunk for trivial exprs.
2429                        // is_rec=false — Ident lookups are safe.
2430                        let value = maybe_thunk(&value_expr, env, false, None);
2431                        // CppNix desugars `a.b = x; a = { c = y; };` into a single
2432                        // merged `a = { b = x; c = y; }` at parse time. rnix keeps
2433                        // the two bindings separate, so when a single-key binding
2434                        // collides with an already-built (dotted) attrs for the
2435                        // same key, deep-MERGE instead of overwrite. Force the RHS
2436                        // to WHNF so merge_nested_insert (which needs concrete
2437                        // Value::Attrs on both sides) can merge — forcing an
2438                        // attrset to WHNF does NOT force its fields, so leaf values
2439                        // stay lazy. Only fires on collision; non-colliding
2440                        // single-key bindings keep the plain fast insert.
2441                        // (This is the pkg-config-wrapper `env.addFlags` drop:
2442                        // `env.addFlags = …` then `env = { wrapperName = …; … }`.)
2443                        // If the earlier binding for this key is still a lazy
2444                        // Thunk (an attrset literal inserted via maybe_thunk), force
2445                        // it to WHNF FIRST so a `key = {..}; key = {..}` collision is
2446                        // seen as attrs-vs-attrs and MERGES, matching nix
2447                        // (`{ s = {a=1;}; s = {b=2;}; }` → `{ s = {a=1; b=2;}; }`).
2448                        // Without this the `Some(Value::Attrs(_))` test below is false
2449                        // on a Thunk and the second binding overwrites, dropping the
2450                        // first's keys. The dotted branch below already does this; R3
2451                        // (eval-okay-merge-dynamic-attrs set1/set2) needs it here too.
2452                        // WHNF force does not force fields → leaf laziness preserved.
2453                        // (A non-attrs dup like `s = 1; s = 2` still overwrites here,
2454                        // unchanged — nix errors there, an eval-FAIL case out of scope.)
2455                        if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2456                            let existing = attrs.get(&key).cloned().unwrap();
2457                            let forced_existing = force_value(&existing)?;
2458                            attrs.insert(key.clone(), forced_existing);
2459                        }
2460                        if matches!(attrs.get(&key), Some(Value::Attrs(_))) {
2461                            let forced = force_value(&value)?;
2462                            merge_nested_insert(&mut attrs, key, forced);
2463                        } else {
2464                            attrs.insert(key, value);
2465                        }
2466                    } else {
2467                        let key = path_keys[0].clone();
2468                        let value = build_nested_attr(&path_keys[1..], &value_expr, env)?;
2469                        // CppNix desugars `a = { x = …; }; a.y = …;` into a
2470                        // single merged `a = { x = …; y = …; }`. When the
2471                        // full-set binding for `a` was inserted FIRST it is a
2472                        // lazy Thunk (attrset literals go through maybe_thunk),
2473                        // so merge_nested_insert — which only merges when the
2474                        // existing value is a concrete Value::Attrs — would
2475                        // NOT see the earlier keys and would overwrite `a`
2476                        // with just `{ y = … }`, silently dropping `x`. Force
2477                        // the existing entry to WHNF on collision so the merge
2478                        // sees the concrete attrs (forcing to WHNF does not
2479                        // force the fields, so leaf laziness is preserved).
2480                        // (This is the gst-plugins-base `passthru.waylandEnabled`
2481                        // drop: `passthru = { … }; passthru.tests.x = …;`.)
2482                        if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2483                            let existing = attrs.get(&key).cloned().unwrap();
2484                            let forced = force_value(&existing)?;
2485                            attrs.insert(key.clone(), forced);
2486                        }
2487                        merge_nested_insert(&mut attrs, key, value);
2488                    }
2489                }
2490                ast::Entry::Inherit(inherit) => {
2491                    eval_inherit(&inherit, env, &mut attrs, None, None)?;
2492                }
2493            }
2494        }
2495    }
2496
2497    // Record the literal's static-key source positions for
2498    // `builtins.unsafeGetAttrPos` (the `attrTag` `declarations` — options.json
2499    // dock root). Cheap: one entry walk over static Ident/Str keys, no
2500    // forcing; attaches nothing (a pointer-sized `None`) when the set has no
2501    // single-static-key bindings.
2502    attach_attrset_positions(set, &mut attrs, env);
2503
2504    Ok(Value::Attrs(Rc::new(attrs)))
2505}
2506
2507fn eval_inherit(
2508    inherit: &ast::Inherit,
2509    env: &Env,
2510    attrs: &mut NixAttrs,
2511    bind_env: Option<&mut Env>,
2512    mut thunks: Option<&mut Vec<(String, Thunk)>>,
2513) -> Result<(), EvalError> {
2514    if let Some(from) = inherit.from() {
2515        // inherit (expr) a b c;
2516        //
2517        // The source expression must NOT be eagerly evaluated. nixpkgs
2518        // `lib/trivial.nix` has `inherit (lib.trivial) isFunction ...`
2519        // at the top of a file that itself defines `lib.trivial`. If
2520        // we eagerly force `lib.trivial`, we hit a self-referential
2521        // thunk blackhole. Instead: build a thunk per inherited
2522        // name that, when forced, evaluates the source and pulls
2523        // out that one attribute. This is what real Nix does.
2524        //
2525        // For `rec { inherit (X) name; ...; foo = name; }` we ALSO
2526        // need to bind the name in the enclosing rec env so the
2527        // sibling `foo = name` can reference it. The caller passes
2528        // its rec env in `bind_env`.
2529        //
2530        // When `thunks` is provided (rec attrsets), InheritSelect
2531        // thunks are collected so Phase 2 can update their captured
2532        // env to the full recursive scope. Without this, the source
2533        // expression cannot reference sibling bindings.
2534        let source_expr = from
2535            .expr()
2536            .ok_or_else(|| EvalError::ParseError("inherit from missing expr".to_string()))?;
2537        // Shared source thunk — all inherited names share one source
2538        // evaluation (the source thunk's own memoization ensures at
2539        // most one evaluation).
2540        let source_thunk = Thunk::new_suspended(source_expr, env.clone());
2541        let mut be = bind_env;
2542        for attr in inherit.attrs() {
2543            let name = eval_attr(&attr, env)?;
2544            let thunk = Thunk::new_inherit_select(source_thunk.clone(), name.clone());
2545            let value = Value::Thunk(thunk.clone());
2546            attrs.insert(name.clone(), value.clone());
2547            if let Some(ref mut e) = be {
2548                e.bind(name.clone(), value);
2549            }
2550            if let Some(ref mut t) = thunks {
2551                t.push((name, thunk));
2552            }
2553        }
2554    } else {
2555        // inherit a b c;
2556        //
2557        // CppNix resolves a bare `inherit x;` LAZILY, exactly like a plain
2558        // reference to `x` — it does NOT eagerly force the enclosing scope.
2559        // This matters when `x` is provided only by an enclosing `with`
2560        // scope whose value is a fixpoint still being constructed (a
2561        // blackhole): eager `env.lookup` returns None → spurious
2562        // `UndefinedVar`. nixpkgs `all-packages.nix` is
2563        // `… with pkgs; { nettle = import … { inherit callPackage; }; }`,
2564        // so `inherit callPackage` must resolve `callPackage` from the
2565        // `with pkgs` scope AT FORCE TIME, not eagerly at attrset
2566        // construction. Mirror `maybe_thunk`'s Ident path: try the fast
2567        // lookup, and on a miss defer to a WithIdent thunk (or a suspended
2568        // env lookup) so the resolution happens lazily against the settled
2569        // scope. (This was the `nettle` UndefinedVar('callPackage') drop.)
2570        let mut be = bind_env;
2571        for attr in inherit.attrs() {
2572            let name = eval_attr(&attr, env)?;
2573            let sym = crate::value::intern(&name);
2574            let value = if let Some(v) = env.lookup_fast(sym, &name) {
2575                v
2576            } else if let Some((scope_cache, scope_value)) =
2577                env.innermost_with_scope()
2578            {
2579                Value::Thunk(Thunk::new_with_ident(
2580                    SmolStr::from(name.as_str()),
2581                    scope_cache,
2582                    scope_value,
2583                    env.clone(),
2584                ))
2585            } else {
2586                return Err(EvalError::UndefinedVar(format!(
2587                    "'{name}'{}",
2588                    eval_file_ctx()
2589                )));
2590            };
2591            attrs.insert(name.clone(), value.clone());
2592            if let Some(ref mut e) = be {
2593                e.bind(name, value);
2594            }
2595        }
2596    }
2597    Ok(())
2598}
2599
2600fn build_nested_attr(
2601    path: &[String],
2602    expr: &ast::Expr,
2603    env: &Env,
2604) -> Result<Value, EvalError> {
2605    if path.is_empty() {
2606        // CRITICAL: Wrap leaf in a thunk instead of eagerly evaluating.
2607        // For dotted paths like `config.warnings = optionals config.x [...]`,
2608        // the leaf expression must be lazy — eagerly evaluating it during
2609        // attrset construction forces fixpoint thunks prematurely.
2610        return Ok(maybe_thunk(expr, env, false, None));
2611    }
2612    let key = path[0].clone();
2613    let inner = build_nested_attr(&path[1..], expr, env)?;
2614    let mut attrs = NixAttrs::new();
2615    attrs.insert(key, inner);
2616    Ok(Value::Attrs(Rc::new(attrs)))
2617}
2618
2619/// True if a single attr is a DYNAMIC key — one whose resolution runs
2620/// arbitrary expression code and therefore must not be forced at
2621/// attrset-construction time.
2622///
2623/// Two forms are dynamic:
2624///   * `ast::Attr::Dynamic` — a bare `${e}` antiquotation.
2625///   * `ast::Attr::Str` **containing an interpolation** — an interpolated
2626///     string key like `"iwd/${nm}"`.  A `Str` with NO interpolation
2627///     (`"foo bar"`) is a plain static string literal and is NOT dynamic.
2628///
2629/// M2.6 ROOT #3: `attrs_have_dynamic` previously matched ONLY
2630/// `Attr::Dynamic`, so an interpolated-string tail key (`config.a."p${e}"`)
2631/// fell to the eager path and forced `e` at construction.  In the module
2632/// system that forces a `config.<x>` read while `config` is mid-fixpoint
2633/// (`environment.etc."iwd/${configFile.name}"`, where `configFile` reads
2634/// `with config.networking.networkmanager`), yielding the empty-Promise
2635/// partial → the `set/null` softening.  Treating an interpolated `Str` as
2636/// dynamic routes it through the same per-level deferral as `${e}`
2637/// (ROOT #1/#2), so `e` forces only when the enclosing head is demanded —
2638/// exactly CppNix's nested-attrset-literal desugaring.
2639fn attr_is_dynamic(attr: &ast::Attr) -> bool {
2640    match attr {
2641        ast::Attr::Dynamic(_) => true,
2642        // A string attr key is dynamic iff it has ≥1 interpolation part;
2643        // a purely-literal string key forces nothing and stays eager.
2644        ast::Attr::Str(s) => s
2645            .normalized_parts()
2646            .iter()
2647            .any(|p| matches!(p, InterpolPart::Interpolation(_))),
2648        ast::Attr::Ident(_) => false,
2649    }
2650}
2651
2652/// True if any attr in the slice is a dynamic (interpolated) key.
2653///
2654/// A dynamic key beyond the HEAD of an attrpath must NOT be evaluated at
2655/// attrset-construction time — CppNix defers it inside the head's lazy
2656/// value, so `{ a.${e} = v; }` never forces `e` until `.a` is demanded.
2657/// Static string/ident keys are cheap and force nothing, so they don't
2658/// need deferral.
2659fn attrs_have_dynamic(attrs: &[ast::Attr]) -> bool {
2660    attrs.iter().any(attr_is_dynamic)
2661}
2662
2663/// Build the nested attrset for the TAIL of an attrpath, deferring
2664/// evaluation of dynamic tail keys until the value is forced.
2665///
2666/// Given tail attrs `[b, ${e}, c]` and a value expr, produce a lazy
2667/// `Value::Thunk` that, when forced, evaluates each tail key (including
2668/// the dynamic `${e}`) against `env` and builds `{ b = { ${e} = { c =
2669/// <leaf-thunk> }; }; }`. This mirrors CppNix: the inner attrset (and
2670/// thus its dynamic keys) is constructed only when the enclosing head
2671/// attribute is demanded — never at construction of the outer attrset.
2672///
2673/// A dynamic key that evaluates to `null` skips the whole binding
2674/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
2675fn build_deferred_tail_attr(
2676    tail: &[ast::Attr],
2677    value_expr: &ast::Expr,
2678    env: &Env,
2679) -> Value {
2680    let tail: Vec<ast::Attr> = tail.to_vec();
2681    let value_expr = value_expr.clone();
2682    let env = env.clone();
2683    Value::Thunk(Thunk::new_native(move || {
2684        build_tail_attrs_now(&tail, &value_expr, &env)
2685    }))
2686}
2687
2688/// Resolve ONE level of the deferred attrpath tail — used from inside
2689/// the deferred thunk above once the enclosing head is demanded.
2690///
2691/// M2.6 ROOT #2 (the OVER-FORCE fix): this resolves *only* `tail[0]`'s
2692/// key and wraps the remaining tail `tail[1..]` in another DEFERRED
2693/// thunk — it does NOT recurse eagerly through the whole tail. This is
2694/// exactly CppNix's desugaring of `a.b.c = v` into nested attrset
2695/// literals `a = { b = { c = v; }; }`, where forcing `a` to WHNF yields
2696/// `{ b = <thunk {c=v}> }` — the inner level (`b`, and any dynamic key
2697/// under it) stays lazy until `.b` is demanded.
2698///
2699/// Forcing the enclosing head therefore resolves ONE tail key, never
2700/// the whole chain: `config.homes.${cfg.pleme.userName} = 7` demanded
2701/// as `config` yields `{ homes = <deferred> }` WITHOUT forcing the
2702/// `${cfg.pleme.userName}` key. The prior implementation recursed the
2703/// whole tail eagerly, forcing that dynamic key while only `.config`
2704/// (or its `._type`) was demanded — the over-force cppnix never does.
2705///
2706/// A dynamic key that evaluates to `null` skips the whole binding
2707/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
2708fn build_tail_attrs_now(
2709    tail: &[ast::Attr],
2710    value_expr: &ast::Expr,
2711    env: &Env,
2712) -> Result<Value, EvalError> {
2713    if tail.is_empty() {
2714        return Ok(maybe_thunk(value_expr, env, false, None));
2715    }
2716    if std::env::var_os("SUI_M26_TAILTRACE").is_some() {
2717        let t: String = tail[0].syntax().text().to_string().chars().take(40).collect();
2718        eprintln!("[M26 TAIL-RESOLVE] forcing dynamic tail key `{t}`");
2719        if attrs_have_dynamic(&tail[..1]) {
2720            crate::trace::dump_force_stack_ids();
2721        }
2722    }
2723    let key = match eval_attr_maybe_null(&tail[0], env)? {
2724        Some(k) => k,
2725        // Null dynamic key → the whole binding is skipped; an empty
2726        // attrset is the identity for merge_nested_insert.
2727        None => return Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
2728    };
2729    // Resolve ONE level: if more tail remains, defer it (a new lazy
2730    // thunk) rather than recursing eagerly. Only the leaf (empty tail)
2731    // is built here. This keeps each nested level lazy, exactly like
2732    // CppNix's nested-attrset-literal desugaring — so forcing this
2733    // level does NOT force the next level's (possibly dynamic) key.
2734    let inner = if tail.len() == 1 {
2735        maybe_thunk(value_expr, env, false, None)
2736    } else {
2737        build_deferred_tail_attr(&tail[1..], value_expr, env)
2738    };
2739    let mut attrs = NixAttrs::new();
2740    attrs.insert(key, inner);
2741    Ok(Value::Attrs(Rc::new(attrs)))
2742}
2743
2744/// M2.6 ROOT #3 (collision case): splice a DEFERRED dynamic-tail binding
2745/// into an ALREADY-PRESENT head value without forcing the dynamic key.
2746///
2747/// `existing` is the value already stored at the attrpath's head (written
2748/// by a sibling binding — e.g. `systemd.services.… = …`). `tail` is the
2749/// remaining attrpath (`path_attrs[1..]`) of the new binding, which
2750/// contains ≥1 dynamic attr (`systemd.tmpfiles.….${dirname …}.d`).
2751///
2752/// We descend `existing` along the LONGEST STATIC PREFIX of `tail`
2753/// (`tmpfiles`, `settings`, `"10-osquery"` — all static, forced-free
2754/// keys), forcing each already-present sub-attrset to WHNF so the merge
2755/// sees concrete keys (forcing to WHNF never forces leaf VALUES, so leaf
2756/// laziness is preserved), and at the first DYNAMIC level splice a
2757/// `build_deferred_tail_attr` thunk. The dynamic key therefore forces
2758/// only when that exact nested path is later demanded — CppNix's
2759/// nested-attrset-literal desugaring, now honoured through a sibling
2760/// collision too.
2761fn merge_deferred_dynamic_tail(
2762    existing: Value,
2763    tail: &[ast::Attr],
2764    value_expr: &ast::Expr,
2765    env: &Env,
2766) -> Result<Value, EvalError> {
2767    // `tail` is non-empty and contains a dynamic attr somewhere (the
2768    // caller guarantees `attrs_have_dynamic(tail)`).
2769    debug_assert!(!tail.is_empty());
2770
2771    // If the FIRST tail attr is itself dynamic, there is no static prefix
2772    // to descend — the whole tail is deferred and merged as a lazy
2773    // overlay onto the existing head (a `//`-style right-merge; the
2774    // deferred attrset only materialises its dynamic key on demand).
2775    if attr_is_dynamic(&tail[0]) {
2776        let deferred = build_deferred_tail_attr(tail, value_expr, env);
2777        return Ok(lazy_overlay_merge(existing, deferred));
2778    }
2779
2780    // The head static key of `tail`. Resolve it (static → forces nothing
2781    // relevant; a null dynamic can't occur here since tail[0] is static).
2782    let key = match eval_attr_maybe_null(&tail[0], env)? {
2783        Some(k) => k,
2784        None => return Ok(existing),
2785    };
2786
2787    // Force the existing head to a concrete attrset so we can descend +
2788    // merge on the resolved static key. Forcing to WHNF does NOT force
2789    // its field VALUES, so leaf laziness is preserved.
2790    let existing_forced = force_value(&existing)?;
2791    let mut base = match existing_forced {
2792        Value::Attrs(a) => (*a).clone(),
2793        // The existing head is not an attrset (a sibling wrote a leaf
2794        // here); CppNix would error on the merge, but to stay lazy we
2795        // defer the tail and let a later demand surface the real merge
2796        // conflict. Build the deferred tail as a fresh attrset.
2797        _ => {
2798            let deferred = build_deferred_tail_attr(tail, value_expr, env);
2799            return Ok(deferred);
2800        }
2801    };
2802
2803    // Recurse: merge the REMAINING tail (`tail[1..]`) under `key`.
2804    let child_existing = base.get(&key).cloned();
2805    let new_child = match child_existing {
2806        Some(child) if tail.len() > 1 => {
2807            // Deeper static/dynamic prefix under an existing sub-attrset.
2808            merge_deferred_dynamic_tail(child, &tail[1..], value_expr, env)?
2809        }
2810        Some(child) => {
2811            // tail == [key]; the leaf collides with an existing value.
2812            // Static leaf collision — build the leaf and lazy-merge.
2813            let leaf = maybe_thunk(value_expr, env, false, None);
2814            lazy_overlay_merge(child, leaf)
2815        }
2816        None if tail.len() > 1 => {
2817            // No existing child; the remaining tail may itself start with
2818            // a dynamic key — defer it whole (build_deferred_tail_attr
2819            // handles the static/dynamic split per-level).
2820            build_deferred_tail_attr(&tail[1..], value_expr, env)
2821        }
2822        None => maybe_thunk(value_expr, env, false, None),
2823    };
2824    base.insert(key, new_child);
2825    Ok(Value::Attrs(Rc::new(base)))
2826}
2827
2828/// Lazy right-merge of two values that are (or will force to) attrsets,
2829/// preserving leaf laziness. Used by [`merge_deferred_dynamic_tail`] to
2830/// combine a deferred dynamic-tail attrset with an existing value without
2831/// forcing either's dynamic keys eagerly. When both are concrete attrs we
2832/// deep-merge in place (reusing [`merge_nested_insert`]); otherwise we
2833/// build a lazy overlay thunk that merges on demand.
2834fn lazy_overlay_merge(left: Value, right: Value) -> Value {
2835    match (&left, &right) {
2836        (Value::Attrs(la), Value::Attrs(_)) => {
2837            crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
2838            let mut merged = (**la).clone();
2839            if let Value::Attrs(ra) = &right {
2840                // Merging distinct override keys into `merged` is order-
2841                // independent (per-key right-wins), and the result map is
2842                // unordered storage — the sorted `iter()` was dead work.
2843                for (k, v) in ra.iter_unsorted() {
2844                    merge_nested_insert(&mut merged, k.clone(), v.clone());
2845                }
2846            }
2847            Value::Attrs(Rc::new(merged))
2848        }
2849        _ => {
2850            // At least one side is a thunk (a deferred dynamic tail).
2851            // Defer the merge behind a Native thunk so neither side's
2852            // dynamic key forces until the merged attrset is demanded.
2853            Value::Thunk(Thunk::new_native(move || {
2854                let lf = force_value(&left)?;
2855                let rf = force_value(&right)?;
2856                let la = lf.as_attrs()?;
2857                let ra = rf.as_attrs()?;
2858                crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
2859                let mut merged = (*la).clone();
2860                for (k, v) in ra.iter_unsorted() {
2861                    merge_nested_insert(&mut merged, k.clone(), v.clone());
2862                }
2863                Ok(Value::Attrs(Rc::new(merged)))
2864            }))
2865        }
2866    }
2867}
2868
2869/// Like [`build_nested_attr`] but wraps the leaf in a [`Thunk`] instead of
2870/// eagerly evaluating it. Used inside `rec { ... }` and `let ... in` so
2871/// that dotted-path leaf expressions can reference sibling bindings
2872/// through the recursive env (which is finalised in Phase 2).
2873///
2874/// Every thunk created is appended to `thunks` so Phase 2 can update
2875/// its captured environment.
2876fn build_nested_attr_thunk(
2877    path: &[String],
2878    expr: &ast::Expr,
2879    env: &Env,
2880    thunks: &mut Vec<(String, Thunk)>,
2881) -> Value {
2882    if path.is_empty() {
2883        let thunk = Thunk::new_suspended(expr.clone(), env.clone());
2884        let val = Value::Thunk(thunk.clone());
2885        thunks.push((String::new(), thunk));
2886        return val;
2887    }
2888    let key = path[0].clone();
2889    let inner = build_nested_attr_thunk(&path[1..], expr, env, thunks);
2890    let mut attrs = NixAttrs::new();
2891    attrs.insert(key, inner);
2892    Value::Attrs(Rc::new(attrs))
2893}
2894
2895/// Insert `value` at `key` in `target`. If `target` already has a
2896/// concrete `Value::Attrs` at that key AND `value` is also a
2897/// concrete `Value::Attrs`, deep-merge them rather than overwriting.
2898/// This is what makes `{ a.b.c = 1; a.b.d = 2; a.e = 3; }` produce
2899/// `{ a = { b = { c = 1; d = 2; }; e = 3; }; }` instead of
2900/// dropping siblings — every nixpkgs module relies on this.
2901fn merge_nested_insert(target: &mut NixAttrs, key: String, value: Value) {
2902    // Fast path: no existing entry at this key → plain insert, keeping the
2903    // value lazy (the overwhelmingly common non-colliding case, so we never
2904    // force a thunk here).
2905    let existing = match target.get(&key) {
2906        Some(e) => e.clone(),
2907        None => {
2908            target.insert(key, value);
2909            return;
2910        }
2911    };
2912    // A collision exists.  A deep merge is warranted only when BOTH the
2913    // existing entry AND the new value are attrset-shaped.  M2.6 ROOT #4b
2914    // (byte-verified): either side may be a lazy `Thunk` wrapping a
2915    // full-set leaf — both dotted-path orderings hit this:
2916    //   forward  `o.a = { x = 1; }; o.a.y = 2;` → EXISTING `a` is a thunk
2917    //            (`build_nested_attr` puts the `{x=1}` leaf through
2918    //            `maybe_thunk`), NEW `a` is `{ y = … }`;
2919    //   reverse  `o.a.y = 2; o.a = { x = 1; };` → EXISTING `a` is `{y}`,
2920    //            NEW `a` is the `<thunk {x=1}>`.
2921    // The old `should_merge` required BOTH sides to already be concrete
2922    // `Value::Attrs`, so a Thunk-vs-Attrs collision fell to the overwrite
2923    // path and silently dropped the earlier leaf's keys.  cppnix desugars
2924    // BOTH orderings into one merged `o.a = { x = 1; y = 2; }`.  Force each
2925    // side's thunk to WHNF ON COLLISION ONLY (forcing an attrset to WHNF
2926    // does NOT force its fields, so leaf laziness is preserved); a thunk
2927    // that forces to a non-attrset (or errors) makes the merge a plain
2928    // overwrite (leaf last-write-wins).
2929    // Symptom this closes: nixpkgs' alsa module declares
2930    // `options.hardware.alsa = { enable = …; cardAliases = …; … }` AND
2931    // `options.hardware.alsa.enablePersistence = …`; sui merged them to
2932    // only `{enablePersistence}`, so `hardware.alsa.cardAliases` "does not
2933    // exist" — the M2.6 frontier once the `with`-namespace over-force (#4a)
2934    // was fixed.
2935    let value = match value {
2936        Value::Thunk(_) => match force_value(&value) {
2937            Ok(v @ Value::Attrs(_)) => v,
2938            _ => value,
2939        },
2940        other => other,
2941    };
2942    if !matches!(value, Value::Attrs(_)) {
2943        target.insert(key, value);
2944        return;
2945    }
2946    // Normalize the existing side to concrete attrs too (forcing a thunk
2947    // to WHNF if needed); if it isn't attrset-shaped, the new attrs wins.
2948    let existing_concrete = match &existing {
2949        Value::Attrs(_) => existing.clone(),
2950        Value::Thunk(_) => match force_value(&existing) {
2951            Ok(v @ Value::Attrs(_)) => v,
2952            _ => {
2953                target.insert(key, value);
2954                return;
2955            }
2956        },
2957        _ => {
2958            target.insert(key, value);
2959            return;
2960        }
2961    };
2962    // Both sides are concrete attrs — merge in place. We pop the
2963    // existing entry, then walk the new attrs and recursively
2964    // merge each child onto it.
2965    let mut existing_attrs = match existing_concrete {
2966        Value::Attrs(a) => (*a).clone(),
2967        _ => unreachable!(),
2968    };
2969    let new_attrs = match value {
2970        Value::Attrs(ref a) => a,
2971        _ => unreachable!(),
2972    };
2973    for (k, v) in new_attrs.iter_unsorted() {
2974        merge_nested_insert(&mut existing_attrs, k.clone(), v.clone());
2975    }
2976    target.insert(key, Value::Attrs(Rc::new(existing_attrs)));
2977}
2978
2979/// Evaluate entries from any HasEntry node (LegacyLet).
2980fn eval_entries<N: HasEntry + AstNode>(node: &N, env: &mut Env) -> Result<(), EvalError> {
2981    for entry in node.entries() {
2982        match entry {
2983            ast::Entry::AttrpathValue(apv) => {
2984                let attrpath = apv.attrpath().ok_or_else(|| {
2985                    EvalError::ParseError("binding missing attrpath".to_string())
2986                })?;
2987                let value_expr = apv.value().ok_or_else(|| {
2988                    EvalError::ParseError("binding missing value".to_string())
2989                })?;
2990                let mut path_keys: Vec<String> = attrpath
2991                    .attrs()
2992                    .map(|a| eval_attr(&a, env))
2993                    .collect::<Result<_, _>>()?;
2994                if path_keys.len() == 1 {
2995                    let key = path_keys.pop().unwrap();
2996                    let value = eval_expr(&value_expr, env)?;
2997                    env.bind(key, value);
2998                }
2999                // Multi-key paths in let are not standard; skip for now.
3000            }
3001            ast::Entry::Inherit(inherit) => {
3002                if let Some(from) = inherit.from() {
3003                    let source_expr = from.expr().ok_or_else(|| {
3004                        EvalError::ParseError("inherit from missing expr".to_string())
3005                    })?;
3006                    let source = force_value(&eval_expr(&source_expr, env)?)?;
3007                    let source_attrs = source.as_attrs()?;
3008                    for attr in inherit.attrs() {
3009                        let name = eval_attr(&attr, env)?;
3010                        let value = source_attrs
3011                            .get(&name)
3012                            .cloned()
3013                            .ok_or_else(|| EvalError::AttrNotFound(
3014                                format!("'{name}' in inherit{}", eval_file_ctx()),
3015                            ))?;
3016                        env.bind(name, value);
3017                    }
3018                } else {
3019                    for attr in inherit.attrs() {
3020                        let name = eval_attr(&attr, env)?;
3021                        let value = env
3022                            .lookup(&name)
3023                            .ok_or_else(|| EvalError::UndefinedVar(
3024                                format!("'{name}'{}", eval_file_ctx()),
3025                            ))?;
3026                        env.bind(name, value);
3027                    }
3028                }
3029            }
3030        }
3031    }
3032    Ok(())
3033}
3034
3035fn eval_binop(
3036    op: ast::BinOpKind,
3037    lhs: &ast::Expr,
3038    rhs: &ast::Expr,
3039    env: &Env,
3040) -> Result<Value, EvalError> {
3041    // Short-circuit for && and ||
3042    match op {
3043        ast::BinOpKind::And => {
3044            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3045            if !l {
3046                return Ok(Value::Bool(false));
3047            }
3048            return eval_expr(rhs, env);
3049        }
3050        ast::BinOpKind::Or => {
3051            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3052            if l {
3053                return Ok(Value::Bool(true));
3054            }
3055            return eval_expr(rhs, env);
3056        }
3057        ast::BinOpKind::Implication => {
3058            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3059            if !l {
3060                return Ok(Value::Bool(true));
3061            }
3062            return eval_expr(rhs, env);
3063        }
3064        _ => {}
3065    }
3066
3067    let lc = force_concrete(&eval_expr(lhs, env)?)?;
3068    let rc = force_concrete(&eval_expr(rhs, env)?)?;
3069    // Consume the Concretes (move, don't clone) so `l`/`r` hold the sole Rc to
3070    // any heap payload. This is byte-neutral — `into_value` yields the identical
3071    // `Value` as `to_value` — but it drops `lc`/`rc`, which is what lets the
3072    // `Concat` arm's structural-share fast path see a uniquely-owned left list
3073    // for a fresh `++` temporary (`Rc::try_unwrap` → append in place). Keeping
3074    // `lc` alive via `to_value` pinned the refcount at ≥2 and defeated reuse.
3075    let l = lc.into_value();
3076    let r = rc.into_value();
3077
3078    match op {
3079        ast::BinOpKind::Add => match (&l, &r) {
3080            (Value::Int(a), Value::Int(b)) => a
3081                .checked_add(*b)
3082                .map(Value::Int)
3083                .ok_or_else(|| int_overflow("adding", *a, '+', *b)),
3084            (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
3085            (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
3086            (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
3087            (Value::String(a), Value::String(b)) => {
3088                let mut ctx = a.context.clone();
3089                ctx.merge(&b.context);
3090                // Byte-identical to `format!("{}{}", a.chars, b.chars)` but
3091                // routes around the `core::fmt` runtime (its dispatch was the
3092                // #1 self-time frame on the string-concat hot path): a single
3093                // exact-capacity `String` + two `push_str` reserves the final
3094                // size once, so the left operand is copied exactly once instead
3095                // of copied-then-regrown. Result string + context unchanged →
3096                // ByteSufficient. (Also removes a `format!` — TYPED EMISSION.)
3097                let mut s = String::with_capacity(a.chars.len() + b.chars.len());
3098                s.push_str(&a.chars);
3099                s.push_str(&b.chars);
3100                Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
3101            }
3102            (Value::Path(a), Value::String(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}{}", b.chars).as_str())))),
3103            (Value::Path(a), Value::Path(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}/{b}").as_str())))),
3104            // CppNix coerces attrsets with outPath when used with +
3105            (Value::Attrs(_), _) | (_, Value::Attrs(_)) => {
3106                let (ls, lctx) = l.coerce_to_string()?;
3107                let (rs, rctx) = r.coerce_to_string()?;
3108                let mut ctx = lctx;
3109                ctx.merge(&rctx);
3110                Ok(Value::String(Rc::new(NixString::with_context(
3111                    format!("{ls}{rs}"),
3112                    ctx,
3113                ))))
3114            }
3115            _ => Err(EvalError::op_type("add", l.type_name(), r.type_name())),
3116        },
3117        ast::BinOpKind::Sub => num_op(
3118            &l,
3119            &r,
3120            |a, b| a.checked_sub(b),
3121            |a, b| a - b,
3122            |a, b| int_overflow("subtracting", a, '-', b),
3123        ),
3124        ast::BinOpKind::Mul => num_op(
3125            &l,
3126            &r,
3127            |a, b| a.checked_mul(b),
3128            |a, b| a * b,
3129            |a, b| int_overflow("multiplying", a, '*', b),
3130        ),
3131        ast::BinOpKind::Div => {
3132            // CppNix rejects division by zero for both int and float
3133            // operands; Rust's native int-div-by-0 panics (we handle
3134            // that below) but float-div-by-0 silently returns `inf`
3135            // or `NaN`, which sui was then serializing as `null` —
3136            // an invisible silent-Ok bug surfaced by the error-case
3137            // differential corpus.
3138            //
3139            // Cover every zero-denominator case explicitly.
3140            let rhs_is_zero = match &r {
3141                Value::Int(0) => true,
3142                Value::Float(f) => *f == 0.0,
3143                _ => false,
3144            };
3145            if rhs_is_zero {
3146                return Err(EvalError::DivisionByZero);
3147            }
3148            num_op(
3149                &l,
3150                &r,
3151                |a, b| a.checked_div(b),
3152                |a, b| a / b,
3153                |a, b| int_overflow("dividing", a, '/', b),
3154            )
3155        }
3156        ast::BinOpKind::Equal => Ok(Value::Bool(l == r)),
3157        ast::BinOpKind::NotEqual => Ok(Value::Bool(l != r)),
3158        ast::BinOpKind::Less => compare(&l, &r, |o| o == std::cmp::Ordering::Less),
3159        ast::BinOpKind::LessOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Greater),
3160        ast::BinOpKind::More => compare(&l, &r, |o| o == std::cmp::Ordering::Greater),
3161        ast::BinOpKind::MoreOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Less),
3162        ast::BinOpKind::Update => {
3163            let la = l.to_attrs()?;
3164            let ra = r.to_attrs()?;
3165            // O(1) lazy overlay — defers merge until attribute access.
3166            Ok(Value::Attrs(Rc::new(la.overlay(ra))))
3167        }
3168        ast::BinOpKind::Concat => {
3169            // Structural-share fast path: when the left operand's `Rc<Vec>` is
3170            // uniquely owned (a fresh temporary, as in a left-associative `++`
3171            // fold `acc ++ [x]`), append the right elements IN PLACE instead of
3172            // cloning the whole accumulator. This turns an O(n) copy per concat
3173            // into amortized O(1), byte-identically — the result is the same
3174            // ordered sequence of the same Rc-shared lazy thunks (no forcing,
3175            // no reordering, no identity change). When the Rc is shared (the
3176            // left came from a still-live binding/thunk) we fall back to the
3177            // clone-extend path, preserving the shared list unchanged.
3178            crate::value::concat_lists(l, r.as_list()?)
3179        }
3180        ast::BinOpKind::And | ast::BinOpKind::Or | ast::BinOpKind::Implication => {
3181            unreachable!("handled above")
3182        }
3183        ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
3184            Err(EvalError::NotImplemented("pipe operators".to_string()))
3185        }
3186    }
3187}
3188
3189/// CppNix aborts (uncatchably) on i64 arithmetic overflow, e.g.
3190/// `integer overflow in adding 9223372036854775807 + 1`. `EvalError::Abort` is
3191/// the uncatchable variant (`tryEval` catches only `Throw`/`AssertionFailed`),
3192/// matching nix — a wrapping result would silently produce a wrong drvPath.
3193#[inline]
3194fn int_overflow(verb: &str, a: i64, sym: char, b: i64) -> EvalError {
3195    EvalError::Abort(format!("integer overflow in {verb} {a} {sym} {b}"))
3196}
3197
3198fn num_op(
3199    l: &Value,
3200    r: &Value,
3201    int_op: impl Fn(i64, i64) -> Option<i64>,
3202    float_op: impl Fn(f64, f64) -> f64,
3203    overflow: impl Fn(i64, i64) -> EvalError,
3204) -> Result<Value, EvalError> {
3205    match (l, r) {
3206        (Value::Int(a), Value::Int(b)) => {
3207            int_op(*a, *b).map(Value::Int).ok_or_else(|| overflow(*a, *b))
3208        }
3209        (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
3210        (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
3211        (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
3212        _ => Err(EvalError::op_type("perform arithmetic on", l.type_name(), r.type_name())),
3213    }
3214}
3215
3216fn compare(
3217    l: &Value,
3218    r: &Value,
3219    pred: impl Fn(std::cmp::Ordering) -> bool,
3220) -> Result<Value, EvalError> {
3221    let ord = match (l, r) {
3222        (Value::Int(a), Value::Int(b)) => a.cmp(b),
3223        (Value::Float(a), Value::Float(b)) => {
3224            a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
3225        }
3226        (Value::Int(a), Value::Float(b)) => (*a as f64)
3227            .partial_cmp(b)
3228            .unwrap_or(std::cmp::Ordering::Equal),
3229        (Value::Float(a), Value::Int(b)) => a
3230            .partial_cmp(&(*b as f64))
3231            .unwrap_or(std::cmp::Ordering::Equal),
3232        (Value::String(a), Value::String(b)) => a.chars.cmp(&b.chars),
3233        _ => {
3234            return Err(EvalError::op_type("compare", l.type_name(), r.type_name()));
3235        }
3236    };
3237    Ok(Value::Bool(pred(ord)))
3238}
3239
3240/// Apply a function to an argument.
3241///
3242/// Supports `__functor`: if `func` is an attrset with a `__functor` key,
3243/// calls `__functor self arg` (the Nix `__functor` protocol).
3244///
3245/// For lambda with a simple ident parameter, the argument is NOT forced
3246/// before binding -- this enables fixpoint combinators (`lib.fix`) where
3247/// the argument is a self-referential thunk.
3248/// Apply a function and force the result.
3249///
3250/// Builtins that inspect the return value (via `as_list`, `as_bool`, etc.)
3251/// must use this instead of bare `apply` — otherwise a thunk-wrapped result
3252/// will cause "thunk in as_list: force first" errors.
3253pub fn apply_and_force(func: Value, arg: Value) -> Result<Value, EvalError> {
3254    force_value(&apply(func, arg)?)
3255}
3256
3257pub fn apply(func: Value, arg: Value) -> Result<Value, EvalError> {
3258    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || apply_inner(func, arg))
3259}
3260
3261fn apply_inner(func: Value, arg: Value) -> Result<Value, EvalError> {
3262    crate::perf::inc(crate::perf::Counter::Apply);
3263    let func = force_concrete(&func)?.into_value();
3264    match func {
3265        Value::Lambda(closure) => {
3266            // Hot function tracker: log source file + param name for each lambda call
3267            if crate::perf::enabled() {
3268                APPLY_SITES.with(|sites| {
3269                    let file = closure.env.eval_file()
3270                        .map(|p| p.display().to_string())
3271                        .unwrap_or_else(|| "<eval>".into());
3272                    // Include param info for identification
3273                    let param_name = match &closure.param {
3274                        rnix::ast::Param::IdentParam(ip) => ip.ident().map(|i| ident_text(&i)).unwrap_or_default(),
3275                        rnix::ast::Param::Pattern(pat) => {
3276                            let mut names: Vec<String> = pat.pat_entries()
3277                                .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3278                                .take(3)
3279                                .collect();
3280                            if pat.pat_entries().count() > 3 { names.push("...".to_string()); }
3281                            format!("{{{}}}", names.join(","))
3282                        }
3283                    };
3284                    let key = format!("{}:{}", file.rsplit_once("-source/").map_or(file.as_str(), |(_,s)| s), param_name);
3285                    *sites.borrow_mut().entry(key).or_insert(0u64) += 1;
3286                });
3287            }
3288            let mut call_env = closure.env.child();
3289            // ALWAYS push a frame, even when the closure captured no file:
3290            // `.map(push_eval_file)` pushed nothing for `None`, leaving the
3291            // CALLER's file on top, so a literal written in a fileless
3292            // context got stamped with the callee's path. CppNix returns
3293            // `null` there. See `EVAL_FILE_STACK`.
3294            let _file_guard = push_eval_frame(closure.env.eval_file().cloned());
3295            // Push Nix-level trace frame for function calls. Lazy: stores
3296            // only the raw ingredients (O(1) Rc-clone of the closure env +
3297            // the current-eval-file snapshot) and defers the format!/strip
3298            // work to the cold `attach_trace` path. Renders byte-identical
3299            // to the eager form.
3300            let _trace = push_nix_trace_lambda(&closure.env);
3301            match &closure.param {
3302                rnix::ast::Param::IdentParam(_) => {
3303                    // Simple ident param: bind argument WITHOUT forcing.
3304                    // This is critical for fixpoint / call-by-need semantics.
3305                    bind_param(&closure.param, &arg, &mut call_env)?;
3306                }
3307                rnix::ast::Param::Pattern(_) => {
3308                    // Pattern param needs the arg to be an attrset, so force.
3309                    let forced_arg = force_concrete(&arg)?.into_value();
3310                    bind_param(&closure.param, &forced_arg, &mut call_env)?;
3311                }
3312            }
3313            eval_expr(&closure.body, &call_env)
3314        }
3315        Value::Builtin(b) => {
3316            let _trace = push_nix_trace(format!("while calling the '{}' builtin", b.name));
3317            // Special builtins that must receive UNFORCED arguments:
3318            // - tryEval: must catch throw/abort during its own forcing
3319            // - addErrorContext<partial>: wraps value with error context
3320            //   without forcing (the value is the fixpoint `config` which
3321            //   causes infinite recursion if forced during collectModules)
3322            // - seq<partial>: forces first arg but returns second UNFORCED
3323            // Same lazy-arg set as `eval_apply` (single source of truth) — these
3324            // builtins receive the arg UNFORCED. foldl'<p1> is the nul accumulator
3325            // (nix's foldl' is strict in each op RESULT, NOT in the nul).
3326            if builtin_takes_lazy_arg(&b.name) {
3327                (b.func)(&[arg])
3328            } else {
3329                let forced_arg = force_value(&arg)?;
3330                (b.func)(&[forced_arg])
3331            }
3332        }
3333        Value::Attrs(ref attrs) => {
3334            if let Some(functor) = attrs.get("__functor") {
3335                let functor = force_value(functor)?;
3336                // __functor protocol: (functor self) arg
3337                let partial = apply(functor, func.clone())?;
3338                apply(partial, arg)
3339            } else if crate::value::in_promise_eval() {
3340                // M2.6 Promise softening: an attrset without __functor
3341                // being called as a function — typically the empty-
3342                // attrset sentinel inside a fix-point body.  Return
3343                // null so eval can proceed.
3344                Ok(Value::Null)
3345            } else {
3346                Err(EvalError::type_error(
3347                    format!("cannot call {} (missing __functor){}", func.type_name(), eval_file_ctx()),
3348                ))
3349            }
3350        }
3351        _ if crate::value::in_promise_eval() => {
3352            // M2.6 Promise softening: calling null / int / string / list
3353            // as a function inside a Promise body is the sentinel
3354            // cascade landing somewhere it doesn't belong.  Return null
3355            // so the fix-point continues instead of erroring.
3356            Ok(Value::Null)
3357        }
3358        _ => Err(EvalError::type_error(
3359            format!("cannot call {}{}", func.type_name(), eval_file_ctx()),
3360        )),
3361    }
3362}
3363
3364/// Dark-side lever `batch-bind` (byte-SAFE, `RedundantWrite`) — OFF by default.
3365/// When `SUI_BATCH_BIND=1`, an N-formal pattern binds in ONE copy-on-write step
3366/// (`Env::bind_many`) instead of N successive `env.bind()` calls. Byte-identical
3367/// either way (same intern, same insert order, same final HAMT — Phase 2's
3368/// `update_env` makes each default thunk's initial env capture unobservable).
3369/// Gated because the extra `Vec` allocation could regress the common small-pattern
3370/// case, and the win is unmeasured under load — never change the default path on a
3371/// hunch (never-ship-a-regression). Cached so the default path pays zero per call.
3372/// Ledger: `sui-spec/specs/darkside.lisp` (`batch-bind`, DarkGated).
3373static SUI_BATCH_BIND: std::sync::LazyLock<bool> =
3374    std::sync::LazyLock::new(|| std::env::var_os("SUI_BATCH_BIND").is_some());
3375
3376fn bind_param(param: &ast::Param, arg: &Value, env: &mut Env) -> Result<(), EvalError> {
3377    match param {
3378        ast::Param::IdentParam(ip) => {
3379            let ident = ip
3380                .ident()
3381                .ok_or_else(|| EvalError::ParseError("ident param missing ident".to_string()))?;
3382            let name = ident_text(&ident);
3383            env.bind(name, arg.clone());
3384        }
3385        ast::Param::Pattern(pat) => {
3386            let attrs = arg.as_attrs()?;
3387
3388            // @-binding (either `args @ { ... }` or `{ ... } @ args`)
3389            if let Some(pat_bind) = pat.pat_bind()
3390                && let Some(ident) = pat_bind.ident()
3391            {
3392                let name = ident_text(&ident);
3393                env.bind(name, arg.clone());
3394            }
3395
3396            let has_ellipsis = pat.ellipsis_token().is_some();
3397            let entries: Vec<ast::PatEntry> = pat.pat_entries().collect();
3398
3399            // Two-phase binding (matching CppNix semantics):
3400            // Phase 1: Bind all formals. Defaults get thunks with a
3401            //   preliminary env. We collect thunks for Phase 2 update.
3402            // Phase 2: Update default thunks to capture the final env
3403            //   (which now has ALL formals bound). This allows defaults
3404            //   to reference any other formal — including forward refs.
3405            let mut default_thunks: Vec<Thunk> = Vec::new();
3406            // batch-bind (byte-SAFE `RedundantWrite`, OFF unless `SUI_BATCH_BIND=1`):
3407            // the flag path collects every formal's (name, value) pair and binds
3408            // them in ONE copy-on-write step (`bind_many`) instead of N successive
3409            // `env.bind()` calls. Byte-identical either way — the default thunks
3410            // capture `env.clone()` (pre-batch) and Phase 2's `update_env` re-points
3411            // every one to the final all-formals-bound env, so a thunk's *initial*
3412            // capture is unobservable (overwritten before any force); same intern,
3413            // same insert order, same final HAMT. The default path (flag unset) is
3414            // the original per-formal loop, byte- AND perf-identical (no Vec alloc).
3415            let use_batch = *SUI_BATCH_BIND;
3416            let mut pairs: Vec<(String, Value)> =
3417                if use_batch { Vec::with_capacity(entries.len()) } else { Vec::new() };
3418
3419            for entry in &entries {
3420                let ident = entry.ident().ok_or_else(|| {
3421                    EvalError::ParseError("pat entry missing ident".to_string())
3422                })?;
3423                let name = ident_text(&ident);
3424                let value = if let Some(v) = attrs.get(&name) {
3425                    v.clone()
3426                } else if let Some(default_expr) = entry.default() {
3427                    // Default values in pattern parameters must be lazy
3428                    // (wrapped in thunks), matching CppNix semantics.
3429                    // Patterns like `vendor ? assert false; null` rely on
3430                    // the default never being forced when the body checks
3431                    // `args ? vendor` instead of using `vendor` directly.
3432                    let thunk = Thunk::new_suspended(
3433                        ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3434                        env.clone(),
3435                    );
3436                    default_thunks.push(thunk.clone());
3437                    Value::Thunk(thunk)
3438                } else {
3439                    return Err(EvalError::type_error(
3440                        format!("missing argument '{name}'{}", eval_file_ctx()),
3441                    ));
3442                };
3443                if use_batch {
3444                    pairs.push((name, value));
3445                } else {
3446                    env.bind(name, value);
3447                }
3448            }
3449            if use_batch {
3450                env.bind_many(pairs);
3451            }
3452
3453            // Phase 2: Update default thunks to see ALL formals.
3454            for thunk in &default_thunks {
3455                thunk.update_env(env);
3456            }
3457
3458            if !has_ellipsis {
3459                let entry_names: std::collections::HashSet<String> = entries
3460                    .iter()
3461                    .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3462                    .collect();
3463                for key in attrs.keys() {
3464                    if !entry_names.contains(key.as_str()) {
3465                        return Err(EvalError::type_error(
3466                            format!("unexpected argument '{key}'{}", eval_file_ctx()),
3467                        ));
3468                    }
3469                }
3470            }
3471        }
3472    }
3473    Ok(())
3474}
3475
3476#[cfg(test)]
3477mod tests {
3478    use super::*;
3479
3480    fn ev(input: &str) -> Value {
3481        eval(input).unwrap()
3482    }
3483
3484    // Regression (2026-07-10): the let-scope fix-point detector must count
3485    // only GENUINE variable references, not attribute names / attrset keys
3486    // (which sit under a `NODE_ATTRPATH`).  nixpkgs `lib/types.nix` has
3487    // `placeholder = if lhs.placeholder == …` whose RHS mentions the
3488    // *attribute* `.placeholder`; the old raw-token match falsely flagged
3489    // the binding self-recursive and routed it through the Promise path.
3490    #[test]
3491    fn is_self_recursive_binding_ignores_attribute_names() {
3492        fn expr(s: &str) -> ast::Expr {
3493            rnix::Root::parse(s).tree().expr().expect("parse")
3494        }
3495        // attribute names / keys are NOT references to the binding
3496        assert!(!is_self_recursive_binding(&expr("lhs.placeholder"), "placeholder"));
3497        assert!(!is_self_recursive_binding(&expr("{ placeholder = 1; }"), "placeholder"));
3498        assert!(!is_self_recursive_binding(
3499            &expr("if lhs.placeholder == rhs.placeholder then lhs.placeholder else null"),
3500            "placeholder",
3501        ));
3502        // genuine variable references ARE detected
3503        assert!(is_self_recursive_binding(&expr("placeholder + 1"), "placeholder"));
3504        assert!(is_self_recursive_binding(
3505            &expr("if placeholder then 1 else 2"),
3506            "placeholder"
3507        ));
3508    }
3509
3510    // M2 thunk-waste (byte-safe eager constant): a NON-interpolated string in a
3511    // maybe_thunk site is evaluated directly (no suspended thunk). The value +
3512    // its (empty) context must be byte-identical to forcing a thunk of it.
3513    #[test]
3514    fn maybe_thunk_eager_constant_str_is_byte_identical() {
3515        fn expr(s: &str) -> ast::Expr {
3516            rnix::Root::parse(s).tree().expr().expect("parse")
3517        }
3518        let env = Env::new();
3519        // Constant string → returned as a concrete String, NOT a Thunk.
3520        let v = maybe_thunk(&expr(r#""abc""#), &env, false, None);
3521        assert!(matches!(v, Value::String(_)), "constant str should be eager, got {v:?}");
3522        assert_eq!(force_value(&v).unwrap(), Value::string("abc"));
3523        // Interpolated string → MUST stay a thunk (lazy `${…}` force).
3524        let vi = maybe_thunk(&expr(r#""a${b}c""#), &env, false, None);
3525        assert!(matches!(vi, Value::Thunk(_)), "interpolated str must stay thunked");
3526    }
3527
3528    // The pure-constant arg classifier admits ONLY literals + non-interpolated
3529    // strings/paths, and rejects everything that could throw/diverge/observe a
3530    // fixpoint — the laziness safety boundary of the apply-arg optimization.
3531    #[test]
3532    fn eval_pure_constant_arg_classification() {
3533        fn expr(s: &str) -> ast::Expr {
3534            rnix::Root::parse(s).tree().expr().expect("parse")
3535        }
3536        // ADMIT: pure constants (byte-safe to eval eagerly in an arg position).
3537        assert!(eval_pure_constant_arg(&expr("42")).is_some());
3538        assert!(eval_pure_constant_arg(&expr("3.14")).is_some());
3539        assert!(eval_pure_constant_arg(&expr(r#""const""#)).is_some());
3540        assert!(eval_pure_constant_arg(&expr("/abs/path")).is_some());
3541        // REJECT: anything that could throw / diverge / observe laziness.
3542        assert!(eval_pure_constant_arg(&expr(r#""a${b}c""#)).is_none(), "interpolated str");
3543        // `true`/`false`/`null` are IDENTS in nix (shadowable), not literals —
3544        // rejected to avoid a with-scope force, correctly conservative.
3545        assert!(eval_pure_constant_arg(&expr("true")).is_none(), "bool is an ident");
3546        assert!(eval_pure_constant_arg(&expr("x")).is_none(), "ident (with-scope force)");
3547        assert!(eval_pure_constant_arg(&expr("a.b")).is_none(), "select (fixpoint)");
3548        assert!(eval_pure_constant_arg(&expr("f x")).is_none(), "apply (may throw)");
3549        assert!(eval_pure_constant_arg(&expr("1 + 1")).is_none(), "binop (may throw)");
3550        assert!(eval_pure_constant_arg(&expr("throw \"x\"")).is_none(), "throw stays lazy");
3551    }
3552
3553    // LAZINESS GUARD: a lambda that IGNORES its arg must NOT force it — even a
3554    // throwing arg. The pure-constant optimization only touches inert constants,
3555    // so a `throw`-ing arg stays fully thunked and the ignoring lambda succeeds.
3556    #[test]
3557    fn ignored_throwing_arg_stays_lazy() {
3558        assert_eq!(ev(r#"(x: 7) (throw "boom")"#), Value::Int(7));
3559        // And an ignored constant arg is equally invisible.
3560        assert_eq!(ev(r#"(x: 7) "const""#), Value::Int(7));
3561        // A USED constant arg produces the right value.
3562        assert_eq!(ev(r#"(x: x) "used""#), Value::string("used"));
3563    }
3564
3565    #[test]
3566    fn eval_int() { assert_eq!(ev("42"), Value::Int(42)); }
3567
3568    #[test]
3569    fn eval_float() { assert_eq!(ev("3.14"), Value::Float(3.14)); }
3570
3571    #[test]
3572    fn eval_string() { assert_eq!(ev(r#""hello""#), Value::string("hello")); }
3573
3574    #[test]
3575    fn eval_bool() { assert_eq!(ev("true"), Value::Bool(true)); }
3576
3577    #[test]
3578    fn eval_null() { assert_eq!(ev("null"), Value::Null); }
3579
3580    #[test]
3581    fn eval_arithmetic() {
3582        assert_eq!(ev("1 + 2"), Value::Int(3));
3583        assert_eq!(ev("10 - 3"), Value::Int(7));
3584        assert_eq!(ev("2 * 3"), Value::Int(6));
3585        assert_eq!(ev("10 / 3"), Value::Int(3));
3586    }
3587
3588    #[test]
3589    fn eval_precedence() {
3590        assert_eq!(ev("1 + 2 * 3"), Value::Int(7));
3591        assert_eq!(ev("(1 + 2) * 3"), Value::Int(9));
3592    }
3593
3594    #[test]
3595    fn eval_comparison() {
3596        assert_eq!(ev("1 == 1"), Value::Bool(true));
3597        assert_eq!(ev("1 == 2"), Value::Bool(false));
3598        assert_eq!(ev("1 < 2"), Value::Bool(true));
3599        assert_eq!(ev("2 <= 2"), Value::Bool(true));
3600    }
3601
3602    #[test]
3603    fn eval_logic() {
3604        assert_eq!(ev("true && false"), Value::Bool(false));
3605        assert_eq!(ev("true || false"), Value::Bool(true));
3606        assert_eq!(ev("!true"), Value::Bool(false));
3607    }
3608
3609    #[test]
3610    fn eval_string_concat() {
3611        assert_eq!(ev(r#""hello" + " " + "world""#), Value::string("hello world"));
3612    }
3613
3614    #[test]
3615    fn eval_if() {
3616        assert_eq!(ev("if true then 1 else 2"), Value::Int(1));
3617        assert_eq!(ev("if false then 1 else 2"), Value::Int(2));
3618    }
3619
3620    #[test]
3621    fn eval_let() {
3622        assert_eq!(ev("let x = 1; in x"), Value::Int(1));
3623        assert_eq!(ev("let x = 1; y = 2; in x + y"), Value::Int(3));
3624    }
3625
3626    #[test]
3627    fn eval_let_dotted_simple() {
3628        // Two dotted bindings sharing the top-level key `a`.
3629        assert_eq!(ev("let a.b = 1; a.c = 2; in a.b + a.c"), Value::Int(3));
3630    }
3631
3632    #[test]
3633    fn eval_let_dotted_deep() {
3634        // Deeply nested dotted path.
3635        assert_eq!(ev("let a.b.c = 1; in a.b.c"), Value::Int(1));
3636    }
3637
3638    #[test]
3639    fn eval_let_dotted_mixed() {
3640        // Mix of simple and dotted bindings.
3641        assert_eq!(
3642            ev("let a.x = 1; b = 2; a.y = 3; in a.x + a.y + b"),
3643            Value::Int(6),
3644        );
3645    }
3646
3647    #[test]
3648    fn eval_let_dotted_produces_attrset() {
3649        // Dotted let bindings produce a real attrset.
3650        let v = ev("let a.b = 1; a.c = 2; in a");
3651        if let Value::Attrs(attrs) = v {
3652            assert_eq!(attrs.get("b"), Some(&Value::Int(1)));
3653            assert_eq!(attrs.get("c"), Some(&Value::Int(2)));
3654        } else {
3655            panic!("expected Attrs, got {v:?}");
3656        }
3657    }
3658
3659    // ── Inner dynamic attrpath key laziness ──────────────────
3660    // CppNix defers a dynamic key that is NOT at the head of an attrpath:
3661    // `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`, so `e` never
3662    // forces until `.a` is demanded. Reading a sibling must not force the
3663    // inner dynamic key. Root fix: `build_deferred_tail_attr` in eval.rs.
3664    // This is the pure-builtins reduction of the NixOS module-system
3665    // `config.homes.${cfg.userName}` fixpoint divergence.
3666    #[test]
3667    fn dynamic_inner_attr_key_is_lazy_on_sibling_read() {
3668        // The dynamic key throws; reading the SIBLING must NOT force it.
3669        assert_eq!(
3670            ev(r#"let s = { a.${throw "KEYFORCED"} = 7; other = 9; }; in s.other"#),
3671            Value::Int(9),
3672        );
3673    }
3674
3675    #[test]
3676    fn dynamic_inner_attr_key_resolves_on_head_demand() {
3677        // Demanding the head DOES resolve the deferred dynamic key.
3678        let v = ev(r#"let u = "bob"; s = { homes.${u} = 7; }; in s.homes"#);
3679        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3680            assert_eq!(attrs.get("bob"), Some(&Value::Int(7)));
3681        } else {
3682            panic!("expected Attrs");
3683        }
3684    }
3685
3686    #[test]
3687    fn dynamic_inner_attr_key_merges_with_static_sibling() {
3688        // Collision under one head still deep-merges (static + dynamic).
3689        let v = ev(r#"let u = "x"; s = { a.${u} = 1; a.b = 2; }; in s.a"#);
3690        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3691            assert_eq!(attrs.get("x"), Some(&Value::Int(1)));
3692            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
3693        } else {
3694            panic!("expected Attrs");
3695        }
3696    }
3697
3698    #[test]
3699    fn dynamic_inner_attr_key_null_skips_binding() {
3700        // A null dynamic inner key skips the definition (CppNix rule):
3701        // `a` becomes an empty attrset, the sibling stays.
3702        let v = ev(
3703            r#"let c = true; s = { a.${if c then null else "n"} = 5; b = 1; }; in s.b"#,
3704        );
3705        assert_eq!(v, Value::Int(1));
3706    }
3707
3708    // ── M2.6 ROOT #3: interpolated-STRING tail keys are dynamic too ──────
3709    // `{ a."p${e}" = v; }` must build `{ a = <thunk {"p${e}"=v}>; }` — an
3710    // interpolated-string attr key references `e` and so must defer like a
3711    // bare `${e}`, never force at construction. Reading a sibling must NOT
3712    // force it (the KEYFORCE discriminator, now for a `Str` key).
3713    #[test]
3714    fn interpolated_string_attr_key_is_lazy_on_sibling_read() {
3715        assert_eq!(
3716            ev(r#"let s = { a."p/${throw "KEYFORCED"}" = 7; other = 9; }; in s.other"#),
3717            Value::Int(9),
3718        );
3719    }
3720
3721    #[test]
3722    fn interpolated_string_attr_key_resolves_on_head_demand() {
3723        // Demanding the head DOES resolve the deferred interpolated key.
3724        let v = ev(r#"let u = "bob"; s = { homes."u/${u}" = 7; }; in s.homes"#);
3725        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3726            assert_eq!(attrs.get("u/bob"), Some(&Value::Int(7)));
3727        } else {
3728            panic!("expected Attrs");
3729        }
3730    }
3731
3732    #[test]
3733    fn purely_literal_string_attr_key_stays_eager_static() {
3734        // A `Str` key with NO interpolation is a plain static key and must
3735        // NOT be treated as dynamic (it forces nothing, deep-merges).
3736        let v = ev(r#"let s = { a."foo bar" = 1; a.b = 2; }; in s.a"#);
3737        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
3738            assert_eq!(attrs.get("foo bar"), Some(&Value::Int(1)));
3739            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
3740        } else {
3741            panic!("expected Attrs");
3742        }
3743    }
3744
3745    // ── M2.6 ROOT #3 (collision case): dynamic tail key under a head that
3746    // a sibling binding already wrote must stay lazy AND deep-merge.
3747    #[test]
3748    fn dynamic_tail_key_under_colliding_head_is_lazy() {
3749        // `sd.services.x` writes head `sd`; the second binding's dynamic
3750        // key must NOT force when a SIBLING (`sd.services`) is read.
3751        let v = ev(
3752            r#"let s = { sd.services.x = 1; sd.tmpfiles.${throw "KEYFORCED"}.d = 2; }; in s.sd.services.x"#,
3753        );
3754        assert_eq!(v, Value::Int(1));
3755    }
3756
3757    #[test]
3758    fn dynamic_tail_key_under_colliding_head_resolves_and_merges() {
3759        // Demanding the dynamic branch resolves the key; the sibling
3760        // static branch (`sd.services`) survives the merge intact.
3761        let v = ev(
3762            r#"let k = "z"; s = { sd.services.x = 1; sd.tmpfiles.${k}.d = 2; }; in s.sd"#,
3763        );
3764        let sd = force_value(&v).unwrap();
3765        if let Value::Attrs(sd_attrs) = &sd {
3766            // static sibling intact
3767            let services = force_value(sd_attrs.get("services").unwrap()).unwrap();
3768            if let Value::Attrs(a) = &services {
3769                assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
3770            } else { panic!("expected services attrs"); }
3771            // dynamic branch resolved to key "z"
3772            let tmpfiles = force_value(sd_attrs.get("tmpfiles").unwrap()).unwrap();
3773            if let Value::Attrs(a) = &tmpfiles {
3774                let z = force_value(a.get("z").unwrap()).unwrap();
3775                if let Value::Attrs(zd) = &z {
3776                    assert_eq!(force_value(zd.get("d").unwrap()).unwrap(), Value::Int(2));
3777                } else { panic!("expected z attrs"); }
3778            } else { panic!("expected tmpfiles attrs"); }
3779        } else {
3780            panic!("expected sd attrs");
3781        }
3782    }
3783
3784    // ── M2.6 ROOT #4a — `with` namespace must be LAZY ─────────────────
3785    // `with X; body` stores the namespace as a thunk forced only on a
3786    // bare-ident fallthrough lookup; demanding only the body's WHNF/keys
3787    // must NOT force X.  cppnix: `attrNames (with (throw "X"); {a=1;})`
3788    // → ["a"].  Before the fix, sui EVALUATED the namespace at `with`-entry
3789    // and threw.  This is the load-bearing over-force behind the M2.6
3790    // `concatLists null` (nixpkgs' `config = mkIf … (with config.services.X;
3791    // { … })` module shape forced `config.services.X` during collection).
3792    #[test]
3793    fn with_namespace_is_lazy_on_body_whnf() {
3794        let v = ev(r#"builtins.attrNames (with (throw "WITH-FORCED"); { a = 1; b = 2; })"#);
3795        if let Value::List(items) = force_value(&v).unwrap() {
3796            let names: Vec<String> = items
3797                .iter()
3798                .map(|i| match force_value(i).unwrap() {
3799                    Value::String(s) => s.as_str().to_string(),
3800                    other => panic!("expected string, got {}", other.type_name()),
3801                })
3802                .collect();
3803            assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
3804        } else {
3805            panic!("expected list");
3806        }
3807    }
3808
3809    #[test]
3810    fn with_namespace_forces_only_on_fallthrough() {
3811        // A bare ident that falls through lexical scope DOES resolve via
3812        // the namespace (correct cppnix semantics) — proves the deferred
3813        // thunk is real and gets forced on demand, not an accidental no-op.
3814        assert_eq!(ev(r#"with { x = 42; }; x"#), Value::Int(42));
3815        // A lexical binding shadows the with-scope, so the (throwing)
3816        // namespace is never forced — the laziness we rely on for M2.6.
3817        assert_eq!(ev(r#"let x = 7; in with (throw "NS"); x"#), Value::Int(7));
3818    }
3819
3820    // ── M2.6 ROOT #4b — depth-≥2 dotted full-set leaf must deep-merge ──
3821    // `o.a = { x = 1; }` inserts `o = { a = <thunk {x=1}> }` (leaf goes
3822    // through maybe_thunk); a deeper sibling `o.a.y = 2` recurses
3823    // merge_nested_insert down to key `a` where the existing value is that
3824    // thunk.  Before the fix, merge_nested_insert required BOTH sides to be
3825    // concrete Attrs, so the Thunk-vs-Attrs collision OVERWROTE — dropping
3826    // `x`.  cppnix desugars both orderings into `o.a = { x = 1; y = 2; }`.
3827    // This is the M2.6 post-`with`-fix frontier (nixpkgs alsa's
3828    // `options.hardware.alsa = { … }` + `options.hardware.alsa.enablePersistence
3829    // = …` merged to only {enablePersistence} → `cardAliases` "does not exist").
3830    #[test]
3831    fn dotted_fullset_leaf_deep_merges_with_deeper_sibling() {
3832        let v = ev(r#"{ o.a = { x = 1; }; o.a.y = 2; }.o.a"#);
3833        if let Value::Attrs(a) = force_value(&v).unwrap() {
3834            assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
3835            assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
3836        } else {
3837            panic!("expected attrs");
3838        }
3839    }
3840
3841    #[test]
3842    fn dotted_fullset_leaf_deep_merge_reverse_order() {
3843        // Deeper sibling FIRST, full-set leaf SECOND — the NEW value is the
3844        // `<thunk {x=1}>`; must still merge (the collision forces it).
3845        let v = ev(r#"{ o.a.y = 2; o.a = { x = 1; }; }.o.a"#);
3846        if let Value::Attrs(a) = force_value(&v).unwrap() {
3847            assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
3848            assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
3849        } else {
3850            panic!("expected attrs");
3851        }
3852    }
3853
3854    #[test]
3855    fn dotted_fullset_leaf_merge_preserves_leaf_laziness() {
3856        // The merge forces the existing/new leaf to WHNF (keys) but MUST
3857        // NOT force the leaf VALUES — a throwing sibling value that is never
3858        // demanded stays lazy.
3859        assert_eq!(ev(r#"{ o.a = { x = throw "X-NEVER"; }; o.a.y = 2; }.o.a.y"#), Value::Int(2));
3860    }
3861
3862    #[test]
3863    fn eval_nested_let() {
3864        assert_eq!(ev("let a = 1; b = let c = 2; in c; in a + b"), Value::Int(3));
3865    }
3866
3867    #[test]
3868    fn eval_lambda() {
3869        assert_eq!(ev("(x: x + 1) 41"), Value::Int(42));
3870    }
3871
3872    #[test]
3873    fn eval_lambda_multi_arg() {
3874        assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
3875    }
3876
3877    #[test]
3878    fn eval_list() {
3879        let v = ev("[1 2 3]");
3880        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]));
3881    }
3882
3883    #[test]
3884    fn eval_list_concat() {
3885        let v = ev("[1 2] ++ [3 4]");
3886        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]));
3887    }
3888
3889    #[test]
3890    fn eval_attrset() {
3891        let v = ev("{ a = 1; b = 2; }");
3892        if let Value::Attrs(attrs) = v {
3893            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
3894            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
3895        } else {
3896            panic!("expected attrset");
3897        }
3898    }
3899
3900    #[test]
3901    fn eval_select() {
3902        assert_eq!(ev("{ a = 42; }.a"), Value::Int(42));
3903    }
3904
3905    #[test]
3906    fn eval_select_or() {
3907        assert_eq!(ev("{ a = 42; }.b or 0"), Value::Int(0));
3908    }
3909
3910    #[test]
3911    fn eval_has_attr() {
3912        assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
3913        assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
3914    }
3915
3916    #[test]
3917    fn eval_update() {
3918        let v = ev("{ a = 1; b = 2; } // { b = 3; c = 4; }");
3919        if let Value::Attrs(attrs) = v {
3920            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
3921            assert_eq!(attrs.get("b"), Some(&Value::Int(3)));
3922            assert_eq!(attrs.get("c"), Some(&Value::Int(4)));
3923        } else {
3924            panic!("expected attrset");
3925        }
3926    }
3927
3928    #[test]
3929    fn eval_with() {
3930        assert_eq!(ev("with { x = 42; }; x"), Value::Int(42));
3931    }
3932
3933    #[test]
3934    fn eval_assert() {
3935        assert_eq!(ev("assert true; 42"), Value::Int(42));
3936        assert!(eval("assert false; 42").is_err());
3937    }
3938
3939    #[test]
3940    fn eval_formals() {
3941        assert_eq!(ev("({ a, b }: a + b) { a = 1; b = 2; }"), Value::Int(3));
3942    }
3943
3944    #[test]
3945    fn eval_formals_default() {
3946        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 1; }"), Value::Int(11));
3947    }
3948
3949    #[test]
3950    fn eval_formals_ellipsis() {
3951        assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; }"), Value::Int(1));
3952    }
3953
3954    #[test]
3955    fn eval_named_formals() {
3956        assert_eq!(ev("(args @ { a }: args.a) { a = 42; }"), Value::Int(42));
3957    }
3958
3959    #[test]
3960    fn eval_rec_attrset() {
3961        assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
3962    }
3963
3964    #[test]
3965    fn eval_negation() {
3966        assert_eq!(ev("-42"), Value::Int(-42));
3967    }
3968
3969    #[test]
3970    fn eval_float_arithmetic() {
3971        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
3972        assert_eq!(ev("1 + 1.5"), Value::Float(2.5));
3973    }
3974
3975    #[test]
3976    fn eval_division_by_zero() {
3977        assert!(eval("1 / 0").is_err());
3978    }
3979
3980    #[test]
3981    fn eval_builtins_available() {
3982        assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
3983        assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
3984    }
3985
3986    #[test]
3987    fn eval_builtins_length() {
3988        assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
3989    }
3990
3991    #[test]
3992    fn eval_builtins_head_tail() {
3993        assert_eq!(ev("builtins.head [1 2 3]"), Value::Int(1));
3994        assert_eq!(ev("builtins.length (builtins.tail [1 2 3])"), Value::Int(2));
3995    }
3996
3997    #[test]
3998    fn eval_builtins_add() {
3999        assert_eq!(ev("builtins.add 1 2"), Value::Int(3));
4000    }
4001
4002    #[test]
4003    fn eval_builtins_to_string() {
4004        assert_eq!(ev("builtins.toString 42"), Value::string("42"));
4005    }
4006
4007    #[test]
4008    fn eval_implication() {
4009        assert_eq!(ev("false -> true"), Value::Bool(true));
4010        assert_eq!(ev("true -> false"), Value::Bool(false));
4011        assert_eq!(ev("true -> true"), Value::Bool(true));
4012    }
4013
4014    // ── New tests ────────────────────────────────────────
4015
4016    #[test]
4017    fn eval_error_undefined_variable() {
4018        let result = eval("nonexistent");
4019        assert!(result.is_err());
4020        let msg = format!("{}", result.unwrap_err());
4021        assert!(msg.contains("undefined variable"));
4022    }
4023
4024    #[test]
4025    fn eval_error_type_mismatch_arithmetic() {
4026        let result = eval(r#"1 + "hello""#);
4027        assert!(result.is_err());
4028        let msg = format!("{}", result.unwrap_err());
4029        assert!(msg.contains("cannot add") || msg.contains("type"));
4030    }
4031
4032    #[test]
4033    fn eval_error_unexpected_argument() {
4034        let result = eval("({ a }: a) { a = 1; b = 2; }");
4035        assert!(result.is_err());
4036        let msg = format!("{}", result.unwrap_err());
4037        assert!(msg.contains("unexpected argument"));
4038    }
4039
4040    #[test]
4041    fn eval_error_missing_required_argument() {
4042        let result = eval("({ a, b }: a + b) { a = 1; }");
4043        assert!(result.is_err());
4044        let msg = format!("{}", result.unwrap_err());
4045        assert!(msg.contains("missing argument"));
4046    }
4047
4048    #[test]
4049    fn eval_builtins_attr_names_sorted() {
4050        let v = ev("builtins.attrNames { z = 1; a = 2; m = 3; }");
4051        // BTreeMap keys are already sorted
4052        assert_eq!(
4053            v,
4054            Value::list(vec![
4055                Value::string("a"),
4056                Value::string("m"),
4057                Value::string("z"),
4058            ]),
4059        );
4060    }
4061
4062    #[test]
4063    fn eval_builtins_attr_values() {
4064        let v = ev("builtins.attrValues { a = 1; b = 2; }");
4065        // BTreeMap iteration is sorted by key, so a=1 first, b=2 second
4066        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
4067    }
4068
4069    #[test]
4070    fn eval_builtins_is_null() {
4071        assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
4072        assert_eq!(ev("builtins.isNull 1"), Value::Bool(false));
4073    }
4074
4075    #[test]
4076    fn eval_builtins_is_int() {
4077        assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
4078        assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
4079    }
4080
4081    #[test]
4082    fn eval_builtins_is_bool() {
4083        assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
4084        assert_eq!(ev("builtins.isBool 0"), Value::Bool(false));
4085    }
4086
4087    #[test]
4088    fn eval_builtins_is_string() {
4089        assert_eq!(ev(r#"builtins.isString "hi""#), Value::Bool(true));
4090        assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
4091    }
4092
4093    #[test]
4094    fn eval_builtins_is_list() {
4095        assert_eq!(ev("builtins.isList [1 2]"), Value::Bool(true));
4096        assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
4097    }
4098
4099    #[test]
4100    fn eval_builtins_is_attrs() {
4101        assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
4102        assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
4103    }
4104
4105    #[test]
4106    fn eval_builtins_string_length() {
4107        assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
4108        assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
4109    }
4110
4111    #[test]
4112    fn eval_builtins_to_json_roundtrip() {
4113        // toJSON produces a JSON string; fromJSON parses it back
4114        assert_eq!(
4115            ev(r#"builtins.fromJSON (builtins.toJSON 42)"#),
4116            Value::Int(42),
4117        );
4118        assert_eq!(
4119            ev(r#"builtins.fromJSON (builtins.toJSON [1 2 3])"#),
4120            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4121        );
4122    }
4123
4124    #[test]
4125    fn eval_builtins_from_json() {
4126        assert_eq!(
4127            ev(r#"builtins.fromJSON "{\"a\": 1}""#),
4128            {
4129                let mut attrs = NixAttrs::new();
4130                attrs.insert("a".to_string(), Value::Int(1));
4131                Value::Attrs(Rc::new(attrs))
4132            },
4133        );
4134        assert_eq!(ev(r#"builtins.fromJSON "null""#), Value::Null);
4135        assert_eq!(ev(r#"builtins.fromJSON "true""#), Value::Bool(true));
4136    }
4137
4138    #[test]
4139    fn eval_nested_function_application() {
4140        // (f 1) 2 where f = x: y: x + y
4141        assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4142        // equivalent parenthesized form
4143        assert_eq!(ev("((x: y: x + y) 1) 2"), Value::Int(3));
4144    }
4145
4146    #[test]
4147    fn eval_recursive_let() {
4148        assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4149        assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4150    }
4151
4152    #[test]
4153    fn eval_string_comparison() {
4154        assert_eq!(ev(r#""a" < "b""#), Value::Bool(true));
4155        assert_eq!(ev(r#""b" < "a""#), Value::Bool(false));
4156        assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4157        assert_eq!(ev(r#""abc" != "def""#), Value::Bool(true));
4158    }
4159
4160    #[test]
4161    fn eval_list_in_attrset() {
4162        let v = ev("{ x = [1 2 3]; }.x");
4163        assert_eq!(
4164            v,
4165            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4166        );
4167    }
4168
4169    #[test]
4170    fn eval_nested_attrset_select() {
4171        assert_eq!(ev("{ a = { b = 42; }; }.a.b"), Value::Int(42));
4172    }
4173
4174    #[test]
4175    fn eval_let_shadows_outer() {
4176        assert_eq!(
4177            ev("let x = 1; in let x = 2; in x"),
4178            Value::Int(2),
4179        );
4180    }
4181
4182    #[test]
4183    fn eval_with_provides_scope() {
4184        // `with` scope is available for name resolution
4185        assert_eq!(
4186            ev("with { x = 42; y = 10; }; x + y"),
4187            Value::Int(52),
4188        );
4189    }
4190
4191    #[test]
4192    fn eval_list_equality() {
4193        assert_eq!(ev("[1 2] == [1 2]"), Value::Bool(true));
4194        assert_eq!(ev("[1 2] == [1 3]"), Value::Bool(false));
4195    }
4196
4197    #[test]
4198    fn eval_attrset_equality() {
4199        assert_eq!(ev("{ a = 1; } == { a = 1; }"), Value::Bool(true));
4200        assert_eq!(ev("{ a = 1; } == { a = 2; }"), Value::Bool(false));
4201    }
4202
4203    // ═══════════════════════════════════════════════════════════
4204    // 1. LITERAL TYPES
4205    // ═══════════════════════════════════════════════════════════
4206
4207    #[test]
4208    fn literal_int_large_zero_negative() {
4209        // Large positive integer (within i64 range)
4210        assert_eq!(ev("9223372036854775807"), Value::Int(i64::MAX));
4211        // Zero
4212        assert_eq!(ev("0"), Value::Int(0));
4213        // Negative via unary negate
4214        assert_eq!(ev("-1"), Value::Int(-1));
4215        assert_eq!(ev("-999999"), Value::Int(-999999));
4216    }
4217
4218    #[test]
4219    fn literal_float_small_large() {
4220        assert_eq!(ev("0.001"), Value::Float(0.001));
4221        assert_eq!(ev("999999.999"), Value::Float(999999.999));
4222        // Float with scientific notation via expression (1e6 parsed by rnix)
4223        assert_eq!(ev("1.0e3"), Value::Float(1000.0));
4224        assert_eq!(ev("1.5e2"), Value::Float(150.0));
4225    }
4226
4227    #[test]
4228    fn literal_string_empty_and_escapes() {
4229        assert_eq!(ev(r#""""#), Value::string(""));
4230        // Escape sequences within strings
4231        assert_eq!(ev(r#""hello\nworld""#), Value::string("hello\nworld"));
4232        assert_eq!(ev(r#""tab\there""#), Value::string("tab\there"));
4233    }
4234
4235    #[test]
4236    fn literal_multiline_string() {
4237        // Indented string ('' ... '')
4238        assert_eq!(
4239            ev("''hello''"),
4240            Value::string("hello"),
4241        );
4242        // Multiline indented string strips common indentation
4243        assert_eq!(
4244            ev("''\n  line1\n  line2\n''"),
4245            Value::string("line1\nline2\n"),
4246        );
4247    }
4248
4249    #[test]
4250    fn literal_paths() {
4251        // Relative path
4252        assert_eq!(ev("./foo"), Value::Path(Box::new(SmolStr::from("./foo"))));
4253        // Absolute path
4254        assert_eq!(ev("/nix/store/abc"), Value::Path(Box::new(SmolStr::from("/nix/store/abc"))));
4255        // Home path
4256        assert_eq!(ev("~/myfile"), Value::Path(Box::new(SmolStr::from("~/myfile"))));
4257    }
4258
4259    // ── Interpolated path literals (cid-marquee root, 2026-07-12) ──
4260    //
4261    // CppNix path literals may contain `${e}` antiquotations: `./${x}.nix`,
4262    // `/a/${e}`, `~/${e}`. sui previously flattened the whole path token to
4263    // raw text and dropped the interpolation (`import ./${x}.nix` →
4264    // `No such file or directory`). The `${e}` must be evaluated,
4265    // string-coerced (plain, no copy-to-store), spliced, and the result is
4266    // still a `path` value. Oracles taken from cppnix.
4267
4268    #[test]
4269    fn interp_path_abs_splices_and_types_path() {
4270        // /a/${x}/b with x="foo" → /a/foo/b, type path (nix oracle).
4271        let v = ev(r#"let x = "foo"; in /a/${x}/b"#);
4272        assert_eq!(v, Value::Path(Box::new(SmolStr::from("/a/foo/b"))));
4273    }
4274
4275    #[test]
4276    fn interp_path_abs_multi_and_slash_in_value() {
4277        // Multiple interpolations + a slash inside the spliced value.
4278        assert_eq!(
4279            ev(r#"let a = "x"; b = "y/z"; in /p/${a}/${b}.nix"#),
4280            Value::Path(Box::new(SmolStr::from("/p/x/y/z.nix"))),
4281        );
4282    }
4283
4284    #[test]
4285    fn interp_path_abs_normalizes_double_slash_seam() {
4286        // A path-typed interpolation splices the raw path (no copy-to-store)
4287        // and the `/` seam is normalized: `/bar/` + `/tmp/foo` → /bar/tmp/foo.
4288        assert_eq!(
4289            ev(r#"/bar/${/tmp/foo}"#),
4290            Value::Path(Box::new(SmolStr::from("/bar/tmp/foo"))),
4291        );
4292    }
4293
4294    #[test]
4295    fn interp_path_rel_resolves_against_eval_dir() {
4296        // The spicetify `map (x: ./${x}.nix) [...]` root: a relative
4297        // interpolated path resolves against the defining file's directory,
4298        // exactly like a plain `./foo.nix` literal.
4299        let _g = push_eval_file(std::path::PathBuf::from("/tmp/example/default.nix"));
4300        assert_eq!(
4301            ev(r#"let x = "foo"; in ./${x}.nix"#),
4302            Value::Path(Box::new(SmolStr::from("/tmp/example/foo.nix"))),
4303        );
4304    }
4305
4306    #[test]
4307    fn interp_path_rel_no_eval_dir_keeps_relative_text() {
4308        // With no eval-file context the plain branch keeps the raw relative
4309        // text; the interpolated branch splices then does the same.
4310        assert_eq!(
4311            ev(r#"let x = "foo"; in ./${x}.nix"#),
4312            Value::Path(Box::new(SmolStr::from("./foo.nix"))),
4313        );
4314    }
4315
4316    #[test]
4317    fn interp_path_home_splices_leading_tilde_preserved() {
4318        // Home paths splice their `${e}`; the leading `~` is carried as-is
4319        // (matching sui's plain `~/foo` behavior — `~`-expansion is a
4320        // separate, pre-existing concern, not introduced here).
4321        assert_eq!(
4322            ev(r#"let x = "foo"; in ~/${x}/bar"#),
4323            Value::Path(Box::new(SmolStr::from("~/foo/bar"))),
4324        );
4325    }
4326
4327    #[test]
4328    fn interp_path_non_interpolated_still_raw() {
4329        // A path with no `${…}` must keep the trivial raw-text shortcut
4330        // (byte-for-byte identical to the plain branch).
4331        assert_eq!(ev("/a/b/c"), Value::Path(Box::new(SmolStr::from("/a/b/c"))));
4332        assert_eq!(ev("~/plain"), Value::Path(Box::new(SmolStr::from("~/plain"))));
4333    }
4334
4335    #[test]
4336    fn literal_null_true_false_standalone() {
4337        assert_eq!(ev("null"), Value::Null);
4338        assert_eq!(ev("true"), Value::Bool(true));
4339        assert_eq!(ev("false"), Value::Bool(false));
4340    }
4341
4342    // ═══════════════════════════════════════════════════════════
4343    // 2. OPERATORS — COMPLETE COVERAGE
4344    // ═══════════════════════════════════════════════════════════
4345
4346    #[test]
4347    fn op_arithmetic_int() {
4348        assert_eq!(ev("100 + 200"), Value::Int(300));
4349        assert_eq!(ev("50 - 30"), Value::Int(20));
4350        assert_eq!(ev("7 * 8"), Value::Int(56));
4351        assert_eq!(ev("17 / 3"), Value::Int(5)); // integer division
4352    }
4353
4354    #[test]
4355    fn op_arithmetic_float() {
4356        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4357        assert_eq!(ev("5.0 - 1.5"), Value::Float(3.5));
4358        assert_eq!(ev("2.0 * 3.0"), Value::Float(6.0));
4359        assert_eq!(ev("7.0 / 2.0"), Value::Float(3.5));
4360    }
4361
4362    #[test]
4363    fn op_arithmetic_mixed_int_float() {
4364        // int + float => float
4365        assert_eq!(ev("1 + 2.5"), Value::Float(3.5));
4366        assert_eq!(ev("2.5 + 1"), Value::Float(3.5));
4367        // int * float => float
4368        assert_eq!(ev("2 * 1.5"), Value::Float(3.0));
4369        // float - int => float
4370        assert_eq!(ev("5.5 - 2"), Value::Float(3.5));
4371    }
4372
4373    #[test]
4374    fn op_string_concat() {
4375        assert_eq!(ev(r#""foo" + "bar""#), Value::string("foobar"));
4376        assert_eq!(ev(r#""" + "x""#), Value::string("x"));
4377        assert_eq!(ev(r#""a" + "" + "b""#), Value::string("ab"));
4378    }
4379
4380    #[test]
4381    fn op_path_concat() {
4382        // path + string
4383        assert_eq!(ev(r#"./foo + "/bar""#), Value::Path(Box::new(SmolStr::from("./foo/bar"))));
4384        // path + path (should join with /)
4385        assert_eq!(ev("./a + ./b"), Value::Path(Box::new(SmolStr::from("./a/./b"))));
4386    }
4387
4388    #[test]
4389    fn op_comparison_ints() {
4390        assert_eq!(ev("1 < 2"), Value::Bool(true));
4391        assert_eq!(ev("2 < 1"), Value::Bool(false));
4392        assert_eq!(ev("2 > 1"), Value::Bool(true));
4393        assert_eq!(ev("1 > 2"), Value::Bool(false));
4394        assert_eq!(ev("2 <= 2"), Value::Bool(true));
4395        assert_eq!(ev("3 <= 2"), Value::Bool(false));
4396        assert_eq!(ev("2 >= 2"), Value::Bool(true));
4397        assert_eq!(ev("1 >= 2"), Value::Bool(false));
4398    }
4399
4400    #[test]
4401    fn op_comparison_floats() {
4402        assert_eq!(ev("1.5 < 2.5"), Value::Bool(true));
4403        assert_eq!(ev("2.5 > 1.5"), Value::Bool(true));
4404        assert_eq!(ev("1.5 <= 1.5"), Value::Bool(true));
4405        assert_eq!(ev("1.5 >= 1.5"), Value::Bool(true));
4406    }
4407
4408    #[test]
4409    fn op_comparison_strings() {
4410        assert_eq!(ev(r#""apple" < "banana""#), Value::Bool(true));
4411        assert_eq!(ev(r#""banana" > "apple""#), Value::Bool(true));
4412        assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4413        assert_eq!(ev(r#""abc" != "xyz""#), Value::Bool(true));
4414        assert_eq!(ev(r#""abc" <= "abd""#), Value::Bool(true));
4415        assert_eq!(ev(r#""abc" >= "abb""#), Value::Bool(true));
4416    }
4417
4418    #[test]
4419    fn op_equality_various_types() {
4420        assert_eq!(ev("null == null"), Value::Bool(true));
4421        assert_eq!(ev("true == true"), Value::Bool(true));
4422        assert_eq!(ev("false == false"), Value::Bool(true));
4423        assert_eq!(ev("true == false"), Value::Bool(false));
4424        assert_eq!(ev("1 == 1"), Value::Bool(true));
4425        assert_eq!(ev("1 != 2"), Value::Bool(true));
4426        // Different types are not equal
4427        assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
4428        assert_eq!(ev("null == false"), Value::Bool(false));
4429    }
4430
4431    #[test]
4432    fn op_logic_short_circuit() {
4433        // false && <error> should NOT evaluate the RHS
4434        assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
4435        // true || <error> should NOT evaluate the RHS
4436        assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
4437    }
4438
4439    #[test]
4440    fn op_logic_full() {
4441        assert_eq!(ev("true && true"), Value::Bool(true));
4442        assert_eq!(ev("true && false"), Value::Bool(false));
4443        assert_eq!(ev("false && true"), Value::Bool(false));
4444        assert_eq!(ev("false && false"), Value::Bool(false));
4445        assert_eq!(ev("true || true"), Value::Bool(true));
4446        assert_eq!(ev("true || false"), Value::Bool(true));
4447        assert_eq!(ev("false || true"), Value::Bool(true));
4448        assert_eq!(ev("false || false"), Value::Bool(false));
4449        assert_eq!(ev("!true"), Value::Bool(false));
4450        assert_eq!(ev("!false"), Value::Bool(true));
4451    }
4452
4453    #[test]
4454    fn op_implication_truth_table() {
4455        // false -> anything = true
4456        assert_eq!(ev("false -> false"), Value::Bool(true));
4457        assert_eq!(ev("false -> true"), Value::Bool(true));
4458        // true -> x = x
4459        assert_eq!(ev("true -> true"), Value::Bool(true));
4460        assert_eq!(ev("true -> false"), Value::Bool(false));
4461    }
4462
4463    #[test]
4464    fn op_implication_short_circuit() {
4465        // false -> <error> should NOT evaluate the RHS
4466        assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
4467    }
4468
4469    #[test]
4470    fn op_update_merge() {
4471        let v = ev("{ a = 1; } // { b = 2; }");
4472        if let Value::Attrs(attrs) = v {
4473            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4474            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4475        } else {
4476            panic!("expected attrs");
4477        }
4478    }
4479
4480    #[test]
4481    fn op_update_right_wins() {
4482        assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4483    }
4484
4485    #[test]
4486    fn op_list_concat() {
4487        assert_eq!(
4488            ev("[1 2] ++ [3 4]"),
4489            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]),
4490        );
4491        // Empty list concat
4492        assert_eq!(ev("[] ++ [1]"), Value::list(vec![Value::Int(1)]));
4493        assert_eq!(ev("[1] ++ []"), Value::list(vec![Value::Int(1)]));
4494    }
4495
4496    #[test]
4497    fn op_has_attr_present_and_absent() {
4498        assert_eq!(ev("{ x = 1; y = 2; } ? x"), Value::Bool(true));
4499        assert_eq!(ev("{ x = 1; } ? z"), Value::Bool(false));
4500        assert_eq!(ev("{} ? anything"), Value::Bool(false));
4501    }
4502
4503    #[test]
4504    fn op_unary_negate() {
4505        assert_eq!(ev("-42"), Value::Int(-42));
4506        assert_eq!(ev("-3.14"), Value::Float(-3.14));
4507        // Double negate
4508        assert_eq!(ev("- -5"), Value::Int(5));
4509    }
4510
4511    // ═══════════════════════════════════════════════════════════
4512    // 3. CONTROL FLOW
4513    // ═══════════════════════════════════════════════════════════
4514
4515    #[test]
4516    fn control_if_true_branch() {
4517        assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
4518    }
4519
4520    #[test]
4521    fn control_if_false_branch() {
4522        assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
4523    }
4524
4525    #[test]
4526    fn control_if_nested() {
4527        assert_eq!(
4528            ev("if true then (if false then 1 else 2) else 3"),
4529            Value::Int(2),
4530        );
4531        assert_eq!(
4532            ev("if false then 1 else (if true then 2 else 3)"),
4533            Value::Int(2),
4534        );
4535    }
4536
4537    #[test]
4538    fn control_assert_passing() {
4539        assert_eq!(ev("assert 1 == 1; 42"), Value::Int(42));
4540        assert_eq!(ev("assert true; true"), Value::Bool(true));
4541    }
4542
4543    #[test]
4544    fn control_assert_failing() {
4545        assert!(eval("assert false; 42").is_err());
4546        assert!(eval("assert 1 == 2; 42").is_err());
4547    }
4548
4549    #[test]
4550    fn control_with_basic_scope() {
4551        assert_eq!(ev("with { a = 1; b = 2; }; a + b"), Value::Int(3));
4552    }
4553
4554    #[test]
4555    fn control_with_lexical_precedence() {
4556        // let binding takes precedence over with scope
4557        assert_eq!(
4558            ev("let x = 10; in with { x = 99; }; x"),
4559            Value::Int(10),
4560        );
4561    }
4562
4563    #[test]
4564    fn control_with_nested() {
4565        assert_eq!(
4566            ev("with { a = 1; }; with { b = 2; }; a + b"),
4567            Value::Int(3),
4568        );
4569    }
4570
4571    #[test]
4572    fn control_with_lazy_fix_self() {
4573        // THE critical pattern that nixpkgs requires:
4574        // fix (self: with self; { a = 1; b = a + 1; })
4575        // Before the lazy-with fix, this would hit the blackhole detector
4576        // because `with` eagerly forced `self`.
4577        let result = eval(
4578            "let fix = f: let x = f x; in x; in fix (self: with self; { a = 1; b = a + 1; })"
4579        );
4580        assert!(result.is_ok(), "fix with self should work: {:?}", result);
4581        if let Ok(Value::Attrs(attrs)) = result {
4582            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4583            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4584        } else {
4585            panic!("expected Attrs, got {:?}", result);
4586        }
4587    }
4588
4589    #[test]
4590    fn control_with_lazy_fix_self_lib_pattern() {
4591        // The nixpkgs pattern: self-referential package set with lib.
4592        // Access via select to force through the thunk layer.
4593        let result = eval(r#"
4594            let fix = f: let x = f x; in x;
4595            in (fix (self: with self; {
4596                lib = { version = "1.0"; };
4597                hello = "hello ${lib.version}";
4598            })).hello
4599        "#);
4600        assert!(result.is_ok(), "nixpkgs-style lib pattern: {:?}", result);
4601        assert_eq!(
4602            result.unwrap(),
4603            Value::String(Rc::new(NixString::plain("hello 1.0"))),
4604        );
4605    }
4606
4607    #[test]
4608    fn control_with_non_attrset_errors() {
4609        // CppNix errors when with-scope is not an attrset and a lookup hits it
4610        let result = eval("with 42; 1");
4611        // The body `1` is a literal and doesn't look up anything in the
4612        // with-scope, so this should succeed (the scope is never forced).
4613        assert_eq!(result.unwrap(), Value::Int(1));
4614    }
4615
4616    #[test]
4617    fn control_with_non_attrset_lookup_falls_through() {
4618        // If the with scope is not an attrset, lookups should fall through
4619        // to outer scopes rather than crashing.
4620        let result = eval("let x = 1; in with 42; x");
4621        assert_eq!(result.unwrap(), Value::Int(1));
4622    }
4623
4624    #[test]
4625    fn control_let_simple_and_multiple() {
4626        assert_eq!(ev("let x = 5; in x"), Value::Int(5));
4627        assert_eq!(ev("let x = 1; y = 2; z = 3; in x + y + z"), Value::Int(6));
4628    }
4629
4630    #[test]
4631    fn control_let_shadow_outer() {
4632        assert_eq!(
4633            ev("let x = 1; in let x = 2; in x"),
4634            Value::Int(2),
4635        );
4636    }
4637
4638    #[test]
4639    fn control_let_recursive_reference() {
4640        assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4641        assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4642    }
4643
4644    #[test]
4645    fn control_nested_let_expression() {
4646        assert_eq!(
4647            ev("let a = let b = 1; in b; in a"),
4648            Value::Int(1),
4649        );
4650        assert_eq!(
4651            ev("let a = let b = 10; in b + 5; in a * 2"),
4652            Value::Int(30),
4653        );
4654    }
4655
4656    // ═══════════════════════════════════════════════════════════
4657    // 4. FUNCTIONS — COMPLETE COVERAGE
4658    // ═══════════════════════════════════════════════════════════
4659
4660    #[test]
4661    fn func_identity_lambda() {
4662        assert_eq!(ev("(x: x) 42"), Value::Int(42));
4663        assert_eq!(ev(r#"(x: x) "hello""#), Value::string("hello"));
4664    }
4665
4666    #[test]
4667    fn func_curried_two_args() {
4668        assert_eq!(ev("(x: y: x + y) 3 4"), Value::Int(7));
4669    }
4670
4671    #[test]
4672    fn func_curried_three_args() {
4673        assert_eq!(ev("(a: b: c: a + b + c) 1 2 3"), Value::Int(6));
4674    }
4675
4676    #[test]
4677    fn func_formals_basic() {
4678        assert_eq!(ev("({ a, b }: a + b) { a = 3; b = 7; }"), Value::Int(10));
4679    }
4680
4681    #[test]
4682    fn func_formals_with_defaults() {
4683        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; }"), Value::Int(15));
4684        // Providing the default-able argument overrides the default
4685        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; b = 20; }"), Value::Int(25));
4686    }
4687
4688    #[test]
4689    fn func_formals_with_ellipsis() {
4690        assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; c = 3; }"), Value::Int(1));
4691    }
4692
4693    #[test]
4694    fn func_named_formals_at_before() {
4695        // args @ { a, b }: ...
4696        assert_eq!(
4697            ev("(args @ { a, b }: args.a + args.b) { a = 3; b = 4; }"),
4698            Value::Int(7),
4699        );
4700    }
4701
4702    #[test]
4703    fn func_named_formals_at_after() {
4704        // { a, b } @ args: ...
4705        assert_eq!(
4706            ev("({ a, b } @ args: args.a + args.b) { a = 10; b = 20; }"),
4707            Value::Int(30),
4708        );
4709    }
4710
4711    #[test]
4712    fn func_nested_application() {
4713        // Explicit parenthesized application
4714        assert_eq!(ev("((x: y: x * y) 3) 4"), Value::Int(12));
4715    }
4716
4717    #[test]
4718    fn func_higher_order_map() {
4719        assert_eq!(
4720            ev("builtins.map (x: x * 2) [1 2 3]"),
4721            Value::list(vec![Value::Int(2), Value::Int(4), Value::Int(6)]),
4722        );
4723    }
4724
4725    #[test]
4726    fn func_higher_order_filter() {
4727        assert_eq!(
4728            ev("builtins.filter (x: x > 2) [1 2 3 4 5]"),
4729            Value::list(vec![Value::Int(3), Value::Int(4), Value::Int(5)]),
4730        );
4731    }
4732
4733    #[test]
4734    fn func_higher_order_foldl() {
4735        // Sum of list via foldl'
4736        assert_eq!(
4737            ev("builtins.foldl' (acc: x: acc + x) 0 [1 2 3 4]"),
4738            Value::Int(10),
4739        );
4740    }
4741
4742    #[test]
4743    fn func_as_attrset_value() {
4744        assert_eq!(
4745            ev("let s = { f = x: x + 1; }; in s.f 5"),
4746            Value::Int(6),
4747        );
4748    }
4749
4750    #[test]
4751    fn func_immediate_application() {
4752        assert_eq!(ev("(x: x * x) 7"), Value::Int(49));
4753    }
4754
4755    #[test]
4756    fn func_in_let_binding() {
4757        assert_eq!(
4758            ev("let double = x: x * 2; in double 21"),
4759            Value::Int(42),
4760        );
4761    }
4762
4763    // ═══════════════════════════════════════════════════════════
4764    // 5. ATTRIBUTE SETS — COMPLETE COVERAGE
4765    // ═══════════════════════════════════════════════════════════
4766
4767    #[test]
4768    fn attrs_empty_set() {
4769        let v = ev("{}");
4770        if let Value::Attrs(attrs) = v {
4771            assert!(attrs.is_empty());
4772        } else {
4773            panic!("expected attrs");
4774        }
4775    }
4776
4777    #[test]
4778    fn attrs_simple() {
4779        assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
4780    }
4781
4782    #[test]
4783    fn attrs_nested_access() {
4784        assert_eq!(ev("{ a = { b = { c = 42; }; }; }.a.b.c"), Value::Int(42));
4785    }
4786
4787    #[test]
4788    fn attrs_recursive_set() {
4789        assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
4790    }
4791
4792    #[test]
4793    fn attrs_update_disjoint() {
4794        let v = ev("{ a = 1; } // { b = 2; }");
4795        if let Value::Attrs(attrs) = v {
4796            assert_eq!(attrs.len(), 2);
4797            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4798            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4799        } else {
4800            panic!("expected attrs");
4801        }
4802    }
4803
4804    #[test]
4805    fn attrs_update_override() {
4806        assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4807    }
4808
4809    #[test]
4810    fn attrs_has_attr_operator() {
4811        assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
4812        assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
4813    }
4814
4815    #[test]
4816    fn attrs_select_with_default() {
4817        assert_eq!(ev("{ a = 1; }.a or 99"), Value::Int(1));
4818        assert_eq!(ev("{}.missing or 99"), Value::Int(99));
4819        assert_eq!(ev("{ a = 1; }.b or 42"), Value::Int(42));
4820    }
4821
4822    #[test]
4823    fn attrs_nested_attr_path_in_binding() {
4824        // { a.b = 1; } creates { a = { b = 1; }; }
4825        assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
4826    }
4827
4828    #[test]
4829    fn attrs_inherit_from_scope() {
4830        assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.x"), Value::Int(1));
4831        assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.y"), Value::Int(2));
4832    }
4833
4834    #[test]
4835    fn attrs_inherit_from_expr() {
4836        assert_eq!(
4837            ev("{ inherit ({ a = 42; b = 10; }) a; }.a"),
4838            Value::Int(42),
4839        );
4840    }
4841
4842    #[test]
4843    fn attrs_dynamic_attr_name() {
4844        assert_eq!(
4845            ev(r#"let name = "x"; in { ${name} = 42; }.x"#),
4846            Value::Int(42),
4847        );
4848    }
4849
4850    #[test]
4851    fn attrs_attr_names_sorted() {
4852        assert_eq!(
4853            ev("builtins.attrNames { z = 1; m = 2; a = 3; }"),
4854            Value::list(vec![
4855                Value::string("a"),
4856                Value::string("m"),
4857                Value::string("z"),
4858            ]),
4859        );
4860    }
4861
4862    #[test]
4863    fn attrs_attr_values_follow_key_order() {
4864        // BTreeMap iteration order: a=1, b=2, c=3
4865        assert_eq!(
4866            ev("builtins.attrValues { c = 3; a = 1; b = 2; }"),
4867            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4868        );
4869    }
4870
4871    #[test]
4872    fn attrs_update_is_shallow() {
4873        // // is a shallow merge; nested attrs are replaced, not merged
4874        assert_eq!(
4875            ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a ? x"),
4876            Value::Bool(false),
4877        );
4878        assert_eq!(
4879            ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a.y"),
4880            Value::Int(2),
4881        );
4882    }
4883
4884    // ═══════════════════════════════════════════════════════════
4885    // 6. LISTS — COMPLETE COVERAGE
4886    // ═══════════════════════════════════════════════════════════
4887
4888    #[test]
4889    fn list_empty() {
4890        assert_eq!(ev("[]"), Value::list(vec![]));
4891    }
4892
4893    #[test]
4894    fn list_single_element() {
4895        assert_eq!(ev("[1]"), Value::list(vec![Value::Int(1)]));
4896    }
4897
4898    #[test]
4899    fn list_mixed_types() {
4900        assert_eq!(
4901            ev(r#"[1 "two" true null]"#),
4902            Value::list(vec![
4903                Value::Int(1),
4904                Value::string("two"),
4905                Value::Bool(true),
4906                Value::Null,
4907            ]),
4908        );
4909    }
4910
4911    #[test]
4912    fn list_nested() {
4913        assert_eq!(
4914            ev("[[1 2] [3 4]]"),
4915            Value::list(vec![
4916                Value::list(vec![Value::Int(1), Value::Int(2)]),
4917                Value::list(vec![Value::Int(3), Value::Int(4)]),
4918            ]),
4919        );
4920    }
4921
4922    #[test]
4923    fn list_concat_operator() {
4924        assert_eq!(
4925            ev("[1] ++ [2] ++ [3]"),
4926            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4927        );
4928    }
4929
4930    #[test]
4931    fn list_builtins_length() {
4932        assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4933        assert_eq!(ev("builtins.length []"), Value::Int(0));
4934    }
4935
4936    #[test]
4937    fn list_builtins_elem_at() {
4938        assert_eq!(ev("builtins.elemAt [10 20 30] 0"), Value::Int(10));
4939        assert_eq!(ev("builtins.elemAt [10 20 30] 1"), Value::Int(20));
4940        assert_eq!(ev("builtins.elemAt [10 20 30] 2"), Value::Int(30));
4941    }
4942
4943    #[test]
4944    fn list_equality() {
4945        assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
4946        assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
4947        assert_eq!(ev("[] == []"), Value::Bool(true));
4948    }
4949
4950    // ═══════════════════════════════════════════════════════════
4951    // 7. STRING INTERPOLATION
4952    // ═══════════════════════════════════════════════════════════
4953
4954    #[test]
4955    fn interp_simple_variable() {
4956        assert_eq!(
4957            ev(r#"let name = "world"; in "hello ${name}""#),
4958            Value::string("hello world"),
4959        );
4960    }
4961
4962    #[test]
4963    fn interp_nested_expression() {
4964        assert_eq!(
4965            ev(r#""result: ${builtins.toString (1 + 2)}""#),
4966            Value::string("result: 3"),
4967        );
4968    }
4969
4970    #[test]
4971    fn interp_int_coercion() {
4972        // Ints are coerced to string in interpolation
4973        assert_eq!(
4974            ev(r#"let x = 42; in "count: ${builtins.toString x}""#),
4975            Value::string("count: 42"),
4976        );
4977    }
4978
4979    #[test]
4980    fn interp_multiple() {
4981        assert_eq!(
4982            ev(r#"let a = "foo"; b = "bar"; in "${a} and ${b}""#),
4983            Value::string("foo and bar"),
4984        );
4985    }
4986
4987    #[test]
4988    fn interp_in_let() {
4989        assert_eq!(
4990            ev(r#"let x = "world"; in "hello ${x}""#),
4991            Value::string("hello world"),
4992        );
4993    }
4994
4995    #[test]
4996    fn interp_empty_result() {
4997        assert_eq!(
4998            ev(r#"let x = ""; in "a${x}b""#),
4999            Value::string("ab"),
5000        );
5001    }
5002
5003    #[test]
5004    fn interp_path_in_string_context() {
5005        // CppNix string interpolation is copy-to-store coercion: a nonexistent
5006        // path errors "path '…' does not exist" (previously sui spliced the raw
5007        // relative path "./foo" verbatim, diverging from nix). The positive
5008        // copy-to-store case is byte-verified in
5009        // interp_path_copies_to_store_byte_matches_cppnix below.
5010        assert!(eval(r#""path: ${./foo-nonexistent-xyz}""#).is_err());
5011    }
5012
5013    #[test]
5014    fn interp_adjacent_interpolations() {
5015        assert_eq!(
5016            ev(r#"let a = "x"; b = "y"; in "${a}${b}""#),
5017            Value::string("xy"),
5018        );
5019    }
5020
5021    // ═══════════════════════════════════════════════════════════
5022    // 8. BUILTINS — VERIFY ALL MAJOR ONES
5023    // ═══════════════════════════════════════════════════════════
5024
5025    #[test]
5026    fn builtins_map_filter_foldl() {
5027        // map
5028        assert_eq!(
5029            ev("builtins.map (x: x + 10) [1 2 3]"),
5030            Value::list(vec![Value::Int(11), Value::Int(12), Value::Int(13)]),
5031        );
5032        // filter
5033        assert_eq!(
5034            ev("builtins.filter (x: x > 1) [1 2 3]"),
5035            Value::list(vec![Value::Int(2), Value::Int(3)]),
5036        );
5037        // foldl' — product
5038        assert_eq!(
5039            ev("builtins.foldl' (a: b: a * b) 1 [2 3 4]"),
5040            Value::Int(24),
5041        );
5042    }
5043
5044    #[test]
5045    fn builtins_map_attrs() {
5046        assert_eq!(
5047            ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).a"),
5048            Value::Int(2),
5049        );
5050        assert_eq!(
5051            ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).b"),
5052            Value::Int(4),
5053        );
5054    }
5055
5056    #[test]
5057    fn builtins_list_to_attrs() {
5058        assert_eq!(
5059            ev(r#"(builtins.listToAttrs [{ name = "x"; value = 1; } { name = "y"; value = 2; }]).x"#),
5060            Value::Int(1),
5061        );
5062    }
5063
5064    #[test]
5065    fn builtins_list_to_attrs_duplicate_key_first_wins() {
5066        // Nix `listToAttrs` keeps the FIRST occurrence of a duplicate `name`
5067        // (later duplicates are ignored). cppnix returns 1 here, not 2.
5068        // Byte-parity root (cid darwin): a Cargo.lock listing a crate twice
5069        // (registry entry then git entry of the same name+version) must
5070        // resolve to the FIRST source, so `substrate/lockfile-delta.nix`'s
5071        // `lockByKey` picks the registry crate exactly as nix does. Last-wins
5072        // silently switched the source to git and produced a structurally
5073        // different `rust_<crate>` derivation.
5074        assert_eq!(
5075            ev(r#"(builtins.listToAttrs [{ name = "k"; value = 1; } { name = "k"; value = 2; }]).k"#),
5076            Value::Int(1),
5077        );
5078    }
5079
5080    #[test]
5081    fn builtins_concat_map() {
5082        assert_eq!(
5083            ev("builtins.concatMap (x: [x (x * 2)]) [1 2 3]"),
5084            Value::list(vec![
5085                Value::Int(1), Value::Int(2),
5086                Value::Int(2), Value::Int(4),
5087                Value::Int(3), Value::Int(6),
5088            ]),
5089        );
5090    }
5091
5092    #[test]
5093    fn builtins_concat_lists() {
5094        assert_eq!(
5095            ev("builtins.concatLists [[1 2] [3] [4 5]]"),
5096            Value::list(vec![
5097                Value::Int(1), Value::Int(2), Value::Int(3),
5098                Value::Int(4), Value::Int(5),
5099            ]),
5100        );
5101    }
5102
5103    #[test]
5104    fn builtins_concat_strings_sep() {
5105        assert_eq!(
5106            ev(r#"builtins.concatStringsSep ", " ["a" "b" "c"]"#),
5107            Value::string("a, b, c"),
5108        );
5109        assert_eq!(
5110            ev(r#"builtins.concatStringsSep "" ["x" "y"]"#),
5111            Value::string("xy"),
5112        );
5113    }
5114
5115    #[test]
5116    fn builtins_replace_strings() {
5117        assert_eq!(
5118            ev(r#"builtins.replaceStrings ["o"] ["0"] "foobar""#),
5119            Value::string("f00bar"),
5120        );
5121        assert_eq!(
5122            ev(r#"builtins.replaceStrings ["hello"] ["goodbye"] "hello world""#),
5123            Value::string("goodbye world"),
5124        );
5125    }
5126
5127    #[test]
5128    fn builtins_has_prefix_has_suffix() {
5129        assert_eq!(ev(r#"builtins.hasPrefix "he" "hello""#), Value::Bool(true));
5130        assert_eq!(ev(r#"builtins.hasPrefix "xx" "hello""#), Value::Bool(false));
5131        assert_eq!(ev(r#"builtins.hasSuffix "lo" "hello""#), Value::Bool(true));
5132        assert_eq!(ev(r#"builtins.hasSuffix "xx" "hello""#), Value::Bool(false));
5133    }
5134
5135    #[test]
5136    fn builtins_all_any() {
5137        assert_eq!(ev("builtins.all (x: x > 0) [1 2 3]"), Value::Bool(true));
5138        assert_eq!(ev("builtins.all (x: x > 1) [1 2 3]"), Value::Bool(false));
5139        assert_eq!(ev("builtins.any (x: x > 2) [1 2 3]"), Value::Bool(true));
5140        assert_eq!(ev("builtins.any (x: x > 5) [1 2 3]"), Value::Bool(false));
5141    }
5142
5143    #[test]
5144    fn builtins_sort() {
5145        assert_eq!(
5146            ev("builtins.sort (a: b: a < b) [3 1 2]"),
5147            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5148        );
5149    }
5150
5151    #[test]
5152    fn builtins_remove_attrs() {
5153        let v = ev(r#"builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b" "c"]"#);
5154        if let Value::Attrs(attrs) = v {
5155            assert_eq!(attrs.len(), 1);
5156            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5157            assert!(attrs.get("b").is_none());
5158        } else {
5159            panic!("expected attrs");
5160        }
5161    }
5162
5163    #[test]
5164    fn builtins_intersect_attrs() {
5165        let v = ev("builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }");
5166        if let Value::Attrs(attrs) = v {
5167            assert_eq!(attrs.len(), 1);
5168            // intersectAttrs returns values from the second set
5169            assert_eq!(attrs.get("b"), Some(&Value::Int(20)));
5170        } else {
5171            panic!("expected attrs");
5172        }
5173    }
5174
5175    #[test]
5176    fn builtins_type_of_all_types() {
5177        assert_eq!(ev("builtins.typeOf null"), Value::string("null"));
5178        assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
5179        assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
5180        assert_eq!(ev("builtins.typeOf 3.14"), Value::string("float"));
5181        assert_eq!(ev(r#"builtins.typeOf "hi""#), Value::string("string"));
5182        assert_eq!(ev("builtins.typeOf [1]"), Value::string("list"));
5183        assert_eq!(ev("builtins.typeOf {}"), Value::string("set"));
5184        assert_eq!(ev("builtins.typeOf (x: x)"), Value::string("lambda"));
5185    }
5186
5187    #[test]
5188    fn builtins_is_type_checks() {
5189        assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
5190        assert_eq!(ev("builtins.isNull 0"), Value::Bool(false));
5191        assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
5192        assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
5193        assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
5194        assert_eq!(ev("builtins.isBool 1"), Value::Bool(false));
5195        assert_eq!(ev(r#"builtins.isString "x""#), Value::Bool(true));
5196        assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
5197        assert_eq!(ev("builtins.isList []"), Value::Bool(true));
5198        assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
5199        assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
5200        assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
5201        assert_eq!(ev("builtins.isFunction (x: x)"), Value::Bool(true));
5202        assert_eq!(ev("builtins.isFunction 1"), Value::Bool(false));
5203        assert_eq!(ev("builtins.isFloat 3.14"), Value::Bool(true));
5204        assert_eq!(ev("builtins.isFloat 1"), Value::Bool(false));
5205    }
5206
5207    #[test]
5208    fn builtins_to_json_from_json_roundtrip() {
5209        // int roundtrip
5210        assert_eq!(ev("builtins.fromJSON (builtins.toJSON 42)"), Value::Int(42));
5211        // string roundtrip
5212        assert_eq!(
5213            ev(r#"builtins.fromJSON (builtins.toJSON "hello")"#),
5214            Value::string("hello"),
5215        );
5216        // list roundtrip
5217        assert_eq!(
5218            ev("builtins.fromJSON (builtins.toJSON [1 2 3])"),
5219            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5220        );
5221        // null roundtrip
5222        assert_eq!(ev("builtins.fromJSON (builtins.toJSON null)"), Value::Null);
5223        // bool roundtrip
5224        assert_eq!(ev("builtins.fromJSON (builtins.toJSON true)"), Value::Bool(true));
5225    }
5226
5227    #[test]
5228    fn builtins_to_string_various() {
5229        assert_eq!(ev("builtins.toString 42"), Value::string("42"));
5230        assert_eq!(ev("builtins.toString true"), Value::string("1"));
5231        assert_eq!(ev("builtins.toString false"), Value::string(""));
5232        assert_eq!(ev("builtins.toString null"), Value::string(""));
5233        assert_eq!(ev(r#"builtins.toString "hello""#), Value::string("hello"));
5234    }
5235
5236    #[test]
5237    fn builtins_function_args() {
5238        let v = ev("builtins.functionArgs ({ a, b ? 1 }: a)");
5239        if let Value::Attrs(attrs) = v {
5240            assert_eq!(attrs.get("a"), Some(&Value::Bool(false))); // no default
5241            assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));  // has default
5242        } else {
5243            panic!("expected attrs");
5244        }
5245    }
5246
5247    #[test]
5248    fn builtins_gen_list() {
5249        assert_eq!(
5250            ev("builtins.genList (x: x * x) 5"),
5251            Value::list(vec![
5252                Value::Int(0), Value::Int(1), Value::Int(4),
5253                Value::Int(9), Value::Int(16),
5254            ]),
5255        );
5256        assert_eq!(ev("builtins.genList (x: x) 0"), Value::list(vec![]));
5257    }
5258
5259    #[test]
5260    fn builtins_elem() {
5261        assert_eq!(ev("builtins.elem 2 [1 2 3]"), Value::Bool(true));
5262        assert_eq!(ev("builtins.elem 5 [1 2 3]"), Value::Bool(false));
5263        assert_eq!(ev("builtins.elem 1 []"), Value::Bool(false));
5264    }
5265
5266    #[test]
5267    fn builtins_head_tail() {
5268        assert_eq!(ev("builtins.head [10 20 30]"), Value::Int(10));
5269        assert_eq!(
5270            ev("builtins.tail [10 20 30]"),
5271            Value::list(vec![Value::Int(20), Value::Int(30)]),
5272        );
5273    }
5274
5275    #[test]
5276    fn builtins_string_length() {
5277        assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
5278        assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
5279        assert_eq!(ev(r#"builtins.stringLength "abc def""#), Value::Int(7));
5280    }
5281
5282    #[test]
5283    fn builtins_ceil_floor() {
5284        assert_eq!(ev("builtins.ceil 2.3"), Value::Int(3));
5285        assert_eq!(ev("builtins.ceil 2.0"), Value::Int(2));
5286        assert_eq!(ev("builtins.floor 2.9"), Value::Int(2));
5287        assert_eq!(ev("builtins.floor 2.0"), Value::Int(2));
5288        // Int coercion: ceil/floor on int should work via to_float()
5289        assert_eq!(ev("builtins.ceil 5"), Value::Int(5));
5290        assert_eq!(ev("builtins.floor 5"), Value::Int(5));
5291    }
5292
5293    #[test]
5294    fn builtins_try_eval() {
5295        let v = ev("builtins.tryEval 42");
5296        if let Value::Attrs(attrs) = v {
5297            assert_eq!(attrs.get("success"), Some(&Value::Bool(true)));
5298            assert_eq!(attrs.get("value"), Some(&Value::Int(42)));
5299        } else {
5300            panic!("expected attrs");
5301        }
5302    }
5303
5304    #[test]
5305    fn builtins_throw() {
5306        let result = eval(r#"builtins.throw "oops""#);
5307        assert!(result.is_err());
5308        let msg = format!("{}", result.unwrap_err());
5309        assert!(msg.contains("oops"));
5310    }
5311
5312    #[test]
5313    fn builtins_seq_deep_seq() {
5314        // seq forces first arg, returns second
5315        assert_eq!(ev("builtins.seq 1 42"), Value::Int(42));
5316        // deepSeq similarly
5317        assert_eq!(ev("builtins.deepSeq [1 2 3] 99"), Value::Int(99));
5318    }
5319
5320    #[test]
5321    fn builtins_current_system() {
5322        let v = ev("builtins.currentSystem");
5323        if let Value::String(ns) = v {
5324            let s = &ns.chars;
5325            // Should be a valid system string
5326            assert!(
5327                s == "aarch64-darwin"
5328                    || s == "x86_64-darwin"
5329                    || s == "aarch64-linux"
5330                    || s == "x86_64-linux",
5331                "unexpected system: {s}",
5332            );
5333        } else {
5334            panic!("expected string");
5335        }
5336    }
5337
5338    // ═══════════════════════════════════════════════════════════
5339    // 9. REAL-WORLD NIXPKGS PATTERNS
5340    // ═══════════════════════════════════════════════════════════
5341
5342    #[test]
5343    fn pattern_mkif_like() {
5344        // lib.mkIf pattern: if condition then { key = value; } else {}
5345        assert_eq!(
5346            ev("(if true then { x = 1; } else {}).x"),
5347            Value::Int(1),
5348        );
5349        let v = ev("if false then { x = 1; } else {}");
5350        if let Value::Attrs(attrs) = v {
5351            assert!(attrs.is_empty());
5352        } else {
5353            panic!("expected attrs");
5354        }
5355    }
5356
5357    #[test]
5358    fn pattern_optional_attrs() {
5359        // lib.optionalAttrs pattern
5360        assert_eq!(
5361            ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in (optionalAttrs true { a = 1; }).a"),
5362            Value::Int(1),
5363        );
5364        let v = ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in optionalAttrs false { a = 1; }");
5365        if let Value::Attrs(attrs) = v {
5366            assert!(attrs.is_empty());
5367        } else {
5368            panic!("expected attrs");
5369        }
5370    }
5371
5372    #[test]
5373    fn pattern_filter_attrs_via_remove() {
5374        // lib.filterAttrs pattern via removeAttrs
5375        assert_eq!(
5376            ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]).a"#),
5377            Value::Int(1),
5378        );
5379        assert_eq!(
5380            ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]) ? b"#),
5381            Value::Bool(false),
5382        );
5383    }
5384
5385    #[test]
5386    fn pattern_override() {
5387        // default // overrides pattern
5388        let v = ev(r#"
5389            let
5390                defaults = { debug = false; port = 8080; host = "localhost"; };
5391                overrides = { debug = true; port = 9090; };
5392            in defaults // overrides
5393        "#);
5394        if let Value::Attrs(attrs) = v {
5395            assert_eq!(attrs.get("debug"), Some(&Value::Bool(true)));
5396            assert_eq!(attrs.get("port"), Some(&Value::Int(9090)));
5397            assert_eq!(attrs.get("host"), Some(&Value::string("localhost")));
5398        } else {
5399            panic!("expected attrs");
5400        }
5401    }
5402
5403    #[test]
5404    fn pattern_functor() {
5405        // { __functor = self: x: self.value + x; value = 10; } 5
5406        assert_eq!(
5407            ev("let s = { __functor = self: x: self.value + x; value = 10; }; in s 5"),
5408            Value::Int(15),
5409        );
5410    }
5411
5412    #[test]
5413    fn pattern_platform_check() {
5414        // Check pattern: if builtins.currentSystem == "..." then ... else ...
5415        let v = ev(r#"if builtins.currentSystem == "aarch64-darwin" then "arm" else "other""#);
5416        // We just verify it evaluates without error and produces a string
5417        if let Value::String(_) = v {
5418            // ok
5419        } else {
5420            panic!("expected string");
5421        }
5422    }
5423
5424    #[test]
5425    fn pattern_recursive_overlay_lambda_structure() {
5426        // Test the lambda structure of an overlay (self: super: { ... })
5427        let v = ev("let overlay = self: super: { pkg = 42; }; in overlay {} {}");
5428        if let Value::Attrs(attrs) = v {
5429            assert_eq!(attrs.get("pkg"), Some(&Value::Int(42)));
5430        } else {
5431            panic!("expected attrs");
5432        }
5433    }
5434
5435    #[test]
5436    fn pattern_call_package_simplified() {
5437        // Simplified callPackage: f: f { inherit lib; }
5438        assert_eq!(
5439            ev("let callPkg = f: f { lib = { id = x: x; }; }; lib = { id = x: x; }; in callPkg ({ lib }: lib.id 42)"),
5440            Value::Int(42),
5441        );
5442    }
5443
5444    #[test]
5445    fn pattern_derivation_like_attrset() {
5446        let v = ev(r#"{ type = "derivation"; name = "hello"; system = builtins.currentSystem; builder = "/bin/sh"; }"#);
5447        if let Value::Attrs(attrs) = v {
5448            assert_eq!(attrs.get("type"), Some(&Value::string("derivation")));
5449            assert_eq!(attrs.get("name"), Some(&Value::string("hello")));
5450            assert_eq!(attrs.get("builder"), Some(&Value::string("/bin/sh")));
5451            // system should be a string (may be a thunk that forces to string)
5452            let system = force_value(attrs.get("system").unwrap()).unwrap();
5453            assert!(matches!(system, Value::String(_)), "expected string, got {system:?}");
5454        } else {
5455            panic!("expected attrs");
5456        }
5457    }
5458
5459    #[test]
5460    fn pattern_module_system_simplified() {
5461        // Simplified NixOS module evaluation
5462        assert_eq!(
5463            ev(r#"
5464                let
5465                    eval = m: m { config = {}; lib = { mkDefault = x: x; }; };
5466                in eval ({ config, lib }: { result = lib.mkDefault 42; })
5467            "#),
5468            {
5469                let mut attrs = NixAttrs::new();
5470                attrs.insert("result".to_string(), Value::Int(42));
5471                Value::Attrs(Rc::new(attrs))
5472            },
5473        );
5474    }
5475
5476    // ═══════════════════════════════════════════════════════════
5477    // 10. ERROR HANDLING
5478    // ═══════════════════════════════════════════════════════════
5479
5480    #[test]
5481    fn error_undefined_variable() {
5482        let result = eval("nonexistent_var");
5483        assert!(result.is_err());
5484        let msg = format!("{}", result.unwrap_err());
5485        assert!(msg.contains("undefined variable") || msg.contains("nonexistent_var"));
5486    }
5487
5488    #[test]
5489    fn error_type_mismatch_arithmetic() {
5490        let result = eval(r#"1 + "hello""#);
5491        assert!(result.is_err());
5492    }
5493
5494    #[test]
5495    fn error_missing_attribute() {
5496        let result = eval("{}.nonexistent");
5497        assert!(result.is_err());
5498        let msg = format!("{}", result.unwrap_err());
5499        assert!(msg.contains("nonexistent") || msg.contains("not found"));
5500    }
5501
5502    #[test]
5503    fn error_division_by_zero() {
5504        assert!(eval("1 / 0").is_err());
5505        assert!(eval("100 / 0").is_err());
5506    }
5507
5508    #[test]
5509    fn error_missing_required_function_arg() {
5510        let result = eval("({ a, b }: a + b) { a = 1; }");
5511        assert!(result.is_err());
5512        let msg = format!("{}", result.unwrap_err());
5513        assert!(msg.contains("missing argument"));
5514    }
5515
5516    #[test]
5517    fn error_unexpected_function_arg() {
5518        let result = eval("({ a }: a) { a = 1; b = 2; }");
5519        assert!(result.is_err());
5520        let msg = format!("{}", result.unwrap_err());
5521        assert!(msg.contains("unexpected argument"));
5522    }
5523
5524    #[test]
5525    fn error_assertion_failure() {
5526        assert!(eval("assert false; 1").is_err());
5527        assert!(eval("assert 1 == 2; 1").is_err());
5528    }
5529
5530    #[test]
5531    fn error_infinite_recursion() {
5532        // `let x = x; in x` should either hit the depth guard or fail on
5533        // undefined variable (since sequential let can't see its own binding).
5534        let result = eval("let x = x; in x");
5535        assert!(result.is_err());
5536    }
5537
5538    #[test]
5539    fn error_infinite_recursion_via_lambda() {
5540        // A true infinite recursion via self-application -- depth guard catches this.
5541        let result = eval("let f = x: f x; in f 1");
5542        assert!(result.is_err());
5543        let msg = format!("{}", result.unwrap_err());
5544        assert!(
5545            msg.contains("infinite recursion") || msg.contains("eval depth") || msg.contains("undefined"),
5546        );
5547    }
5548
5549    // ═══════════════════════════════════════════════════════════
5550    // ADDITIONAL COVERAGE: edge cases and integration
5551    // ═══════════════════════════════════════════════════════════
5552
5553    #[test]
5554    fn integration_let_with_function_returning_attrset() {
5555        assert_eq!(
5556            ev("let mkPkg = name: { inherit name; version = 1; }; in (mkPkg \"hello\").name"),
5557            Value::string("hello"),
5558        );
5559    }
5560
5561    #[test]
5562    fn integration_chained_updates() {
5563        assert_eq!(
5564            ev("({ a = 1; } // { b = 2; } // { c = 3; }).c"),
5565            Value::Int(3),
5566        );
5567    }
5568
5569    #[test]
5570    fn integration_map_over_attrnames() {
5571        // Common nixpkgs pattern: map over attrNames
5572        assert_eq!(
5573            ev(r#"
5574                let
5575                    set = { a = 1; b = 2; };
5576                    names = builtins.attrNames set;
5577                in builtins.length names
5578            "#),
5579            Value::Int(2),
5580        );
5581    }
5582
5583    #[test]
5584    fn integration_compose_functions() {
5585        // Function composition
5586        assert_eq!(
5587            ev("let compose = f: g: x: f (g x); double = x: x * 2; inc = x: x + 1; in compose double inc 5"),
5588            Value::Int(12), // (5 + 1) * 2
5589        );
5590    }
5591
5592    #[test]
5593    fn integration_recursive_list_building() {
5594        // Build a list using genList and map
5595        assert_eq!(
5596            ev("builtins.map (x: x * x) (builtins.genList (x: x + 1) 4)"),
5597            Value::list(vec![Value::Int(1), Value::Int(4), Value::Int(9), Value::Int(16)]),
5598        );
5599    }
5600
5601    #[test]
5602    fn integration_attrset_from_list() {
5603        // Convert list to attrset via listToAttrs + map
5604        let v = ev(r#"
5605            builtins.listToAttrs (builtins.map (x: { name = x; value = true; }) ["a" "b" "c"])
5606        "#);
5607        if let Value::Attrs(attrs) = v {
5608            assert_eq!(attrs.get("a"), Some(&Value::Bool(true)));
5609            assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));
5610            assert_eq!(attrs.get("c"), Some(&Value::Bool(true)));
5611        } else {
5612            panic!("expected attrs");
5613        }
5614    }
5615
5616    #[test]
5617    fn integration_nested_with_and_let() {
5618        assert_eq!(
5619            ev("let x = 10; in with { y = 20; }; x + y"),
5620            Value::Int(30),
5621        );
5622    }
5623
5624    #[test]
5625    fn integration_complex_pattern_match() {
5626        // Complex function with defaults, ellipsis, and @ pattern
5627        assert_eq!(
5628            ev("(args @ { a, b ? 5, ... }: a + b + (if args ? c then args.c else 0)) { a = 1; c = 10; }"),
5629            Value::Int(16), // 1 + 5 + 10
5630        );
5631    }
5632
5633    #[test]
5634    fn integration_substring() {
5635        assert_eq!(
5636            ev(r#"builtins.substring 0 5 "hello world""#),
5637            Value::string("hello"),
5638        );
5639        assert_eq!(
5640            ev(r#"builtins.substring 6 5 "hello world""#),
5641            Value::string("world"),
5642        );
5643    }
5644
5645    #[test]
5646    fn integration_has_attr_on_nested() {
5647        // ? on nested attr paths
5648        assert_eq!(ev("{ a = { b = 1; }; } ? a"), Value::Bool(true));
5649        assert_eq!(
5650            ev("({ a = { b = 1; }; }.a) ? b"),
5651            Value::Bool(true),
5652        );
5653    }
5654
5655    #[test]
5656    fn integration_cat_attrs() {
5657        assert_eq!(
5658            ev(r#"builtins.catAttrs "x" [{ x = 1; } { y = 2; } { x = 3; }]"#),
5659            Value::list(vec![Value::Int(1), Value::Int(3)]),
5660        );
5661    }
5662
5663    #[test]
5664    fn integration_get_attr_builtin() {
5665        assert_eq!(
5666            ev(r#"builtins.getAttr "a" { a = 42; b = 10; }"#),
5667            Value::Int(42),
5668        );
5669    }
5670
5671    #[test]
5672    fn integration_has_attr_builtin() {
5673        assert_eq!(
5674            ev(r#"builtins.hasAttr "a" { a = 1; }"#),
5675            Value::Bool(true),
5676        );
5677        assert_eq!(
5678            ev(r#"builtins.hasAttr "z" { a = 1; }"#),
5679            Value::Bool(false),
5680        );
5681    }
5682
5683    #[test]
5684    fn integration_is_path() {
5685        assert_eq!(ev("builtins.isPath ./foo"), Value::Bool(true));
5686        assert_eq!(ev("builtins.isPath 42"), Value::Bool(false));
5687    }
5688
5689    #[test]
5690    fn integration_builtins_trace() {
5691        // trace prints the first arg (as debug) and returns the second
5692        assert_eq!(ev(r#"builtins.trace "debug msg" 42"#), Value::Int(42));
5693    }
5694
5695    #[test]
5696    fn integration_builtins_split() {
5697        // Nix spec: split returns alternating non-match strings and match group lists.
5698        // When the regex has no capture groups, separator positions get empty lists.
5699        // split "/" "a/b/c" => ["a" [] "b" [] "c"]
5700        assert_eq!(
5701            ev(r#"builtins.split "/" "a/b/c""#),
5702            Value::list(vec![
5703                Value::string("a"),
5704                Value::list(vec![]),
5705                Value::string("b"),
5706                Value::list(vec![]),
5707                Value::string("c"),
5708            ]),
5709        );
5710        // With a capture group, the captured text appears in the list.
5711        // split "(/)" "a/b/c" => ["a" ["/"] "b" ["/"] "c"]
5712        assert_eq!(
5713            ev(r#"builtins.split "(/)" "a/b/c""#),
5714            Value::list(vec![
5715                Value::string("a"),
5716                Value::list(vec![Value::string("/")]),
5717                Value::string("b"),
5718                Value::list(vec![Value::string("/")]),
5719                Value::string("c"),
5720            ]),
5721        );
5722    }
5723
5724    #[test]
5725    fn integration_builtins_split_no_capture_groups() {
5726        // builtins.split with no capture groups returns empty lists
5727        // at separator positions — matches CppNix behavior.
5728        // This is critical for nixpkgs lib.splitString which uses
5729        // builtins.filter builtins.isString on the result.
5730        assert_eq!(
5731            ev(r#"builtins.split "-" "aarch64-darwin""#),
5732            Value::list(vec![
5733                Value::string("aarch64"),
5734                Value::list(vec![]),
5735                Value::string("darwin"),
5736            ]),
5737        );
5738    }
5739
5740    #[test]
5741    fn integration_builtins_split_system_string_filter() {
5742        // Simulates nixpkgs lib.splitString: filter isString (split pattern string)
5743        // This is the exact pattern that parses system strings like "aarch64-darwin".
5744        assert_eq!(
5745            ev(r#"builtins.filter builtins.isString (builtins.split "-" "aarch64-darwin")"#),
5746            Value::list(vec![
5747                Value::string("aarch64"),
5748                Value::string("darwin"),
5749            ]),
5750        );
5751    }
5752
5753    #[test]
5754    fn integration_deeply_nested_let() {
5755        // Deeply nested let-in expressions
5756        assert_eq!(
5757            ev("let a = let b = let c = 10; in c * 2; in b + 1; in a"),
5758            Value::Int(21),
5759        );
5760    }
5761
5762    #[test]
5763    fn integration_if_in_attrset_value() {
5764        assert_eq!(
5765            ev("{ x = if true then 1 else 2; }.x"),
5766            Value::Int(1),
5767        );
5768    }
5769
5770    #[test]
5771    fn integration_lambda_in_list() {
5772        // Store lambdas in a list and apply them
5773        assert_eq!(
5774            ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 0) 5"),
5775            Value::Int(6),
5776        );
5777        assert_eq!(
5778            ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 1) 5"),
5779            Value::Int(10),
5780        );
5781    }
5782
5783    #[test]
5784    fn integration_nixpkgs_lib_id() {
5785        // lib.id = x: x
5786        assert_eq!(
5787            ev("let lib = { id = x: x; const = a: b: a; }; in lib.id 42"),
5788            Value::Int(42),
5789        );
5790        assert_eq!(
5791            ev("let lib = { id = x: x; const = a: b: a; }; in lib.const 1 2"),
5792            Value::Int(1),
5793        );
5794    }
5795
5796    #[test]
5797    fn integration_multiple_inherit() {
5798        assert_eq!(
5799            ev("let a = 1; b = 2; c = 3; in { inherit a b c; }.b"),
5800            Value::Int(2),
5801        );
5802    }
5803
5804    #[test]
5805    fn integration_rec_set_with_builtins() {
5806        assert_eq!(
5807            ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
5808            Value::Int(5),
5809        );
5810    }
5811
5812    // ═══════════════════════════════════════════════════════════
5813    // 11. __FUNCTOR PROTOCOL
5814    // ═══════════════════════════════════════════════════════════
5815
5816    #[test]
5817    fn functor_simple_callable_attrset() {
5818        assert_eq!(
5819            ev("let s = { __functor = self: x: x + 1; }; in s 41"),
5820            Value::Int(42),
5821        );
5822    }
5823
5824    #[test]
5825    fn functor_with_self_reference() {
5826        assert_eq!(
5827            ev("let s = { __functor = self: x: self.base + x; base = 100; }; in s 23"),
5828            Value::Int(123),
5829        );
5830    }
5831
5832    #[test]
5833    fn functor_updated_attrset() {
5834        // Override a field in the attrset, functor still works
5835        assert_eq!(
5836            ev(r#"
5837                let
5838                    mk = { __functor = self: x: self.n + x; n = 0; };
5839                    s = mk // { n = 50; };
5840                in s 7
5841            "#),
5842            Value::Int(57),
5843        );
5844    }
5845
5846    #[test]
5847    fn functor_error_on_non_callable_attrset() {
5848        // Attrset without __functor should produce error when called
5849        let result = eval("let s = { a = 1; }; in s 5");
5850        assert!(result.is_err());
5851    }
5852
5853    // ═══════════════════════════════════════════════════════════
5854    // 12. __TOSTRING PROTOCOL
5855    // ═══════════════════════════════════════════════════════════
5856
5857    #[test]
5858    fn to_string_protocol_in_interpolation() {
5859        assert_eq!(
5860            ev(r#"let s = { __toString = self: "world"; }; in "hello ${s}""#),
5861            Value::string("hello world"),
5862        );
5863    }
5864
5865    #[test]
5866    fn to_string_protocol_accesses_self() {
5867        assert_eq!(
5868            ev(r#"let s = { __toString = self: self.val; val = "abc"; }; in "${s}""#),
5869            Value::string("abc"),
5870        );
5871    }
5872
5873    #[test]
5874    fn to_string_protocol_via_builtin_to_string() {
5875        assert_eq!(
5876            ev(r#"builtins.toString { __toString = self: "via-builtin"; }"#),
5877            Value::string("via-builtin"),
5878        );
5879    }
5880
5881    #[test]
5882    fn to_string_protocol_attrset_without_toString_fails() {
5883        // An attrset without __toString should fail in string context
5884        let result = eval(r#""${{}}"#);
5885        assert!(result.is_err());
5886    }
5887
5888    // ═══════════════════════════════════════════════════════════
5889    // 13. NEWLY IMPLEMENTED BUILTINS (eval-level tests)
5890    // ═══════════════════════════════════════════════════════════
5891
5892    #[test]
5893    fn eval_builtins_concat_strings() {
5894        assert_eq!(
5895            ev(r#"builtins.concatStrings ["a" "b" "c"]"#),
5896            Value::string("abc"),
5897        );
5898        assert_eq!(
5899            ev(r#"builtins.concatStrings []"#),
5900            Value::string(""),
5901        );
5902    }
5903
5904    #[test]
5905    fn eval_builtins_partition() {
5906        let v = ev("builtins.partition (x: x > 3) [1 2 3 4 5]");
5907        if let Value::Attrs(a) = v {
5908            assert_eq!(a.get("right"), Some(&Value::list(vec![Value::Int(4), Value::Int(5)])));
5909            assert_eq!(a.get("wrong"), Some(&Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)])));
5910        } else {
5911            panic!("expected attrs");
5912        }
5913    }
5914
5915    #[test]
5916    fn eval_builtins_group_by() {
5917        let v = ev(r#"builtins.groupBy (x: if x > 0 then "pos" else "neg") [1 (0 - 2) 3 (0 - 4)]"#);
5918        if let Value::Attrs(a) = v {
5919            assert_eq!(a.get("pos"), Some(&Value::list(vec![Value::Int(1), Value::Int(3)])));
5920            assert_eq!(a.get("neg"), Some(&Value::list(vec![Value::Int(-2), Value::Int(-4)])));
5921        } else {
5922            panic!("expected attrs");
5923        }
5924    }
5925
5926    #[test]
5927    fn eval_builtins_zip_attrs_with() {
5928        let v = ev("builtins.zipAttrsWith (n: vs: builtins.head vs) [{ a = 1; } { a = 2; b = 3; }]");
5929        if let Value::Attrs(a) = v {
5930            assert_eq!(a.get("a"), Some(&Value::Int(1)));
5931            assert_eq!(a.get("b"), Some(&Value::Int(3)));
5932        } else {
5933            panic!("expected attrs");
5934        }
5935    }
5936
5937    #[test]
5938    fn eval_builtins_compare_versions() {
5939        assert_eq!(ev(r#"builtins.compareVersions "2.0" "1.0""#), Value::Int(1));
5940        assert_eq!(ev(r#"builtins.compareVersions "1.0" "2.0""#), Value::Int(-1));
5941        assert_eq!(ev(r#"builtins.compareVersions "1.0" "1.0""#), Value::Int(0));
5942    }
5943
5944    #[test]
5945    fn eval_builtins_parse_drv_name() {
5946        let v = ev(r#"builtins.parseDrvName "nix-2.3.4""#);
5947        if let Value::Attrs(a) = v {
5948            assert_eq!(a.get("name"), Some(&Value::string("nix")));
5949            assert_eq!(a.get("version"), Some(&Value::string("2.3.4")));
5950        } else {
5951            panic!("expected attrs");
5952        }
5953    }
5954
5955    #[test]
5956    fn eval_builtins_base_name_of() {
5957        assert_eq!(
5958            ev(r#"builtins.baseNameOf "/foo/bar/baz""#),
5959            Value::string("baz"),
5960        );
5961    }
5962
5963    #[test]
5964    fn eval_builtins_dir_of() {
5965        assert_eq!(
5966            ev(r#"builtins.dirOf "/foo/bar/baz""#),
5967            Value::string("/foo/bar"),
5968        );
5969    }
5970
5971    #[test]
5972    fn eval_builtins_add_error_context() {
5973        assert_eq!(
5974            ev(r#"builtins.addErrorContext "some context" 42"#),
5975            Value::Int(42),
5976        );
5977    }
5978
5979    #[test]
5980    fn eval_builtins_abort() {
5981        let result = eval(r#"builtins.abort "fatal error""#);
5982        assert!(result.is_err());
5983        let msg = format!("{}", result.unwrap_err());
5984        assert!(msg.contains("fatal error"));
5985    }
5986
5987    // ═══════════════════════════════════════════════════════════
5988    // 14. INDENTED STRINGS ('' ... '')
5989    // ═══════════════════════════════════════════════════════════
5990
5991    #[test]
5992    fn indented_string_simple() {
5993        assert_eq!(ev("''hello''"), Value::string("hello"));
5994    }
5995
5996    #[test]
5997    fn indented_string_multiline_strips_indent() {
5998        assert_eq!(
5999            ev("''\n  line1\n  line2\n''"),
6000            Value::string("line1\nline2\n"),
6001        );
6002    }
6003
6004    #[test]
6005    fn indented_string_with_interpolation() {
6006        let code = "let x = \"world\"; in ''hello ${x}''";
6007        assert_eq!(
6008            ev(code),
6009            Value::string("hello world"),
6010        );
6011    }
6012
6013    #[test]
6014    fn indented_string_deeper_indent_preserved() {
6015        // Common indent is 2 spaces; the 4-space line keeps 2 extra
6016        assert_eq!(
6017            ev("''\n  a\n    b\n''"),
6018            Value::string("a\n  b\n"),
6019        );
6020    }
6021
6022    // ═══════════════════════════════════════════════════════════
6023    // 15. DYNAMIC ATTRIBUTE NAMES
6024    // ═══════════════════════════════════════════════════════════
6025
6026    #[test]
6027    fn dynamic_attr_name_in_set() {
6028        assert_eq!(
6029            ev(r#"let key = "mykey"; in { ${key} = 42; }.mykey"#),
6030            Value::Int(42),
6031        );
6032    }
6033
6034    #[test]
6035    fn dynamic_attr_name_with_expression() {
6036        assert_eq!(
6037            ev(r#"let prefix = "foo"; in { ${"${prefix}bar"} = 1; }.foobar"#),
6038            Value::Int(1),
6039        );
6040    }
6041
6042    // ═══════════════════════════════════════════════════════════
6043    // 16. IGNORED TESTS — features needing major infrastructure
6044    // ═══════════════════════════════════════════════════════════
6045
6046    #[test]
6047    fn eval_builtins_match() {
6048        assert_eq!(
6049            ev(r#"builtins.match "([0-9]+)" "42""#),
6050            Value::list(vec![Value::string("42")]),
6051        );
6052    }
6053
6054    #[test]
6055    fn eval_builtins_hash_string() {
6056        let v = ev(r#"builtins.hashString "sha256" "hello""#);
6057        if let Value::String(ns) = v {
6058            assert_eq!(ns.chars.len(), 64);
6059        } else {
6060            panic!("expected string");
6061        }
6062    }
6063
6064    #[test]
6065    fn eval_builtins_import() {
6066        let dir = std::env::temp_dir();
6067        let path = dir.join("sui_eval_test_import_eval.nix");
6068        std::fs::write(&path, "42").unwrap();
6069        let expr = format!(r#"import "{}""#, path.display());
6070        let v = eval(&expr).unwrap();
6071        assert_eq!(v, Value::Int(42));
6072        std::fs::remove_file(&path).ok();
6073    }
6074
6075    #[test]
6076    fn eval_builtins_derivation() {
6077        let v = eval(r#"builtins.derivation { name = "test"; system = "x86_64-linux"; builder = "/bin/sh"; }"#).unwrap();
6078        if let Value::Attrs(a) = v {
6079            assert_eq!(a.get("type"), Some(&Value::string("derivation")));
6080        } else {
6081            panic!("expected attrs");
6082        }
6083    }
6084
6085    #[test]
6086    fn eval_mutual_recursive_let() {
6087        // Multi-pass evaluation allows forward references in let bindings.
6088        // After 3 passes (placeholder + eval + re-eval), `a.x` resolves to
6089        // the value of `b` from the previous pass, and `a.x.y` is an attrset.
6090        // Full semantic equivalence with Nix (a.x.y == a) requires lazy
6091        // thunks, but the multi-pass approach is sufficient for common
6092        // patterns like mutual module references.
6093        let v = eval("let a = { x = b; }; b = { y = a; }; in a.x.y");
6094        assert!(v.is_ok(), "mutual recursive let should not error: {v:?}");
6095        // a.x.y should be an attrset (it's a's value from a prior pass)
6096        let val = v.unwrap();
6097        assert!(
6098            matches!(val, Value::Attrs(_)),
6099            "a.x.y should be an attrset, got: {val:?}",
6100        );
6101    }
6102
6103    #[test]
6104    fn eval_mutual_recursive_let_simple() {
6105        // Simpler case: forward reference in sequential let bindings
6106        let v = eval("let a = b; b = 42; in a");
6107        assert!(v.is_ok());
6108        // After multi-pass: pass 2 sets a=Null (b not yet bound), b=42
6109        // pass 3 sets a=42, b=42
6110        assert_eq!(v.unwrap(), Value::Int(42));
6111    }
6112
6113    #[test]
6114    fn eval_builtins_read_dir() {
6115        let dir = std::env::temp_dir().join("sui_eval_test_readdir_eval");
6116        let _ = std::fs::remove_dir_all(&dir);
6117        std::fs::create_dir_all(&dir).unwrap();
6118        std::fs::write(dir.join("a.txt"), "").unwrap();
6119        let expr = format!(r#"builtins.readDir "{}""#, dir.display());
6120        let v = eval(&expr).unwrap();
6121        if let Value::Attrs(a) = v {
6122            assert_eq!(a.get("a.txt"), Some(&Value::string("regular")));
6123        } else {
6124            panic!("expected attrs");
6125        }
6126        let _ = std::fs::remove_dir_all(&dir);
6127    }
6128
6129    // ═══════════════════════════════════════════════════════════
6130    // 17. THUNK / LAZY EVALUATION
6131    // ═══════════════════════════════════════════════════════════
6132
6133    #[test]
6134    fn thunk_basic_let() {
6135        // Simple let binding through thunk.
6136        assert_eq!(ev("let x = 1; in x"), Value::Int(1));
6137    }
6138
6139    #[test]
6140    fn thunk_forward_ref() {
6141        // Forward reference: `a` references `b` which is defined later.
6142        assert_eq!(ev("let a = b; b = 1; in a"), Value::Int(1));
6143    }
6144
6145    #[test]
6146    fn thunk_mutual_rec_attrset_in_let() {
6147        // Mutual recursion through attrsets in let bindings.
6148        assert_eq!(ev("let a = { x = b; }; b = { y = 1; }; in a.x.y"), Value::Int(1));
6149    }
6150
6151    #[test]
6152    fn thunk_rec_attrset() {
6153        // rec { a = b; b = 1; } -- forward ref within rec set.
6154        assert_eq!(ev("(rec { a = b; b = 1; }).a"), Value::Int(1));
6155    }
6156
6157    #[test]
6158    fn thunk_rec_attrset_chain() {
6159        // Longer chain: c depends on b depends on a.
6160        assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
6161    }
6162
6163    #[test]
6164    fn thunk_fixpoint() {
6165        // Classic fixpoint combinator -- the core of nixpkgs' `lib.fix`.
6166        assert_eq!(
6167            ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; })).b"),
6168            Value::Int(2),
6169        );
6170    }
6171
6172    #[test]
6173    fn thunk_blackhole_self_reference() {
6174        // `let x = x; in x` is infinite recursion -- blackhole detection.
6175        let result = eval("let x = x; in x");
6176        assert!(result.is_err());
6177        let msg = format!("{}", result.unwrap_err());
6178        assert!(
6179            msg.contains("infinite recursion") || msg.contains("blackhole"),
6180            "expected blackhole error, got: {msg}",
6181        );
6182    }
6183
6184    #[test]
6185    fn thunk_mutual_blackhole() {
6186        // `let a = b; b = a; in a` -- mutual infinite recursion.
6187        let result = eval("let a = b; b = a; in a");
6188        assert!(result.is_err());
6189    }
6190
6191    #[test]
6192    fn thunk_let_body_forces_correctly() {
6193        // The let body should be able to use thunked bindings in arithmetic.
6194        assert_eq!(ev("let a = 10; b = 20; in a + b"), Value::Int(30));
6195    }
6196
6197    #[test]
6198    fn thunk_only_forced_when_needed() {
6199        // The binding `bad` would error if forced, but it is never used.
6200        assert_eq!(ev("let bad = 1 / 0; good = 42; in good"), Value::Int(42));
6201    }
6202
6203    #[test]
6204    fn thunk_forward_ref_in_function_body() {
6205        // Forward reference used inside a function body.
6206        assert_eq!(
6207            ev("let f = x: x + b; b = 10; in f 5"),
6208            Value::Int(15),
6209        );
6210    }
6211
6212    #[test]
6213    fn thunk_rec_set_self_ref_through_self() {
6214        // rec set where `b` references `a` which is in the same set.
6215        assert_eq!(
6216            ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6217            Value::Int(5),
6218        );
6219    }
6220
6221    #[test]
6222    fn thunk_nested_let_forward_ref() {
6223        // Forward reference in nested let.
6224        assert_eq!(
6225            ev("let a = b + 1; b = 2; in a"),
6226            Value::Int(3),
6227        );
6228    }
6229
6230    #[test]
6231    fn thunk_deep_chain() {
6232        // Chain of forward references: e -> d -> c -> b -> a.
6233        assert_eq!(
6234            ev("let a = 1; b = a; c = b; d = c; e = d; in e"),
6235            Value::Int(1),
6236        );
6237    }
6238
6239    #[test]
6240    fn thunk_rec_set_fixpoint() {
6241        // Fixpoint through rec set -- common nixpkgs pattern.
6242        assert_eq!(
6243            ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; c = self.b + 1; })).c"),
6244            Value::Int(3),
6245        );
6246    }
6247
6248    #[test]
6249    fn thunk_let_with_inherit() {
6250        // Inherit in let should work alongside thunked bindings.
6251        assert_eq!(
6252            ev("let a = 1; in let inherit a; b = a + 1; in b"),
6253            Value::Int(2),
6254        );
6255    }
6256
6257    #[test]
6258    fn thunk_attrset_value_lazy() {
6259        // Values in non-rec attrsets are evaluated eagerly, but the test
6260        // verifies that thunked let bindings inside attrset values work.
6261        assert_eq!(
6262            ev("let x = 42; in { a = x; }.a"),
6263            Value::Int(42),
6264        );
6265    }
6266
6267    #[test]
6268    fn thunk_unused_error_not_forced() {
6269        // Multiple bindings, only `ok` is used. `bad` throws but is never forced.
6270        assert_eq!(
6271            ev(r#"let bad = builtins.throw "boom"; ok = 1; in ok"#),
6272            Value::Int(1),
6273        );
6274    }
6275
6276    #[test]
6277    fn thunk_rec_set_mutual_reference() {
6278        // Mutual reference within rec set.
6279        let v = ev("rec { a = { val = b.val + 1; }; b = { val = 10; }; }");
6280        if let Value::Attrs(attrs) = v {
6281            let a = attrs.get("a").unwrap();
6282            let a_forced = force_value(a).unwrap();
6283            if let Value::Attrs(a_attrs) = a_forced {
6284                assert_eq!(a_attrs.get("val"), Some(&Value::Int(11)));
6285            } else {
6286                panic!("expected attrs for a");
6287            }
6288        } else {
6289            panic!("expected attrs");
6290        }
6291    }
6292
6293    // ── let-rec self-reference corner cases ───────────────
6294
6295    #[test]
6296    fn let_rec_self_reference_simple() {
6297        assert_eq!(
6298            ev("let x = 1; y = x + 1; in y"),
6299            Value::Int(2),
6300        );
6301    }
6302
6303    #[test]
6304    fn let_rec_self_reference_chain() {
6305        assert_eq!(
6306            ev("let a = 1; b = a + 1; c = b + 1; in c"),
6307            Value::Int(3),
6308        );
6309    }
6310
6311    #[test]
6312    fn let_rec_self_reference_with_function() {
6313        assert_eq!(
6314            ev("let f = x: x + 1; y = f 10; in y"),
6315            Value::Int(11),
6316        );
6317    }
6318
6319    #[test]
6320    fn let_rec_mutual_recursion_via_if() {
6321        assert_eq!(
6322            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"),
6323            Value::Bool(true),
6324        );
6325    }
6326
6327    #[test]
6328    fn let_rec_forward_ref_in_list() {
6329        assert_eq!(
6330            ev("let xs = [a b]; a = 1; b = 2; in builtins.length xs"),
6331            Value::Int(2),
6332        );
6333    }
6334
6335    // ── with-shadowing corner cases ───────────────────────
6336
6337    #[test]
6338    fn with_shadowing_let_wins_over_with() {
6339        assert_eq!(
6340            ev("let x = 1; in with { x = 2; }; x"),
6341            Value::Int(1),
6342        );
6343    }
6344
6345    #[test]
6346    fn with_shadowing_inner_with_wins() {
6347        assert_eq!(
6348            ev("with { x = 1; }; with { x = 2; }; x"),
6349            Value::Int(2),
6350        );
6351    }
6352
6353    #[test]
6354    fn with_shadowing_outer_provides_missing() {
6355        assert_eq!(
6356            ev("with { x = 1; y = 10; }; with { x = 2; }; x + y"),
6357            Value::Int(12),
6358        );
6359    }
6360
6361    #[test]
6362    fn with_shadowing_lambda_arg_wins() {
6363        assert_eq!(
6364            ev("(x: with { x = 99; }; x) 42"),
6365            Value::Int(42),
6366        );
6367    }
6368
6369    #[test]
6370    fn with_shadowing_nested_let_wins_over_with() {
6371        assert_eq!(
6372            ev("with { x = 1; }; let x = 2; in x"),
6373            Value::Int(2),
6374        );
6375    }
6376
6377    #[test]
6378    fn with_scope_dynamic_attrs() {
6379        assert_eq!(
6380            ev(r#"with { x = 1; y = 2; z = 3; }; x + y + z"#),
6381            Value::Int(6),
6382        );
6383    }
6384
6385    #[test]
6386    fn with_scope_over_lazy_thunk_chain_resolves() {
6387        // A `with`-head that resolves through a NESTED thunk chain
6388        // (`Thunk(Thunk(Attrs))`) must still be searched: the lookup
6389        // has to FULLY force the head (chase the chain), not take a
6390        // single force step. A single step leaves a `Value::Thunk`
6391        // that `type_name()` reports as "set" but the `Value::Attrs`
6392        // match rejects — the scope is skipped and a bare ident
6393        // through it fails with a spurious UndefinedVar. This corners
6394        // the nixpkgs `platforms = with lib.platforms; unix;` shape.
6395        assert_eq!(
6396            ev(r#"let outer = if true then (if true then { unix = 42; } else {}) else {};
6397                      # force a two-deep lazy wrap of the with-head
6398                      head = (x: x) ((y: y) outer);
6399                  in with head; unix"#),
6400            Value::Int(42),
6401        );
6402    }
6403
6404    #[test]
6405    fn with_scope_head_from_deep_select_resolves() {
6406        // `with a.b.c; key` where a.b.c is a lazily-selected attrset —
6407        // the bare-ident body must find `key` through the forced head.
6408        assert_eq!(
6409            ev(r#"let a = { b = { c = { key = 7; }; }; }; in with a.b.c; key"#),
6410            Value::Int(7),
6411        );
6412    }
6413
6414    // ── attrset deep merge ────────────────────────────────
6415
6416    #[test]
6417    fn attrset_deep_merge_simple() {
6418        let v = ev("{ a.b = 1; a.c = 2; }");
6419        if let Value::Attrs(attrs) = v {
6420            let a = force_value(attrs.get("a").unwrap()).unwrap();
6421            if let Value::Attrs(inner) = a {
6422                assert_eq!(force_value(inner.get("b").unwrap()).unwrap(), Value::Int(1));
6423                assert_eq!(force_value(inner.get("c").unwrap()).unwrap(), Value::Int(2));
6424            } else {
6425                panic!("expected nested attrs");
6426            }
6427        } else {
6428            panic!("expected attrs");
6429        }
6430    }
6431
6432    #[test]
6433    fn attrset_deep_merge_three_levels() {
6434        let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
6435        if let Value::Attrs(attrs) = v {
6436            let a = force_value(attrs.get("a").unwrap()).unwrap();
6437            if let Value::Attrs(a_inner) = a {
6438                let e = force_value(a_inner.get("e").unwrap()).unwrap();
6439                assert_eq!(e, Value::Int(3));
6440                let b = force_value(a_inner.get("b").unwrap()).unwrap();
6441                if let Value::Attrs(b_inner) = b {
6442                    assert_eq!(force_value(b_inner.get("c").unwrap()).unwrap(), Value::Int(1));
6443                    assert_eq!(force_value(b_inner.get("d").unwrap()).unwrap(), Value::Int(2));
6444                } else {
6445                    panic!("expected nested attrs for b");
6446                }
6447            } else {
6448                panic!("expected nested attrs for a");
6449            }
6450        } else {
6451            panic!("expected attrs");
6452        }
6453    }
6454
6455    #[test]
6456    fn attrset_deep_merge_preserves_siblings() {
6457        assert_eq!(
6458            ev("{ a.x = 1; b = 2; a.y = 3; }.b"),
6459            Value::Int(2),
6460        );
6461    }
6462
6463    #[test]
6464    fn attrset_deep_merge_in_let() {
6465        let v = ev("let s = { a.b = 1; a.c = 2; }; in s.a.b + s.a.c");
6466        assert_eq!(v, Value::Int(3));
6467    }
6468
6469    #[test]
6470    fn attrset_deep_merge_fullset_then_dotted() {
6471        // General root (gst-plugins-base `passthru.waylandEnabled` drop):
6472        // `a = { x = 1; }; a.y = 2;` — the full-set binding is a lazy
6473        // Thunk (attrset literals go through maybe_thunk), so a naive
6474        // merge_nested_insert (which only merges concrete Value::Attrs)
6475        // overwrote `a` with `{ y = 2 }`, silently dropping `x`. The
6476        // collision must force the existing thunk to WHNF first.
6477        let v = ev("let s = { a = { x = 1; }; a.y = 2; }; in s.a.x + s.a.y");
6478        assert_eq!(v, Value::Int(3));
6479        // both keys must survive (not just their sum)
6480        let both = ev("let s = { a = { x = 1; }; a.y = 2; }; in [ s.a.x s.a.y ]");
6481        if let Value::List(items) = both {
6482            assert_eq!(force_value(&items[0]).unwrap(), Value::Int(1));
6483            assert_eq!(force_value(&items[1]).unwrap(), Value::Int(2));
6484        } else {
6485            panic!("expected list");
6486        }
6487    }
6488
6489    // ── inherit-from patterns ─────────────────────────────
6490
6491    #[test]
6492    fn inherit_from_basic() {
6493        assert_eq!(
6494            ev("let s = { x = 1; y = 2; }; in let inherit (s) x y; in x + y"),
6495            Value::Int(3),
6496        );
6497    }
6498
6499    #[test]
6500    fn inherit_from_with_shadowing() {
6501        assert_eq!(
6502            ev("let x = 10; in let inherit ({ x = 20; }) x; in x"),
6503            Value::Int(20),
6504        );
6505    }
6506
6507    #[test]
6508    fn inherit_from_in_attrset() {
6509        let v = ev(r#"let s = { a = 1; b = 2; }; in { inherit (s) a b; c = 3; }"#);
6510        if let Value::Attrs(attrs) = v {
6511            assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
6512            assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
6513            assert_eq!(force_value(attrs.get("c").unwrap()).unwrap(), Value::Int(3));
6514        } else {
6515            panic!("expected attrs");
6516        }
6517    }
6518
6519    #[test]
6520    fn inherit_from_rec_set() {
6521        assert_eq!(
6522            ev("rec { inherit ({ x = 42; }) x; y = x; }.y"),
6523            Value::Int(42),
6524        );
6525    }
6526
6527    #[test]
6528    fn inherit_plain_from_scope() {
6529        assert_eq!(
6530            ev("let x = 1; in { inherit x; }.x"),
6531            Value::Int(1),
6532        );
6533    }
6534
6535    // Regression (2026-07-11): a bare `inherit x;` must resolve LAZILY, like
6536    // a plain reference to `x` — not eagerly at attrset construction. When
6537    // `x` is provided only by an enclosing `with` scope whose value is a
6538    // fixpoint still being constructed, eager resolution spuriously threw
6539    // `UndefinedVar`. nixpkgs `all-packages.nix` is
6540    // `with pkgs; { nettle = import … { inherit callPackage; }; }`, so
6541    // `inherit callPackage` must resolve from the `with pkgs` scope at force
6542    // time. (This was the nettle UndefinedVar('callPackage') drop.)
6543    #[test]
6544    fn inherit_plain_from_with_scope_lazy() {
6545        // `inherit cp` reads `cp` from a `with self` fixpoint scope; the
6546        // attr forcing it (`a`) must resolve `cp` lazily against the settled
6547        // scope, not eagerly during attrset construction.
6548        assert_eq!(
6549            ev("let fix = f: let x = f x; in x;
6550                    self = fix (self: with self; {
6551                      a = use { inherit cp; };
6552                      use = { cp }: cp 5;
6553                      cp = x: x + 100;
6554                    });
6555                in self.a"),
6556            Value::Int(105),
6557        );
6558        // Simpler: bare inherit from a plain (non-blackhole) with scope.
6559        assert_eq!(
6560            ev("with { y = 7; }; { inherit y; }.y"),
6561            Value::Int(7),
6562        );
6563    }
6564
6565    #[test]
6566    fn inherit_multiple_from_expr() {
6567        assert_eq!(
6568            ev("let s = { a = 10; b = 20; c = 30; }; in let inherit (s) a b c; in a + b + c"),
6569            Value::Int(60),
6570        );
6571    }
6572
6573    // ── string interpolation edge cases ───────────────────
6574
6575    #[test]
6576    fn interp_nested_attrset_access() {
6577        assert_eq!(
6578            ev(r#"let x = { a = "hello"; }; in "${x.a} world""#),
6579            Value::string("hello world"),
6580        );
6581    }
6582
6583    #[test]
6584    fn interp_with_let_expression() {
6585        assert_eq!(
6586            ev(r#""${let x = "inner"; in x}""#),
6587            Value::string("inner"),
6588        );
6589    }
6590
6591    #[test]
6592    fn interp_float_coercion() {
6593        // CppNix %f-format: always 6 decimal places.
6594        assert_eq!(
6595            ev(r#""${toString 3.14}""#),
6596            Value::string("3.140000"),
6597        );
6598    }
6599
6600    // ── comparison edge cases ─────────────────────────────
6601
6602    #[test]
6603    fn compare_mixed_int_float() {
6604        assert_eq!(ev("1 < 1.5"), Value::Bool(true));
6605        assert_eq!(ev("1.5 > 1"), Value::Bool(true));
6606        assert_eq!(ev("2.0 == 2"), Value::Bool(true));
6607    }
6608
6609    #[test]
6610    fn compare_string_lexicographic() {
6611        assert_eq!(ev(r#""abc" < "abd""#), Value::Bool(true));
6612        assert_eq!(ev(r#""abc" < "abc""#), Value::Bool(false));
6613        assert_eq!(ev(r#""abc" <= "abc""#), Value::Bool(true));
6614    }
6615
6616    // ── update operator edge cases ────────────────────────
6617
6618    #[test]
6619    fn update_empty_sets() {
6620        let v = ev("{} // {}");
6621        if let Value::Attrs(a) = v { assert!(a.is_empty()); } else { panic!(); }
6622    }
6623
6624    #[test]
6625    fn update_right_overrides_completely() {
6626        assert_eq!(
6627            ev("{ a = 1; b = 2; } // { a = 10; c = 30; }"),
6628            ev("{ a = 10; b = 2; c = 30; }"),
6629        );
6630    }
6631
6632    #[test]
6633    fn update_chained() {
6634        assert_eq!(
6635            ev("{ a = 1; } // { b = 2; } // { c = 3; }"),
6636            ev("{ a = 1; b = 2; c = 3; }"),
6637        );
6638    }
6639
6640    // ── force_value edge cases ────────────────────────────
6641
6642    #[test]
6643    fn force_value_concrete_unchanged() {
6644        let v = Value::Int(42);
6645        assert_eq!(force_value(&v).unwrap(), Value::Int(42));
6646    }
6647
6648    #[test]
6649    fn force_value_null() {
6650        assert_eq!(force_value(&Value::Null).unwrap(), Value::Null);
6651    }
6652
6653    // ── eval_with_file ────────────────────────────────────
6654
6655    #[test]
6656    fn eval_with_file_none() {
6657        let result = eval_with_file("1 + 2", None).unwrap();
6658        assert_eq!(result, Value::Int(3));
6659    }
6660
6661    // ── error messages ────────────────────────────────────
6662
6663    #[test]
6664    fn error_type_mismatch_in_comparison() {
6665        let result = eval(r#"1 < "a""#);
6666        assert!(result.is_err());
6667    }
6668
6669    #[test]
6670    fn error_select_from_non_set() {
6671        let result = eval("42.x");
6672        assert!(result.is_err());
6673    }
6674
6675    #[test]
6676    fn error_call_non_function() {
6677        let result = eval("42 1");
6678        assert!(result.is_err());
6679    }
6680
6681    #[test]
6682    fn error_negate_string() {
6683        let result = eval(r#"-"hello""#);
6684        assert!(result.is_err());
6685    }
6686
6687    // ── multiline string edge cases ───────────────────────
6688
6689    #[test]
6690    fn multiline_string_empty() {
6691        assert_eq!(ev("''''"), Value::string(""));
6692    }
6693
6694    #[test]
6695    fn multiline_string_with_trailing_newline() {
6696        let v = ev("''\n  hello\n''");
6697        assert_eq!(v, Value::string("hello\n"));
6698    }
6699
6700    // ── list operations ───────────────────────────────────
6701
6702    #[test]
6703    fn list_concat_empty_left() {
6704        assert_eq!(ev("[] ++ [1 2]"), Value::list(vec![Value::Int(1), Value::Int(2)]));
6705    }
6706
6707    #[test]
6708    fn list_concat_empty_right() {
6709        assert_eq!(ev("[1 2] ++ []"), Value::list(vec![Value::Int(1), Value::Int(2)]));
6710    }
6711
6712    #[test]
6713    fn list_concat_both_empty() {
6714        assert_eq!(ev("[] ++ []"), Value::list(vec![]));
6715    }
6716
6717    // ── pattern matching / formals edge cases ─────────────
6718
6719    #[test]
6720    fn formals_at_pattern_accessible() {
6721        assert_eq!(
6722            ev("({ x, ... } @ args: builtins.length (builtins.attrNames args)) { x = 1; y = 2; z = 3; }"),
6723            Value::Int(3),
6724        );
6725    }
6726
6727    #[test]
6728    fn formals_default_uses_other_arg() {
6729        assert_eq!(
6730            ev("({ x, y ? x + 1 }: y) { x = 10; }"),
6731            Value::Int(11),
6732        );
6733    }
6734
6735    #[test]
6736    fn formals_default_lazy_assert_false() {
6737        // nixpkgs parse.nix pattern: default is `assert false; null` but
6738        // the body checks `args ? vendor` instead of using `vendor`
6739        // directly, so the default must never be forced.
6740        assert_eq!(
6741            ev("({ cpu, vendor ? assert false; null, kernel } @ args: if args ? vendor then vendor else \"inferred\") { cpu = \"x86_64\"; kernel = \"linux\"; }"),
6742            Value::String(Rc::new(NixString::plain("inferred"))),
6743        );
6744    }
6745
6746    #[test]
6747    fn formals_default_lazy_only_forced_when_accessed() {
6748        // When the default IS accessed, it should still evaluate correctly.
6749        assert_eq!(
6750            ev("({ a, b ? 42 }: b) { a = 1; }"),
6751            Value::Int(42),
6752        );
6753    }
6754
6755    #[test]
6756    fn formals_ellipsis_ignores_extra() {
6757        assert_eq!(
6758            ev("({ x, ... }: x) { x = 1; y = 2; z = 3; }"),
6759            Value::Int(1),
6760        );
6761    }
6762
6763    // ── pure mode ─────────────────────────────────────────
6764
6765    #[test]
6766    fn pure_mode_roundtrip() {
6767        let was_pure = is_pure_mode();
6768        set_pure_mode(true);
6769        assert!(is_pure_mode());
6770        set_pure_mode(false);
6771        assert!(!is_pure_mode());
6772        set_pure_mode(was_pure);
6773    }
6774
6775    // ── path operations ───────────────────────────────────
6776
6777    #[test]
6778    fn path_concat_with_string() {
6779        assert_eq!(
6780            ev(r#"/foo + "bar""#),
6781            Value::Path(Box::new(SmolStr::from("/foobar"))),
6782        );
6783    }
6784
6785    #[test]
6786    fn path_concat_with_path() {
6787        assert_eq!(
6788            ev("/foo + /bar"),
6789            Value::Path(Box::new(SmolStr::from("/foo//bar"))),
6790        );
6791    }
6792
6793    // ── EvalFileGuard / current_eval_dir ───────────────────
6794
6795    #[test]
6796    fn current_eval_dir_empty_when_no_file_pushed() {
6797        // Without a push, current_eval_dir should yield None.
6798        // (Note: this test is order-dependent; we accept whatever the
6799        // top of the stack happens to be when called.)
6800        let snapshot = current_eval_dir();
6801        // At minimum the API doesn't panic and returns Option.
6802        let _ = snapshot;
6803    }
6804
6805    #[test]
6806    fn push_eval_file_sets_current_dir() {
6807        let p = std::path::PathBuf::from("/tmp/example/file.nix");
6808        {
6809            let _g = push_eval_file(p.clone());
6810            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/tmp/example")));
6811        }
6812        // Guard dropped, stack popped — current dir is whatever was below.
6813        // We can't assert exact value without snapshotting first, but the
6814        // value before push should be restored.
6815    }
6816
6817    #[test]
6818    fn push_eval_file_nested_stack() {
6819        let outer = std::path::PathBuf::from("/a/x.nix");
6820        let inner = std::path::PathBuf::from("/b/y.nix");
6821        {
6822            let _g_outer = push_eval_file(outer.clone());
6823            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
6824            {
6825                let _g_inner = push_eval_file(inner.clone());
6826                assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/b")));
6827            }
6828            // Inner dropped — outer is back on top.
6829            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
6830        }
6831    }
6832
6833    /// A fileless frame MASKS the parent's file rather than being skipped.
6834    ///
6835    /// Regression: the stack used to be `Vec<PathBuf>`, so a thunk captured in
6836    /// a `--expr` context pushed nothing when it forced and the callee's file
6837    /// stayed visible. `builtins.unsafeGetAttrPos` then reported the callee's
6838    /// path where CppNix reports `null`, which set `eval-config.nix`'s
6839    /// `modulesLocation` and permuted NixOS module definition order.
6840    #[test]
6841    fn fileless_frame_masks_parent_file() {
6842        let outer = std::path::PathBuf::from("/a/x.nix");
6843        let _g_outer = push_eval_file(outer.clone());
6844        assert_eq!(current_eval_file(), Some(outer.clone()));
6845        {
6846            let _g_none = push_eval_frame(None);
6847            // The whole point: NOT Some("/a/x.nix").
6848            assert_eq!(current_eval_file(), None);
6849            assert_eq!(current_eval_dir(), None);
6850            assert_eq!(eval_file_stack_snapshot().last().map(String::as_str), Some("<no-file>"));
6851        }
6852        // Popped — the parent is visible again.
6853        assert_eq!(current_eval_file(), Some(outer));
6854    }
6855
6856    // ── Source-mapped error context ────────────────────────
6857
6858    #[test]
6859    fn error_undefined_var_includes_file_context() {
6860        let p = std::path::PathBuf::from("/nix/store/abc-default.nix");
6861        let _g = push_eval_file(p);
6862        let result = eval("nonexistent_xyz");
6863        let msg = format!("{}", result.unwrap_err());
6864        assert!(msg.contains("undefined variable"), "msg: {msg}");
6865        assert!(msg.contains("nonexistent_xyz"), "msg: {msg}");
6866        assert!(msg.contains("abc-default.nix"), "msg: {msg}");
6867    }
6868
6869    #[test]
6870    fn error_attr_not_found_includes_file_context() {
6871        let p = std::path::PathBuf::from("/nix/store/xyz-module.nix");
6872        let _g = push_eval_file(p);
6873        let result = eval("{}.missing_key");
6874        let msg = format!("{}", result.unwrap_err());
6875        assert!(msg.contains("not found") || msg.contains("missing_key"), "msg: {msg}");
6876        assert!(msg.contains("xyz-module.nix"), "msg: {msg}");
6877    }
6878
6879    #[test]
6880    fn error_assertion_failed_includes_file_context() {
6881        let p = std::path::PathBuf::from("/nix/store/test-assert.nix");
6882        let _g = push_eval_file(p);
6883        let result = eval("assert false; 1");
6884        let msg = format!("{}", result.unwrap_err());
6885        assert!(msg.contains("assertion failed"), "msg: {msg}");
6886        assert!(msg.contains("test-assert.nix"), "msg: {msg}");
6887    }
6888
6889    /// A missing-argument error names the file the LAMBDA came from.
6890    ///
6891    /// Evaluated with `eval_with_file`, not `push_eval_file` + bare `eval`, and
6892    /// the difference is the point. Calling a closure now pushes the closure's
6893    /// OWN file — including a fileless frame when it has none — so a lambda
6894    /// defined in a fileless string no longer borrows whatever unrelated file
6895    /// happens to sit on the stack. That borrowing is what the old form
6896    /// asserted, and CppNix does not do it: an `--expr` lambda has no file.
6897    /// Associating the source with a file, as every real `import` does, keeps
6898    /// the original intent (errors carry file context) while testing the path
6899    /// production actually takes. Verified against CppNix: for a lambda in a
6900    /// real file both engines name that file.
6901    #[test]
6902    fn error_missing_argument_includes_file_context() {
6903        let p = std::path::PathBuf::from("/nix/store/func.nix");
6904        let result = eval_with_file("({ a, b }: a) { a = 1; }", Some(p));
6905        let msg = format!("{}", result.unwrap_err());
6906        assert!(msg.contains("missing argument"), "msg: {msg}");
6907        assert!(msg.contains("func.nix"), "msg: {msg}");
6908    }
6909
6910    #[test]
6911    fn error_cannot_call_includes_file_context() {
6912        let p = std::path::PathBuf::from("/nix/store/call.nix");
6913        let _g = push_eval_file(p);
6914        let result = eval("42 99");
6915        let msg = format!("{}", result.unwrap_err());
6916        assert!(msg.contains("cannot call"), "msg: {msg}");
6917        assert!(msg.contains("call.nix"), "msg: {msg}");
6918    }
6919
6920    #[test]
6921    fn error_without_file_has_no_in_prefix() {
6922        // When no file is on the eval stack, error messages should
6923        // not contain ", in" context.
6924        let result = eval("nonexistent_xyz");
6925        let msg = format!("{}", result.unwrap_err());
6926        assert!(msg.contains("undefined variable"), "msg: {msg}");
6927        assert!(!msg.contains(", in"), "msg should not contain file context: {msg}");
6928    }
6929
6930    // ── pure mode getter/setter independence ───────────────
6931
6932    #[test]
6933    fn pure_mode_set_get_independence() {
6934        let was = is_pure_mode();
6935        set_pure_mode(true);
6936        assert!(is_pure_mode());
6937        set_pure_mode(false);
6938        assert!(!is_pure_mode());
6939        set_pure_mode(was);
6940    }
6941
6942    // ── eval_with_file with file path ──────────────────────
6943
6944    #[test]
6945    fn eval_with_file_some_path_arithmetic() {
6946        let p = std::path::PathBuf::from("/tmp/imaginary.nix");
6947        let result = eval_with_file("1 + 2", Some(p)).unwrap();
6948        assert_eq!(result, Value::Int(3));
6949    }
6950
6951    // ── unsafeGetAttrPos — the options.json `attrTag` declarations root ──
6952    //
6953    // Seals the CppNix-matching behavior: for a literal attrset built in a
6954    // FILE, `builtins.unsafeGetAttrPos <key> <set>` returns
6955    // `{ file; line=1; column=<key byte offset>+1; }`; for a `<string>` eval
6956    // (no file) it returns `null`. Byte-verified against `nix eval`.
6957
6958    #[test]
6959    fn unsafe_get_attr_pos_reports_file_and_offset_column() {
6960        // The real `attrTag` path: a literal attrset built in an IMPORTED file.
6961        // `import` registers the file's source text + pushes it on the eval
6962        // stack, so `eval_attrset` captures the key positions against that file
6963        // and `unsafeGetAttrPos` resolves them. CppNix reports the file plus a
6964        // real newline-resolved line and BYTE column.
6965        //
6966        // Re-baselined: this used to assert line 1 and column = the key's
6967        // 1-based byte offset in the whole file, citing "verified against nix
6968        // eval". It was not — that was sui's own output taken as the oracle,
6969        // and the same false rule was pinned in pos.rs. Measured on nix 2.31.5:
6970        // for `{ a = 1;\n  b = 2; }` the `b` key is 2:3, not 1:12.
6971        let dir = tempfile::tempdir().unwrap();
6972        // The literal's `b` key sits at a known byte offset in this file.
6973        let file_body = "{ a = 1;\n  b = 2; }\n";
6974        let f = dir.path().join("lit.nix");
6975        std::fs::write(&f, file_body).unwrap();
6976        let src = format!("builtins.unsafeGetAttrPos \"b\" (import {})", f.display());
6977        let v = eval(&src).unwrap();
6978        let attrs = match v { Value::Attrs(a) => a, other => panic!("expected attrs, got {other:?}") };
6979        assert_eq!(
6980            attrs.get("file").unwrap().as_string().unwrap(),
6981            f.to_string_lossy(),
6982        );
6983        // `b` is on the SECOND line, at byte column 3.
6984        let off = file_body.find("b = 2").unwrap();
6985        let bol = file_body[..off].rfind('\n').map_or(0, |i| i + 1);
6986        let expected_line = 1 + file_body[..off].matches('\n').count() as i64;
6987        let expected_col = (off - bol) as i64 + 1;
6988        assert_eq!(expected_line, 2, "fixture must put `b` on line 2");
6989        assert_eq!(*attrs.get("line").unwrap(), Value::Int(expected_line));
6990        let col = match attrs.get("column").unwrap() { Value::Int(n) => *n, o => panic!("{o:?}") };
6991        assert_eq!(col, expected_col, "column must be the 1-based BYTE column");
6992    }
6993
6994    #[test]
6995    fn unsafe_get_attr_pos_null_for_string_origin() {
6996        // A `<string>`-eval'd literal (no file on the stack) has no position → null.
6997        let v = eval("builtins.unsafeGetAttrPos \"a\" { a = 1; }").unwrap();
6998        assert_eq!(v, Value::Null);
6999    }
7000
7001    #[test]
7002    fn unsafe_get_attr_pos_null_for_missing_key() {
7003        // A key absent from an imported set → null.
7004        let dir = tempfile::tempdir().unwrap();
7005        let f = dir.path().join("lit.nix");
7006        std::fs::write(&f, "{ a = 1; }\n").unwrap();
7007        let src = format!("builtins.unsafeGetAttrPos \"zzz\" (import {})", f.display());
7008        let v = eval(&src).unwrap();
7009        assert_eq!(v, Value::Null);
7010    }
7011
7012    // ── String interpolation primitive coercions ───────────
7013
7014    #[test]
7015    fn interp_int_into_string() {
7016        // Integer interpolated into a string is coerced to its decimal repr.
7017        assert_eq!(ev(r#""val=${toString 42}""#), Value::string("val=42"));
7018    }
7019
7020    #[test]
7021    fn interp_bool_true_becomes_one() {
7022        // Per eval_str: Bool(true) → "1", Bool(false) → "" (empty)
7023        let v = ev(r#"let x = true; in "${builtins.toString x}""#);
7024        assert_eq!(v, Value::string("1"));
7025    }
7026
7027    #[test]
7028    fn interp_null_becomes_empty() {
7029        // Null in interpolation is empty.
7030        let v = ev(r#"let x = null; in "${builtins.toString x}""#);
7031        assert_eq!(v, Value::string(""));
7032    }
7033
7034    #[test]
7035    fn interp_attrset_without_to_string_errors() {
7036        // An attrset interpolated without __toString is a type error.
7037        let result = eval(r#"let s = { x = 1; }; in "${s}""#);
7038        assert!(result.is_err());
7039    }
7040
7041    #[test]
7042    fn interp_attrset_with_to_string_protocol() {
7043        // __toString protocol returns a string when called with self.
7044        let v = ev(r#""${{ __toString = self: "ok"; }}""#);
7045        assert_eq!(v, Value::string("ok"));
7046    }
7047
7048    // ── Path PathRel / PathHome / PathAbs ─────────────────
7049
7050    #[test]
7051    fn eval_path_absolute_literal() {
7052        let v = ev("/tmp/foo");
7053        match v {
7054            Value::Path(p) => assert!(p.contains("/tmp/foo")),
7055            _ => panic!("expected Path"),
7056        }
7057    }
7058
7059    #[test]
7060    fn eval_path_home_literal() {
7061        let v = ev("~/foo.nix");
7062        match v {
7063            Value::Path(p) => assert!(p.contains("~/foo.nix") || p.ends_with("foo.nix")),
7064            _ => panic!("expected Path"),
7065        }
7066    }
7067
7068    // ── search path miss ──────────────────────────────────
7069
7070    #[test]
7071    fn path_search_unmatched_errors() {
7072        // Without NIX_PATH entries matching, <nonexistent> errors out.
7073        // We unset NIX_PATH locally to ensure no entries match.
7074        let saved = std::env::var("NIX_PATH").ok();
7075        // SAFETY: tests run sequentially in single-threaded mode by
7076        // default? The thread_local NIX_PATH is per-thread but std::env
7077        // is process-global. We restore it after.
7078        unsafe {
7079            std::env::remove_var("NIX_PATH");
7080        }
7081        let result = eval("<this_should_not_resolve>");
7082        if let Some(v) = saved {
7083            unsafe {
7084                std::env::set_var("NIX_PATH", v);
7085            }
7086        }
7087        assert!(result.is_err());
7088    }
7089
7090    // ── Unary operators ────────────────────────────────────
7091
7092    #[test]
7093    fn unary_negate_int() {
7094        assert_eq!(ev("-7"), Value::Int(-7));
7095    }
7096
7097    #[test]
7098    fn unary_negate_float() {
7099        assert_eq!(ev("-2.5"), Value::Float(-2.5));
7100    }
7101
7102    #[test]
7103    fn unary_invert_true() {
7104        assert_eq!(ev("!true"), Value::Bool(false));
7105    }
7106
7107    #[test]
7108    fn unary_invert_false() {
7109        assert_eq!(ev("!false"), Value::Bool(true));
7110    }
7111
7112    #[test]
7113    fn unary_negate_bool_errors() {
7114        let result = eval("-true");
7115        assert!(result.is_err());
7116    }
7117
7118    #[test]
7119    fn unary_invert_int_errors() {
7120        let result = eval("!42");
7121        assert!(result.is_err());
7122    }
7123
7124    // ── Binary op type errors ──────────────────────────────
7125
7126    #[test]
7127    fn binop_add_attrs_errors() {
7128        let result = eval("{a=1;} + {b=2;}");
7129        assert!(result.is_err());
7130    }
7131
7132    #[test]
7133    fn binop_sub_string_errors() {
7134        let result = eval(r#""a" - "b""#);
7135        assert!(result.is_err());
7136    }
7137
7138    #[test]
7139    fn binop_mul_string_errors() {
7140        let result = eval(r#""a" * "b""#);
7141        assert!(result.is_err());
7142    }
7143
7144    #[test]
7145    fn binop_div_string_errors() {
7146        let result = eval(r#""a" / "b""#);
7147        assert!(result.is_err());
7148    }
7149
7150    #[test]
7151    fn binop_compare_attrs_errors() {
7152        let result = eval("{a=1;} < {b=2;}");
7153        assert!(result.is_err());
7154    }
7155
7156    #[test]
7157    fn binop_div_float_by_zero_int() {
7158        // Float / int(0) is NOT a DivisionByZero error in this evaluator —
7159        // only int/int matches the DivisionByZero branch. This documents
7160        // that branch.
7161        let result = eval("1.0 / 0");
7162        // Either inf or error is acceptable; the documented branch is
7163        // the int/int(0) → DivisionByZero one.
7164        let _ = result;
7165    }
7166
7167    #[test]
7168    fn binop_int_div_zero_is_division_by_zero() {
7169        let result = eval("5 / 0");
7170        match result {
7171            Err(EvalError::DivisionByZero) => {}
7172            other => panic!("expected DivisionByZero, got {other:?}"),
7173        }
7174    }
7175
7176    // ── if/then/else laziness ──────────────────────────────
7177
7178    #[test]
7179    fn if_else_only_chosen_branch_evaluated_then() {
7180        // The else branch contains a divide-by-zero that would error
7181        // if eagerly evaluated. Choosing the then branch must skip it.
7182        assert_eq!(ev("if true then 42 else 1 / 0"), Value::Int(42));
7183    }
7184
7185    #[test]
7186    fn if_else_only_chosen_branch_evaluated_else() {
7187        assert_eq!(ev("if false then 1 / 0 else 99"), Value::Int(99));
7188    }
7189
7190    #[test]
7191    fn if_condition_must_be_bool() {
7192        let result = eval("if 1 then 1 else 2");
7193        assert!(result.is_err());
7194    }
7195
7196    #[test]
7197    fn if_condition_lazy_does_not_force_unused() {
7198        // Lazy `let` ensures that `bad` is only forced if the chosen
7199        // branch references it.
7200        assert_eq!(
7201            ev("let bad = 1 / 0; in if true then 42 else bad"),
7202            Value::Int(42),
7203        );
7204    }
7205
7206    // ── Logic short-circuit laziness ───────────────────────
7207
7208    #[test]
7209    fn and_short_circuits_on_false() {
7210        // RHS contains an error; should never run.
7211        assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
7212    }
7213
7214    #[test]
7215    fn or_short_circuits_on_true() {
7216        assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
7217    }
7218
7219    #[test]
7220    fn implication_short_circuits_on_false_lhs() {
7221        // false -> anything is true; RHS not evaluated.
7222        assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
7223    }
7224
7225    // ── Lambda fixpoint via let ────────────────────────────
7226
7227    #[test]
7228    fn lambda_fix_combinator_returns_attrset() {
7229        // The classic `fix = f: let x = f x; in x` shape.
7230        let v = ev(
7231            "let fix = f: let x = f x; in x; in
7232              (fix (self: { val = 1; double = self.val * 2; })).double",
7233        );
7234        assert_eq!(v, Value::Int(2));
7235    }
7236
7237    // ── eval_attrset rec scope details ─────────────────────
7238
7239    #[test]
7240    fn rec_attrset_self_reference() {
7241        // rec set with simple forward reference.
7242        let v = ev("(rec { a = b; b = 1; }).a");
7243        assert_eq!(v, Value::Int(1));
7244    }
7245
7246    #[test]
7247    fn rec_attrset_inherit_from_uses_outer_scope() {
7248        // inherit-from in rec uses the OUTER (lexical) scope to evaluate
7249        // the source expression, not the rec scope. We bind `src` in
7250        // an outer let so the inherit can find it.
7251        let v = ev(
7252            "let src = { a = 10; }; in
7253              rec {
7254                inherit (src) a;
7255                b = a + 1;
7256              }",
7257        );
7258        if let Value::Attrs(attrs) = v {
7259            let b = attrs.get("b").unwrap();
7260            let b_forced = force_value(b).unwrap();
7261            assert_eq!(b_forced, Value::Int(11));
7262        } else {
7263            panic!("expected attrs");
7264        }
7265    }
7266
7267    #[test]
7268    fn nonrec_attrset_no_self_reference() {
7269        // In a non-rec set, a name doesn't see its sibling. The error
7270        // surfaces as an UndefinedVar when the thunk is forced.
7271        let result = eval("({ a = 1; b = a + 1; }).b");
7272        assert!(result.is_err());
7273    }
7274
7275    // ── eval_attrset deep merge edge cases ─────────────────
7276
7277    #[test]
7278    fn dotted_binding_three_segments_then_sibling() {
7279        let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
7280        if let Value::Attrs(attrs) = v {
7281            let a = attrs.get("a").unwrap();
7282            let a_forced = force_value(a).unwrap();
7283            if let Value::Attrs(a_attrs) = a_forced {
7284                let b = a_attrs.get("b").unwrap();
7285                let b_forced = force_value(b).unwrap();
7286                if let Value::Attrs(b_attrs) = b_forced {
7287                    assert_eq!(force_value(b_attrs.get("c").unwrap()).unwrap(), Value::Int(1));
7288                    assert_eq!(force_value(b_attrs.get("d").unwrap()).unwrap(), Value::Int(2));
7289                } else {
7290                    panic!("expected b to be attrs");
7291                }
7292                assert_eq!(force_value(a_attrs.get("e").unwrap()).unwrap(), Value::Int(3));
7293            } else {
7294                panic!("expected a to be attrs");
7295            }
7296        } else {
7297            panic!("expected outer attrs");
7298        }
7299    }
7300
7301    // ── rec/let dotted bindings in recursive scope ────────
7302
7303    #[test]
7304    fn rec_dotted_bindings_visible_to_siblings() {
7305        // Dotted bindings in rec blocks must be visible to sibling
7306        // bindings -- this is the nixpkgs lib/systems/parse.nix pattern.
7307        let v = ev("rec { types.openSB = 1; types.openCpu = 2; foo = types.openSB; }.foo");
7308        assert_eq!(v, Value::Int(1));
7309    }
7310
7311    #[test]
7312    fn rec_dotted_leaf_uses_rec_scope() {
7313        // Leaf expressions in dotted bindings must see sibling
7314        // rec-bindings, not just the parent scope.
7315        let v = ev("rec { types.a = f 1; f = x: x + 1; }.types.a");
7316        assert_eq!(v, Value::Int(2));
7317    }
7318
7319    #[test]
7320    fn rec_dotted_multiple_keys_merge() {
7321        // Multiple dotted bindings sharing a top-level key must merge.
7322        let v = ev("rec { types.a = 1; types.b = 2; x = types; }.x");
7323        if let Value::Attrs(attrs) = v {
7324            assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7325            assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7326        } else {
7327            panic!("expected attrs");
7328        }
7329    }
7330
7331    #[test]
7332    fn rec_nixpkgs_parse_pattern() {
7333        // Simplified nixpkgs lib/systems/parse.nix pattern:
7334        // rec block with dotted types.xxx bindings that reference
7335        // each other through the rec scope.
7336        let v = ev(r#"
7337            let
7338              mkOptionType = x: x;
7339              mergeOneOption = "merge";
7340              attrValues = builtins.attrValues;
7341              setType = name: value: { __type = name; } // value;
7342              mapAttrs = builtins.mapAttrs;
7343              enum = xs: mkOptionType { name = "enum"; check = x: builtins.elem x xs; };
7344              setTypes = type: mapAttrs (name: value: setType type.name ({ inherit name; } // value));
7345            in
7346            rec {
7347              types.openSB = mkOptionType { name = "sb"; merge = mergeOneOption; };
7348              types.significantByte = enum (attrValues significantBytes);
7349              significantBytes = setTypes types.openSB { bigEndian = {}; littleEndian = {}; };
7350              types.openCpuType = mkOptionType { name = "cpu-type"; };
7351              types.cpuType = enum (attrValues cpuTypes);
7352              cpuTypes = setTypes types.openCpuType { arm = { bits = 32; }; };
7353            }.types.openCpuType
7354        "#);
7355        if let Value::Attrs(attrs) = v {
7356            assert_eq!(
7357                force_value(attrs.get("name").unwrap()).unwrap(),
7358                Value::string("cpu-type")
7359            );
7360        } else {
7361            panic!("expected attrs");
7362        }
7363    }
7364
7365    #[test]
7366    fn let_dotted_leaf_uses_let_scope() {
7367        // Dotted binding leaf in a let block sees sibling let-bindings.
7368        let v = ev("let a.x = f 1; f = x: x + 1; in a.x");
7369        assert_eq!(v, Value::Int(2));
7370    }
7371
7372    #[test]
7373    fn let_inherit_from_plus_dotted_overrides() {
7374        // inherit-from and dotted bindings for the same key in a let
7375        // block: CppNix rejects this as a duplicate definition.  Sui
7376        // currently lets the dotted binding win (last-write-wins).
7377        // This test documents the current behaviour -- when we add
7378        // duplicate detection it should change to assert an error.
7379        let v = ev(r#"
7380            let
7381              src = { types = { existing = true; }; };
7382              inherit (src) types;
7383              types.added = true;
7384            in types
7385        "#);
7386        if let Value::Attrs(attrs) = v {
7387            // Dotted binding overwrites the inherited value
7388            assert_eq!(
7389                force_value(attrs.get("added").unwrap()).unwrap(),
7390                Value::Bool(true)
7391            );
7392            // Inherited 'existing' is lost because dotted replaced it
7393            assert!(attrs.get("existing").is_none());
7394        } else {
7395            panic!("expected attrs");
7396        }
7397    }
7398
7399    // ── Function pattern variations ────────────────────────
7400
7401    #[test]
7402    fn pattern_empty_no_args_no_ellipsis() {
7403        // {} pattern accepts only an empty attrset.
7404        assert_eq!(ev("({}: 1) {}"), Value::Int(1));
7405    }
7406
7407    #[test]
7408    fn pattern_empty_with_ellipsis_accepts_extra() {
7409        assert_eq!(ev("({...}: 1) { a = 1; b = 2; }"), Value::Int(1));
7410    }
7411
7412    #[test]
7413    fn pattern_all_defaults() {
7414        assert_eq!(
7415            ev("({a ? 1, b ? 2}: a + b) {}"),
7416            Value::Int(3),
7417        );
7418    }
7419
7420    #[test]
7421    fn pattern_at_bind_before() {
7422        // args @ { x }: args.x — bind name comes before pattern.
7423        assert_eq!(ev("(args @ { x }: args.x) { x = 7; }"), Value::Int(7));
7424    }
7425
7426    #[test]
7427    fn pattern_at_bind_after() {
7428        // { x } @ args: args.x — bind name comes after pattern.
7429        assert_eq!(ev("({ x } @ args: args.x) { x = 7; }"), Value::Int(7));
7430    }
7431
7432    #[test]
7433    fn pattern_default_references_other_arg() {
7434        // The default for `b` references `a` (which exists).
7435        assert_eq!(ev("({a, b ? a + 1}: b) {a = 10;}"), Value::Int(11));
7436    }
7437
7438    #[test]
7439    fn pattern_required_missing_errors() {
7440        let result = eval("({ a, b }: a) { a = 1; }");
7441        assert!(result.is_err());
7442    }
7443
7444    #[test]
7445    fn pattern_unexpected_errors_without_ellipsis() {
7446        let result = eval("({ a }: a) { a = 1; b = 2; }");
7447        assert!(result.is_err());
7448    }
7449
7450    // ── apply: error on non-callable ───────────────────────
7451
7452    #[test]
7453    fn apply_int_errors() {
7454        let result = eval("42 5");
7455        assert!(result.is_err());
7456    }
7457
7458    #[test]
7459    fn apply_string_errors() {
7460        let result = eval(r#""hi" 5"#);
7461        assert!(result.is_err());
7462    }
7463
7464    #[test]
7465    fn apply_attrset_without_functor_errors() {
7466        let result = eval("{ x = 1; } 5");
7467        assert!(result.is_err());
7468        let msg = format!("{}", result.unwrap_err());
7469        assert!(msg.contains("__functor") || msg.contains("cannot call"));
7470    }
7471
7472    // ── Select with multi-segment + default ────────────────
7473
7474    #[test]
7475    fn select_multi_segment_with_default() {
7476        // a.b.missing or 99 -- the missing segment yields the default.
7477        assert_eq!(ev("{ a = { b = 1; }; }.a.c or 99"), Value::Int(99));
7478    }
7479
7480    #[test]
7481    fn select_from_int_errors() {
7482        let result = eval("(1).x");
7483        assert!(result.is_err());
7484    }
7485
7486    // ── HasAttr edge cases ─────────────────────────────────
7487
7488    #[test]
7489    fn has_attr_on_non_set_returns_false() {
7490        // `expr ? a` where expr is not a set returns false (not error).
7491        assert_eq!(ev("1 ? x"), Value::Bool(false));
7492    }
7493
7494    #[test]
7495    fn has_attr_nested_path_present() {
7496        assert_eq!(ev("{ a = { b = 1; }; } ? a.b"), Value::Bool(true));
7497    }
7498
7499    #[test]
7500    fn has_attr_nested_path_missing() {
7501        assert_eq!(ev("{ a = { b = 1; }; } ? a.c"), Value::Bool(false));
7502    }
7503
7504    #[test]
7505    fn has_attr_intermediate_missing_returns_false() {
7506        assert_eq!(ev("{} ? a.b.c"), Value::Bool(false));
7507    }
7508
7509    // ── List eval edge cases ───────────────────────────────
7510
7511    #[test]
7512    fn list_with_function_value() {
7513        let v = ev("[(x: x + 1)]");
7514        if let Value::List(items) = v {
7515            assert_eq!(items.len(), 1);
7516            // List elements are now lazy (thunked). Force to check type.
7517            let forced = force_value(&items[0]).unwrap();
7518            assert!(matches!(forced, Value::Lambda(_)));
7519        } else {
7520            panic!("expected list");
7521        }
7522    }
7523
7524    // ── eval_inherit edge: inherit from missing var ────────
7525
7526    #[test]
7527    fn inherit_unknown_name_errors() {
7528        let result = eval("let x = 1; in let inherit nonexistent; in nonexistent");
7529        assert!(result.is_err());
7530    }
7531
7532    // ── String op: string concat preserves context ─────────
7533
7534    #[test]
7535    fn string_concat_no_context_when_both_plain() {
7536        let v = ev(r#""abc" + "def""#);
7537        if let Value::String(ns) = v {
7538            assert_eq!(ns.chars, "abcdef");
7539            assert!(!ns.has_context());
7540        } else {
7541            panic!("expected string");
7542        }
7543    }
7544
7545    // ── Parens / Root ──────────────────────────────────────
7546
7547    #[test]
7548    fn parens_around_expression() {
7549        assert_eq!(ev("(1 + 2)"), Value::Int(3));
7550    }
7551
7552    #[test]
7553    fn nested_parens() {
7554        assert_eq!(ev("(((42)))"), Value::Int(42));
7555    }
7556
7557    // ── Throw via builtins ─────────────────────────────────
7558
7559    #[test]
7560    fn throw_propagates_as_error() {
7561        let result = eval(r#"builtins.throw "kaboom""#);
7562        match result {
7563            Err(EvalError::Throw(s)) => assert!(s.contains("kaboom")),
7564            other => panic!("expected Throw, got {other:?}"),
7565        }
7566    }
7567
7568    #[test]
7569    fn assert_failed_propagates_as_error() {
7570        let result = eval("assert false; 1");
7571        match result {
7572            Err(EvalError::AssertionFailed(_)) => {}
7573            other => panic!("expected AssertionFailed, got {other:?}"),
7574        }
7575    }
7576
7577    // ── eval_str InterpolPart::Literal only ────────────────
7578
7579    #[test]
7580    fn string_no_interp_yields_no_context() {
7581        let v = ev(r#""just literal""#);
7582        if let Value::String(ns) = v {
7583            assert!(!ns.has_context());
7584        } else {
7585            panic!("expected string");
7586        }
7587    }
7588
7589    // ── Path interpolation adds context ───────────────────
7590
7591    // Byte-parity root #5: interpolating a source path is CppNix copy-to-store
7592    // coercion — the path is NAR-copied into /nix/store/<hash>-<name> and the
7593    // store path (with store-path context) is spliced in, not the raw path.
7594    // NAR of a single regular file is content+basename only (location-
7595    // independent), so a temp <dir>/data.txt of "hello\n" yields the exact
7596    // store path nix 2.34 produced: /nix/store/y9dmv…-data.txt.
7597    #[test]
7598    fn interp_path_copies_to_store_byte_matches_cppnix() {
7599        let dir = std::env::temp_dir().join(format!("sui-r5-interp-{}", std::process::id()));
7600        let _ = std::fs::remove_dir_all(&dir);
7601        std::fs::create_dir_all(&dir).unwrap();
7602        let f = dir.join("data.txt");
7603        std::fs::write(&f, b"hello\n").unwrap();
7604        let expr = format!(r#""${{{}}}""#, f.display());
7605        let v = eval(&expr).unwrap();
7606        if let Value::String(ns) = v {
7607            assert_eq!(
7608                ns.chars.to_string(),
7609                "/nix/store/y9dmvfhip31hg8ia4njwjz9vfa3ndphr-data.txt",
7610            );
7611            assert!(ns.has_context());
7612        } else {
7613            panic!("expected string");
7614        }
7615        let _ = std::fs::remove_dir_all(&dir);
7616    }
7617
7618    // ── pipe operators (NotImplemented) ────────────────────
7619    // Pipe operators (|>, <|) are parsed as PipeRight/PipeLeft and
7620    // currently return NotImplemented. We can't easily evaluate them
7621    // here because rnix may not even parse them, so we just rely on
7622    // the binop branch existing.
7623
7624    // ── ParseError surface ─────────────────────────────────
7625
7626    #[test]
7627    fn parse_error_unbalanced_braces() {
7628        let result = eval("{ a = 1");
7629        assert!(result.is_err());
7630        let err = result.unwrap_err();
7631        assert!(matches!(err, EvalError::ParseError(_)));
7632    }
7633
7634    #[test]
7635    fn parse_error_dangling_let() {
7636        let result = eval("let in");
7637        assert!(result.is_err());
7638    }
7639
7640    #[test]
7641    fn parse_error_empty_input() {
7642        let result = eval("");
7643        assert!(result.is_err());
7644    }
7645
7646    // ── num_op coverage via float ops ──────────────────────
7647
7648    #[test]
7649    fn float_int_subtraction() {
7650        assert_eq!(ev("3.5 - 1"), Value::Float(2.5));
7651    }
7652
7653    #[test]
7654    fn int_float_subtraction() {
7655        assert_eq!(ev("3 - 0.5"), Value::Float(2.5));
7656    }
7657
7658    #[test]
7659    fn float_float_division() {
7660        assert_eq!(ev("6.0 / 2.0"), Value::Float(3.0));
7661    }
7662
7663    #[test]
7664    fn int_float_multiplication() {
7665        assert_eq!(ev("3 * 2.5"), Value::Float(7.5));
7666    }
7667
7668    // ── compare with mixed numerics ────────────────────────
7669
7670    #[test]
7671    fn compare_int_float_less() {
7672        assert_eq!(ev("1 < 1.5"), Value::Bool(true));
7673    }
7674
7675    #[test]
7676    fn compare_float_int_more() {
7677        assert_eq!(ev("3.5 > 3"), Value::Bool(true));
7678    }
7679
7680    #[test]
7681    fn compare_equal_int_float() {
7682        assert_eq!(ev("3 <= 3.0"), Value::Bool(true));
7683    }
7684
7685    // ── Equality ──────────────────────────────────────────
7686
7687    #[test]
7688    fn equal_lists_same() {
7689        assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
7690    }
7691
7692    #[test]
7693    fn equal_lists_diff_length() {
7694        assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
7695    }
7696
7697    #[test]
7698    fn not_equal_lists() {
7699        assert_eq!(ev("[1] != [2]"), Value::Bool(true));
7700    }
7701
7702    #[test]
7703    fn equal_attrsets_same() {
7704        assert_eq!(ev("{a = 1; b = 2;} == {b = 2; a = 1;}"), Value::Bool(true));
7705    }
7706
7707    // ── Lambda identity equality (Rc ptr_eq) ────────────────
7708    // Regression test: same lambda via Rc must compare equal.
7709    // Without this, nixpkgs stdenv evaluation enters an infinite loop
7710    // because `crossSystem != localSystem` returns true even when both
7711    // are the same elaborate result (containing shared function attrs).
7712
7713    #[test]
7714    fn lambda_self_equality_in_attrset() {
7715        // Same closure shared via let → inherit must be equal
7716        assert_eq!(
7717            ev("let f = x: x; in { a = 1; inherit f; } == { a = 1; inherit f; }"),
7718            Value::Bool(true),
7719        );
7720    }
7721
7722    #[test]
7723    fn lambda_self_reference_attrset_equality() {
7724        // Attrset with function attr: x == x must be true
7725        assert_eq!(
7726            ev("let x = { a = 1; f = y: y; }; in x == x"),
7727            Value::Bool(true),
7728        );
7729    }
7730
7731    #[test]
7732    fn lambda_different_closures_not_equal() {
7733        // Different lambda closures (even structurally identical) must be false
7734        assert_eq!(
7735            ev("{ f = x: x; } == { f = x: x; }"),
7736            Value::Bool(false),
7737        );
7738    }
7739
7740    #[test]
7741    fn lambda_ne_does_not_force_unused_branch() {
7742        // If crossSystem == localSystem (same obj), != returns false,
7743        // and the then-branch (with throw) is never forced.
7744        assert_eq!(
7745            ev("let ls = { a = 1; f = x: x; }; in if ls != ls then builtins.throw \"bug\" else 42"),
7746            Value::Int(42),
7747        );
7748    }
7749
7750    // ── force_value chains thunks ──────────────────────────
7751
7752    #[test]
7753    fn force_value_through_thunk() {
7754        let root = rnix::Root::parse("1 + 2");
7755        let expr = root.tree().expr().unwrap();
7756        let thunk = Thunk::new_suspended(expr, Env::new());
7757        let val = Value::Thunk(thunk);
7758        assert_eq!(force_value(&val).unwrap(), Value::Int(3));
7759    }
7760
7761    // ── Builtin name "tryEval" lazy arg path ──────────────
7762
7763    #[test]
7764    fn try_eval_catches_thrown_error() {
7765        // tryEval wraps the thunk and catches throws inside.
7766        let v = ev(r#"(builtins.tryEval (builtins.throw "oops")).success"#);
7767        assert_eq!(v, Value::Bool(false));
7768    }
7769
7770    #[test]
7771    fn try_eval_returns_value_on_success() {
7772        let v = ev("(builtins.tryEval 42).value");
7773        assert_eq!(v, Value::Int(42));
7774    }
7775
7776    // ── LegacyLet (`let { body = ...; ...}`) ───────────────
7777
7778    #[test]
7779    fn legacy_let_returns_body_attr() {
7780        // `let { x = 1; body = x + 41; }` is the legacy let form: it
7781        // is desugared as a recursive set whose `body` attr is the
7782        // result.
7783        assert_eq!(ev("let { x = 1; body = x + 41; }"), Value::Int(42));
7784    }
7785
7786    #[test]
7787    fn legacy_let_missing_body_errors() {
7788        let result = eval("let { x = 1; }");
7789        assert!(result.is_err());
7790    }
7791
7792    #[test]
7793    fn legacy_let_with_inherit_from_scope() {
7794        assert_eq!(
7795            ev("let outer = 5; in let { inherit outer; body = outer * 2; }"),
7796            Value::Int(10),
7797        );
7798    }
7799
7800    // ── eval_str interpolation more cases ──────────────────
7801
7802    #[test]
7803    fn interp_with_string_concat_preserves_order() {
7804        assert_eq!(
7805            ev(r#"let a = "x"; b = "y"; in "${a}-${b}""#),
7806            Value::string("x-y"),
7807        );
7808    }
7809
7810    #[test]
7811    fn interp_only_literal_part() {
7812        assert_eq!(ev(r#""no interp here""#), Value::string("no interp here"));
7813    }
7814
7815    // ── eval_attr dynamic / string keys ────────────────────
7816
7817    #[test]
7818    fn dynamic_attr_via_string_key_in_set() {
7819        // `{ "a" = 1; }.a` works because attr keys can be string literals.
7820        assert_eq!(ev(r#"{ "a" = 1; }.a"#), Value::Int(1));
7821    }
7822
7823    #[test]
7824    fn dynamic_attr_via_interpolated_key() {
7825        let v = ev(r#"let k = "foo"; in { ${k} = 99; }.foo"#);
7826        assert_eq!(v, Value::Int(99));
7827    }
7828
7829    // ── String key access via select with dynamic ──────────
7830
7831    #[test]
7832    fn select_with_string_key() {
7833        let v = ev(r#"{ a = 42; }."a""#);
7834        assert_eq!(v, Value::Int(42));
7835    }
7836
7837    // ── Apply via __functor on attrset ─────────────────────
7838
7839    #[test]
7840    fn apply_attrset_with_functor_works() {
7841        let v = ev("let s = { __functor = self: x: x + 1; }; in s 5");
7842        assert_eq!(v, Value::Int(6));
7843    }
7844
7845    // ── Negation of negative ───────────────────────────────
7846
7847    #[test]
7848    fn double_negate_int() {
7849        assert_eq!(ev("- (-5)"), Value::Int(5));
7850    }
7851
7852    // ── Inherit from rec scope binding visibility ──────────
7853
7854    #[test]
7855    fn inherit_in_let_makes_name_available() {
7856        assert_eq!(
7857            ev("let src = { a = 7; }; in let inherit (src) a; in a"),
7858            Value::Int(7),
7859        );
7860    }
7861
7862    // ── String + path ──────────────────────────────────────
7863
7864    #[test]
7865    fn path_plus_string_yields_path() {
7866        let v = ev(r#"/foo + "/bar""#);
7867        match v {
7868            Value::Path(p) => assert_eq!(&*p, "/foo/bar"),
7869            _ => panic!("expected path"),
7870        }
7871    }
7872
7873    // ── Lazy attrset value not forced unless selected ──────
7874
7875    #[test]
7876    fn attrset_value_not_forced_unless_selected() {
7877        // `bad` is an attr whose value would error if forced, but we
7878        // only ever select `good`, so it's never touched.
7879        assert_eq!(
7880            ev(r#"{ bad = builtins.throw "boom"; good = 42; }.good"#),
7881            Value::Int(42),
7882        );
7883    }
7884
7885    // ── Lambda calling itself via let ──────────────────────
7886
7887    #[test]
7888    fn lambda_recursive_via_let() {
7889        // factorial via let-bound recursive function
7890        assert_eq!(
7891            ev("let fact = n: if n == 0 then 1 else n * fact (n - 1); in fact 5"),
7892            Value::Int(120),
7893        );
7894    }
7895
7896    // ── Dynamic key in select ──────────────────────────────
7897
7898    #[test]
7899    fn select_with_dynamic_key_via_var() {
7900        // ${k} interpolation in select position is not standard Nix
7901        // syntax, but a string-literal key works for select.
7902        assert_eq!(ev(r#"let k = { x = 1; }; in k.x"#), Value::Int(1));
7903    }
7904
7905    // ── Compare strings ────────────────────────────────────
7906
7907    #[test]
7908    fn compare_string_lex_greater_or_equal() {
7909        assert_eq!(ev(r#""b" >= "a""#), Value::Bool(true));
7910        assert_eq!(ev(r#""a" >= "a""#), Value::Bool(true));
7911        assert_eq!(ev(r#""a" >= "b""#), Value::Bool(false));
7912    }
7913
7914    // ── PartialEq across types ─────────────────────────────
7915
7916    #[test]
7917    fn equal_int_string_false() {
7918        assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
7919    }
7920
7921    #[test]
7922    fn equal_null_int_false() {
7923        assert_eq!(ev("null == 0"), Value::Bool(false));
7924    }
7925
7926    // ── Update operator on thunked operands ────────────────
7927
7928    #[test]
7929    fn update_with_let_bound_operands() {
7930        assert_eq!(
7931            ev("let a = { x = 1; }; b = { y = 2; }; in (a // b).y"),
7932            Value::Int(2),
7933        );
7934    }
7935
7936    // ── Concat on let-bound lists ──────────────────────────
7937
7938    #[test]
7939    fn concat_lists_from_let() {
7940        assert_eq!(
7941            ev("let a = [1 2]; b = [3 4]; in builtins.length (a ++ b)"),
7942            Value::Int(4),
7943        );
7944    }
7945
7946    // ── String interpolation: list coercion ─────────────────
7947
7948    #[test]
7949    fn interp_list_coerces_with_spaces() {
7950        // Lists in interpolation are now coerced via coerce_to_string
7951        // (space-joined elements).
7952        assert_eq!(
7953            ev(r#""${toString [1 2 3]}""#),
7954            Value::string("1 2 3"),
7955        );
7956    }
7957
7958    #[test]
7959    fn interp_list_directly_coerces() {
7960        // Direct list interpolation space-joins elements via coerce_to_string.
7961        assert_eq!(
7962            ev(r#""${[1 2]}""#),
7963            Value::string("1 2"),
7964        );
7965    }
7966
7967    // ── String interpolation: outPath ─────────────────────
7968
7969    #[test]
7970    fn interp_outpath_attrset() {
7971        assert_eq!(
7972            ev(r#"let x = { outPath = "/nix/store/abc"; }; in "${x}""#),
7973            Value::string("/nix/store/abc"),
7974        );
7975    }
7976
7977    #[test]
7978    fn interp_tostring_takes_priority_over_outpath() {
7979        assert_eq!(
7980            ev(r#"let x = { __toString = self: "custom"; outPath = "/ignored"; }; in "${x}""#),
7981            Value::string("custom"),
7982        );
7983    }
7984
7985    #[test]
7986    fn interp_derivation_coerces_to_outpath() {
7987        // derivation produces an attrset with outPath
7988        let result = eval(r#"
7989            let drv = builtins.derivation {
7990                name = "test";
7991                system = "x86_64-linux";
7992                builder = "/bin/sh";
7993            };
7994            in "${drv}"
7995        "#).unwrap();
7996        if let Value::String(s) = result {
7997            assert!(s.chars.starts_with("/nix/store/"), "got: {}", s.chars);
7998        } else {
7999            panic!("expected string");
8000        }
8001    }
8002
8003    // ── String interpolation: lambda error ─────────────────
8004
8005    #[test]
8006    fn interp_lambda_errors() {
8007        let result = eval(r#""${x: x}""#);
8008        assert!(result.is_err());
8009    }
8010
8011    // ── force_value tests ────────────────────────────────────
8012
8013    #[test]
8014    fn force_value_int_returns_same() {
8015        let v = Value::Int(42);
8016        assert_eq!(force_value(&v).unwrap(), Value::Int(42));
8017    }
8018
8019    #[test]
8020    fn force_value_bool_returns_same() {
8021        let v = Value::Bool(true);
8022        assert_eq!(force_value(&v).unwrap(), Value::Bool(true));
8023    }
8024
8025    #[test]
8026    fn force_value_string_returns_same() {
8027        let v = Value::string("hello");
8028        assert_eq!(force_value(&v).unwrap(), Value::string("hello"));
8029    }
8030
8031    #[test]
8032    fn force_value_attrs_returns_same() {
8033        let mut a = NixAttrs::new();
8034        a.insert("x".to_string(), Value::Int(1));
8035        let v = Value::Attrs(Rc::new(a.clone()));
8036        assert_eq!(force_value(&v).unwrap(), Value::Attrs(Rc::new(a)));
8037    }
8038
8039    #[test]
8040    fn force_value_list_returns_same() {
8041        let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
8042        assert_eq!(
8043            force_value(&v).unwrap(),
8044            Value::list(vec![Value::Int(1), Value::Int(2)]),
8045        );
8046    }
8047
8048    #[test]
8049    fn force_value_null_returns_null() {
8050        let v = Value::Null;
8051        assert_eq!(force_value(&v).unwrap(), Value::Null);
8052    }
8053
8054    #[test]
8055    fn force_value_evaluated_thunk_returns_cached() {
8056        // Thunk wrapping a simple expression should evaluate and cache
8057        let v = ev("let x = 1 + 2; in x");
8058        assert_eq!(v, Value::Int(3));
8059        // Force again — should return the cached value
8060        assert_eq!(force_value(&v).unwrap(), Value::Int(3));
8061    }
8062
8063    // ── Tail-call loop tests ─────────────────────────────────
8064
8065    #[test]
8066    fn tco_if_true_condition() {
8067        assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
8068    }
8069
8070    #[test]
8071    fn tco_if_false_condition() {
8072        assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
8073    }
8074
8075    #[test]
8076    fn tco_deeply_nested_if_else_chain() {
8077        // Build a chain: if false then 1 else if false then 2 else ... else 150
8078        // All conditions are false except the final else, which produces 150.
8079        let mut expr = String::from("150");
8080        for i in (1..150).rev() {
8081            expr = format!("if false then {} else {}", i, expr);
8082        }
8083        let v = ev(&expr);
8084        assert_eq!(v, Value::Int(150));
8085    }
8086
8087    #[test]
8088    fn tco_assert_true_passes_through() {
8089        assert_eq!(ev("assert true; 42"), Value::Int(42));
8090    }
8091
8092    #[test]
8093    fn tco_assert_false_throws_assertion_failed() {
8094        let result = eval("assert false; 42");
8095        assert!(result.is_err());
8096        let err = result.unwrap_err();
8097        assert!(
8098            matches!(err, EvalError::AssertionFailed(_)),
8099            "expected AssertionFailed, got: {err}",
8100        );
8101    }
8102
8103    #[test]
8104    fn tco_with_makes_scope_available() {
8105        assert_eq!(ev("with { x = 10; y = 20; }; x + y"), Value::Int(30));
8106    }
8107
8108    #[test]
8109    fn tco_let_in_creates_bindings() {
8110        assert_eq!(ev("let a = 5; in a"), Value::Int(5));
8111    }
8112
8113    #[test]
8114    fn tco_let_in_multiple_bindings() {
8115        assert_eq!(ev("let a = 1; b = 2; c = 3; in a + b + c"), Value::Int(6));
8116    }
8117
8118    // ── eval_attrset tests ───────────────────────────────────
8119
8120    #[test]
8121    fn eval_attrset_empty() {
8122        let v = ev("{}");
8123        if let Value::Attrs(attrs) = v {
8124            assert!(attrs.is_empty(), "expected empty attrset");
8125        } else {
8126            panic!("expected attrset, got {v:?}");
8127        }
8128    }
8129
8130    #[test]
8131    fn eval_attrset_simple_kv() {
8132        let v = ev("{ a = 1; b = 2; }");
8133        if let Value::Attrs(attrs) = v {
8134            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8135            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8136        } else {
8137            panic!("expected attrset, got {v:?}");
8138        }
8139    }
8140
8141    #[test]
8142    fn eval_attrset_recursive() {
8143        assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
8144        assert_eq!(ev("(rec { a = 1; b = a + 1; }).a"), Value::Int(1));
8145    }
8146
8147    #[test]
8148    fn eval_attrset_inherit_from_scope() {
8149        assert_eq!(ev("let x = 1; in { inherit x; }.x"), Value::Int(1));
8150    }
8151
8152    #[test]
8153    fn eval_attrset_inherit_from_expr() {
8154        assert_eq!(
8155            ev("{ inherit (builtins) true; }.true"),
8156            Value::Bool(true),
8157        );
8158    }
8159
8160    #[test]
8161    fn eval_attrset_dotted_path() {
8162        assert_eq!(ev("{ a.b.c = 1; }.a.b.c"), Value::Int(1));
8163    }
8164
8165    #[test]
8166    fn eval_attrset_update_merge() {
8167        let v = ev("{ a = 1; } // { b = 2; }");
8168        if let Value::Attrs(attrs) = v {
8169            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8170            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8171        } else {
8172            panic!("expected attrset, got {v:?}");
8173        }
8174    }
8175
8176    // ── eval_apply tests ─────────────────────────────────────
8177
8178    #[test]
8179    fn eval_apply_simple_function() {
8180        assert_eq!(ev("(x: x + 1) 2"), Value::Int(3));
8181    }
8182
8183    #[test]
8184    fn eval_apply_pattern_destructuring() {
8185        assert_eq!(ev("({a, b}: a + b) { a = 1; b = 2; }"), Value::Int(3));
8186    }
8187
8188    #[test]
8189    fn eval_apply_default_arguments() {
8190        assert_eq!(ev("({a, b ? 0}: a + b) { a = 1; }"), Value::Int(1));
8191    }
8192
8193    #[test]
8194    fn eval_apply_ellipsis() {
8195        assert_eq!(ev("({a, ...}: a) { a = 1; b = 2; }"), Value::Int(1));
8196    }
8197
8198    // ── eval_select tests ────────────────────────────────────
8199
8200    #[test]
8201    fn eval_select_single_key() {
8202        assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
8203    }
8204
8205    #[test]
8206    fn eval_select_multi_level() {
8207        assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
8208    }
8209
8210    #[test]
8211    fn eval_select_with_or_default() {
8212        assert_eq!(ev("{}.a or 42"), Value::Int(42));
8213    }
8214
8215    #[test]
8216    fn eval_select_missing_key_without_default_throws() {
8217        let result = eval("{}.a");
8218        assert!(result.is_err());
8219    }
8220
8221    // ── BinOp tests ──────────────────────────────────────────
8222
8223    #[test]
8224    fn binop_add_ints() {
8225        assert_eq!(ev("1 + 2"), Value::Int(3));
8226    }
8227
8228    #[test]
8229    fn binop_sub_ints() {
8230        assert_eq!(ev("3 - 1"), Value::Int(2));
8231    }
8232
8233    #[test]
8234    fn binop_mul_ints() {
8235        assert_eq!(ev("2 * 3"), Value::Int(6));
8236    }
8237
8238    #[test]
8239    fn binop_div_ints() {
8240        assert_eq!(ev("6 / 2"), Value::Int(3));
8241    }
8242
8243    #[test]
8244    fn binop_float_arithmetic() {
8245        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
8246    }
8247
8248    #[test]
8249    fn binop_string_concat() {
8250        assert_eq!(
8251            ev(r#""hello" + " " + "world""#),
8252            Value::string("hello world"),
8253        );
8254    }
8255
8256    #[test]
8257    fn binop_list_concat() {
8258        assert_eq!(
8259            ev("[1 2] ++ [3 4]"),
8260            Value::list(vec![
8261                Value::Int(1),
8262                Value::Int(2),
8263                Value::Int(3),
8264                Value::Int(4),
8265            ]),
8266        );
8267    }
8268
8269    #[test]
8270    fn binop_attrset_update() {
8271        let v = ev("{ a = 1; } // { b = 2; }");
8272        if let Value::Attrs(attrs) = v {
8273            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8274            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8275        } else {
8276            panic!("expected attrset, got {v:?}");
8277        }
8278    }
8279
8280    #[test]
8281    fn binop_less_than() {
8282        assert_eq!(ev("1 < 2"), Value::Bool(true));
8283        assert_eq!(ev("2 < 1"), Value::Bool(false));
8284    }
8285
8286    #[test]
8287    fn binop_greater_than() {
8288        assert_eq!(ev("2 > 1"), Value::Bool(true));
8289        assert_eq!(ev("1 > 2"), Value::Bool(false));
8290    }
8291
8292    #[test]
8293    fn binop_equal() {
8294        assert_eq!(ev("1 == 1"), Value::Bool(true));
8295        assert_eq!(ev("1 == 2"), Value::Bool(false));
8296    }
8297
8298    #[test]
8299    fn binop_not_equal() {
8300        assert_eq!(ev("1 != 2"), Value::Bool(true));
8301        assert_eq!(ev("1 != 1"), Value::Bool(false));
8302    }
8303
8304    #[test]
8305    fn binop_logical_and() {
8306        assert_eq!(ev("true && false"), Value::Bool(false));
8307        assert_eq!(ev("true && true"), Value::Bool(true));
8308    }
8309
8310    #[test]
8311    fn binop_logical_or() {
8312        assert_eq!(ev("true || false"), Value::Bool(true));
8313        assert_eq!(ev("false || false"), Value::Bool(false));
8314    }
8315
8316    #[test]
8317    fn binop_logical_not() {
8318        assert_eq!(ev("!true"), Value::Bool(false));
8319        assert_eq!(ev("!false"), Value::Bool(true));
8320    }
8321
8322    #[test]
8323    fn binop_implication() {
8324        assert_eq!(ev("false -> true"), Value::Bool(true));
8325        assert_eq!(ev("false -> false"), Value::Bool(true));
8326        assert_eq!(ev("true -> true"), Value::Bool(true));
8327        assert_eq!(ev("true -> false"), Value::Bool(false));
8328    }
8329}