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