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