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 (`None`).
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///
340/// "No limit" is carried by `None`, NOT by a `usize::MAX` sentinel. The
341/// sentinel form obliged every reader of this constant to re-guard it
342/// (`MAX_EVAL_DEPTH != usize::MAX && depth > MAX_EVAL_DEPTH`), and that
343/// guard did not actually remove the nonsense comparison it was written to
344/// suppress — `depth > usize::MAX` is false for every `usize`, which
345/// `clippy::absurd_extreme_comparisons` reports at deny level. With the
346/// bound typed as an `Option`, the non-test build contains no comparison
347/// at all and the absurd form has no way to be written.
348#[cfg(test)]
349const MAX_EVAL_DEPTH: Option<usize> = Some(2_048);
350#[cfg(not(test))]
351const MAX_EVAL_DEPTH: Option<usize> = None;
352
353/// Lightweight depth guard.
354///
355/// In non-test builds `MAX_EVAL_DEPTH` is `None`, so the guard is a no-op
356/// (the arm never matches). The compiler should be able to elide most of
357/// the overhead.
358struct DepthGuard;
359
360/// Release-active runaway backstop for the overlay-fixpoint promotion.
361///
362/// Release builds set `MAX_EVAL_DEPTH = None` (no eval-depth guard)
363/// so nixpkgs' legitimately-deep fixpoints evaluate.  But a promoted
364/// empty-attrs partial that corrupts a downstream `makeOverridable` /
365/// `commonAttrs` fixpoint (the cross-system Darwin `apple-sdk` path `hello`
366/// hits under `builtins.currentSystem = macOS`) recurses through
367/// `eval_expr` without bound — and that recursion does NOT climb the force
368/// stack, so only an `eval_expr`-level bound catches it before the OS stack
369/// aborts.  Armed ONLY once a promotion has fired (`promotion_occurred()`),
370/// so ordinary deep evaluation (never after a promotion) is untouched.  The
371/// converging native-system fixpoint (`libxcrypt`) peaks well under this
372/// bound and is unaffected; the non-converging cross-system runaway is
373/// caught here, converting a hard native-stack abort into a recoverable
374/// `InfiniteRecursion` that `x.y or default` recovers exactly like nix
375/// (`hello` returns to a clean value-diverge instead of aborting).
376const PROMOTION_RUNAWAY_EVAL_DEPTH: usize = 500;
377
378impl DepthGuard {
379    #[inline(always)]
380    fn enter() -> Result<Self, EvalError> {
381        EVAL_DEPTH.with(|d| {
382            let depth = d.get();
383            if matches!(MAX_EVAL_DEPTH, Some(max) if depth > max) {
384                return Err(EvalError::InfiniteRecursion(
385                    "eval depth exceeded".into(),
386                ));
387            }
388            if depth > PROMOTION_RUNAWAY_EVAL_DEPTH
389                && crate::value::promotion_occurred()
390            {
391                return Err(EvalError::InfiniteRecursion(
392                    "overlay-fixpoint promotion runaway (eval depth exceeded)".into(),
393                ));
394            }
395            d.set(depth + 1);
396            Ok(DepthGuard)
397        })
398    }
399}
400
401impl Drop for DepthGuard {
402    #[inline(always)]
403    fn drop(&mut self) {
404        EVAL_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
405    }
406}
407
408/// Collect ALL identifier names referenced in an AST expression.
409///
410/// Walks the full expression tree (including inside `with` bodies)
411/// and collects every `Ident` node. This is an OVER-APPROXIMATION:
412/// it includes shadowed names and names inside `with` bodies.
413///
414/// Over-approximation is SAFE for dead binding elimination — we may
415/// keep a binding that's unused (waste) but never skip a binding
416/// that IS used (correctness).
417///
418/// Previous versions bailed out on `with` expressions, disabling
419/// dead binding elimination entirely. The fix: collect idents even
420/// inside `with` bodies. If a binding name doesn't appear as ANY
421/// identifier ANYWHERE in the expression, it's provably dead
422/// regardless of `with` scopes — `with` makes names from the
423/// namespace reachable, not names from the enclosing let-scope.
424fn collect_referenced_names(expr: &ast::Expr) -> HashSet<String> {
425    let mut names = HashSet::new();
426    for node in expr.syntax().descendants() {
427        if let Some(ident) = ast::Ident::cast(node) {
428            names.insert(ident_text(&ident));
429        }
430    }
431    names
432}
433
434/// Compute the set of binding names that are transitively needed
435/// by the body expression in a recursive scope (let-in or rec attrset).
436///
437/// Algorithm:
438/// 1. Collect all ident references from the body → root set
439/// 2. Collect all ident references from each binding's value expression
440/// 3. BFS from root set through binding dependencies
441/// 4. Return the set of reachable binding names
442///
443/// Bindings NOT in the returned set are provably dead and can be skipped.
444/// This is correct even for recursive scopes because the BFS follows
445/// transitive dependencies: if A is needed and A references B, then B
446/// is added to the needed set.
447fn compute_needed_bindings(
448    body: &ast::Expr,
449    binding_info: &[(String, Option<ast::Expr>)], // (name, value_expr) — None for plain inherit
450) -> HashSet<String> {
451    // Step 1: Collect idents from the body
452    let body_refs = collect_referenced_names(body);
453
454    // Build the set of all binding names and their dependencies
455    let mut all_names: HashSet<String> = HashSet::with_capacity(binding_info.len());
456    let mut deps: HashMap<String, HashSet<String>> = HashMap::with_capacity(binding_info.len());
457
458    for (name, value_expr) in binding_info {
459        all_names.insert(name.clone());
460        if let Some(expr) = value_expr {
461            deps.insert(name.clone(), collect_referenced_names(expr));
462        }
463    }
464
465    // Step 2: BFS from body refs through binding dependencies
466    let mut needed: HashSet<String> = body_refs.intersection(&all_names).cloned().collect();
467    let mut queue: VecDeque<String> = needed.iter().cloned().collect();
468
469    while let Some(name) = queue.pop_front() {
470        if let Some(name_deps) = deps.get(&name) {
471            for dep in name_deps {
472                if all_names.contains(dep) && needed.insert(dep.clone()) {
473                    queue.push_back(dep.clone());
474                }
475            }
476        }
477    }
478
479    needed
480}
481
482/// Evaluate a Nix expression string.
483#[must_use = "evaluation result should be used"]
484pub fn eval(input: &str) -> Result<Value, EvalError> {
485    eval_with_file(input, None)
486}
487
488// Whether we are inside a top-level eval (used to avoid nested perf reports).
489thread_local! {
490    static EVAL_NESTING: Cell<usize> = const { Cell::new(0) };
491}
492
493/// Evaluate a Nix expression string, optionally tagged with the
494/// path of the source file. The file is stored on the root `Env`
495/// so that any closure created during evaluation captures it and
496/// can resolve relative path literals (`./foo.nix`) in function
497/// defaults that fire after control has left the file's scope.
498
499pub fn eval_with_file(input: &str, file: Option<std::path::PathBuf>) -> Result<Value, EvalError> {
500    let nesting = EVAL_NESTING.with(|n| {
501        let v = n.get();
502        n.set(v + 1);
503        v
504    });
505    if nesting == 0 {
506        crate::perf::init();
507        crate::perf::start();
508        crate::trace::init_trace();
509        // Clear the identifier symbol cache so that offsets from
510        // previous top-level evaluations don't persist.
511        clear_ident_cache();
512        // ENV-RESOLVE M0 (no-op unless `SUI_RESOLVE=1`): clear the per-source
513        // resolution side-table for the same reason — its `(source_id,
514        // offset)` keys must not survive across independent top-level evals.
515        crate::resolve_env::clear();
516        // SOURCE_TEXTS is deliberately NOT cleared here — it is append-only
517        // for the life of the process. Clearing it on a `nesting == 0`
518        // re-entry was a shared-mutable-cell bug: the top-level
519        // `eval_with_file` RETURNS (nesting → 0) BEFORE its caller
520        // deep-forces the result (e.g. `value.to_json()` at the CLI), and
521        // that deep force triggers lazy `import`s which re-enter
522        // `eval_with_file` at nesting == 0 — so clearing here wiped every
523        // registered file's text mid-force. Any `unsafeGetAttrPos` resolved
524        // after the first deep-force import then failed its `text_for()`
525        // existence check and returned null (the cid `options.json` attrTag
526        // `declarations = []` divergence). SOURCE_TEXTS is keyed by canonical
527        // path and `register_source` stores each path's text only once
528        // (identical on re-parse), so append-only is correct — a path always
529        // maps to its own text — and matches CppNix, which never clears its
530        // source registry. The only cost is bounded growth within one process
531        // (a non-issue for a per-invocation CLI). Removing the clearable cell
532        // makes the whole "absent/wrong source text at resolve time" class
533        // unrepresentable rather than merely guarded.
534    }
535    let parse = rnix::Root::parse(input);
536    if !parse.errors().is_empty() {
537        let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
538        EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
539        return Err(EvalError::ParseError(msgs.join("; ")));
540    }
541
542    // Each parse tree gets a unique source ID so that identifiers
543    // at the same byte offset in different files don't collide in
544    // the symbol cache.
545    let src_id = next_source_id();
546    // ENV-RESOLVE M0 (no-op unless `SUI_RESOLVE=1`): run the parse-time
547    // variable resolver over THIS parse tree and merge its `Lexical`
548    // resolutions into the per-source table under `src_id`. Pure + fail-safe
549    // (any uncertainty is left `Dynamic`), so the eval below is byte-identical
550    // — the `Lexical` fast path only shortcuts a lexical-bindings hit, which
551    // `lookup_fast` returns first anyway.
552    if crate::resolve_env::enabled() {
553        let table = sui_resolve::resolve(&parse.tree());
554        crate::resolve_env::populate(src_id, &table);
555    }
556    // Register this parse tree's file + text so a static key's byte offset
557    // (recorded by `eval_attrset`) resolves to a file/line/column for
558    // `builtins.unsafeGetAttrPos`. The file flows through the eval-file
559    // stack (store-path prefixed for imported inputs); the position resolver
560    // lifts a cache-dir path to its `/nix/store/<h>-source` store path.
561    crate::pos::register_source(file.as_deref(), input);
562    let prev_src_id = CURRENT_SOURCE_ID.with(|s| {
563        let old = s.get();
564        s.set(src_id);
565        old
566    });
567
568    let root = parse.tree();
569    let expr = match root.expr() {
570        Some(e) => e,
571        None => {
572            CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
573            EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
574            return Err(EvalError::ParseError("empty expression".to_string()));
575        }
576    };
577    let mut env = Env::new();
578    env.set_eval_file(file);
579    // Tag the env with THIS parse tree's source_id so a thunk created here
580    // and forced later (cross-file) restores this id on force (see the
581    // source-id guard in `Thunk::force`), keying `IDENT_CACHE` against the
582    // file where the thunk was defined.
583    env.set_source_id(src_id);
584    builtins::register(&mut env);
585    let result = eval_expr(&expr, &env).map_err(|e| attach_trace(e))?;
586    // Force the top-level result so callers always see a concrete value.
587    let final_result = force_value(&result).map_err(|e| attach_trace(e));
588    // Restore the previous source ID (matters for nested imports).
589    CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
590    EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
591    if nesting == 0 {
592        crate::perf::report();
593    }
594    final_result
595}
596
597/// Force a value: if it is a thunk, evaluate and memoize the result.
598/// Concrete values are returned unchanged.
599/// Force a value: if it is a thunk, evaluate and memoize the result.
600/// Concrete values are returned unchanged.
601///
602/// Inlined aggressively so the non-thunk fast path compiles to a
603/// simple clone without a function-call boundary.
604#[inline(always)]
605/// Force a value and return a type-safe `Concrete` (guaranteed non-Thunk).
606///
607/// This is the preferred forcing API. The `Concrete` return type makes it
608/// impossible to accidentally use an unforced thunk — the compiler rejects it.
609pub fn force_concrete(value: &Value) -> Result<Concrete, EvalError> {
610    value.demand()
611}
612
613/// Force a value (legacy API — returns `Value` for backward compatibility).
614///
615/// Prefer `force_concrete()` or `Value::demand()` for new code.
616pub fn force_value(value: &Value) -> Result<Value, EvalError> {
617    crate::perf::inc(crate::perf::Counter::ForceValue);
618    // Fast path: non-thunk values are returned immediately (no clone needed
619    // until we actually have work to do).
620    if !matches!(value, Value::Thunk(_)) {
621        return Ok(value.clone());
622    }
623    // Slow path: chase thunk chains.
624    //
625    // A legitimate chain is typically 1–3 links deep (result of lazy
626    // evaluation wrapping an intermediate value in another thunk).
627    // Reaching 100 means either (a) a self-referential cycle like
628    // `let x = x; in x` that bypassed per-thunk Blackhole detection,
629    // or (b) pathological Thunk(Thunk(...)) nesting. Both are errors.
630    //
631    // Previous behavior silently returned `Ok(last_thunk)` at depth
632    // 100, which hid infinite-recursion bugs — the blackhole tests
633    // in the lib suite failed because `result.is_ok()` instead of
634    // `is_err()`. Returning `Err` here makes the silent-bail visible
635    // at the CppNix-compatible call site (real Nix raises "infinite
636    // recursion encountered").
637    let mut v = value.clone();
638    let mut depth = 0u32;
639    loop {
640        match v {
641            Value::Thunk(ref thunk) => {
642                v = force_thunk(thunk)?;
643                depth += 1;
644                if depth > 100 {
645                    return Err(EvalError::InfiniteRecursion(
646                        "force_value: thunk chain exceeded depth 100 (cycle or runaway lazy wrap)".into(),
647                    ));
648                }
649            }
650            _ => return Ok(v),
651        }
652    }
653}
654
655/// Force with call-site tracking (legacy API).
656pub fn force_value_tracked(value: &Value, site: &str) -> Result<Value, EvalError> {
657    crate::perf::inc(crate::perf::Counter::ForceValue);
658    if let Value::Thunk(thunk) = value {
659        FORCE_SITES.with(|sites| {
660            *sites.borrow_mut().entry(site.to_string()).or_insert(0) += 1;
661        });
662        force_thunk(thunk)
663    } else {
664        Ok(value.clone())
665    }
666}
667
668thread_local! {
669    static FORCE_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
670        std::cell::RefCell::new(std::collections::HashMap::new());
671    static APPLY_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
672        std::cell::RefCell::new(std::collections::HashMap::new());
673}
674
675/// Dump force-site counters (call from perf reporting).
676pub fn dump_force_sites() {
677    FORCE_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!("[force-sites] top thunk force call sites:");
682        for (site, count) in sorted.iter().take(10) {
683            eprintln!("  {count:>8} {site}");
684        }
685    });
686    APPLY_SITES.with(|sites| {
687        let sites = sites.borrow();
688        let mut sorted: Vec<_> = sites.iter().collect();
689        sorted.sort_by(|a, b| b.1.cmp(a.1));
690        eprintln!("[apply-sites] top lambda call sites by source file:");
691        for (site, count) in sorted.iter().take(15) {
692            // Strip nix store prefix for readability
693            let short = site.rsplit_once("-source/").map_or(site.as_str(), |(_,s)| s);
694            eprintln!("  {count:>8} {short}");
695        }
696    });
697}
698
699/// Force a thunk — split out from [`force_value`] so the fast path
700/// (non-thunk clone) stays fully inlined while this cold path can
701/// be a regular function call with stacker protection.
702fn force_thunk(thunk: &Thunk) -> Result<Value, EvalError> {
703    // Ultra-fast path: if the thunk is already cached, skip stacker overhead.
704    if let Some(cached) = thunk.peek() {
705        crate::perf::inc(crate::perf::Counter::ThunkHit);
706        return Ok(cached.clone().into_value());
707    }
708    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
709        // Force ONE level only — matches CppNix's forceValue which does
710        // not transitively chase thunk-in-thunk chains. The caller will
711        // force again when the value is actually needed. This is the key
712        // optimization: CppNix forces 71 thunks for lib.version while
713        // sui was forcing 180K due to transitive forcing.
714        thunk.force(&|expr, env| eval_expr(expr, env))
715    })
716}
717
718/// Decide whether to thunk an expression or evaluate it directly.
719///
720/// Trivial expressions (literals, paths) are evaluated immediately --
721/// no thunk allocation. For non-recursive scopes, variable lookups
722/// (Ident) and lambdas are also evaluated eagerly. This matches
723/// CppNix's `maybeThunk` optimization which avoids a large fraction
724/// of thunk creations on nixpkgs.
725///
726/// For recursive scopes (let-in, rec attrsets), set `is_rec = true` to
727/// prevent eager evaluation of `Ident` and `Lambda` expressions:
728/// - Ident: sibling bindings may not be defined yet (forward refs).
729/// - Lambda: the closure must capture the *final* env (set in Phase 2)
730///   so that the lambda body can reference sibling bindings.
731///
732/// `defined_so_far`: In recursive scopes, names that have already been
733/// bound in this scope (i.e. earlier bindings). Idents referencing these
734/// are backward references and can be resolved directly without thunking.
735/// Forward references (names not yet defined) must still be thunked.
736/// Detect whether `value_expr`'s source structurally references
737/// the identifier `name` — the signal that this let-binding is a
738/// self-recursive fix-point (`let x = f x; in x` or
739/// `let x = { a = 1; b = x.a; }; in x`).  Used at let-binding
740/// thunking time to pick `Thunk::new_suspended_recursive` over the
741/// classic `Thunk::new_suspended`, so inner re-entrance during
742/// force returns the partial value via `ThunkRepr::Promise`
743/// instead of erroring with `InfiniteRecursion`.
744///
745/// Implementation walks the value-expr's rnix syntax tree looking
746/// for `TOKEN_IDENT` whose text equals `name`.  This is a
747/// conservative over-approximation:
748/// - shadowing (e.g. `let x = let x = 1; in x; in x`) marks the
749///   outer thunk recursive even though no real cycle exists;
750/// - the resulting Promise behaviour is a strict superset of
751///   Blackhole for non-cyclic forces (the body runs to completion
752///   and the cell gets the final value), so false positives are
753///   semantically safe — they cost only the extra `Rc<RefCell>`
754///   allocation per recursive let-binding.
755///
756/// False negatives (e.g. the bound name appears only inside an
757/// inherit-from-source clause) leave the existing
758/// `InfiniteRecursion` behaviour intact, which is the conservative
759/// fallback.
760/// `SUI_SCOPE_NARROW` — the scope-narrowing latch.
761///
762/// Every `let` / `rec` / pattern-default binding closes an `Rc` cycle today:
763/// the thunk is bound INTO the scope env, then Phase 2's `update_env` puts
764/// that same env back INTO the thunk. `Rc` has no cycle collector and no
765/// `Weak` sits on that edge, so the whole scope — every innocent leaf in it —
766/// is immortal for the life of the process. Narrowing removes the second half
767/// of the cycle for the bindings that provably do not need it.
768///
769/// * unset / `0` — today's behaviour, byte- AND allocation-identical. Not one
770///   extra tree walk runs on this path.
771/// * `1` — D3 (pattern-lambda formal defaults) + D1 (`let` / `rec` bindings
772///   whose RHS reaches no sibling keep their outer-env capture).
773/// * `2` — additionally D2 (bindings that DO need the scope get a *cluster*
774///   env holding only the names they can reach, so one recursive binding
775///   stops pinning its innocent siblings).
776///
777/// Read once through a `OnceLock` one-way latch — the `resolve_env::enabled()`
778/// idiom — so the value cannot change mid-eval and the default path pays a
779/// single relaxed load.
780/// ★ THE DEFAULT IS 2 (flipped 2026-08-17). `0` and `1` remain selectable for
781/// bisecting a suspected narrowing bug — that is the whole reason the latch
782/// survives rather than the code being inlined.
783///
784/// It shipped as `0`, and NOTHING in the tree set it. So the measured result —
785/// 700.0 MB / 1,020,001 live nodes → 22.2 MB / 0 on the gate probe, with the
786/// process RSS floor at 20.5 MB, i.e. *at the floor* — reached nobody. A fix
787/// present but unreached is the same shape as the VM bridges that were
788/// installed two-of-three, and as `vm_fallback_count()` sitting unread since
789/// the day it was written.
790///
791/// Flipped only after byte-parity was proven at every level, because a wrong
792/// drvPath is far worse than a leak:
793///   - the 117-fixture lang corpus: identical at 0, 1 and 2
794///   - the full `sui-eval` suite at level 2: 1685 pass
795///   - `sui eval --raw <expr>.drvPath` byte-identical across 0/1/2 AND equal to
796///     real nix
797///
798/// The narrowing removes the second half of an `Rc` cycle for bindings that
799/// provably do not need the scope env. It is NOT free of judgement: `P2`, a
800/// genuinely-recursive scope, must still pin, and it does — a narrowing that
801/// improved every probe would mean it was discarding something it should keep.
802fn scope_narrow_level() -> u8 {
803    static LEVEL: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
804    *LEVEL.get_or_init(
805        || match std::env::var("SUI_SCOPE_NARROW").ok().as_deref() {
806            Some("0") => 0,
807            Some("1") => 1,
808            _ => 2,
809        },
810    )
811}
812
813/// True at `SUI_SCOPE_NARROW >= 1` — D1 + D3 are on.
814#[inline]
815fn scope_narrow_enabled() -> bool {
816    scope_narrow_level() >= 1
817}
818
819/// True at `SUI_SCOPE_NARROW = 2` — D2 (the cluster env) is on.
820#[inline]
821fn scope_cluster_enabled() -> bool {
822    scope_narrow_level() >= 2
823}
824
825/// The set of variable-reference ident names in `value_expr`'s subtree
826/// (`NODE_IDENT` whose parent is NOT a `NODE_ATTRPATH` — i.e. genuine
827/// variable references, not attribute names/keys). ONE subtree walk.
828///
829/// Kills the O(N²) re-walk storm (Storm A) at the call sites: previously
830/// `is_self_recursive_binding` did a full subtree walk once per
831/// `(binding × sibling-name)` in every `let`/`rec` scope; now each RHS is
832/// walked ONCE to build this set, then every name is an O(1) set lookup.
833/// Byte-neutral: the recursion verdict is unchanged (a name is self/mutually
834/// recursive iff it is in the set).
835///
836/// NOT cross-call memoized: a process-lifetime memo keyed on ephemeral AST
837/// node identity `(source-id, range)` collides when nodes are parsed/dropped
838/// without a per-eval clear (the standalone-predicate case). The call-site
839/// single-walk is the byte-safe win; `ContentMemo` (sui-intern) is reserved
840/// for sites with a STABLE content key (the NAR-hash memo's `(dir,name)`, the
841/// overlay-flatten per-node cache).
842///
843/// The attrpath exclusion matters: without it, `placeholder = if
844/// lhs.placeholder == …` in nixpkgs `lib/types.nix` would be falsely flagged
845/// self-recursive (its RHS mentions the *attribute* `.placeholder`), routing
846/// the binding through the `Promise` fix-point path whose env handling drops
847/// the let-scope — surfacing as a force-order-dependent `null` in the module
848/// system (`concatLists: expected list, got null`).
849fn referenced_idents(value_expr: &ast::Expr) -> HashSet<SmolStr> {
850    use rnix::SyntaxKind;
851    // Storm A instrumentation (byte-neutral, gated on perf::enabled()): count
852    // this walk + the rnix descendants it visits + its walltime, so the
853    // residual per-fixpoint-iteration self/mutual-recursion detection cost is
854    // VISIBLE in the SUI_EVAL_PERF report — symmetric with sorted_entries /
855    // overlay-flatten. The counter reads add zero output-relevant work.
856    let perf_on = crate::perf::enabled();
857    let t0 = if perf_on {
858        Some(std::time::Instant::now())
859    } else {
860        None
861    };
862    crate::perf::inc(crate::perf::Counter::SelfRecWalkCalls);
863    let mut nodes_walked: u64 = 0;
864    let mut set: HashSet<SmolStr> = HashSet::new();
865    for node in value_expr.syntax().descendants() {
866        nodes_walked += 1;
867        if node.kind() == SyntaxKind::NODE_IDENT
868            && node
869                .parent()
870                .is_none_or(|p| p.kind() != SyntaxKind::NODE_ATTRPATH)
871            && let Some(i) = ast::Ident::cast(node)
872        {
873            set.insert(SmolStr::from(ident_text(&i).as_str()));
874        }
875    }
876    crate::perf::add(crate::perf::Counter::SelfRecWalkNodes, nodes_walked);
877    if let Some(t0) = t0 {
878        crate::trace::add_self_rec_walk_nanos(t0.elapsed().as_nanos());
879    }
880    set
881}
882
883/// True iff `value_expr` references `name` as a variable. Now a set lookup
884/// over one subtree walk (see `referenced_idents`). Byte-neutral vs the prior
885/// per-name-walk implementation.
886fn is_self_recursive_binding(value_expr: &ast::Expr, name: &str) -> bool {
887    referenced_idents(value_expr).contains(name)
888}
889
890fn maybe_thunk(
891    expr: &ast::Expr,
892    env: &Env,
893    is_rec: bool,
894    defined_so_far: Option<&HashSet<String>>,
895) -> Value {
896    match expr {
897        // Literals: evaluate directly (no allocation needed).
898        ast::Expr::Literal(lit) => eval_literal(lit).unwrap_or_else(|_| {
899            Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
900        }),
901        // Ident resolution: try full lookup (lexical + with-scope cache + force).
902        // On successful lookup → return value directly (most common case).
903        // On blackhole (fixpoint being constructed) → env.lookup returns None
904        // → create WithIdent thunk for deferred O(1) cache-based resolution.
905        // This approach: (1) is fast for resolved with-scopes (no thunk overhead),
906        // (2) handles blackhole fixpoints correctly via WithIdent deferral.
907        ast::Expr::Ident(ident) if !is_rec => {
908            // Cache the interned Symbol by (source_id, text_offset) — same
909            // zero-alloc steady-state path as the strict Ident arm in
910            // `eval_expr`. The ident text is materialized only on the
911            // once-per-offset cold miss and on the (rare) blackhole deferral.
912            // Same cross-file aliasing fix as the strict `eval_expr` Ident arm —
913            // key on the env's source id, not the unmaintained thread-local.
914            // This twin had NO stale-symbol guard at all (the one commit
915            // 2d93e77 added sits only on the strict arm's lookup-MISS path,
916            // after the keyword check), so it was the more exposed of the two.
917            let sym = {
918                let src_id = env.source_id();
919                let offset = u32::from(ident.syntax().text_range().start());
920                crate::value::intern_cached_with(src_id, offset, || {
921                    crate::value::intern(&ident_text(ident))
922                })
923            };
924            // Zero-copy keyword check on the resolved Symbol.
925            if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
926                "true" => Some(Value::Bool(true)),
927                "false" => Some(Value::Bool(false)),
928                "null" => Some(Value::Null),
929                _ => None,
930            }) {
931                return kw;
932            }
933            {
934                {
935                    // `name` arg to `lookup_fast` is unused (lookup is by
936                    // Symbol) — pass "" to skip materializing the ident text on
937                    // the hot HIT path.
938                    if let Some(v) = env.lookup_fast(sym, "") {
939                        return v;
940                    }
941                    // Failed — either blackhole or missing. Create WithIdent
942                    // thunk for deferred resolution (only for the blackhole case).
943                    if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
944                        return Value::Thunk(Thunk::new_with_ident(
945                            SmolStr::from(ident_text(ident).as_str()),
946                            scope_cache,
947                            scope_value,
948                            env.clone(),
949                        ));
950                    }
951                    crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
952                    Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
953                }
954            }
955        }
956        // Identifiers in rec scope: check if it's a backward reference
957        // (name already defined earlier in the same scope). If so, we
958        // can resolve it directly instead of creating a wasteful thunk.
959        ast::Expr::Ident(ident) if is_rec => {
960            let name = ident_text(ident);
961            match name.as_str() {
962                "true" => Value::Bool(true),
963                "false" => Value::Bool(false),
964                "null" => Value::Null,
965                _ => {
966                    // If this name was already defined earlier in the
967                    // scope, it's a backward reference — resolve directly.
968                    if defined_so_far.map_or(false, |d| d.contains(&name)) {
969                        env.lookup(&name).unwrap_or_else(|| {
970                            crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
971                            Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
972                        })
973                    } else {
974                        // Forward reference — must thunk
975                        crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
976                        Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
977                    }
978                }
979            }
980        }
981        // Absolute and home paths: trivial text extraction — but ONLY
982        // for the non-interpolated case. An interpolated path (`/a/${e}`,
983        // `~/${e}`) must be thunked so its `${…}` parts are evaluated in
984        // `eval_expr_inner`, never spliced as literal text.
985        ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
986            // CppNix canonicalizes every absolute path literal on eval
987            // (`/.` → `/`, `/a/./b` → `/a/b`, `/a/../b` → `/b`, `..`
988            // clamped at root). A path VALUE carries the canonical form —
989            // the marquee cid root threw in `lib.path.hasStorePathPrefix`
990            // precisely because sui kept the raw `/.` text.
991            let text = crate::path::canon_abs(&p.syntax().text().to_string());
992            Value::Path(Box::new(SmolStr::from(text.as_str())))
993        }
994        ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
995            let text = p.syntax().text().to_string();
996            Value::Path(Box::new(SmolStr::from(text.as_str())))
997        }
998        // Non-interpolated string literal: a constant value with no
999        // interpolation, so `eval_str` runs no `${…}` force/coerce — it is
1000        // pure, non-throwing, side-effect-free, and produces a
1001        // `String(NixString::with_context(text, EMPTY))`. Evaluating it here is
1002        // therefore byte-identical to forcing a suspended thunk of it (M2
1003        // thunk-waste: a constant Str thunk is always pure overhead — it can
1004        // never observably change eval order because it cannot throw or
1005        // diverge). Only the NON-interpolated case is direct; an interpolated
1006        // `"${e}"` must stay thunked so its parts force lazily in the right
1007        // env/order. `eval_str` on the empty-interpolation input cannot fail,
1008        // but fall back to a thunk on the (unreachable) error to preserve
1009        // exact prior behavior.
1010        ast::Expr::Str(st) if !str_has_interpolation(st) => {
1011            eval_str(st, env).unwrap_or_else(|_| {
1012                Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1013            })
1014        }
1015        // Lambda: capture env directly (no computation needed).
1016        // But NOT in recursive scopes -- the closure must capture the
1017        // final env with all sibling bindings (set in Phase 2).
1018        ast::Expr::Lambda(lam) if !is_rec => {
1019            if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1020                Value::Lambda(Rc::new(Closure {
1021                    param,
1022                    body,
1023                    env: env.clone(),
1024                }))
1025            } else {
1026                Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1027            }
1028        }
1029        // Select on a variable: CppNix's maybeThunk evaluates these eagerly
1030        // when the base is a simple ident. However, this breaks fixpoints
1031        // where the base (e.g., `config`) is a thunk being computed — eagerly
1032        // evaluating `config.x` during attrset construction triggers blackhole.
1033        //
1034        // The nixpkgs module system relies on `{ ...; default = config.x; }`
1035        // being lazy. Wrap selects in thunks unconditionally.
1036        // The performance cost is minimal (thunk allocation + deferred eval)
1037        // and correctness is critical for fixpoint patterns.
1038        // Everything else: wrap in a thunk for lazy evaluation.
1039        _ => {
1040            crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeOther);
1041            if crate::perf::enabled() {
1042                let kind = match expr {
1043                    ast::Expr::Select(_) => "Select",
1044                    ast::Expr::Apply(_) => "Apply",
1045                    ast::Expr::BinOp(_) => "BinOp",
1046                    ast::Expr::IfElse(_) => "IfElse",
1047                    ast::Expr::Str(_) => "Str",
1048                    ast::Expr::List(_) => "List",
1049                    ast::Expr::With(_) => "With",
1050                    ast::Expr::Assert(_) => "Assert",
1051                    ast::Expr::HasAttr(_) => "HasAttr",
1052                    ast::Expr::UnaryOp(_) => "UnaryOp",
1053                    ast::Expr::Paren(_) => "Paren",
1054                    ast::Expr::LetIn(_) => "LetIn",
1055                    ast::Expr::AttrSet(_) => "AttrSet",
1056                    ast::Expr::Ident(_) => "Ident(rec)",
1057                    ast::Expr::Lambda(_) => "Lambda(rec)",
1058                    ast::Expr::LegacyLet(_) => "LegacyLet",
1059                    ast::Expr::PathAbs(_)
1060                    | ast::Expr::PathHome(_)
1061                    | ast::Expr::PathRel(_)
1062                    | ast::Expr::PathSearch(_) => "Path(interp)",
1063                    _ => "Other",
1064                };
1065                crate::trace::inc_maybe_other_kind(kind);
1066            }
1067            Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1068        }
1069    }
1070}
1071
1072/// Evaluate an rnix expression in an environment.
1073///
1074/// Uses `stacker::maybe_grow` to dynamically extend the call stack when
1075/// it is close to exhaustion.  This prevents stack overflow on deeply
1076/// nested nixpkgs fixpoints (50+ overlay applications each creating
1077/// multiple recursive `eval_expr` / `force_value` frames).
1078///
1079/// **Fast path:** Ident (~32% of all evals), Literal, Paren, and Root
1080/// expressions don't recurse and are handled directly, skipping the
1081/// `stacker::maybe_grow` overhead for ~40% of all `eval_expr` calls.
1082#[inline(always)]
1083pub fn eval_expr(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1084    // Fast path: trivial expressions that don't recurse.
1085    // Skip stacker overhead for ~40% of all eval_expr calls.
1086    match expr {
1087        ast::Expr::Ident(ident) => {
1088            crate::perf::inc(crate::perf::Counter::EvalExpr);
1089            if crate::perf::enabled() {
1090                crate::perf::inc(crate::perf::Counter::ExprIdent);
1091            }
1092            // ── ENV-RESOLVE M0 fast path (no-op unless `SUI_RESOLVE=1`) ──
1093            // A parse-time-`Lexical` reference carries its precomputed
1094            // Symbol; probe the lexical bindings map DIRECTLY, skipping the
1095            // per-lookup `ident_text().to_string()` + `intern()`. This is
1096            // parity-by-construction: `lookup_fast` probes the SAME lexical
1097            // map by the SAME Symbol FIRST, so a hit here is byte-identical
1098            // to what the unchanged path below returns. Any miss (a
1099            // mid-fixpoint blackhole where the binding isn't in scope yet, an
1100            // unrecorded ident, or `Dynamic`) falls through to the EXACT
1101            // unchanged path — including the whole with-chain + WithIdent
1102            // deferral. The resolver never records keywords, so the
1103            // true/false/null handling below is untouched on this path.
1104            if crate::resolve_env::enabled() {
1105                let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1106                let offset = u32::from(ident.syntax().text_range().start());
1107                if let sui_resolve::Resolution::Lexical { sym } =
1108                    crate::resolve_env::resolution_for(src_id, offset)
1109                {
1110                    if let Some(v) = env.lookup_lexical_sym(sym) {
1111                        return Ok(v);
1112                    }
1113                }
1114                // Miss / Dynamic → fall through to the unchanged path.
1115            }
1116            // Cache the interned Symbol by (source_id, text_offset) so the
1117            // steady-state identifier lookup pays neither a per-lookup
1118            // `ident_text().to_string()` heap alloc nor a string re-hash — the
1119            // ident's text is materialized only on the once-per-offset cold
1120            // miss. The keyword check + the common `lookup_fast` HIT then run
1121            // fully allocation-free; `name` is materialized lazily only on the
1122            // miss/error branches, which need the string anyway.
1123            // KEY ON `env.source_id()`, NOT the thread-local (fixed 2026-07-20).
1124            //
1125            // `CURRENT_SOURCE_ID` is pushed at exactly ONE site —
1126            // `value.rs`'s `ThunkRepr::Suspended` force branch. Lambda
1127            // application and the Native/WithIdent/InheritSelect/Promise force
1128            // branches never push it, so while a callee's body was being
1129            // evaluated the thread-local still named the CALLER's file. The
1130            // `(source_id, offset)` cache key then aliased across files: an
1131            // identifier at byte N in file A could resolve to the Symbol
1132            // interned for a `null`/`true`/`false` token at byte N in file B —
1133            // and the zero-copy keyword check below turned that into a literal
1134            // `Value::Null` for a perfectly well-defined identifier, before any
1135            // environment lookup.
1136            //
1137            // That is what stopped sui evaluating nixpkgs: `hostSuffix` in
1138            // `make-derivation.nix` resolved to `null`, so `attrs.name +
1139            // hostSuffix` raised "cannot add string and null" — observed
1140            // directly as `STALE-KEYWORD ident="hostSuffix" resolvedAs="null"`.
1141            // It is not darwin-specific and has nothing to do with the module
1142            // system; `import <nixpkgs> {}` fails identically on x86_64-linux.
1143            //
1144            // `Env` already carries the correct value: `eval_with_file` sets it
1145            // and `child()` inherits it, and a lambda's `call_env` is
1146            // `closure.env.child()` — so a body's env names its DEFINING file.
1147            // Keying on it fixes every cross-file path at the cause, rather than
1148            // adding a fifth push/pop guard that a sixth path can forget.
1149            let sym = {
1150                let src_id = env.source_id();
1151                let offset = u32::from(ident.syntax().text_range().start());
1152                crate::value::intern_cached_with(src_id, offset, || {
1153                    crate::value::intern(&ident_text(ident))
1154                })
1155            };
1156            // Zero-copy keyword check on the resolved Symbol — the resolver
1157            // never records keywords, so this matches the prior `name.as_str()`
1158            // arm exactly.
1159            if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
1160                "true" => Some(Value::Bool(true)),
1161                "false" => Some(Value::Bool(false)),
1162                "null" => Some(Value::Null),
1163                _ => None,
1164            }) {
1165                return Ok(kw);
1166            }
1167            return {
1168                {
1169                    // `lookup_fast`'s `name` argument is unused (lookup is by
1170                    // Symbol); pass "" to avoid materializing the ident text on
1171                    // the hot HIT path.
1172                    if let Some(v) = env.lookup_fast(sym, "") {
1173                        Ok(v)
1174                    } else {
1175                        let name = ident_text(ident);
1176                        // The `(src_id, text_offset)` identifier-symbol cache
1177                        // (`intern_cached_with`) can hand back a STALE Symbol when
1178                        // a lazily-forced thunk's identifier is resolved under a
1179                        // force-time `CURRENT_SOURCE_ID` that differs from the
1180                        // identifier's PARSE-time src_id — a thunk from file A can
1181                        // be forced while B is the current source, so
1182                        // `(B_src_id, offset)` aliases B's parse tree's identifier
1183                        // at that same byte offset and returns ITS Symbol. (Proven
1184                        // root: nixpkgs `lib/systems/parse.nix` `mkOptionType` — the
1185                        // binding IS present in the env, but the cache returned
1186                        // `Symbol(566)` while the binding was interned under
1187                        // `Symbol(506)`, so `lookup_fast(566)` missed a defined
1188                        // var.) `intern` is deterministic + append-only, so on a
1189                        // miss re-intern the name from its text (the authoritative
1190                        // Symbol) and retry the lexical lookup BEFORE considering
1191                        // with-scopes or undefined. A genuinely undefined variable
1192                        // is unaffected — its fresh lookup also misses and falls
1193                        // through unchanged.
1194                        let fresh = crate::value::intern(name.as_str());
1195                        if fresh != sym {
1196                            if let Some(v) = env.lookup_fast(fresh, name.as_str()) {
1197                                return Ok(v);
1198                            }
1199                        }
1200                        if env.with_scope_count() > 0 {
1201                        // With-scope lookup failed (likely blackhole from fixpoint).
1202                        // Return a WithIdent thunk for deferred resolution.
1203                        // This is the eval_expr equivalent of maybe_thunk's deferral.
1204                        if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
1205                            Ok(Value::Thunk(Thunk::new_with_ident(
1206                                SmolStr::from(name.as_str()),
1207                                scope_cache,
1208                                scope_value,
1209                                env.clone(),
1210                            )))
1211                        } else if crate::value::in_promise_eval() {
1212                            // M2.6 Promise softening: an undefined
1213                            // identifier inside Promise body evaluation
1214                            // typically means a `with` block sourced
1215                            // from the empty-attrset sentinel didn't
1216                            // populate the with-scope.  Returning null
1217                            // lets the eval proceed; the result is
1218                            // wrong-but-bounded (no further forces
1219                            // happen on null until something downstream
1220                            // demands a real value).
1221                            Ok(Value::Null)
1222                        } else {
1223                            Err(EvalError::UndefinedVar(
1224                                format!("'{name}'{}", eval_file_ctx()),
1225                            ))
1226                        }
1227                    } else {
1228                        if let Ok(dbg_var) = std::env::var("SUI_DEBUG_VAR") {
1229                            if dbg_var == name || dbg_var == "*" {
1230                                eprintln!(
1231                                    "[sui-debug] UndefinedVar '{name}' in {}\n\
1232                                     [sui-debug]   env bindings ({} total): {:?}\n\
1233                                     [sui-debug]   with_scopes: {}",
1234                                    eval_file_ctx(),
1235                                    env.binding_count(),
1236                                    env.binding_names_preview(20),
1237                                    env.with_scope_count(),
1238                                );
1239                            }
1240                        }
1241                        if crate::value::in_promise_eval() {
1242                            // Same Promise softening as the with-scope
1243                            // branch above.
1244                            return Ok(Value::Null);
1245                        }
1246                        Err(EvalError::UndefinedVar(
1247                            format!("'{name}'{}", eval_file_ctx()),
1248                        ))
1249                        }
1250                    }
1251                }
1252            };
1253        }
1254        ast::Expr::Literal(lit) => {
1255            crate::perf::inc(crate::perf::Counter::EvalExpr);
1256            if crate::perf::enabled() {
1257                crate::perf::inc(crate::perf::Counter::ExprLiteral);
1258            }
1259            return eval_literal(lit);
1260        }
1261        ast::Expr::Paren(p) => {
1262            if let Some(inner) = p.expr() {
1263                return eval_expr(&inner, env);
1264            }
1265        }
1266        ast::Expr::Root(r) => {
1267            if let Some(inner) = r.expr() {
1268                return eval_expr(&inner, env);
1269            }
1270        }
1271        // Lambda: no recursion — just captures env into a closure.
1272        ast::Expr::Lambda(lam) => {
1273            crate::perf::inc(crate::perf::Counter::EvalExpr);
1274            if crate::perf::enabled() {
1275                crate::perf::inc(crate::perf::Counter::ExprLambda);
1276            }
1277            if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1278                return Ok(Value::Lambda(Rc::new(Closure {
1279                    param,
1280                    body,
1281                    env: env.clone(),
1282                })));
1283            }
1284        }
1285        _ => {}
1286    }
1287    // Complex expressions: need stacker for recursion safety
1288    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
1289        eval_expr_inner(expr, env)
1290    })
1291}
1292
1293/// Inner implementation of [`eval_expr`] — called from the `stacker`
1294/// trampoline so that the stack is guaranteed to have headroom.
1295///
1296/// Uses a tail-call loop: for expressions in tail position (`if/else`,
1297/// `let..in`, `with`, `assert`, `paren`, `root`), we update the local
1298/// `expr` and `env` variables and loop instead of recursing. This
1299/// eliminates millions of stack frames in nixpkgs evaluation.
1300fn eval_expr_inner(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1301    // Tail-call trampoline: expressions in tail position update these
1302    // and `continue` instead of recursing into eval_expr.
1303    let mut cur_expr = expr.clone();
1304    let mut cur_env = env.clone();
1305
1306    loop {
1307    crate::perf::inc(crate::perf::Counter::EvalExpr);
1308    // Track expression type distribution when profiling
1309    if crate::perf::enabled() {
1310        use crate::perf::Counter;
1311        let c = match &cur_expr {
1312            ast::Expr::Ident(_) => Counter::ExprIdent,
1313            ast::Expr::Literal(_) => Counter::ExprLiteral,
1314            ast::Expr::Str(_) => Counter::ExprStr,
1315            ast::Expr::List(_) => Counter::ExprList,
1316            ast::Expr::AttrSet(_) => Counter::ExprAttrs,
1317            ast::Expr::Select(_) => Counter::ExprSelect,
1318            ast::Expr::Apply(_) => Counter::ExprApply,
1319            ast::Expr::LetIn(_) => Counter::ExprLetIn,
1320            ast::Expr::IfElse(_) => Counter::ExprIfElse,
1321            ast::Expr::With(_) => Counter::ExprWith,
1322            ast::Expr::Lambda(_) => Counter::ExprLambda,
1323            ast::Expr::BinOp(_) => Counter::ExprBinOp,
1324            ast::Expr::HasAttr(_) => Counter::ExprHasAttr,
1325            ast::Expr::UnaryOp(_) => Counter::ExprUnaryOp,
1326            ast::Expr::Assert(_) => Counter::ExprAssert,
1327            ast::Expr::PathAbs(_) | ast::Expr::PathRel(_)
1328            | ast::Expr::PathHome(_) | ast::Expr::PathSearch(_) => Counter::ExprPath,
1329            _ => Counter::ExprOther,
1330        };
1331        crate::perf::inc(c);
1332    }
1333    let _guard = DepthGuard::enter()?;
1334    let env = &cur_env;
1335    match &cur_expr {
1336        ast::Expr::Literal(lit) => return eval_literal(lit),
1337
1338        ast::Expr::Str(s) => return eval_str(s, env),
1339
1340        ast::Expr::PathAbs(p) => {
1341            // An interpolated absolute path (`/a/${e}`) splices its
1342            // `${…}` parts; a plain one takes the raw-text shortcut.
1343            let parts = p.parts();
1344            if parts_have_interpolation(&parts) {
1345                return eval_interpol_path_parts(&parts, PathKind::Abs, env);
1346            }
1347            // Canonicalize like CppNix (`/.` → `/`, `.`/`..` collapse,
1348            // `..` clamps at root) — see the WHNF fast-path above.
1349            let text = crate::path::canon_abs(&p.syntax().text().to_string());
1350            return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1351        }
1352        ast::Expr::PathRel(p) => {
1353            // Real Nix resolves `./foo.nix` against the directory
1354            // of the file that *contains* the literal, not the
1355            // process cwd. Use the current eval-file stack; fall
1356            // back to cwd when no file is being evaluated (e.g.,
1357            // top-level `sui eval`).
1358            //
1359            // An interpolated relative path (`./${x}.nix`) first splices
1360            // its `${…}` parts, then resolves the concatenated text the
1361            // same way — the interpolation is evaluated + string-coerced,
1362            // NOT treated as literal `${x}` text.
1363            let parts = p.parts();
1364            if parts_have_interpolation(&parts) {
1365                return eval_interpol_path_parts(&parts, PathKind::Rel, env);
1366            }
1367            let text = p.syntax().text().to_string();
1368            let resolved = if let Some(dir) = current_eval_dir() {
1369                let joined = dir.join(&text);
1370                // Use normalize_path instead of canonicalize so that
1371                // paths with ./  and .. are cleaned without requiring
1372                // the path to exist on disk.
1373                let norm = normalize_path(&joined);
1374                // A relative path literal (`./x`, `../..`) resolves against the
1375                // eval-dir, which for a fetched flake input is the sui fetcher
1376                // CACHE dir. CppNix resolves it against the input's
1377                // `/nix/store/<h>-source` STORE path, so the resulting path
1378                // VALUE must carry the store prefix (this is the value half of
1379                // the store↔cache seam — `materialize`/`dematerialize`). Lift
1380                // the cache path back to the store path so `toString ../..`
1381                // matches CppNix — the options.json `hasPrefix
1382                // <nix-darwin>.outPath decl` rewrite root (`prefix = ../..`).
1383                crate::path::dematerialize(&norm)
1384                    .to_string_lossy()
1385                    .into_owned()
1386            } else {
1387                text.clone()
1388            };
1389            return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1390        }
1391        ast::Expr::PathHome(p) => {
1392            let parts = p.parts();
1393            if parts_have_interpolation(&parts) {
1394                return eval_interpol_path_parts(&parts, PathKind::Home, env);
1395            }
1396            let text = p.syntax().text().to_string();
1397            return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1398        }
1399        ast::Expr::PathSearch(p) => {
1400            // `<name>` or `<name/sub/path>` — resolve via NIX_PATH
1401            // entries (parsed from the env var). If no NIX_PATH entry
1402            // matches, fall through to the literal text so the error
1403            // message points at the name the user wrote.
1404            let text = p.syntax().text().to_string();
1405            let inner = text
1406                .strip_prefix('<')
1407                .and_then(|s| s.strip_suffix('>'))
1408                .unwrap_or(&text);
1409            if let Some(resolved) = crate::builtins::resolve_search_path(inner) {
1410                return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1411            }
1412            // CppNix: search path resolution failure is a throw
1413            // (catchable by tryEval). Used by nixpkgs impure-overlays.nix
1414            // which tries `import <nixpkgs-overlays>` inside tryEval.
1415            return Err(EvalError::Throw(
1416                format!("search path '{text}' not in NIX_PATH"),
1417            ));
1418        }
1419
1420        ast::Expr::Ident(ident) => {
1421            let name = ident_text(ident);
1422            return match name.as_str() {
1423                "true" => Ok(Value::Bool(true)),
1424                "false" => Ok(Value::Bool(false)),
1425                "null" => Ok(Value::Null),
1426                _ => {
1427                    env.lookup(&name)
1428                        .ok_or_else(|| EvalError::UndefinedVar(
1429                            format!("'{name}'{}", eval_file_ctx()),
1430                        ))
1431                }
1432            };
1433        }
1434
1435        ast::Expr::List(list) => {
1436            // Wrap list elements in thunks for maximum laziness.
1437            // CppNix wraps list elements — only forced when accessed.
1438            // This prevents eager evaluation of unused list elements
1439            // (e.g., nixpkgs overlay lists with thousands of entries).
1440            let values: Vec<Value> = list.items()
1441                .map(|e| maybe_thunk(&e, env, false, None))
1442                .collect();
1443            return Ok(Value::list(values));
1444        }
1445
1446        ast::Expr::AttrSet(set) => return eval_attrset(set, env),
1447
1448        ast::Expr::Select(sel) => return eval_select(sel, env),
1449
1450        ast::Expr::HasAttr(ha) => return eval_has_attr(ha, env),
1451
1452        ast::Expr::UnaryOp(op) => return eval_unary_op(op, env),
1453
1454        ast::Expr::BinOp(binop) => {
1455            let lhs_expr = binop
1456                .lhs()
1457                .ok_or_else(|| EvalError::ParseError("binop missing lhs".to_string()))?;
1458            let rhs_expr = binop
1459                .rhs()
1460                .ok_or_else(|| EvalError::ParseError("binop missing rhs".to_string()))?;
1461            let kind = binop
1462                .operator()
1463                .ok_or_else(|| EvalError::ParseError("binop missing operator".to_string()))?;
1464            return eval_binop(kind, &lhs_expr, &rhs_expr, env);
1465        }
1466
1467        ast::Expr::Apply(app) => return eval_apply(app, env),
1468
1469        ast::Expr::IfElse(ie) => {
1470            let cond = ie
1471                .condition()
1472                .ok_or_else(|| EvalError::ParseError("if missing condition".to_string()))?;
1473            let body = ie
1474                .body()
1475                .ok_or_else(|| EvalError::ParseError("if missing then body".to_string()))?;
1476            let else_body = ie
1477                .else_body()
1478                .ok_or_else(|| EvalError::ParseError("if missing else body".to_string()))?;
1479            if force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1480                cur_expr = body;
1481            } else {
1482                cur_expr = else_body;
1483            }
1484            // env stays the same — tail call
1485            continue;
1486        }
1487
1488        ast::Expr::Assert(assert) => {
1489            let cond = assert
1490                .condition()
1491                .ok_or_else(|| EvalError::ParseError("assert missing condition".to_string()))?;
1492            let body = assert
1493                .body()
1494                .ok_or_else(|| EvalError::ParseError("assert missing body".to_string()))?;
1495            if !force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1496                return Err(EvalError::AssertionFailed(eval_file_ctx()));
1497            }
1498            cur_expr = body;
1499            continue;
1500        }
1501
1502        ast::Expr::With(with) => {
1503            let ns = with
1504                .namespace()
1505                .ok_or_else(|| EvalError::ParseError("with missing namespace".to_string()))?;
1506            let body = with
1507                .body()
1508                .ok_or_else(|| EvalError::ParseError("with missing body".to_string()))?;
1509            // Don't force the namespace yet — store as a lazy value.
1510            // CppNix evaluates with-scopes lazily: the namespace is only
1511            // forced when a name lookup actually falls through lexical scope.
1512            // This is critical for `fix (self: with self; { … })` patterns
1513            // used throughout nixpkgs.
1514            //
1515            // M2.6 ROOT #4a (byte-verified): `eval_expr(&ns, env)?` was NOT
1516            // lazy — it EVALUATED the namespace expression eagerly at
1517            // `with`-entry.  For `with (throw "X"); body` that runs the
1518            // throw; for `with config.services.borgbackup; { … }` (nixpkgs'
1519            // module `config` shape) it forces `config.services.borgbackup`
1520            // the instant the `with`-body's WHNF/keys are demanded (during
1521            // module collection's `pushDownProperties`), re-entering the
1522            // mid-force `config` fixpoint → the empty-Promise partial →
1523            // `null` softening → `concatLists null`.  cppnix stores the
1524            // namespace as a thunk and forces it ONLY when a bare-ident
1525            // lookup actually falls through lexical scope into the `with`.
1526            // Reduced repro (no module system, iterates in ms):
1527            //   `builtins.attrNames (with (throw "X"); { a = 1; })`
1528            //   nix → [ "a" ] ; sui (before) → throws "X".
1529            // `maybe_thunk` keeps the fast-path for an already-resolved
1530            // ident namespace (no thunk overhead) while deferring any
1531            // non-trivial namespace (Select / Apply / throw) into a lazy
1532            // thunk the scope-lookup path (`Env::lookup_fast`) forces only
1533            // on fallthrough.
1534            let scope_val = maybe_thunk(&ns, env, false, None);
1535            let new_env = env.child().with_scope(scope_val);
1536            cur_expr = body;
1537            cur_env = new_env;
1538            continue;
1539        }
1540
1541        ast::Expr::LetIn(letin) => {
1542            // ── plan-driven binding (`SUI_NORMALIZE=1`) ──────────────────
1543            //
1544            // `let` obeys the SAME merge rule as an attrset literal, and sui
1545            // never implemented it: `let a = {b=1;}; a = {c=2;}; in a` is
1546            // `{b=1;c=2;}` in nix and was `{c=2;}` here — silent key loss on
1547            // legal nix. A `let` takes the SCOPE rather than the attrset,
1548            // because it is a binder for a body and produces no attrset.
1549            //
1550            // A `None` means the group has no duplicate and no dotted path,
1551            // so the existing path is already correct — see `normalize_env`.
1552            if crate::normalize_env::enabled() {
1553                let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1554                let offset = u32::from(letin.syntax().text_range().start());
1555                if let Some(plan) =
1556                    crate::normalize_env::plan_for_node(letin, true, src_id, offset)
1557                        .map_err(reject)?
1558                {
1559                    let (_attrs, scope) = bind_plan_group(&plan, env)?;
1560                    let body = letin.body().ok_or_else(|| {
1561                        EvalError::ParseError("let missing body".to_string())
1562                    })?;
1563                    cur_expr = body;
1564                    cur_env = scope;
1565                    continue;
1566                }
1567            }
1568
1569            let mut new_env = env.child();
1570
1571            // Phase 1: Create thunks with a dummy env and bind them.
1572            // Collect (key, thunk) pairs so we can update envs later.
1573            let mut thunks: Vec<(String, Thunk)> = Vec::new();
1574
1575            // Track which names have been defined so far in this scope.
1576            // Used by maybe_thunk to resolve backward references directly
1577            // instead of creating wasteful thunks.
1578            let mut defined_so_far: HashSet<String> = HashSet::new();
1579
1580            // Accumulator for dotted-path bindings (`let a.b = 1; a.c = 2; ...`).
1581            // Leaf values are wrapped in thunks so they can reference
1582            // sibling let-bindings (the let scope is recursive in Nix).
1583            let mut dotted_attrs: NixAttrs = NixAttrs::new();
1584
1585            // Pre-pass: collect every binding name in this let-scope
1586            // (single-key bindings + top-level keys of dotted paths +
1587            // names from inherit clauses).  Used by the recursive-thunk
1588            // detector below — a binding is part of the mutual fix-point
1589            // if its RHS references ANY of these names.
1590            //
1591            // D1 (`SUI_SCOPE_NARROW>=1`) — `names_complete` is the honesty half
1592            // of the narrowing. Narrowing is only sound while
1593            // `let_scope_names` is a COMPLETE list of what this scope binds: a
1594            // binding is judged "reaches no sibling" by intersecting its RHS's
1595            // free variables with that set, so a name MISSING from it reads as
1596            // an outer reference and the binding wrongly keeps the outer env.
1597            // A head that does not resolve here contributes nothing, so the
1598            // whole scope forfeits narrowing rather than narrow on a partial
1599            // set. (`Dynamic` heads are excluded even when they do resolve —
1600            // the name is computed, so it is not a syntactic property of the
1601            // scope.) Nothing about the EVALUATION below changes; this only
1602            // decides whether the optimisation is allowed to apply.
1603            let mut names_complete = true;
1604            let let_scope_names: HashSet<String> = {
1605                let mut s = HashSet::new();
1606                for entry in letin.entries() {
1607                    match entry {
1608                        ast::Entry::AttrpathValue(apv) => {
1609                            if let Some(attrpath) = apv.attrpath() {
1610                                if let Some(first) = attrpath.attrs().next() {
1611                                    if let ast::Attr::Dynamic(_) = &first {
1612                                        names_complete = false;
1613                                    }
1614                                    if let Ok(name) = eval_attr(&first, env) {
1615                                        s.insert(name);
1616                                    } else {
1617                                        names_complete = false;
1618                                    }
1619                                } else {
1620                                    names_complete = false;
1621                                }
1622                            } else {
1623                                names_complete = false;
1624                            }
1625                        }
1626                        ast::Entry::Inherit(inherit) => {
1627                            for attr in inherit.attrs() {
1628                                if let ast::Attr::Dynamic(_) = &attr {
1629                                    names_complete = false;
1630                                }
1631                                if let Ok(name) = eval_attr(&attr, env) {
1632                                    s.insert(name);
1633                                } else {
1634                                    names_complete = false;
1635                                }
1636                            }
1637                        }
1638                    }
1639                }
1640                s
1641            };
1642            let narrow = scope_narrow_enabled() && names_complete;
1643
1644            // D2 (`SUI_SCOPE_NARROW=2`) — the CLUSTER env.
1645            //
1646            // D1 alone is not enough, and the reason is the shape of the
1647            // graph: free-variable analysis is per-binding on the
1648            // `thunk -> env` edge, but the `env -> thunk` edge is SHARED. One
1649            // binding that really does reach a sibling keeps `new_env` alive,
1650            // and `new_env` holds EVERY binding in the scope — so a single
1651            // recursive `f` re-pins all fifty innocent leaves and the footprint
1652            // is unchanged. (That is the P4 row, and it is why the headline
1653            // gate is too easy: D1 greens it while doing nothing here.)
1654            //
1655            // The fix is to stop pointing the survivors at the whole scope.
1656            // Phase 2 re-points them at a `fix_env` carrying ONLY the names the
1657            // pinned bindings can actually reach — their own names plus
1658            // `refs ∩ scope_names`. The body still gets the full `new_env`, so
1659            // nothing the LET EXPRESSION evaluates to can change; only the
1660            // envs captured by thunks shrink.
1661            let cluster = narrow && scope_cluster_enabled();
1662            // Every (name, value) bound into `new_env`, so the pinned subset can
1663            // be re-bound into `fix_env`. Allocated only under D2.
1664            let mut all_bound: Vec<(String, Value)> = Vec::new();
1665            // The names that stayed pinned, and the free-variable sets of the
1666            // bindings behind them. `pin` needs only the UNION of those sets, so
1667            // no name→refs association is required — and that union already IS
1668            // the fixpoint: a name added to `pin` that is not itself a pinned
1669            // binding contributes no further refs, and one that is has its refs
1670            // in the union already.
1671            let mut pinned_names: HashSet<String> = HashSet::new();
1672            let mut pinned_refs: Vec<HashSet<SmolStr>> = Vec::new();
1673            // A dotted path (`let a.b = 1;`) pushes LEAF thunks whose names are
1674            // inner path segments, not scope names, and whose free variables are
1675            // never computed here — so `fix_env` cannot be shown to carry what
1676            // they need. Such a scope forfeits D2 (D1 still applies).
1677            let mut has_dotted = false;
1678
1679            for entry in letin.entries() {
1680                match entry {
1681                    ast::Entry::AttrpathValue(ref apv) => {
1682                        let attrpath = apv.attrpath().ok_or_else(|| {
1683                            EvalError::ParseError("binding missing attrpath".to_string())
1684                        })?;
1685                        let value_expr = apv.value().ok_or_else(|| {
1686                            EvalError::ParseError("binding missing value".to_string())
1687                        })?;
1688                        let mut path_keys: Vec<String> = attrpath
1689                            .attrs()
1690                            .map(|a| eval_attr(&a, env))
1691                            .collect::<Result<_, _>>()?;
1692                        if path_keys.len() == 1 {
1693                            let key = path_keys.pop().unwrap();
1694                            // Self/mutual-recursive detection: any binding
1695                            // whose RHS references its own name OR any
1696                            // SIBLING let-scope name is part of the let's
1697                            // mutual fix-point.  Mark as recursive so
1698                            // inner re-entrance during force returns a
1699                            // Promise sentinel instead of erroring with
1700                            // InfiniteRecursion.  This is the M2.6
1701                            // module-system fix path (cppnix's
1702                            // lib/modules.nix uses a deep let-scope with
1703                            // declaredConfig / options / matchedOptions /
1704                            // resultsByName / modules all transitively
1705                            // cycling through each other).
1706                            //
1707                            // `let_scope_names` is collected upfront in a
1708                            // pre-pass so each binding sees every other
1709                            // binding name (not just earlier ones).
1710                            // O(N) not O(N²): compute the RHS's referenced-name
1711                            // set ONCE (memoized), then intersect with the
1712                            // let-scope names. Byte-identical to the prior
1713                            // `references(key) OR references(any sibling)`:
1714                            // chaining `key` covers the self-reference case
1715                            // regardless of whether `key ∈ let_scope_names`.
1716                            let referenced = referenced_idents(&value_expr);
1717                            let in_mutual_cycle = std::iter::once(&key)
1718                                .chain(let_scope_names.iter())
1719                                .any(|n| referenced.contains(n.as_str()));
1720                            let value = if in_mutual_cycle {
1721                                Value::Thunk(Thunk::new_suspended_recursive(
1722                                    value_expr.clone(),
1723                                    env.clone(),
1724                                ))
1725                            } else {
1726                                maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
1727                            };
1728                            new_env.bind(key.clone(), value.clone());
1729                            if cluster {
1730                                all_bound.push((key.clone(), value.clone()));
1731                            }
1732                            if let Value::Thunk(t) = &value {
1733                                // D1: `in_mutual_cycle` is ALREADY the
1734                                // forward-complete "reaches a sibling"
1735                                // predicate here (`let_scope_names` is a full
1736                                // pre-pass, unlike the `rec` arm's
1737                                // backward-only one), so it doubles as the
1738                                // needs-scope test at zero extra cost — no
1739                                // second tree walk.
1740                                //
1741                                // When it is false the RHS references nothing
1742                                // this scope binds, so every name it CAN
1743                                // resolve resolves identically in `env` and in
1744                                // `new_env`: `Env::child` copies `with_scopes`,
1745                                // `eval_file` and `source_id` verbatim, and the
1746                                // only added bindings are the let-scope names
1747                                // this RHS provably does not mention. Skipping
1748                                // the re-point is therefore byte-neutral, and
1749                                // it is what leaves the thunk holding the OUTER
1750                                // env instead of closing
1751                                // `thunk -> new_env -> thunk`.
1752                                if in_mutual_cycle || !narrow {
1753                                    thunks.push((key.clone(), t.clone()));
1754                                    if cluster {
1755                                        pinned_names.insert(key.clone());
1756                                        pinned_refs.push(referenced);
1757                                    }
1758                                    crate::value::census::scope_pinned();
1759                                } else {
1760                                    crate::value::census::scope_narrowed();
1761                                }
1762                            }
1763                            defined_so_far.insert(key);
1764                        } else if path_keys.len() > 1 {
1765                            // Multi-segment dotted path: build a nested
1766                            // attrset with thunks at the leaves so the
1767                            // value expression can reference sibling
1768                            // let-bindings.
1769                            has_dotted = true;
1770                            let key = path_keys[0].clone();
1771                            let value = build_nested_attr_thunk(
1772                                &path_keys[1..],
1773                                &value_expr,
1774                                env,
1775                                &mut thunks,
1776                            );
1777                            merge_nested_insert(&mut dotted_attrs, key, value);
1778                        }
1779                    }
1780                    ast::Entry::Inherit(ref inherit) => {
1781                        if let Some(from) = inherit.from() {
1782                            let source_expr = from.expr().ok_or_else(|| {
1783                                EvalError::ParseError(
1784                                    "inherit from missing expr".to_string(),
1785                                )
1786                            })?;
1787                            // D1: every `InheritSelect` in this clause shares
1788                            // ONE source thunk, and `Thunk::update_env`
1789                            // delegates straight through to it — so all N
1790                            // pushes re-point the SAME env. Whether that
1791                            // re-point is needed is therefore a property of the
1792                            // source expression alone, computed ONCE above the
1793                            // loop instead of N times inside it. Guarded by
1794                            // `!narrow ||` so the default path does not pay the
1795                            // walk at all.
1796                            let source_refs: Option<HashSet<SmolStr>> = if narrow {
1797                                Some(referenced_idents(&source_expr))
1798                            } else {
1799                                None
1800                            };
1801                            let source_needs_scope = match &source_refs {
1802                                Some(refs) => let_scope_names
1803                                    .iter()
1804                                    .any(|n| refs.contains(n.as_str())),
1805                                None => true,
1806                            };
1807                            // Create ONE shared source thunk per
1808                            // `inherit (source)` clause. All inherited
1809                            // names share it via Rc clone — the source
1810                            // is evaluated at most once.
1811                            let source_thunk = Thunk::new_suspended(
1812                                source_expr, env.clone(),
1813                            );
1814                            for attr in inherit.attrs() {
1815                                let name = eval_attr(&attr, env)?;
1816                                let thunk = Thunk::new_inherit_select(
1817                                    source_thunk.clone(),
1818                                    name.clone(),
1819                                );
1820                                new_env.bind(name.clone(), Value::Thunk(thunk.clone()));
1821                                if cluster {
1822                                    all_bound.push((
1823                                        name.clone(),
1824                                        Value::Thunk(thunk.clone()),
1825                                    ));
1826                                }
1827                                if source_needs_scope {
1828                                    if cluster {
1829                                        pinned_names.insert(name.clone());
1830                                    }
1831                                    thunks.push((name, thunk));
1832                                    crate::value::census::scope_pinned();
1833                                } else {
1834                                    crate::value::census::scope_narrowed();
1835                                }
1836                            }
1837                            // One refs set for the whole clause — every name in
1838                            // it re-points the SAME shared source thunk.
1839                            if cluster
1840                                && source_needs_scope
1841                                && let Some(refs) = source_refs
1842                            {
1843                                pinned_refs.push(refs);
1844                            }
1845                        } else {
1846                            // `inherit name1 name2 ...` from the
1847                            // enclosing lexical scope. This stays
1848                            // eager because the names already exist
1849                            // in `env` — no fixpoint involved.
1850                            for attr in inherit.attrs() {
1851                                let name = eval_attr(&attr, env)?;
1852                                let value = env.lookup(&name).ok_or_else(|| {
1853                                    EvalError::UndefinedVar(
1854                                        format!("'{name}'{}", eval_file_ctx()),
1855                                    )
1856                                })?;
1857                                if cluster {
1858                                    all_bound.push((name.clone(), value.clone()));
1859                                }
1860                                new_env.bind(name, value);
1861                            }
1862                        }
1863                    }
1864                }
1865            }
1866
1867            // Phase 1b: Bind accumulated dotted-path attrs into new_env.
1868            // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
1869            // duplicate definition, so we do not attempt to merge with
1870            // existing inherit thunks — just bind directly.
1871            for (key, value) in dotted_attrs.iter() {
1872                new_env.bind(key.clone(), value.clone());
1873                if cluster {
1874                    all_bound.push((key.clone(), value.clone()));
1875                }
1876            }
1877
1878            // D2: the cluster env the survivors get re-pointed at, in place of
1879            // the whole scope. Built only when it can actually shrink anything
1880            // — some binding pinned, some binding not, and no dotted path (see
1881            // `has_dotted`).
1882            let fix_env: Option<Env> = if cluster && !has_dotted && !thunks.is_empty() {
1883                // `pin` = the pinned names, plus every scope name they can
1884                // reach. This union is already the fixpoint: a name pulled in
1885                // that is not itself pinned contributes no further refs (its
1886                // own thunk still holds the OUTER env and so resolves entirely
1887                // outside this scope), and one that is pinned had its refs in
1888                // the union from the start.
1889                let mut pin = pinned_names;
1890                for refs in &pinned_refs {
1891                    for n in &let_scope_names {
1892                        if refs.contains(n.as_str()) {
1893                            pin.insert(n.clone());
1894                        }
1895                    }
1896                }
1897                if pin.len() < all_bound.len() {
1898                    let mut fe = env.child();
1899                    for (name, value) in &all_bound {
1900                        if pin.contains(name) {
1901                            fe.bind(name.clone(), value.clone());
1902                        }
1903                    }
1904                    Some(fe)
1905                } else {
1906                    None
1907                }
1908            } else {
1909                None
1910            };
1911
1912            // Phase 2: Update all thunks to capture the final env
1913            // (which now has all names bound).
1914            let phase2_env: &Env = fix_env.as_ref().unwrap_or(&new_env);
1915            for (_key, thunk) in &thunks {
1916                thunk.update_env(phase2_env);
1917            }
1918
1919            let body = letin
1920                .body()
1921                .ok_or_else(|| EvalError::ParseError("let missing body".to_string()))?;
1922            cur_expr = body;
1923            cur_env = new_env;
1924            continue;
1925        }
1926
1927        ast::Expr::Lambda(lam) => {
1928            let param = lam
1929                .param()
1930                .ok_or_else(|| EvalError::ParseError("lambda missing param".to_string()))?;
1931            let body = lam
1932                .body()
1933                .ok_or_else(|| EvalError::ParseError("lambda missing body".to_string()))?;
1934            return Ok(Value::Lambda(Rc::new(Closure {
1935                param,
1936                body,
1937                env: env.clone(),
1938            })));
1939        }
1940
1941        ast::Expr::Paren(p) => {
1942            let inner = p
1943                .expr()
1944                .ok_or_else(|| EvalError::ParseError("paren missing expr".to_string()))?;
1945            cur_expr = inner;
1946            continue;
1947        }
1948
1949        ast::Expr::Root(r) => {
1950            let inner = r
1951                .expr()
1952                .ok_or_else(|| EvalError::ParseError("root missing expr".to_string()))?;
1953            cur_expr = inner;
1954            continue;
1955        }
1956
1957        ast::Expr::LegacyLet(ll) => {
1958            // ── plan-driven binding (`SUI_NORMALIZE=1`) ──────────────────
1959            //
1960            // `eval_entries` carries the comment "Multi-key paths in let are
1961            // not standard; skip for now" and does exactly that — it SILENTLY
1962            // DISCARDS every multi-segment attrpath, so
1963            // `let { a.b = 1; a.c = 2; body = a; }` loses both. The bytecode
1964            // VM has always handled this correctly, which makes the walker
1965            // the engine that is behind here.
1966            if crate::normalize_env::enabled() {
1967                let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1968                let offset = u32::from(ll.syntax().text_range().start());
1969                if let Some(plan) =
1970                    crate::normalize_env::plan_for_node(ll, true, src_id, offset)
1971                        .map_err(reject)?
1972                {
1973                    let (_attrs, scope) = bind_plan_group(&plan, env)?;
1974                    return scope.lookup("body").ok_or_else(|| {
1975                        EvalError::AttrNotFound(format!(
1976                            "'body' in legacy let{}",
1977                            eval_file_ctx()
1978                        ))
1979                    });
1980                }
1981            }
1982
1983            let mut new_env = env.child();
1984            eval_entries(ll, &mut new_env)?;
1985            // legacy let returns the `body` attr from its bindings
1986            return new_env
1987                .lookup("body")
1988                .ok_or_else(|| EvalError::AttrNotFound(
1989                    format!("'body' in legacy let{}", eval_file_ctx()),
1990                ));
1991        }
1992
1993        ast::Expr::CurPos(_) => return Err(EvalError::NotImplemented("__curPos".to_string())),
1994        ast::Expr::Error(_) => return Err(EvalError::ParseError("parse error node".to_string())),
1995    } // match
1996    } // loop — unreachable, all arms either return or continue
1997}
1998
1999fn eval_literal(lit: &ast::Literal) -> Result<Value, EvalError> {
2000    use ast::LiteralKind;
2001    match lit.kind() {
2002        LiteralKind::Integer(tok) => {
2003            let n = tok
2004                .value()
2005                .map_err(|e| EvalError::ParseError(format!("invalid integer: {e}")))?;
2006            Ok(Value::Int(n))
2007        }
2008        LiteralKind::Float(tok) => {
2009            let f = tok
2010                .value()
2011                .map_err(|e| EvalError::ParseError(format!("invalid float: {e}")))?;
2012            Ok(Value::Float(f))
2013        }
2014        LiteralKind::Uri(tok) => Ok(Value::string(tok.syntax().text().to_string())),
2015    }
2016}
2017
2018/// Result of walking an attrpath on a base value.
2019enum TraverseResult {
2020    /// All keys found; contains the leaf value.
2021    Found(Value),
2022    /// A key was missing; contains the missing key name.
2023    Missing(String),
2024    /// A non-attrset value was encountered during traversal.
2025    NotAttrs(Value),
2026}
2027
2028/// Walk an attrpath on a base value, forcing at each level.
2029///
2030/// Returns `Found(leaf)` when every key exists, `Missing(key)` when
2031/// a key is absent, or `NotAttrs(v)` when a non-attrset is encountered.
2032fn traverse_attrpath(
2033    base: Value,
2034    attrpath: &rnix::ast::Attrpath,
2035    env: &Env,
2036) -> Result<TraverseResult, EvalError> {
2037    let attrs: Vec<_> = attrpath.attrs().collect();
2038    let mut value = base;
2039    for (i, attr) in attrs.iter().enumerate() {
2040        let key = eval_attr(attr, env)?;
2041        // Force the current value to an attrset to select from it.
2042        let forced = force_value(&value)?;
2043        match forced {
2044            Value::Attrs(ref a) => match a.get(&key) {
2045                Some(v) => {
2046                    if i < attrs.len() - 1 {
2047                        // Intermediate step: force to attrset for next selection.
2048                        value = force_value(v)?;
2049                    } else {
2050                        // Final step: return WITHOUT forcing — let the caller
2051                        // decide when to force. Matches CppNix's lazy attr access.
2052                        value = v.clone();
2053                    }
2054                }
2055                None => return Ok(TraverseResult::Missing(key)),
2056            },
2057            _ => return Ok(TraverseResult::NotAttrs(forced)),
2058        }
2059    }
2060    Ok(TraverseResult::Found(value))
2061}
2062
2063fn eval_select(sel: &ast::Select, env: &Env) -> Result<Value, EvalError> {
2064    crate::perf::inc(crate::perf::Counter::Select);
2065    let base_expr = sel.expr().ok_or_else(|| {
2066        EvalError::ParseError("select missing expression".to_string())
2067    })?;
2068    // M2.6 bridge: in `expr.path or default`, an `InfiniteRecursion`
2069    // hit while forcing the LEFT side falls back to the default —
2070    // operationally matches cppnix, which avoids the cycle entirely
2071    // via lazy attribute access during fix-point evaluation.  Without
2072    // a default, the recursion propagates as a real error.  Other
2073    // error kinds (Throw, TypeError, …) always propagate so user
2074    // bugs aren't masked.  Removed when the underlying fix-point /
2075    // lazy-access semantics land — see docs/M2.6-MODULE-SYSTEM-FIXPOINT.md.
2076    let base_result = eval_expr(&base_expr, env)
2077        .and_then(|v| force_concrete(&v).map(Concrete::into_value));
2078    let base = match base_result {
2079        Ok(v) => v,
2080        Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2081            return eval_expr(&sel.default_expr().expect("checked"), env);
2082        }
2083        Err(e) => return Err(e),
2084    };
2085    let base_type = base.type_name();
2086    let attrpath = sel.attrpath().ok_or_else(|| {
2087        EvalError::ParseError("select missing attrpath".to_string())
2088    })?;
2089    // M2.6 bridge: when the blackhole-bridge sentinels are active,
2090    // an attribute lookup that misses (`AttrNotFound`) or hits a
2091    // non-attrset intermediate (`NotAttrs`) on the bridge's empty
2092    // sentinel value gets resolved to `null` instead of erroring.
2093    // cppnix's partial attrset would have CARRIED the keys (with
2094    // their lazy values), so the lookup would succeed; null is the
2095    // cheapest sentinel that propagates through downstream code
2096    // without further type errors.
2097    //
2098    // M2.6 ROOT #4 CLOSED (2026-07-11): the `|| crate::value::in_promise_eval()`
2099    // clause that used to soften a mid-Promise `config.<x>` select-miss to
2100    // `null` is REMOVED.  It was the band-aid masking the two real over-forces
2101    // that ROOT #4a (the `with`-namespace eager eval, above) and ROOT #4b (the
2102    // dropped full-set leaf in `merge_nested_insert`, below) now fix at their
2103    // load-bearing cause.  Verified with the softening gone: both
2104    // `lib.nixosSystem { modules = []; }.config.system.name` → `"nixos"` and
2105    // `attrNames sys.options` → 53 (nix-parity), `sui parity` stays 35 match /
2106    // 0 regressions, 1324 sui-eval lib tests + 30 diff tests pass — nothing
2107    // depended on the sentinel any more.  The two explicit operator-gated
2108    // bridges below stay as opt-in experiments (default-off); only the
2109    // always-on Promise softening is retired.
2110    let bridge_active = std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_ATTRS").is_some()
2111        || std::env::var_os("SUI_BLACKHOLE_AS_NULL").is_some();
2112    let traversal = traverse_attrpath(base, &attrpath, env);
2113    match traversal {
2114        Ok(TraverseResult::Found(v)) => Ok(v),
2115        Ok(TraverseResult::Missing(key)) => {
2116            if let Some(def) = sel.default_expr() {
2117                eval_expr(&def, env)
2118            } else if bridge_active {
2119                if std::env::var_os("SUI_M26_SELTRACE").is_some() {
2120                    let path: Vec<String> = sel.attrpath().map(|ap|
2121                        ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2122                    ).unwrap_or_default();
2123                    eprintln!("[M26 SEL-MISS→null] base_type={base_type} path={path:?} missing-key={key}{}", eval_file_ctx());
2124                }
2125                if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2126                    let path: Vec<String> = sel.attrpath().map(|ap|
2127                        ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2128                    ).unwrap_or_default();
2129                    if path.iter().any(|p| p.contains(&filt)) {
2130                        return Err(EvalError::type_error(format!(
2131                            "M26-HARDSOFTEN path={path:?} key={key}"
2132                        )));
2133                    }
2134                }
2135                Ok(Value::Null)
2136            } else {
2137                Err(EvalError::AttrNotFound(
2138                    format!("'{key}'{}", eval_file_ctx()),
2139                ))
2140            }
2141        }
2142        Ok(TraverseResult::NotAttrs(forced)) => {
2143            // CppNix: `expr.a.b or default` falls back to default for
2144            // ANY error in the path — including intermediate values
2145            // that aren't attrsets (e.g., null). The module system
2146            // relies on this: `x.options.type.name or null` must
2147            // return null when x.options is null, not throw.
2148            if let Some(def) = sel.default_expr() {
2149                eval_expr(&def, env)
2150            } else if bridge_active {
2151                if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2152                    let path: Vec<String> = sel.attrpath().map(|ap|
2153                        ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2154                    ).unwrap_or_default();
2155                    if path.iter().any(|p| p.contains(&filt)) {
2156                        return Err(EvalError::type_error(format!(
2157                            "M26-HARDSOFTEN-NOTATTRS path={path:?} base_type={base_type}"
2158                        )));
2159                    }
2160                }
2161                return Ok(Value::Null);
2162            } else {
2163                if std::env::var("SUI_DEBUG_SELECT").is_ok() {
2164                    let path: Vec<String> = sel.attrpath().map(|ap|
2165                        ap.attrs().filter_map(|a| match a {
2166                            ast::Attr::Ident(i) => Some(i.to_string()),
2167                            ast::Attr::Str(s) => Some(format!("\"{}\"", s.syntax().text())),
2168                            ast::Attr::Dynamic(_) => Some("<dyn>".into()),
2169                        }).collect()
2170                    ).unwrap_or_default();
2171                    let dbg = format!("{:?}", forced);
2172                    let truncated = if dbg.len() > 200 { format!("{}…", &dbg[..200]) } else { dbg };
2173                    eprintln!("[SUI_DEBUG_SELECT] base_type={base_type} path={path:?} base={truncated}{}", eval_file_ctx());
2174                }
2175                Err(attach_trace(EvalError::type_error(
2176                    format!("cannot select from {base_type}"),
2177                )))
2178            }
2179        }
2180        // Same M2.6 bridge as on the base force above: if an
2181        // intermediate step in the attrpath traversal raises
2182        // InfiniteRecursion and `or default` was supplied, the
2183        // default is the operationally-correct value.
2184        Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2185            eval_expr(&sel.default_expr().expect("checked"), env)
2186        }
2187        Err(e) => Err(e),
2188    }
2189}
2190
2191/// Evaluate `expr ? a.b.c` — check key presence without forcing value thunks.
2192fn eval_has_attr(ha: &ast::HasAttr, env: &Env) -> Result<Value, EvalError> {
2193    let base_expr = ha.expr().ok_or_else(|| {
2194        EvalError::ParseError("hasattr missing expression".to_string())
2195    })?;
2196    let base = force_concrete(&eval_expr(&base_expr, env)?)?.into_value();
2197    let attrpath = ha.attrpath().ok_or_else(|| {
2198        EvalError::ParseError("hasattr missing attrpath".to_string())
2199    })?;
2200    match traverse_attrpath(base, &attrpath, env)? {
2201        TraverseResult::Found(_) => Ok(Value::Bool(true)),
2202        TraverseResult::Missing(_) | TraverseResult::NotAttrs(_) => Ok(Value::Bool(false)),
2203    }
2204}
2205
2206fn eval_unary_op(op: &ast::UnaryOp, env: &Env) -> Result<Value, EvalError> {
2207    let inner = op
2208        .expr()
2209        .ok_or_else(|| EvalError::ParseError("unary op missing expr".to_string()))?;
2210    let val = force_value(&eval_expr(&inner, env)?)?;
2211    let kind = op
2212        .operator()
2213        .ok_or_else(|| EvalError::ParseError("unary op missing operator".to_string()))?;
2214    match kind {
2215        ast::UnaryOpKind::Negate => match val {
2216            Value::Int(n) => Ok(Value::Int(-n)),
2217            Value::Float(f) => Ok(Value::Float(-f)),
2218            _ => Err(EvalError::type_error(
2219                format!("cannot negate {}", val.type_name()),
2220            )),
2221        },
2222        ast::UnaryOpKind::Invert => Ok(Value::Bool(!val.as_bool()?)),
2223    }
2224}
2225
2226/// Builtins that must receive their argument UNFORCED (call-by-need). This is the
2227/// SINGLE source of truth consumed by BOTH `eval_apply` (which must THUNK the arg
2228/// instead of eager-evaluating it) AND the builtin apply arm (which must SKIP the
2229/// arg force). The two sites MUST agree: if `eval_apply` eager-evaluates the arg,
2230/// the apply-arm's force-skip is dead (the arg is already forced — or already
2231/// threw) upstream. They were previously inconsistent (only `tryEval` was thunked
2232/// in `eval_apply`), so `seq`/`deepSeq`/`addErrorContext`/`foldl'` silently got
2233/// eager args despite their apply-time exemption — the bug behind
2234/// `builtins.foldl' (_: x: x) (throw "…") […]` throwing instead of returning the
2235/// last element (nix's foldl' is NOT strict in the nul accumulator).
2236#[inline]
2237pub(crate) fn builtin_takes_lazy_arg(name: &str) -> bool {
2238    matches!(
2239        name,
2240        "tryEval" | "addErrorContext<partial>" | "seq<partial>" | "deepSeq<partial>" | "foldl'<p1>"
2241    )
2242}
2243
2244fn eval_apply(app: &ast::Apply, env: &Env) -> Result<Value, EvalError> {
2245    let func_expr = app
2246        .lambda()
2247        .ok_or_else(|| EvalError::ParseError("apply missing function".to_string()))?;
2248    let arg_expr = app
2249        .argument()
2250        .ok_or_else(|| EvalError::ParseError("apply missing argument".to_string()))?;
2251    let func = force_value(&eval_expr(&func_expr, env)?)?;
2252    // Lambda arguments are wrapped in a thunk for call-by-need semantics.
2253    // Thunk strategy depends on function type:
2254    // - Lambda: ALWAYS thunk (call-by-need, enables fixpoints)
2255    // - tryEval: ALWAYS thunk (must catch errors during force)
2256    // - Builtin: evaluate eagerly (builtins always force args anyway;
2257    //   thunking wastes Rc + OnceCell allocation per call)
2258    // - __functor: evaluate eagerly (will be applied immediately)
2259    let arg = match &func {
2260        Value::Lambda(_) => {
2261            // Call-by-need: the arg is thunked so it forces lazily. But a
2262            // PURE-CONSTANT arg (a literal, a non-interpolated string, or a
2263            // non-interpolated path) can never throw or diverge, so producing
2264            // its value directly is byte-neutral whether or not the lambda ever
2265            // forces it — identical eval-order-observable behavior, one fewer
2266            // never-forced thunk. This is `arg_pure_constant` ONLY: any arg that
2267            // could throw/diverge/observe a fixpoint (Ident with-scope, Select,
2268            // Apply, BinOp, …) stays fully thunked to preserve laziness.
2269            if let Some(v) = eval_pure_constant_arg(&arg_expr) {
2270                v
2271            } else {
2272                crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2273                Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2274            }
2275        }
2276        Value::Builtin(b) if builtin_takes_lazy_arg(&b.name) => {
2277            // Call-by-need for the laziness-exempt builtins (tryEval / seq /
2278            // deepSeq / addErrorContext / foldl'<p1>): the arg MUST be thunked,
2279            // not eager-evaluated, so it forces only if/when the builtin demands
2280            // it. Kept in lockstep with the apply-arm skip via `builtin_takes_lazy_arg`.
2281            crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2282            Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2283        }
2284        _ => eval_expr(&arg_expr, env)?,
2285    };
2286    apply(func, arg)
2287}
2288
2289/// If `arg_expr` is a PURE CONSTANT — a literal, a non-interpolated string, or
2290/// a non-interpolated absolute/home path — return its value directly (no thunk).
2291///
2292/// A pure constant has no free variables, cannot throw, cannot diverge, and has
2293/// no fixpoint/laziness interaction: `eval_expr(arg)` is total and produces the
2294/// exact value a suspended thunk of it would yield on force. Producing it
2295/// eagerly in a call-by-need arg position is therefore byte-neutral (the
2296/// lambda that never forces the arg observes no difference — the value is inert).
2297///
2298/// Returns `None` for EVERYTHING else (Ident — may hit a with-scope force;
2299/// Select/Apply/BinOp/If/… — may throw or diverge; interpolated Str/Path —
2300/// must force `${…}` lazily), which keeps those args fully thunked. `env` is
2301/// NOT threaded in because a pure constant needs no environment; if a match
2302/// arm ever needed `env`, it would not be a pure constant.
2303fn eval_pure_constant_arg(arg_expr: &ast::Expr) -> Option<Value> {
2304    match arg_expr {
2305        ast::Expr::Literal(lit) => eval_literal(lit).ok(),
2306        ast::Expr::Str(st) if !str_has_interpolation(st) => {
2307            // No interpolation ⇒ `eval_str` runs no force/coerce; env is unused.
2308            eval_str(st, &Env::new()).ok()
2309        }
2310        ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
2311            let text = crate::path::canon_abs(&p.syntax().text().to_string());
2312            Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2313        }
2314        ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
2315            let text = p.syntax().text().to_string();
2316            Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2317        }
2318        _ => None,
2319    }
2320}
2321
2322fn eval_str(s: &ast::Str, env: &Env) -> Result<Value, EvalError> {
2323    let mut result = String::new();
2324    let mut ctx = StringContext::new();
2325    for part in s.normalized_parts() {
2326        match part {
2327            InterpolPart::Literal(text) => result.push_str(&text),
2328            InterpolPart::Interpolation(interpol) => {
2329                let expr = interpol.expr().ok_or_else(|| {
2330                    EvalError::ParseError("interpolation missing expr".to_string())
2331                })?;
2332                let val = force_value(&eval_expr(&expr, env)?)?;
2333                // CppNix string interpolation is copy-to-store coercion: an
2334                // interpolated source path (`"${./foo}"`) is NAR-copied into
2335                // the store and the store path is spliced in (with context),
2336                // never the raw filesystem path.
2337                let (s, c) = val.coerce_to_string_copy_to_store()?;
2338                result.push_str(&s);
2339                ctx.merge(&c);
2340            }
2341        }
2342    }
2343    Ok(Value::String(Rc::new(NixString::with_context(result, ctx))))
2344}
2345
2346/// Whether a list of path parts contains a `${…}` interpolation. When
2347/// it does not, the raw `.syntax().text()` shortcut is byte-identical
2348/// and cheaper, so the trivial fast paths stay on that shortcut.
2349fn parts_have_interpolation(parts: &[InterpolPart<rnix::ast::PathContent>]) -> bool {
2350    parts
2351        .iter()
2352        .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2353}
2354
2355/// Whether a string literal contains any `${…}` interpolation part. A `false`
2356/// result means the string is a pure constant (`eval_str` runs no force/coerce
2357/// and cannot throw), so `maybe_thunk` may evaluate it eagerly byte-neutrally.
2358fn str_has_interpolation(s: &ast::Str) -> bool {
2359    s.normalized_parts()
2360        .iter()
2361        .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2362}
2363
2364/// Evaluate an interpolatable path literal that contains `${…}` parts.
2365///
2366/// CppNix path interpolation (`./${x}.nix`, `/a/${e}`, `~/x/${e}`):
2367///   * each literal segment is spliced verbatim,
2368///   * each `${e}` is **plain**-coerced to a string with context
2369///     (NOT copy-to-store — path-typed interpolations splice the raw
2370///     store/filesystem path, e.g. `/bar/${./foo}` → `/bar/tmp/foo`),
2371///   * the concatenated text is then resolved exactly like the plain
2372///     path literal of the same kind (relative → joined + normalized
2373///     against the defining file's directory; absolute/home → verbatim),
2374///   * the result is a `path` value.
2375///
2376/// Parts come from rnix's `<PathKind>::parts()` which splits the path
2377/// token stream into `Literal(PathContent)` / `Interpolation(Interpol)`.
2378fn eval_interpol_path_parts(
2379    parts: &[InterpolPart<rnix::ast::PathContent>],
2380    kind: PathKind,
2381    env: &Env,
2382) -> Result<Value, EvalError> {
2383    let mut text = String::new();
2384    for part in parts {
2385        match part {
2386            InterpolPart::Literal(content) => text.push_str(content.text()),
2387            InterpolPart::Interpolation(interpol) => {
2388                let expr = interpol.expr().ok_or_else(|| {
2389                    EvalError::ParseError("path interpolation missing expr".to_string())
2390                })?;
2391                let val = force_value(&eval_expr(&expr, env)?)?;
2392                // Plain coercion (coerceMore = false): a path-typed
2393                // interpolation splices the raw path string, never a
2394                // copied-to-store hash path.
2395                let (s, _ctx) = val.coerce_to_string()?;
2396                text.push_str(&s);
2397            }
2398        }
2399    }
2400    let resolved = match kind {
2401        // Relative path: resolve against the defining file's directory,
2402        // mirroring the plain `PathRel` branch.
2403        PathKind::Rel => {
2404            if let Some(dir) = current_eval_dir() {
2405                let norm = normalize_path(&dir.join(&text));
2406                // Lift cache→store exactly like the plain `PathRel` branch (the
2407                // store↔cache seam value-half). Without this, an interpolated
2408                // relative-path literal (`./${x}`, `./modules/${name}.nix`)
2409                // inside a fetched flake input yielded a Value::Path holding the
2410                // fetcher CACHE dir instead of the input's `/nix/store/<h>-source`
2411                // path — so its `toString`/copy-to-store/inputSrc diverged from
2412                // CppNix (the plain `./x` sibling already dematerializes; the two
2413                // must agree).
2414                crate::path::dematerialize(&norm).to_string_lossy().into_owned()
2415            } else {
2416                // No eval-file context (top-level `sui eval -E`): the
2417                // plain branch keeps the raw text, so match it — but the
2418                // interpolation is still spliced.
2419                text
2420            }
2421        }
2422        // Absolute paths: canonicalize the concatenated text CppNix's way.
2423        // The `${e}` splice routinely introduces a `//` seam (`/bar/` +
2424        // `/tmp/foo`) or a `.`/`..` component that must collapse
2425        // (`/bar//tmp/foo` → `/bar/tmp/foo`), and `..` must clamp at root.
2426        // `canon_abs` is filesystem-free (works on not-yet-materialized
2427        // flake paths) and root-aware (unlike `normalize_path`, which pops
2428        // past root — the marquee-root divergence).
2429        PathKind::Abs => crate::path::canon_abs(&text),
2430        // Home paths (`~/…`) carry a leading `~` component, so they are
2431        // not absolute-rooted; keep the pre-existing normalization.
2432        PathKind::Home => normalize_path(std::path::Path::new(&text))
2433            .to_string_lossy()
2434            .into_owned(),
2435    };
2436    Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))))
2437}
2438
2439/// Which kind of interpolatable path literal — governs how the
2440/// concatenated text is finally resolved.
2441#[derive(Clone, Copy)]
2442enum PathKind {
2443    Abs,
2444    Rel,
2445    Home,
2446}
2447
2448/// Evaluate an attribute name, requiring non-null.
2449/// Use `eval_attr_maybe_null` when null dynamic attrs should be skipped.
2450fn eval_attr(attr: &ast::Attr, env: &Env) -> Result<String, EvalError> {
2451    eval_attr_maybe_null(attr, env)?
2452        .ok_or_else(|| EvalError::TypeError("null dynamic attribute name".into()))
2453}
2454
2455/// Evaluate an attribute name. Returns `None` for null dynamic attrs
2456/// (CppNix silently omits attributes with null names).
2457fn eval_attr_maybe_null(attr: &ast::Attr, env: &Env) -> Result<Option<String>, EvalError> {
2458    match attr {
2459        ast::Attr::Ident(ident) => Ok(Some(ident_text(ident))),
2460        ast::Attr::Dynamic(dyn_) => {
2461            let expr = dyn_
2462                .expr()
2463                .ok_or_else(|| EvalError::ParseError("dynamic attr missing expr".to_string()))?;
2464            let val = force_value(&eval_expr(&expr, env)?)?;
2465            // CppNix: null dynamic attr name → skip the attribute entirely.
2466            // Used by nixpkgs module system: `${if cond then null else "name"} = value;`
2467            if val == Value::Null {
2468                return Ok(None);
2469            }
2470            Ok(Some(val.as_string()?.to_string()))
2471        }
2472        ast::Attr::Str(s) => {
2473            let val = eval_str(s, env)?;
2474            Ok(Some(val.as_string()?.to_string()))
2475        }
2476    }
2477}
2478
2479/// Get the text of an rnix Ident node.
2480pub(crate) fn ident_text(ident: &ast::Ident) -> String {
2481    // Fast path: a `NODE_IDENT` holds a single `TOKEN_IDENT`, whose `text()`
2482    // borrows the source `&str` directly from the green node — no
2483    // `PreorderWithTokens` cursor tree-walk and none of the `NodeData::new`
2484    // allocations that `syntax().text()` (a `SyntaxText` over the node's whole
2485    // descendant span) pays. Byte-identical fallback: the identifier `or` is
2486    // lexed as a nested `TOKEN_OR` (rnix quirk), so `ident_token()` is `None`
2487    // there — walk the full node text in that case, exactly as before.
2488    match ident.ident_token() {
2489        Some(tok) => tok.text().to_string(),
2490        None => ident.syntax().text().to_string(),
2491    }
2492}
2493
2494/// Byte offset of a STATIC attr key (`Ident` or `Str`) in its source text —
2495/// the position `builtins.unsafeGetAttrPos` reports for that key. Returns
2496/// `None` for a dynamic key (`${e}`), which has no fixed source position.
2497///
2498/// CppNix points a binding's position at the KEY token's start; rnix exposes
2499/// it via the syntax node's `text_range().start()`.
2500fn static_attr_offset(attr: &ast::Attr) -> Option<u32> {
2501    let node = match attr {
2502        ast::Attr::Ident(i) => i.syntax(),
2503        ast::Attr::Str(s) => s.syntax(),
2504        ast::Attr::Dynamic(_) => return None,
2505    };
2506    Some(u32::from(node.text_range().start()))
2507}
2508
2509/// Collect a literal attrset's static top-level KEY offsets into an
2510/// [`crate::pos::AttrPositions`] and attach it to `attrs` (behind the value's
2511/// `Rc<AttrPositions>` slot). Records only single-key static bindings — the
2512/// shape `attrTag`'s `tags_` (`{ app = …; file = …; }`) is built from and the
2513/// only shape `builtins.unsafeGetAttrPos` reads in nixpkgs. `None`-costs a
2514/// pointer when the set has no such keys (attaches nothing).
2515fn attach_attrset_positions(set: &ast::AttrSet, attrs: &mut NixAttrs, env: &Env) {
2516    // The FILE is the one the literal is being built in — from the eval-file
2517    // stack, which a thunk restores to its captured file when it forces. This
2518    // is correct under laziness: a `dock.nix` attrset literal forced later
2519    // records `dock.nix`, not whatever file is top-of-stack at force time.
2520    // (`current_source_id`/`CURRENT_SOURCE_ID` is per-`eval_with_file`, NOT
2521    // per-env, so it would mis-attribute a lazily-forced literal.)
2522    let mut table = crate::pos::AttrPositions::new(current_eval_file());
2523    for entry in set.entries() {
2524        if let ast::Entry::AttrpathValue(apv) = entry {
2525            let Some(attrpath) = apv.attrpath() else { continue };
2526            let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2527            // A dotted path `a.b = …` desugars to a nested set and CppNix gives
2528            // the OUTER key the position of the path's HEAD, so record
2529            // `path_attrs[0]` whatever the length. This previously skipped any
2530            // multi-segment path, on the assumption that nixpkgs never asks for
2531            // a dotted tag's position. Measured — for
2532            // `{ …; nested.deep = 3; }` at line 6:
2533            //   nix  nested=6:3      sui  nested=NULL
2534            let Some(head) = path_attrs.first() else { continue };
2535            let Some(offset) = static_attr_offset(head) else { continue };
2536            // Resolve the static key name (Ident/Str) — never forces (a
2537            // dynamic key already returned None above).
2538            if let Ok(Some(name)) = eval_attr_maybe_null(&path_attrs[0], env) {
2539                table.insert(intern(&name), offset);
2540            }
2541        } else if let ast::Entry::Inherit(inh) = entry {
2542            // `inherit x;` and `inherit (src) x;` BIND an attribute exactly as
2543            // `x = …` does, and CppNix gives each inherited name the position of
2544            // its own ident. Skipping them left every inherited key
2545            // position-less — which is most of nixpkgs' `lib`, since
2546            // `lib/default.nix` re-exports through
2547            // `inherit (self.options) mkOption …`. Measured before the fix:
2548            //   unsafeGetAttrPos "mkOption" nixpkgs.lib
2549            //     nix …-source/lib/default.nix     sui null
2550            //
2551            // An earlier attempt at this arm was reverted for reporting line 1;
2552            // that was `pos::line_col` returning a constant, NOT this arm. With
2553            // the real offset→line/column conversion in place it resolves
2554            // exactly.
2555            for attr in inh.attrs() {
2556                let Some(offset) = static_attr_offset(&attr) else { continue };
2557                if let Ok(Some(name)) = eval_attr_maybe_null(&attr, env) {
2558                    table.insert(intern(&name), offset);
2559                }
2560            }
2561        }
2562    }
2563    if !table.is_empty() {
2564        attrs.set_positions(std::rc::Rc::new(table));
2565    }
2566}
2567
2568fn eval_attrset(set: &ast::AttrSet, env: &Env) -> Result<Value, EvalError> {
2569    crate::perf::inc(crate::perf::Counter::Attrset);
2570    let mut attrs = NixAttrs::new();
2571    let is_rec = set.rec_token().is_some();
2572
2573    // ── plan-driven construction (`SUI_NORMALIZE=1`) ──────────────────────
2574    //
2575    // Wired for `rec` first and the non-rec branch last, deliberately. The
2576    // `rec` branch was WRONG (its Phase 1b does a destructive `attrs.insert`
2577    // where the non-rec branch merges), so any change there could only
2578    // improve it. The non-rec branch is the one path that was already correct
2579    // ON KEYS — it merges VALUES via `merge_nested_insert` — and it carries
2580    // every fleet evaluation, so it went last and on its own.
2581    //
2582    // Correct-on-keys is not correct: a value merge gets the key set right and
2583    // the SCOPE wrong, which is why `let b=5; in { a=rec{c=b;}; a={b=9;}; }`
2584    // answered `c=5` where nix says `c=9`. The second side's `b=9` belongs to
2585    // the FIRST node's rec scope, and no value-level merge can put it there.
2586    //
2587    // A `None` here is a POSITIVE statement, not a fallback: `sui-normalize`
2588    // records a group only when it has a duplicate static key or a dotted
2589    // path, so no plan means this group is already built correctly.
2590    if crate::normalize_env::enabled() {
2591        let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
2592        let offset = u32::from(set.syntax().text_range().start());
2593        if let Some(plan) =
2594            crate::normalize_env::plan_for_node(set, is_rec, src_id, offset).map_err(reject)?
2595        {
2596            return eval_plan_group(&plan, env);
2597        }
2598    }
2599
2600    if is_rec {
2601        let mut rec_env = env.child();
2602        let mut thunks: Vec<(String, Thunk)> = Vec::new();
2603
2604        // Track which names have been defined so far in this scope.
2605        // Used by maybe_thunk to resolve backward references directly
2606        // instead of creating wasteful thunks.
2607        let mut defined_so_far: HashSet<String> = HashSet::new();
2608
2609        // Accumulator for dotted-path bindings (`rec { a.b = 1; a.c = 2; ... }`).
2610        // Leaf values are wrapped in thunks so they participate in the
2611        // recursive env fixpoint, matching CppNix semantics where
2612        // `rec { types.a = f 1; f = x: x + 1; }` allows `f` to be a
2613        // sibling binding.
2614        let mut dotted_attrs: NixAttrs = NixAttrs::new();
2615
2616        // D1 (`SUI_SCOPE_NARROW>=1`) — a SECOND predicate, deliberately not a
2617        // widening of `is_recursive_binding` below.
2618        //
2619        // THE TRAP: `is_recursive_binding` is BACKWARD-BLIND on purpose — it
2620        // tests `key` plus the siblings seen SO FAR, so `rec { b = a; a = 1; }`
2621        // computes `false` for `b`. That verdict selects Promise semantics, so
2622        // widening it would change which bindings get the fix-point sentinel
2623        // and is not a refactor available here. Yet `b` genuinely does need the
2624        // rec scope, and today gets it from Phase 2's blanket `update_env`.
2625        // Narrowing therefore needs its own forward-complete question — "does
2626        // this RHS reach ANY key this scope binds, declared before or after?" —
2627        // answered against a full pre-pass, while `is_recursive_binding` stays
2628        // byte-identical.
2629        //
2630        // The pre-pass is PURELY SYNTACTIC, which is the second trap: the
2631        // Phase-1 loop below owns the evaluation order of `${…}` keys, and
2632        // calling `eval_attr` here would run that arbitrary code earlier. So a
2633        // head that is not a plain identifier forfeits narrowing for the whole
2634        // scope instead of being evaluated for its name. Starting the flag at
2635        // `scope_narrow_enabled()` also means the default path never walks the
2636        // entries at all.
2637        let mut names_complete = scope_narrow_enabled();
2638        let rec_scope_names: HashSet<String> = if names_complete {
2639            let mut s = HashSet::new();
2640            for entry in set.entries() {
2641                match entry {
2642                    ast::Entry::AttrpathValue(apv) => {
2643                        match apv.attrpath().and_then(|p| p.attrs().next()) {
2644                            Some(ast::Attr::Ident(i)) => {
2645                                s.insert(ident_text(&i));
2646                            }
2647                            _ => names_complete = false,
2648                        }
2649                    }
2650                    ast::Entry::Inherit(inh) => {
2651                        for attr in inh.attrs() {
2652                            match attr {
2653                                ast::Attr::Ident(i) => {
2654                                    s.insert(ident_text(&i));
2655                                }
2656                                _ => names_complete = false,
2657                            }
2658                        }
2659                    }
2660                }
2661            }
2662            s
2663        } else {
2664            HashSet::new()
2665        };
2666        let narrow = names_complete;
2667
2668        // Phase 1: Create thunks with placeholder env and bind them.
2669        for entry in set.entries() {
2670            match entry {
2671                ast::Entry::AttrpathValue(apv) => {
2672                    let attrpath = apv.attrpath().ok_or_else(|| {
2673                        EvalError::ParseError("binding missing attrpath".to_string())
2674                    })?;
2675                    let value_expr = apv.value().ok_or_else(|| {
2676                        EvalError::ParseError("binding missing value".to_string())
2677                    })?;
2678                    let mut path_keys: Vec<String> = attrpath
2679                        .attrs()
2680                        .filter_map(|a| eval_attr_maybe_null(&a, env).transpose())
2681                        .collect::<Result<_, _>>()?;
2682                    // Null dynamic attr name → skip entire binding (CppNix compat)
2683                    if path_keys.is_empty() { continue; }
2684                    if path_keys.len() == 1 {
2685                        let key = path_keys.pop().unwrap();
2686                        // Self-recursive detection in a `rec { … }` scope:
2687                        // any binding whose value-expr references the
2688                        // bound name OR any sibling key declared in this
2689                        // rec scope is potentially self-recursive (the
2690                        // siblings' thunks share the rec_env via Phase 2).
2691                        // Mark as recursive so inner re-entrance during
2692                        // force returns a Promise sentinel instead of
2693                        // erroring with InfiniteRecursion.
2694                        //
2695                        // For simplicity we check `key` and all already-
2696                        // defined siblings; siblings defined later are
2697                        // covered when THEIR thunks force (they reference
2698                        // back into this rec scope via Phase 2's env update).
2699                        // O(N) not O(N²): one memoized referenced-name set,
2700                        // intersected with key + already-defined siblings.
2701                        // Byte-identical to the prior per-name walks.
2702                        let referenced = referenced_idents(&value_expr);
2703                        let is_recursive_binding = referenced.contains(key.as_str())
2704                            || defined_so_far
2705                                .iter()
2706                                .any(|n| referenced.contains(n.as_str()));
2707                        let value = if is_recursive_binding {
2708                            Value::Thunk(Thunk::new_suspended_recursive(
2709                                value_expr.clone(),
2710                                env.clone(),
2711                            ))
2712                        } else {
2713                            // maybeThunk: skip thunk for trivial exprs.
2714                            // is_rec=true because rec attrset bindings
2715                            // can reference each other.
2716                            // Pass defined_so_far so backward refs
2717                            // resolve directly.
2718                            maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
2719                        };
2720                        // Forward-complete needs-scope test (see the pre-pass
2721                        // above). `is_recursive_binding` is folded in as
2722                        // belt-and-braces: it is a subset whenever `narrow`
2723                        // holds, since every key it can name came from an
2724                        // `Ident` head and so is in `rec_scope_names`.
2725                        let needs_scope = !narrow
2726                            || is_recursive_binding
2727                            || rec_scope_names
2728                                .iter()
2729                                .any(|n| referenced.contains(n.as_str()));
2730                        rec_env.bind(key.clone(), value.clone());
2731                        attrs.insert(key.clone(), value.clone());
2732                        if let Value::Thunk(t) = &value {
2733                            if needs_scope {
2734                                thunks.push((key.clone(), t.clone()));
2735                                crate::value::census::scope_pinned();
2736                            } else {
2737                                crate::value::census::scope_narrowed();
2738                            }
2739                        }
2740                        defined_so_far.insert(key);
2741                    } else {
2742                        // Multi-segment dotted path: build a nested attrset
2743                        // with a thunk at the leaf so the value expression
2744                        // can reference sibling rec-bindings.
2745                        let key = path_keys[0].clone();
2746                        let value =
2747                            build_nested_attr_thunk(&path_keys[1..], &value_expr, env, &mut thunks);
2748                        merge_nested_insert(&mut dotted_attrs, key, value);
2749                    }
2750                }
2751                ast::Entry::Inherit(inherit) => {
2752                    eval_inherit(&inherit, env, &mut attrs, Some(&mut rec_env), Some(&mut thunks))?;
2753                }
2754            }
2755        }
2756
2757        // Phase 1b: Bind accumulated dotted-path attrs into attrs and rec_env.
2758        // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
2759        // duplicate definition, so we do not attempt to merge with
2760        // existing inherit thunks — just bind directly.
2761        for (key, value) in dotted_attrs.iter() {
2762            attrs.insert(key.clone(), value.clone());
2763            rec_env.bind(key.clone(), value.clone());
2764        }
2765
2766        // Phase 2: Update all thunks (both Suspended and InheritSelect)
2767        // to capture the final rec_env (which now has all names bound).
2768        for (_key, thunk) in &thunks {
2769            thunk.update_env(&rec_env);
2770        }
2771    } else {
2772        for entry in set.entries() {
2773            match entry {
2774                ast::Entry::AttrpathValue(apv) => {
2775                    let attrpath = apv.attrpath().ok_or_else(|| {
2776                        EvalError::ParseError("binding missing attrpath".to_string())
2777                    })?;
2778                    let value_expr = apv.value().ok_or_else(|| {
2779                        EvalError::ParseError("binding missing value".to_string())
2780                    })?;
2781                    let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2782                    // CppNix defers a dynamic key that is NOT at the HEAD of the
2783                    // attrpath: `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`,
2784                    // so `e` never forces until `.a` is demanded. Evaluating the
2785                    // whole path eagerly would force `e` at construction and — in
2786                    // the module-system fixpoint — read `config.<x>` while `config`
2787                    // is mid-force (the M2.6 divergence: `homes.null` instead of
2788                    // `homes.<name>`). Only the head is eager; a lone dynamic tail
2789                    // becomes a deferred thunk. A rarer collision under the same
2790                    // head stays eager (forced) so static deep-merge still works.
2791                    let tail_is_dynamic =
2792                        path_attrs.len() > 1 && attrs_have_dynamic(&path_attrs[1..]);
2793                    let head_key = match eval_attr_maybe_null(&path_attrs[0], env)? {
2794                        Some(k) => k,
2795                        // Null dynamic HEAD attr name → skip entire binding.
2796                        None => continue,
2797                    };
2798                    if tail_is_dynamic && attrs.get(&head_key).is_none() {
2799                        let value =
2800                            build_deferred_tail_attr(&path_attrs[1..], &value_expr, env);
2801                        attrs.insert(head_key, value);
2802                        continue;
2803                    }
2804                    // M2.6 ROOT #3 (collision case): the tail has a dynamic key
2805                    // AND the head already exists (a sibling binding wrote it,
2806                    // e.g. osquery's `systemd.services.… = …` then
2807                    // `systemd.tmpfiles.settings."10-osquery".${dirname …}.d`).
2808                    // The plain deferral above bails (head present), and the
2809                    // eager path below would force the dynamic key at
2810                    // construction — re-reading `config.<x>` mid-fixpoint →
2811                    // the empty-Promise partial. Instead, descend the existing
2812                    // head along the tail's STATIC prefix and splice a DEFERRED
2813                    // thunk at the first dynamic level, so the dynamic key
2814                    // stays lazy exactly as CppNix's nested-literal desugaring
2815                    // does — while preserving the static deep-merge with the
2816                    // sibling binding.
2817                    if tail_is_dynamic {
2818                        if let Some(existing) = attrs.get(&head_key).cloned() {
2819                            let merged = merge_deferred_dynamic_tail(
2820                                existing,
2821                                &path_attrs[1..],
2822                                &value_expr,
2823                                env,
2824                            )?;
2825                            attrs.insert(head_key, merged);
2826                            continue;
2827                        }
2828                    }
2829                    // Eager path: evaluate the remaining (static, or collision)
2830                    // keys now. A null dynamic tail key skips the binding.
2831                    let mut path_keys: Vec<String> = {
2832                        let mut v = Vec::with_capacity(path_attrs.len());
2833                        v.push(head_key);
2834                        let mut skip = false;
2835                        for a in &path_attrs[1..] {
2836                            match eval_attr_maybe_null(a, env)? {
2837                                Some(k) => v.push(k),
2838                                None => { skip = true; break; }
2839                            }
2840                        }
2841                        if skip { v.clear(); }
2842                        v
2843                    };
2844                    // Null dynamic attr name → skip entire binding (CppNix compat)
2845                    if path_keys.is_empty() { continue; }
2846                    if path_keys.len() == 1 {
2847                        let key = path_keys.pop().unwrap();
2848                        // maybeThunk: skip thunk for trivial exprs.
2849                        // is_rec=false — Ident lookups are safe.
2850                        let value = maybe_thunk(&value_expr, env, false, None);
2851                        // CppNix desugars `a.b = x; a = { c = y; };` into a single
2852                        // merged `a = { b = x; c = y; }` at parse time. rnix keeps
2853                        // the two bindings separate, so when a single-key binding
2854                        // collides with an already-built (dotted) attrs for the
2855                        // same key, deep-MERGE instead of overwrite. Force the RHS
2856                        // to WHNF so merge_nested_insert (which needs concrete
2857                        // Value::Attrs on both sides) can merge — forcing an
2858                        // attrset to WHNF does NOT force its fields, so leaf values
2859                        // stay lazy. Only fires on collision; non-colliding
2860                        // single-key bindings keep the plain fast insert.
2861                        // (This is the pkg-config-wrapper `env.addFlags` drop:
2862                        // `env.addFlags = …` then `env = { wrapperName = …; … }`.)
2863                        // If the earlier binding for this key is still a lazy
2864                        // Thunk (an attrset literal inserted via maybe_thunk), force
2865                        // it to WHNF FIRST so a `key = {..}; key = {..}` collision is
2866                        // seen as attrs-vs-attrs and MERGES, matching nix
2867                        // (`{ s = {a=1;}; s = {b=2;}; }` → `{ s = {a=1; b=2;}; }`).
2868                        // Without this the `Some(Value::Attrs(_))` test below is false
2869                        // on a Thunk and the second binding overwrites, dropping the
2870                        // first's keys. The dotted branch below already does this; R3
2871                        // (eval-okay-merge-dynamic-attrs set1/set2) needs it here too.
2872                        // WHNF force does not force fields → leaf laziness preserved.
2873                        // (A non-attrs dup like `s = 1; s = 2` still overwrites here,
2874                        // unchanged — nix errors there, an eval-FAIL case out of scope.)
2875                        if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2876                            let existing = attrs.get(&key).cloned().unwrap();
2877                            let forced_existing = force_value(&existing)?;
2878                            attrs.insert(key.clone(), forced_existing);
2879                        }
2880                        if matches!(attrs.get(&key), Some(Value::Attrs(_))) {
2881                            let forced = force_value(&value)?;
2882                            merge_nested_insert(&mut attrs, key, forced);
2883                        } else {
2884                            attrs.insert(key, value);
2885                        }
2886                    } else {
2887                        let key = path_keys[0].clone();
2888                        let value = build_nested_attr(&path_keys[1..], &value_expr, env)?;
2889                        // CppNix desugars `a = { x = …; }; a.y = …;` into a
2890                        // single merged `a = { x = …; y = …; }`. When the
2891                        // full-set binding for `a` was inserted FIRST it is a
2892                        // lazy Thunk (attrset literals go through maybe_thunk),
2893                        // so merge_nested_insert — which only merges when the
2894                        // existing value is a concrete Value::Attrs — would
2895                        // NOT see the earlier keys and would overwrite `a`
2896                        // with just `{ y = … }`, silently dropping `x`. Force
2897                        // the existing entry to WHNF on collision so the merge
2898                        // sees the concrete attrs (forcing to WHNF does not
2899                        // force the fields, so leaf laziness is preserved).
2900                        // (This is the gst-plugins-base `passthru.waylandEnabled`
2901                        // drop: `passthru = { … }; passthru.tests.x = …;`.)
2902                        if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2903                            let existing = attrs.get(&key).cloned().unwrap();
2904                            let forced = force_value(&existing)?;
2905                            attrs.insert(key.clone(), forced);
2906                        }
2907                        merge_nested_insert(&mut attrs, key, value);
2908                    }
2909                }
2910                ast::Entry::Inherit(inherit) => {
2911                    eval_inherit(&inherit, env, &mut attrs, None, None)?;
2912                }
2913            }
2914        }
2915    }
2916
2917    // Record the literal's static-key source positions for
2918    // `builtins.unsafeGetAttrPos` (the `attrTag` `declarations` — options.json
2919    // dock root). Cheap: one entry walk over static Ident/Str keys, no
2920    // forcing; attaches nothing (a pointer-sized `None`) when the set has no
2921    // single-static-key bindings.
2922    attach_attrset_positions(set, &mut attrs, env);
2923
2924    Ok(Value::Attrs(Rc::new(attrs)))
2925}
2926
2927fn eval_inherit(
2928    inherit: &ast::Inherit,
2929    env: &Env,
2930    attrs: &mut NixAttrs,
2931    bind_env: Option<&mut Env>,
2932    mut thunks: Option<&mut Vec<(String, Thunk)>>,
2933) -> Result<(), EvalError> {
2934    if let Some(from) = inherit.from() {
2935        // inherit (expr) a b c;
2936        //
2937        // The source expression must NOT be eagerly evaluated. nixpkgs
2938        // `lib/trivial.nix` has `inherit (lib.trivial) isFunction ...`
2939        // at the top of a file that itself defines `lib.trivial`. If
2940        // we eagerly force `lib.trivial`, we hit a self-referential
2941        // thunk blackhole. Instead: build a thunk per inherited
2942        // name that, when forced, evaluates the source and pulls
2943        // out that one attribute. This is what real Nix does.
2944        //
2945        // For `rec { inherit (X) name; ...; foo = name; }` we ALSO
2946        // need to bind the name in the enclosing rec env so the
2947        // sibling `foo = name` can reference it. The caller passes
2948        // its rec env in `bind_env`.
2949        //
2950        // When `thunks` is provided (rec attrsets), InheritSelect
2951        // thunks are collected so Phase 2 can update their captured
2952        // env to the full recursive scope. Without this, the source
2953        // expression cannot reference sibling bindings.
2954        let source_expr = from
2955            .expr()
2956            .ok_or_else(|| EvalError::ParseError("inherit from missing expr".to_string()))?;
2957        // Shared source thunk — all inherited names share one source
2958        // evaluation (the source thunk's own memoization ensures at
2959        // most one evaluation).
2960        let source_thunk = Thunk::new_suspended(source_expr, env.clone());
2961        let mut be = bind_env;
2962        for attr in inherit.attrs() {
2963            let name = eval_attr(&attr, env)?;
2964            let thunk = Thunk::new_inherit_select(source_thunk.clone(), name.clone());
2965            let value = Value::Thunk(thunk.clone());
2966            attrs.insert(name.clone(), value.clone());
2967            if let Some(ref mut e) = be {
2968                e.bind(name.clone(), value);
2969            }
2970            if let Some(ref mut t) = thunks {
2971                t.push((name, thunk));
2972            }
2973        }
2974    } else {
2975        // inherit a b c;
2976        //
2977        // CppNix resolves a bare `inherit x;` LAZILY, exactly like a plain
2978        // reference to `x` — it does NOT eagerly force the enclosing scope.
2979        // This matters when `x` is provided only by an enclosing `with`
2980        // scope whose value is a fixpoint still being constructed (a
2981        // blackhole): eager `env.lookup` returns None → spurious
2982        // `UndefinedVar`. nixpkgs `all-packages.nix` is
2983        // `… with pkgs; { nettle = import … { inherit callPackage; }; }`,
2984        // so `inherit callPackage` must resolve `callPackage` from the
2985        // `with pkgs` scope AT FORCE TIME, not eagerly at attrset
2986        // construction. Mirror `maybe_thunk`'s Ident path: try the fast
2987        // lookup, and on a miss defer to a WithIdent thunk (or a suspended
2988        // env lookup) so the resolution happens lazily against the settled
2989        // scope. (This was the `nettle` UndefinedVar('callPackage') drop.)
2990        let mut be = bind_env;
2991        for attr in inherit.attrs() {
2992            let name = eval_attr(&attr, env)?;
2993            let sym = crate::value::intern(&name);
2994            let value = if let Some(v) = env.lookup_fast(sym, &name) {
2995                v
2996            } else if let Some((scope_cache, scope_value)) =
2997                env.innermost_with_scope()
2998            {
2999                Value::Thunk(Thunk::new_with_ident(
3000                    SmolStr::from(name.as_str()),
3001                    scope_cache,
3002                    scope_value,
3003                    env.clone(),
3004                ))
3005            } else {
3006                return Err(EvalError::UndefinedVar(format!(
3007                    "'{name}'{}",
3008                    eval_file_ctx()
3009                )));
3010            };
3011            attrs.insert(name.clone(), value.clone());
3012            if let Some(ref mut e) = be {
3013                e.bind(name, value);
3014            }
3015        }
3016    }
3017    Ok(())
3018}
3019
3020fn build_nested_attr(
3021    path: &[String],
3022    expr: &ast::Expr,
3023    env: &Env,
3024) -> Result<Value, EvalError> {
3025    if path.is_empty() {
3026        // CRITICAL: Wrap leaf in a thunk instead of eagerly evaluating.
3027        // For dotted paths like `config.warnings = optionals config.x [...]`,
3028        // the leaf expression must be lazy — eagerly evaluating it during
3029        // attrset construction forces fixpoint thunks prematurely.
3030        return Ok(maybe_thunk(expr, env, false, None));
3031    }
3032    let key = path[0].clone();
3033    let inner = build_nested_attr(&path[1..], expr, env)?;
3034    let mut attrs = NixAttrs::new();
3035    attrs.insert(key, inner);
3036    Ok(Value::Attrs(Rc::new(attrs)))
3037}
3038
3039/// True if a single attr is a DYNAMIC key — one whose resolution runs
3040/// arbitrary expression code and therefore must not be forced at
3041/// attrset-construction time.
3042///
3043/// Two forms are dynamic:
3044///   * `ast::Attr::Dynamic` — a bare `${e}` antiquotation.
3045///   * `ast::Attr::Str` **containing an interpolation** — an interpolated
3046///     string key like `"iwd/${nm}"`.  A `Str` with NO interpolation
3047///     (`"foo bar"`) is a plain static string literal and is NOT dynamic.
3048///
3049/// M2.6 ROOT #3: `attrs_have_dynamic` previously matched ONLY
3050/// `Attr::Dynamic`, so an interpolated-string tail key (`config.a."p${e}"`)
3051/// fell to the eager path and forced `e` at construction.  In the module
3052/// system that forces a `config.<x>` read while `config` is mid-fixpoint
3053/// (`environment.etc."iwd/${configFile.name}"`, where `configFile` reads
3054/// `with config.networking.networkmanager`), yielding the empty-Promise
3055/// partial → the `set/null` softening.  Treating an interpolated `Str` as
3056/// dynamic routes it through the same per-level deferral as `${e}`
3057/// (ROOT #1/#2), so `e` forces only when the enclosing head is demanded —
3058/// exactly CppNix's nested-attrset-literal desugaring.
3059fn attr_is_dynamic(attr: &ast::Attr) -> bool {
3060    match attr {
3061        ast::Attr::Dynamic(_) => true,
3062        // A string attr key is dynamic iff it has ≥1 interpolation part;
3063        // a purely-literal string key forces nothing and stays eager.
3064        ast::Attr::Str(s) => s
3065            .normalized_parts()
3066            .iter()
3067            .any(|p| matches!(p, InterpolPart::Interpolation(_))),
3068        ast::Attr::Ident(_) => false,
3069    }
3070}
3071
3072/// True if any attr in the slice is a dynamic (interpolated) key.
3073///
3074/// A dynamic key beyond the HEAD of an attrpath must NOT be evaluated at
3075/// attrset-construction time — CppNix defers it inside the head's lazy
3076/// value, so `{ a.${e} = v; }` never forces `e` until `.a` is demanded.
3077/// Static string/ident keys are cheap and force nothing, so they don't
3078/// need deferral.
3079fn attrs_have_dynamic(attrs: &[ast::Attr]) -> bool {
3080    attrs.iter().any(attr_is_dynamic)
3081}
3082
3083/// Build the nested attrset for the TAIL of an attrpath, deferring
3084/// evaluation of dynamic tail keys until the value is forced.
3085///
3086/// Given tail attrs `[b, ${e}, c]` and a value expr, produce a lazy
3087/// `Value::Thunk` that, when forced, evaluates each tail key (including
3088/// the dynamic `${e}`) against `env` and builds `{ b = { ${e} = { c =
3089/// <leaf-thunk> }; }; }`. This mirrors CppNix: the inner attrset (and
3090/// thus its dynamic keys) is constructed only when the enclosing head
3091/// attribute is demanded — never at construction of the outer attrset.
3092///
3093/// A dynamic key that evaluates to `null` skips the whole binding
3094/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3095fn build_deferred_tail_attr(
3096    tail: &[ast::Attr],
3097    value_expr: &ast::Expr,
3098    env: &Env,
3099) -> Value {
3100    let tail: Vec<ast::Attr> = tail.to_vec();
3101    let value_expr = value_expr.clone();
3102    let env = env.clone();
3103    Value::Thunk(Thunk::new_native(move || {
3104        build_tail_attrs_now(&tail, &value_expr, &env)
3105    }))
3106}
3107
3108/// Resolve ONE level of the deferred attrpath tail — used from inside
3109/// the deferred thunk above once the enclosing head is demanded.
3110///
3111/// M2.6 ROOT #2 (the OVER-FORCE fix): this resolves *only* `tail[0]`'s
3112/// key and wraps the remaining tail `tail[1..]` in another DEFERRED
3113/// thunk — it does NOT recurse eagerly through the whole tail. This is
3114/// exactly CppNix's desugaring of `a.b.c = v` into nested attrset
3115/// literals `a = { b = { c = v; }; }`, where forcing `a` to WHNF yields
3116/// `{ b = <thunk {c=v}> }` — the inner level (`b`, and any dynamic key
3117/// under it) stays lazy until `.b` is demanded.
3118///
3119/// Forcing the enclosing head therefore resolves ONE tail key, never
3120/// the whole chain: `config.homes.${cfg.pleme.userName} = 7` demanded
3121/// as `config` yields `{ homes = <deferred> }` WITHOUT forcing the
3122/// `${cfg.pleme.userName}` key. The prior implementation recursed the
3123/// whole tail eagerly, forcing that dynamic key while only `.config`
3124/// (or its `._type`) was demanded — the over-force cppnix never does.
3125///
3126/// A dynamic key that evaluates to `null` skips the whole binding
3127/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3128fn build_tail_attrs_now(
3129    tail: &[ast::Attr],
3130    value_expr: &ast::Expr,
3131    env: &Env,
3132) -> Result<Value, EvalError> {
3133    if tail.is_empty() {
3134        return Ok(maybe_thunk(value_expr, env, false, None));
3135    }
3136    if std::env::var_os("SUI_M26_TAILTRACE").is_some() {
3137        let t: String = tail[0].syntax().text().to_string().chars().take(40).collect();
3138        eprintln!("[M26 TAIL-RESOLVE] forcing dynamic tail key `{t}`");
3139        if attrs_have_dynamic(&tail[..1]) {
3140            crate::trace::dump_force_stack_ids();
3141        }
3142    }
3143    let key = match eval_attr_maybe_null(&tail[0], env)? {
3144        Some(k) => k,
3145        // Null dynamic key → the whole binding is skipped; an empty
3146        // attrset is the identity for merge_nested_insert.
3147        None => return Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
3148    };
3149    // Resolve ONE level: if more tail remains, defer it (a new lazy
3150    // thunk) rather than recursing eagerly. Only the leaf (empty tail)
3151    // is built here. This keeps each nested level lazy, exactly like
3152    // CppNix's nested-attrset-literal desugaring — so forcing this
3153    // level does NOT force the next level's (possibly dynamic) key.
3154    let inner = if tail.len() == 1 {
3155        maybe_thunk(value_expr, env, false, None)
3156    } else {
3157        build_deferred_tail_attr(&tail[1..], value_expr, env)
3158    };
3159    let mut attrs = NixAttrs::new();
3160    attrs.insert(key, inner);
3161    Ok(Value::Attrs(Rc::new(attrs)))
3162}
3163
3164/// M2.6 ROOT #3 (collision case): splice a DEFERRED dynamic-tail binding
3165/// into an ALREADY-PRESENT head value without forcing the dynamic key.
3166///
3167/// `existing` is the value already stored at the attrpath's head (written
3168/// by a sibling binding — e.g. `systemd.services.… = …`). `tail` is the
3169/// remaining attrpath (`path_attrs[1..]`) of the new binding, which
3170/// contains ≥1 dynamic attr (`systemd.tmpfiles.….${dirname …}.d`).
3171///
3172/// We descend `existing` along the LONGEST STATIC PREFIX of `tail`
3173/// (`tmpfiles`, `settings`, `"10-osquery"` — all static, forced-free
3174/// keys), forcing each already-present sub-attrset to WHNF so the merge
3175/// sees concrete keys (forcing to WHNF never forces leaf VALUES, so leaf
3176/// laziness is preserved), and at the first DYNAMIC level splice a
3177/// `build_deferred_tail_attr` thunk. The dynamic key therefore forces
3178/// only when that exact nested path is later demanded — CppNix's
3179/// nested-attrset-literal desugaring, now honoured through a sibling
3180/// collision too.
3181fn merge_deferred_dynamic_tail(
3182    existing: Value,
3183    tail: &[ast::Attr],
3184    value_expr: &ast::Expr,
3185    env: &Env,
3186) -> Result<Value, EvalError> {
3187    // `tail` is non-empty and contains a dynamic attr somewhere (the
3188    // caller guarantees `attrs_have_dynamic(tail)`).
3189    debug_assert!(!tail.is_empty());
3190
3191    // If the FIRST tail attr is itself dynamic, there is no static prefix
3192    // to descend — the whole tail is deferred and merged as a lazy
3193    // overlay onto the existing head (a `//`-style right-merge; the
3194    // deferred attrset only materialises its dynamic key on demand).
3195    if attr_is_dynamic(&tail[0]) {
3196        let deferred = build_deferred_tail_attr(tail, value_expr, env);
3197        return Ok(lazy_overlay_merge(existing, deferred));
3198    }
3199
3200    // The head static key of `tail`. Resolve it (static → forces nothing
3201    // relevant; a null dynamic can't occur here since tail[0] is static).
3202    let key = match eval_attr_maybe_null(&tail[0], env)? {
3203        Some(k) => k,
3204        None => return Ok(existing),
3205    };
3206
3207    // Force the existing head to a concrete attrset so we can descend +
3208    // merge on the resolved static key. Forcing to WHNF does NOT force
3209    // its field VALUES, so leaf laziness is preserved.
3210    let existing_forced = force_value(&existing)?;
3211    let mut base = match existing_forced {
3212        Value::Attrs(a) => (*a).clone(),
3213        // The existing head is not an attrset (a sibling wrote a leaf
3214        // here); CppNix would error on the merge, but to stay lazy we
3215        // defer the tail and let a later demand surface the real merge
3216        // conflict. Build the deferred tail as a fresh attrset.
3217        _ => {
3218            let deferred = build_deferred_tail_attr(tail, value_expr, env);
3219            return Ok(deferred);
3220        }
3221    };
3222
3223    // Recurse: merge the REMAINING tail (`tail[1..]`) under `key`.
3224    let child_existing = base.get(&key).cloned();
3225    let new_child = match child_existing {
3226        Some(child) if tail.len() > 1 => {
3227            // Deeper static/dynamic prefix under an existing sub-attrset.
3228            merge_deferred_dynamic_tail(child, &tail[1..], value_expr, env)?
3229        }
3230        Some(child) => {
3231            // tail == [key]; the leaf collides with an existing value.
3232            // Static leaf collision — build the leaf and lazy-merge.
3233            let leaf = maybe_thunk(value_expr, env, false, None);
3234            lazy_overlay_merge(child, leaf)
3235        }
3236        None if tail.len() > 1 => {
3237            // No existing child; the remaining tail may itself start with
3238            // a dynamic key — defer it whole (build_deferred_tail_attr
3239            // handles the static/dynamic split per-level).
3240            build_deferred_tail_attr(&tail[1..], value_expr, env)
3241        }
3242        None => maybe_thunk(value_expr, env, false, None),
3243    };
3244    base.insert(key, new_child);
3245    Ok(Value::Attrs(Rc::new(base)))
3246}
3247
3248/// Lazy right-merge of two values that are (or will force to) attrsets,
3249/// preserving leaf laziness. Used by [`merge_deferred_dynamic_tail`] to
3250/// combine a deferred dynamic-tail attrset with an existing value without
3251/// forcing either's dynamic keys eagerly. When both are concrete attrs we
3252/// deep-merge in place (reusing [`merge_nested_insert`]); otherwise we
3253/// build a lazy overlay thunk that merges on demand.
3254fn lazy_overlay_merge(left: Value, right: Value) -> Value {
3255    match (&left, &right) {
3256        (Value::Attrs(la), Value::Attrs(_)) => {
3257            crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3258            let mut merged = (**la).clone();
3259            if let Value::Attrs(ra) = &right {
3260                // Merging distinct override keys into `merged` is order-
3261                // independent (per-key right-wins), and the result map is
3262                // unordered storage — the sorted `iter()` was dead work.
3263                for (k, v) in ra.iter_unsorted() {
3264                    merge_nested_insert(&mut merged, k.clone(), v.clone());
3265                }
3266            }
3267            Value::Attrs(Rc::new(merged))
3268        }
3269        _ => {
3270            // At least one side is a thunk (a deferred dynamic tail).
3271            // Defer the merge behind a Native thunk so neither side's
3272            // dynamic key forces until the merged attrset is demanded.
3273            Value::Thunk(Thunk::new_native(move || {
3274                let lf = force_value(&left)?;
3275                let rf = force_value(&right)?;
3276                let la = lf.as_attrs()?;
3277                let ra = rf.as_attrs()?;
3278                crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3279                let mut merged = (*la).clone();
3280                for (k, v) in ra.iter_unsorted() {
3281                    merge_nested_insert(&mut merged, k.clone(), v.clone());
3282                }
3283                Ok(Value::Attrs(Rc::new(merged)))
3284            }))
3285        }
3286    }
3287}
3288
3289/// Like [`build_nested_attr`] but wraps the leaf in a [`Thunk`] instead of
3290/// eagerly evaluating it. Used inside `rec { ... }` and `let ... in` so
3291/// that dotted-path leaf expressions can reference sibling bindings
3292/// through the recursive env (which is finalised in Phase 2).
3293///
3294/// Every thunk created is appended to `thunks` so Phase 2 can update
3295/// its captured environment.
3296fn build_nested_attr_thunk(
3297    path: &[String],
3298    expr: &ast::Expr,
3299    env: &Env,
3300    thunks: &mut Vec<(String, Thunk)>,
3301) -> Value {
3302    if path.is_empty() {
3303        let thunk = Thunk::new_suspended(expr.clone(), env.clone());
3304        let val = Value::Thunk(thunk.clone());
3305        thunks.push((String::new(), thunk));
3306        return val;
3307    }
3308    let key = path[0].clone();
3309    let inner = build_nested_attr_thunk(&path[1..], expr, env, thunks);
3310    let mut attrs = NixAttrs::new();
3311    attrs.insert(key, inner);
3312    Value::Attrs(Rc::new(attrs))
3313}
3314
3315/// Insert `value` at `key` in `target`. If `target` already has a
3316/// concrete `Value::Attrs` at that key AND `value` is also a
3317/// concrete `Value::Attrs`, deep-merge them rather than overwriting.
3318/// This is what makes `{ a.b.c = 1; a.b.d = 2; a.e = 3; }` produce
3319/// `{ a = { b = { c = 1; d = 2; }; e = 3; }; }` instead of
3320/// dropping siblings — every nixpkgs module relies on this.
3321fn merge_nested_insert(target: &mut NixAttrs, key: String, value: Value) {
3322    // Fast path: no existing entry at this key → plain insert, keeping the
3323    // value lazy (the overwhelmingly common non-colliding case, so we never
3324    // force a thunk here).
3325    let existing = match target.get(&key) {
3326        Some(e) => e.clone(),
3327        None => {
3328            target.insert(key, value);
3329            return;
3330        }
3331    };
3332    // A collision exists.  A deep merge is warranted only when BOTH the
3333    // existing entry AND the new value are attrset-shaped.  M2.6 ROOT #4b
3334    // (byte-verified): either side may be a lazy `Thunk` wrapping a
3335    // full-set leaf — both dotted-path orderings hit this:
3336    //   forward  `o.a = { x = 1; }; o.a.y = 2;` → EXISTING `a` is a thunk
3337    //            (`build_nested_attr` puts the `{x=1}` leaf through
3338    //            `maybe_thunk`), NEW `a` is `{ y = … }`;
3339    //   reverse  `o.a.y = 2; o.a = { x = 1; };` → EXISTING `a` is `{y}`,
3340    //            NEW `a` is the `<thunk {x=1}>`.
3341    // The old `should_merge` required BOTH sides to already be concrete
3342    // `Value::Attrs`, so a Thunk-vs-Attrs collision fell to the overwrite
3343    // path and silently dropped the earlier leaf's keys.  cppnix desugars
3344    // BOTH orderings into one merged `o.a = { x = 1; y = 2; }`.  Force each
3345    // side's thunk to WHNF ON COLLISION ONLY (forcing an attrset to WHNF
3346    // does NOT force its fields, so leaf laziness is preserved); a thunk
3347    // that forces to a non-attrset (or errors) makes the merge a plain
3348    // overwrite (leaf last-write-wins).
3349    // Symptom this closes: nixpkgs' alsa module declares
3350    // `options.hardware.alsa = { enable = …; cardAliases = …; … }` AND
3351    // `options.hardware.alsa.enablePersistence = …`; sui merged them to
3352    // only `{enablePersistence}`, so `hardware.alsa.cardAliases` "does not
3353    // exist" — the M2.6 frontier once the `with`-namespace over-force (#4a)
3354    // was fixed.
3355    let value = match value {
3356        Value::Thunk(_) => match force_value(&value) {
3357            Ok(v @ Value::Attrs(_)) => v,
3358            _ => value,
3359        },
3360        other => other,
3361    };
3362    if !matches!(value, Value::Attrs(_)) {
3363        target.insert(key, value);
3364        return;
3365    }
3366    // Normalize the existing side to concrete attrs too (forcing a thunk
3367    // to WHNF if needed); if it isn't attrset-shaped, the new attrs wins.
3368    let existing_concrete = match &existing {
3369        Value::Attrs(_) => existing.clone(),
3370        Value::Thunk(_) => match force_value(&existing) {
3371            Ok(v @ Value::Attrs(_)) => v,
3372            _ => {
3373                target.insert(key, value);
3374                return;
3375            }
3376        },
3377        _ => {
3378            target.insert(key, value);
3379            return;
3380        }
3381    };
3382    // Both sides are concrete attrs — merge in place. We pop the
3383    // existing entry, then walk the new attrs and recursively
3384    // merge each child onto it.
3385    let mut existing_attrs = match existing_concrete {
3386        Value::Attrs(a) => (*a).clone(),
3387        _ => unreachable!(),
3388    };
3389    let new_attrs = match value {
3390        Value::Attrs(ref a) => a,
3391        _ => unreachable!(),
3392    };
3393    for (k, v) in new_attrs.iter_unsorted() {
3394        merge_nested_insert(&mut existing_attrs, k.clone(), v.clone());
3395    }
3396    target.insert(key, Value::Attrs(Rc::new(existing_attrs)));
3397}
3398
3399/// Evaluate entries from any HasEntry node (LegacyLet).
3400fn eval_entries<N: HasEntry + AstNode>(node: &N, env: &mut Env) -> Result<(), EvalError> {
3401    for entry in node.entries() {
3402        match entry {
3403            ast::Entry::AttrpathValue(apv) => {
3404                let attrpath = apv.attrpath().ok_or_else(|| {
3405                    EvalError::ParseError("binding missing attrpath".to_string())
3406                })?;
3407                let value_expr = apv.value().ok_or_else(|| {
3408                    EvalError::ParseError("binding missing value".to_string())
3409                })?;
3410                let mut path_keys: Vec<String> = attrpath
3411                    .attrs()
3412                    .map(|a| eval_attr(&a, env))
3413                    .collect::<Result<_, _>>()?;
3414                if path_keys.len() == 1 {
3415                    let key = path_keys.pop().unwrap();
3416                    let value = eval_expr(&value_expr, env)?;
3417                    env.bind(key, value);
3418                }
3419                // Multi-key paths in let are not standard; skip for now.
3420            }
3421            ast::Entry::Inherit(inherit) => {
3422                if let Some(from) = inherit.from() {
3423                    let source_expr = from.expr().ok_or_else(|| {
3424                        EvalError::ParseError("inherit from missing expr".to_string())
3425                    })?;
3426                    let source = force_value(&eval_expr(&source_expr, env)?)?;
3427                    let source_attrs = source.as_attrs()?;
3428                    for attr in inherit.attrs() {
3429                        let name = eval_attr(&attr, env)?;
3430                        let value = source_attrs
3431                            .get(&name)
3432                            .cloned()
3433                            .ok_or_else(|| EvalError::AttrNotFound(
3434                                format!("'{name}' in inherit{}", eval_file_ctx()),
3435                            ))?;
3436                        env.bind(name, value);
3437                    }
3438                } else {
3439                    for attr in inherit.attrs() {
3440                        let name = eval_attr(&attr, env)?;
3441                        let value = env
3442                            .lookup(&name)
3443                            .ok_or_else(|| EvalError::UndefinedVar(
3444                                format!("'{name}'{}", eval_file_ctx()),
3445                            ))?;
3446                        env.bind(name, value);
3447                    }
3448                }
3449            }
3450        }
3451    }
3452    Ok(())
3453}
3454
3455fn eval_binop(
3456    op: ast::BinOpKind,
3457    lhs: &ast::Expr,
3458    rhs: &ast::Expr,
3459    env: &Env,
3460) -> Result<Value, EvalError> {
3461    // Short-circuit for && and ||
3462    match op {
3463        ast::BinOpKind::And => {
3464            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3465            if !l {
3466                return Ok(Value::Bool(false));
3467            }
3468            return eval_expr(rhs, env);
3469        }
3470        ast::BinOpKind::Or => {
3471            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3472            if l {
3473                return Ok(Value::Bool(true));
3474            }
3475            return eval_expr(rhs, env);
3476        }
3477        ast::BinOpKind::Implication => {
3478            let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3479            if !l {
3480                return Ok(Value::Bool(true));
3481            }
3482            return eval_expr(rhs, env);
3483        }
3484        _ => {}
3485    }
3486
3487    let lc = force_concrete(&eval_expr(lhs, env)?)?;
3488    let rc = force_concrete(&eval_expr(rhs, env)?)?;
3489    // Consume the Concretes (move, don't clone) so `l`/`r` hold the sole Rc to
3490    // any heap payload. This is byte-neutral — `into_value` yields the identical
3491    // `Value` as `to_value` — but it drops `lc`/`rc`, which is what lets the
3492    // `Concat` arm's structural-share fast path see a uniquely-owned left list
3493    // for a fresh `++` temporary (`Rc::try_unwrap` → append in place). Keeping
3494    // `lc` alive via `to_value` pinned the refcount at ≥2 and defeated reuse.
3495    let l = lc.into_value();
3496    let r = rc.into_value();
3497
3498    match op {
3499        ast::BinOpKind::Add => match (&l, &r) {
3500            (Value::Int(a), Value::Int(b)) => a
3501                .checked_add(*b)
3502                .map(Value::Int)
3503                .ok_or_else(|| int_overflow("adding", *a, '+', *b)),
3504            (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
3505            (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
3506            (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
3507            (Value::String(a), Value::String(b)) => {
3508                let mut ctx = a.context.clone();
3509                ctx.merge(&b.context);
3510                // Byte-identical to `format!("{}{}", a.chars, b.chars)` but
3511                // routes around the `core::fmt` runtime (its dispatch was the
3512                // #1 self-time frame on the string-concat hot path): a single
3513                // exact-capacity `String` + two `push_str` reserves the final
3514                // size once, so the left operand is copied exactly once instead
3515                // of copied-then-regrown. Result string + context unchanged →
3516                // ByteSufficient. (Also removes a `format!` — TYPED EMISSION.)
3517                let mut s = String::with_capacity(a.chars.len() + b.chars.len());
3518                s.push_str(&a.chars);
3519                s.push_str(&b.chars);
3520                Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
3521            }
3522            (Value::Path(a), Value::String(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}{}", b.chars).as_str())))),
3523            (Value::Path(a), Value::Path(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}/{b}").as_str())))),
3524            // CppNix coerces attrsets with outPath when used with +
3525            (Value::Attrs(_), _) | (_, Value::Attrs(_)) => {
3526                let (ls, lctx) = l.coerce_to_string()?;
3527                let (rs, rctx) = r.coerce_to_string()?;
3528                let mut ctx = lctx;
3529                ctx.merge(&rctx);
3530                Ok(Value::String(Rc::new(NixString::with_context(
3531                    format!("{ls}{rs}"),
3532                    ctx,
3533                ))))
3534            }
3535            _ => Err(EvalError::op_type("add", l.type_name(), r.type_name())),
3536        },
3537        ast::BinOpKind::Sub => num_op(
3538            &l,
3539            &r,
3540            |a, b| a.checked_sub(b),
3541            |a, b| a - b,
3542            |a, b| int_overflow("subtracting", a, '-', b),
3543        ),
3544        ast::BinOpKind::Mul => num_op(
3545            &l,
3546            &r,
3547            |a, b| a.checked_mul(b),
3548            |a, b| a * b,
3549            |a, b| int_overflow("multiplying", a, '*', b),
3550        ),
3551        ast::BinOpKind::Div => {
3552            // CppNix rejects division by zero for both int and float
3553            // operands; Rust's native int-div-by-0 panics (we handle
3554            // that below) but float-div-by-0 silently returns `inf`
3555            // or `NaN`, which sui was then serializing as `null` —
3556            // an invisible silent-Ok bug surfaced by the error-case
3557            // differential corpus.
3558            //
3559            // Cover every zero-denominator case explicitly.
3560            let rhs_is_zero = match &r {
3561                Value::Int(0) => true,
3562                Value::Float(f) => *f == 0.0,
3563                _ => false,
3564            };
3565            if rhs_is_zero {
3566                return Err(EvalError::DivisionByZero);
3567            }
3568            num_op(
3569                &l,
3570                &r,
3571                |a, b| a.checked_div(b),
3572                |a, b| a / b,
3573                |a, b| int_overflow("dividing", a, '/', b),
3574            )
3575        }
3576        // `eq_operator`, NOT `==`: at the operator both operands were just
3577        // materialized by independent `force_concrete` calls, so sui can prove
3578        // they are distinct cells and must answer `false` for two lambdas —
3579        // exactly as CppNix's `ExprOpEq::eval` does. Nested comparisons keep
3580        // `PartialEq`. See `value::eq_operator`.
3581        ast::BinOpKind::Equal => Ok(Value::Bool(crate::value::eq_operator(&l, &r))),
3582        ast::BinOpKind::NotEqual => Ok(Value::Bool(!crate::value::eq_operator(&l, &r))),
3583        ast::BinOpKind::Less => compare(&l, &r, |o| o == std::cmp::Ordering::Less),
3584        ast::BinOpKind::LessOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Greater),
3585        ast::BinOpKind::More => compare(&l, &r, |o| o == std::cmp::Ordering::Greater),
3586        ast::BinOpKind::MoreOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Less),
3587        ast::BinOpKind::Update => {
3588            let la = l.to_attrs()?;
3589            let ra = r.to_attrs()?;
3590            // O(1) lazy overlay — defers merge until attribute access.
3591            Ok(Value::Attrs(Rc::new(la.overlay(ra))))
3592        }
3593        ast::BinOpKind::Concat => {
3594            // Structural-share fast path: when the left operand's `Rc<Vec>` is
3595            // uniquely owned (a fresh temporary, as in a left-associative `++`
3596            // fold `acc ++ [x]`), append the right elements IN PLACE instead of
3597            // cloning the whole accumulator. This turns an O(n) copy per concat
3598            // into amortized O(1), byte-identically — the result is the same
3599            // ordered sequence of the same Rc-shared lazy thunks (no forcing,
3600            // no reordering, no identity change). When the Rc is shared (the
3601            // left came from a still-live binding/thunk) we fall back to the
3602            // clone-extend path, preserving the shared list unchanged.
3603            crate::value::concat_lists(l, r.as_list()?)
3604        }
3605        ast::BinOpKind::And | ast::BinOpKind::Or | ast::BinOpKind::Implication => {
3606            unreachable!("handled above")
3607        }
3608        ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
3609            Err(EvalError::NotImplemented("pipe operators".to_string()))
3610        }
3611    }
3612}
3613
3614/// CppNix aborts (uncatchably) on i64 arithmetic overflow, e.g.
3615/// `integer overflow in adding 9223372036854775807 + 1`. `EvalError::Abort` is
3616/// the uncatchable variant (`tryEval` catches only `Throw`/`AssertionFailed`),
3617/// matching nix — a wrapping result would silently produce a wrong drvPath.
3618#[inline]
3619fn int_overflow(verb: &str, a: i64, sym: char, b: i64) -> EvalError {
3620    EvalError::Abort(format!("integer overflow in {verb} {a} {sym} {b}"))
3621}
3622
3623fn num_op(
3624    l: &Value,
3625    r: &Value,
3626    int_op: impl Fn(i64, i64) -> Option<i64>,
3627    float_op: impl Fn(f64, f64) -> f64,
3628    overflow: impl Fn(i64, i64) -> EvalError,
3629) -> Result<Value, EvalError> {
3630    match (l, r) {
3631        (Value::Int(a), Value::Int(b)) => {
3632            int_op(*a, *b).map(Value::Int).ok_or_else(|| overflow(*a, *b))
3633        }
3634        (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
3635        (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
3636        (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
3637        _ => Err(EvalError::op_type("perform arithmetic on", l.type_name(), r.type_name())),
3638    }
3639}
3640
3641fn compare(
3642    l: &Value,
3643    r: &Value,
3644    pred: impl Fn(std::cmp::Ordering) -> bool,
3645) -> Result<Value, EvalError> {
3646    let ord = match (l, r) {
3647        (Value::Int(a), Value::Int(b)) => a.cmp(b),
3648        (Value::Float(a), Value::Float(b)) => {
3649            a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
3650        }
3651        (Value::Int(a), Value::Float(b)) => (*a as f64)
3652            .partial_cmp(b)
3653            .unwrap_or(std::cmp::Ordering::Equal),
3654        (Value::Float(a), Value::Int(b)) => a
3655            .partial_cmp(&(*b as f64))
3656            .unwrap_or(std::cmp::Ordering::Equal),
3657        (Value::String(a), Value::String(b)) => a.chars.cmp(&b.chars),
3658        _ => {
3659            return Err(EvalError::op_type("compare", l.type_name(), r.type_name()));
3660        }
3661    };
3662    Ok(Value::Bool(pred(ord)))
3663}
3664
3665/// Apply a function to an argument.
3666///
3667/// Supports `__functor`: if `func` is an attrset with a `__functor` key,
3668/// calls `__functor self arg` (the Nix `__functor` protocol).
3669///
3670/// For lambda with a simple ident parameter, the argument is NOT forced
3671/// before binding -- this enables fixpoint combinators (`lib.fix`) where
3672/// the argument is a self-referential thunk.
3673/// Apply a function and force the result.
3674///
3675/// Builtins that inspect the return value (via `as_list`, `as_bool`, etc.)
3676/// must use this instead of bare `apply` — otherwise a thunk-wrapped result
3677/// will cause "thunk in as_list: force first" errors.
3678pub fn apply_and_force(func: Value, arg: Value) -> Result<Value, EvalError> {
3679    force_value(&apply(func, arg)?)
3680}
3681
3682pub fn apply(func: Value, arg: Value) -> Result<Value, EvalError> {
3683    stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || apply_inner(func, arg))
3684}
3685
3686fn apply_inner(func: Value, arg: Value) -> Result<Value, EvalError> {
3687    crate::perf::inc(crate::perf::Counter::Apply);
3688    let func = force_concrete(&func)?.into_value();
3689    match func {
3690        Value::Lambda(closure) => {
3691            // Hot function tracker: log source file + param name for each lambda call
3692            if crate::perf::enabled() {
3693                APPLY_SITES.with(|sites| {
3694                    let file = closure.env.eval_file()
3695                        .map(|p| p.display().to_string())
3696                        .unwrap_or_else(|| "<eval>".into());
3697                    // Include param info for identification
3698                    let param_name = match &closure.param {
3699                        rnix::ast::Param::IdentParam(ip) => ip.ident().map(|i| ident_text(&i)).unwrap_or_default(),
3700                        rnix::ast::Param::Pattern(pat) => {
3701                            let mut names: Vec<String> = pat.pat_entries()
3702                                .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3703                                .take(3)
3704                                .collect();
3705                            if pat.pat_entries().count() > 3 { names.push("...".to_string()); }
3706                            format!("{{{}}}", names.join(","))
3707                        }
3708                    };
3709                    let key = format!("{}:{}", file.rsplit_once("-source/").map_or(file.as_str(), |(_,s)| s), param_name);
3710                    *sites.borrow_mut().entry(key).or_insert(0u64) += 1;
3711                });
3712            }
3713            let mut call_env = closure.env.child();
3714            // ALWAYS push a frame, even when the closure captured no file:
3715            // `.map(push_eval_file)` pushed nothing for `None`, leaving the
3716            // CALLER's file on top, so a literal written in a fileless
3717            // context got stamped with the callee's path. CppNix returns
3718            // `null` there. See `EVAL_FILE_STACK`.
3719            let _file_guard = push_eval_frame(closure.env.eval_file().cloned());
3720            // Push Nix-level trace frame for function calls. Lazy: stores
3721            // only the raw ingredients (O(1) Rc-clone of the closure env +
3722            // the current-eval-file snapshot) and defers the format!/strip
3723            // work to the cold `attach_trace` path. Renders byte-identical
3724            // to the eager form.
3725            let _trace = push_nix_trace_lambda(&closure.env);
3726            match &closure.param {
3727                rnix::ast::Param::IdentParam(_) => {
3728                    // Simple ident param: bind argument WITHOUT forcing.
3729                    // This is critical for fixpoint / call-by-need semantics.
3730                    bind_param(&closure.param, &arg, &mut call_env)?;
3731                }
3732                rnix::ast::Param::Pattern(_) => {
3733                    // Pattern param needs the arg to be an attrset, so force.
3734                    let forced_arg = force_concrete(&arg)?.into_value();
3735                    bind_param(&closure.param, &forced_arg, &mut call_env)?;
3736                }
3737            }
3738            eval_expr(&closure.body, &call_env)
3739        }
3740        Value::Builtin(b) => {
3741            let _trace = push_nix_trace(format!("while calling the '{}' builtin", b.name));
3742            // Special builtins that must receive UNFORCED arguments:
3743            // - tryEval: must catch throw/abort during its own forcing
3744            // - addErrorContext<partial>: wraps value with error context
3745            //   without forcing (the value is the fixpoint `config` which
3746            //   causes infinite recursion if forced during collectModules)
3747            // - seq<partial>: forces first arg but returns second UNFORCED
3748            // Same lazy-arg set as `eval_apply` (single source of truth) — these
3749            // builtins receive the arg UNFORCED. foldl'<p1> is the nul accumulator
3750            // (nix's foldl' is strict in each op RESULT, NOT in the nul).
3751            if builtin_takes_lazy_arg(&b.name) {
3752                (b.func)(&[arg])
3753            } else {
3754                let forced_arg = force_value(&arg)?;
3755                (b.func)(&[forced_arg])
3756            }
3757        }
3758        Value::Attrs(ref attrs) => {
3759            if let Some(functor) = attrs.get("__functor") {
3760                let functor = force_value(functor)?;
3761                // __functor protocol: (functor self) arg
3762                let partial = apply(functor, func.clone())?;
3763                apply(partial, arg)
3764            } else if crate::value::in_promise_eval() {
3765                // M2.6 Promise softening: an attrset without __functor
3766                // being called as a function — typically the empty-
3767                // attrset sentinel inside a fix-point body.  Return
3768                // null so eval can proceed.
3769                Ok(Value::Null)
3770            } else {
3771                Err(EvalError::type_error(
3772                    format!("cannot call {} (missing __functor){}", func.type_name(), eval_file_ctx()),
3773                ))
3774            }
3775        }
3776        _ if crate::value::in_promise_eval() => {
3777            // M2.6 Promise softening: calling null / int / string / list
3778            // as a function inside a Promise body is the sentinel
3779            // cascade landing somewhere it doesn't belong.  Return null
3780            // so the fix-point continues instead of erroring.
3781            Ok(Value::Null)
3782        }
3783        _ => Err(EvalError::type_error(
3784            format!("cannot call {}{}", func.type_name(), eval_file_ctx()),
3785        )),
3786    }
3787}
3788
3789/// Dark-side lever `batch-bind` (byte-SAFE, `RedundantWrite`) — OFF by default.
3790/// When `SUI_BATCH_BIND=1`, an N-formal pattern binds in ONE copy-on-write step
3791/// (`Env::bind_many`) instead of N successive `env.bind()` calls. Byte-identical
3792/// either way (same intern, same insert order, same final HAMT — Phase 2's
3793/// `update_env` makes each default thunk's initial env capture unobservable).
3794/// Gated because the extra `Vec` allocation could regress the common small-pattern
3795/// case, and the win is unmeasured under load — never change the default path on a
3796/// hunch (never-ship-a-regression). Cached so the default path pays zero per call.
3797/// Ledger: `sui-spec/specs/darkside.lisp` (`batch-bind`, DarkGated).
3798static SUI_BATCH_BIND: std::sync::LazyLock<bool> =
3799    std::sync::LazyLock::new(|| std::env::var_os("SUI_BATCH_BIND").is_some());
3800
3801fn bind_param(param: &ast::Param, arg: &Value, env: &mut Env) -> Result<(), EvalError> {
3802    match param {
3803        ast::Param::IdentParam(ip) => {
3804            let ident = ip
3805                .ident()
3806                .ok_or_else(|| EvalError::ParseError("ident param missing ident".to_string()))?;
3807            let name = ident_text(&ident);
3808            env.bind(name, arg.clone());
3809        }
3810        ast::Param::Pattern(pat) => {
3811            let attrs = arg.as_attrs()?;
3812
3813            // @-binding (either `args @ { ... }` or `{ ... } @ args`)
3814            if let Some(pat_bind) = pat.pat_bind()
3815                && let Some(ident) = pat_bind.ident()
3816            {
3817                let name = ident_text(&ident);
3818                env.bind(name, arg.clone());
3819            }
3820
3821            let has_ellipsis = pat.ellipsis_token().is_some();
3822            let entries: Vec<ast::PatEntry> = pat.pat_entries().collect();
3823
3824            // Two-phase binding (matching CppNix semantics):
3825            // Phase 1: Bind all formals. Defaults get thunks with a
3826            //   preliminary env. We collect thunks for Phase 2 update.
3827            // Phase 2: Update default thunks to capture the final env
3828            //   (which now has ALL formals bound). This allows defaults
3829            //   to reference any other formal — including forward refs.
3830            let mut default_thunks: Vec<Thunk> = Vec::new();
3831            // batch-bind (byte-SAFE `RedundantWrite`, OFF unless `SUI_BATCH_BIND=1`):
3832            // the flag path collects every formal's (name, value) pair and binds
3833            // them in ONE copy-on-write step (`bind_many`) instead of N successive
3834            // `env.bind()` calls. Byte-identical either way — the default thunks
3835            // capture `env.clone()` (pre-batch) and Phase 2's `update_env` re-points
3836            // every one to the final all-formals-bound env, so a thunk's *initial*
3837            // capture is unobservable (overwritten before any force); same intern,
3838            // same insert order, same final HAMT. The default path (flag unset) is
3839            // the original per-formal loop, byte- AND perf-identical (no Vec alloc).
3840            let use_batch = *SUI_BATCH_BIND;
3841            let mut pairs: Vec<(String, Value)> =
3842                if use_batch { Vec::with_capacity(entries.len()) } else { Vec::new() };
3843
3844            // D3 (`SUI_SCOPE_NARROW>=1`) — the highest-yield arm of the fix,
3845            // because it fires on every `callPackage`'d
3846            // `{ stdenv, lib, foo ? null }` and every
3847            // `{ config, lib, pkgs, ... }` module in the fleet.
3848            //
3849            // Today EVERY default thunk is re-pointed at the final all-formals
3850            // env by Phase 2, so `{ a, b ? 1 }` closes
3851            // `b-thunk -> env -> b-thunk` and the whole call frame is immortal.
3852            // But a default only NEEDS the final env if it can reach a formal
3853            // that is itself satisfied by a default — those are the only names
3854            // still unbound when the default is built. Everything else (an
3855            // argument-supplied formal, the `@`-bind, any outer name) is
3856            // already in scope, so the capture is complete on the spot and the
3857            // cycle never has to be closed.
3858            //
3859            // Splitting the single pass in two is what makes that true:
3860            // pass A binds every argument-supplied formal FIRST, so pass B's
3861            // captures see all of them regardless of declaration order.
3862            //
3863            // The reorder is byte-safe: formal names are unique (a duplicate
3864            // is a parse error), `bindings` is a hash map read only by key, and
3865            // building a thunk has no side effects — so nothing observes the
3866            // order in which the two passes populate the env, only its final
3867            // contents, which are unchanged.
3868            let narrow = scope_narrow_enabled();
3869            // The formals that will be satisfied BY A DEFAULT — i.e. exactly
3870            // the names not yet bound when pass B runs.
3871            let default_names: HashSet<String> = if narrow {
3872                entries
3873                    .iter()
3874                    .filter(|e| e.default().is_some())
3875                    .filter_map(ast::PatEntry::ident)
3876                    .map(|i| ident_text(&i))
3877                    .filter(|n| attrs.get(n).is_none())
3878                    .collect()
3879            } else {
3880                HashSet::new()
3881            };
3882
3883            if narrow {
3884                // PASS A — argument-supplied formals only. The
3885                // `missing argument` error still fires here, in entry order,
3886                // exactly where the single pass raised it.
3887                let mut deferred: Vec<(String, ast::Expr)> =
3888                    Vec::with_capacity(default_names.len());
3889                for entry in &entries {
3890                    let ident = entry.ident().ok_or_else(|| {
3891                        EvalError::ParseError("pat entry missing ident".to_string())
3892                    })?;
3893                    let name = ident_text(&ident);
3894                    if let Some(v) = attrs.get(&name) {
3895                        env.bind(name, v.clone());
3896                    } else if let Some(default_expr) = entry.default() {
3897                        deferred.push((
3898                            name,
3899                            ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3900                        ));
3901                    } else {
3902                        return Err(EvalError::type_error(
3903                            format!("missing argument '{name}'{}", eval_file_ctx()),
3904                        ));
3905                    }
3906                }
3907                // PASS B — the defaults, capturing an env that already carries
3908                // every argument-supplied formal and the `@`-bind.
3909                for (name, default_expr) in deferred {
3910                    let thunk =
3911                        Thunk::new_suspended(default_expr.clone(), env.clone());
3912                    let referenced = referenced_idents(&default_expr);
3913                    if default_names.iter().any(|n| referenced.contains(n.as_str())) {
3914                        // Reaches another DEFAULTED formal, which may not be
3915                        // bound yet — it needs Phase 2's re-point, and pays
3916                        // the cycle.
3917                        default_thunks.push(thunk.clone());
3918                        crate::value::census::scope_pinned();
3919                    } else {
3920                        crate::value::census::scope_narrowed();
3921                    }
3922                    env.bind(name, Value::Thunk(thunk));
3923                }
3924            } else {
3925                for entry in &entries {
3926                    let ident = entry.ident().ok_or_else(|| {
3927                        EvalError::ParseError("pat entry missing ident".to_string())
3928                    })?;
3929                    let name = ident_text(&ident);
3930                    let value = if let Some(v) = attrs.get(&name) {
3931                        v.clone()
3932                    } else if let Some(default_expr) = entry.default() {
3933                        // Default values in pattern parameters must be lazy
3934                        // (wrapped in thunks), matching CppNix semantics.
3935                        // Patterns like `vendor ? assert false; null` rely on
3936                        // the default never being forced when the body checks
3937                        // `args ? vendor` instead of using `vendor` directly.
3938                        let thunk = Thunk::new_suspended(
3939                            ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3940                            env.clone(),
3941                        );
3942                        default_thunks.push(thunk.clone());
3943                        Value::Thunk(thunk)
3944                    } else {
3945                        return Err(EvalError::type_error(
3946                            format!("missing argument '{name}'{}", eval_file_ctx()),
3947                        ));
3948                    };
3949                    if use_batch {
3950                        pairs.push((name, value));
3951                    } else {
3952                        env.bind(name, value);
3953                    }
3954                }
3955                if use_batch {
3956                    env.bind_many(pairs);
3957                }
3958            }
3959
3960            // Phase 2: Update default thunks to see ALL formals.
3961            for thunk in &default_thunks {
3962                thunk.update_env(env);
3963            }
3964
3965            if !has_ellipsis {
3966                let entry_names: std::collections::HashSet<String> = entries
3967                    .iter()
3968                    .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3969                    .collect();
3970                for key in attrs.keys() {
3971                    if !entry_names.contains(key.as_str()) {
3972                        return Err(EvalError::type_error(
3973                            format!("unexpected argument '{key}'{}", eval_file_ctx()),
3974                        ));
3975                    }
3976                }
3977            }
3978        }
3979    }
3980    Ok(())
3981}
3982
3983#[cfg(test)]
3984mod tests {
3985    use super::*;
3986
3987    fn ev(input: &str) -> Value {
3988        eval(input).unwrap()
3989    }
3990
3991    // Regression (2026-07-10): the let-scope fix-point detector must count
3992    // only GENUINE variable references, not attribute names / attrset keys
3993    // (which sit under a `NODE_ATTRPATH`).  nixpkgs `lib/types.nix` has
3994    // `placeholder = if lhs.placeholder == …` whose RHS mentions the
3995    // *attribute* `.placeholder`; the old raw-token match falsely flagged
3996    // the binding self-recursive and routed it through the Promise path.
3997    #[test]
3998    fn is_self_recursive_binding_ignores_attribute_names() {
3999        fn expr(s: &str) -> ast::Expr {
4000            rnix::Root::parse(s).tree().expr().expect("parse")
4001        }
4002        // attribute names / keys are NOT references to the binding
4003        assert!(!is_self_recursive_binding(&expr("lhs.placeholder"), "placeholder"));
4004        assert!(!is_self_recursive_binding(&expr("{ placeholder = 1; }"), "placeholder"));
4005        assert!(!is_self_recursive_binding(
4006            &expr("if lhs.placeholder == rhs.placeholder then lhs.placeholder else null"),
4007            "placeholder",
4008        ));
4009        // genuine variable references ARE detected
4010        assert!(is_self_recursive_binding(&expr("placeholder + 1"), "placeholder"));
4011        assert!(is_self_recursive_binding(
4012            &expr("if placeholder then 1 else 2"),
4013            "placeholder"
4014        ));
4015    }
4016
4017    // M2 thunk-waste (byte-safe eager constant): a NON-interpolated string in a
4018    // maybe_thunk site is evaluated directly (no suspended thunk). The value +
4019    // its (empty) context must be byte-identical to forcing a thunk of it.
4020    #[test]
4021    fn maybe_thunk_eager_constant_str_is_byte_identical() {
4022        fn expr(s: &str) -> ast::Expr {
4023            rnix::Root::parse(s).tree().expr().expect("parse")
4024        }
4025        let env = Env::new();
4026        // Constant string → returned as a concrete String, NOT a Thunk.
4027        let v = maybe_thunk(&expr(r#""abc""#), &env, false, None);
4028        assert!(matches!(v, Value::String(_)), "constant str should be eager, got {v:?}");
4029        assert_eq!(force_value(&v).unwrap(), Value::string("abc"));
4030        // Interpolated string → MUST stay a thunk (lazy `${…}` force).
4031        let vi = maybe_thunk(&expr(r#""a${b}c""#), &env, false, None);
4032        assert!(matches!(vi, Value::Thunk(_)), "interpolated str must stay thunked");
4033    }
4034
4035    // The pure-constant arg classifier admits ONLY literals + non-interpolated
4036    // strings/paths, and rejects everything that could throw/diverge/observe a
4037    // fixpoint — the laziness safety boundary of the apply-arg optimization.
4038    #[test]
4039    fn eval_pure_constant_arg_classification() {
4040        fn expr(s: &str) -> ast::Expr {
4041            rnix::Root::parse(s).tree().expr().expect("parse")
4042        }
4043        // ADMIT: pure constants (byte-safe to eval eagerly in an arg position).
4044        assert!(eval_pure_constant_arg(&expr("42")).is_some());
4045        assert!(eval_pure_constant_arg(&expr("3.14")).is_some());
4046        assert!(eval_pure_constant_arg(&expr(r#""const""#)).is_some());
4047        assert!(eval_pure_constant_arg(&expr("/abs/path")).is_some());
4048        // REJECT: anything that could throw / diverge / observe laziness.
4049        assert!(eval_pure_constant_arg(&expr(r#""a${b}c""#)).is_none(), "interpolated str");
4050        // `true`/`false`/`null` are IDENTS in nix (shadowable), not literals —
4051        // rejected to avoid a with-scope force, correctly conservative.
4052        assert!(eval_pure_constant_arg(&expr("true")).is_none(), "bool is an ident");
4053        assert!(eval_pure_constant_arg(&expr("x")).is_none(), "ident (with-scope force)");
4054        assert!(eval_pure_constant_arg(&expr("a.b")).is_none(), "select (fixpoint)");
4055        assert!(eval_pure_constant_arg(&expr("f x")).is_none(), "apply (may throw)");
4056        assert!(eval_pure_constant_arg(&expr("1 + 1")).is_none(), "binop (may throw)");
4057        assert!(eval_pure_constant_arg(&expr("throw \"x\"")).is_none(), "throw stays lazy");
4058    }
4059
4060    // LAZINESS GUARD: a lambda that IGNORES its arg must NOT force it — even a
4061    // throwing arg. The pure-constant optimization only touches inert constants,
4062    // so a `throw`-ing arg stays fully thunked and the ignoring lambda succeeds.
4063    #[test]
4064    fn ignored_throwing_arg_stays_lazy() {
4065        assert_eq!(ev(r#"(x: 7) (throw "boom")"#), Value::Int(7));
4066        // And an ignored constant arg is equally invisible.
4067        assert_eq!(ev(r#"(x: 7) "const""#), Value::Int(7));
4068        // A USED constant arg produces the right value.
4069        assert_eq!(ev(r#"(x: x) "used""#), Value::string("used"));
4070    }
4071
4072    #[test]
4073    fn eval_int() { assert_eq!(ev("42"), Value::Int(42)); }
4074
4075    #[test]
4076    fn eval_float() { assert_eq!(ev("3.14"), Value::Float(3.14)); }
4077
4078    #[test]
4079    fn eval_string() { assert_eq!(ev(r#""hello""#), Value::string("hello")); }
4080
4081    #[test]
4082    fn eval_bool() { assert_eq!(ev("true"), Value::Bool(true)); }
4083
4084    #[test]
4085    fn eval_null() { assert_eq!(ev("null"), Value::Null); }
4086
4087    #[test]
4088    fn eval_arithmetic() {
4089        assert_eq!(ev("1 + 2"), Value::Int(3));
4090        assert_eq!(ev("10 - 3"), Value::Int(7));
4091        assert_eq!(ev("2 * 3"), Value::Int(6));
4092        assert_eq!(ev("10 / 3"), Value::Int(3));
4093    }
4094
4095    #[test]
4096    fn eval_precedence() {
4097        assert_eq!(ev("1 + 2 * 3"), Value::Int(7));
4098        assert_eq!(ev("(1 + 2) * 3"), Value::Int(9));
4099    }
4100
4101    #[test]
4102    fn eval_comparison() {
4103        assert_eq!(ev("1 == 1"), Value::Bool(true));
4104        assert_eq!(ev("1 == 2"), Value::Bool(false));
4105        assert_eq!(ev("1 < 2"), Value::Bool(true));
4106        assert_eq!(ev("2 <= 2"), Value::Bool(true));
4107    }
4108
4109    #[test]
4110    fn eval_logic() {
4111        assert_eq!(ev("true && false"), Value::Bool(false));
4112        assert_eq!(ev("true || false"), Value::Bool(true));
4113        assert_eq!(ev("!true"), Value::Bool(false));
4114    }
4115
4116    #[test]
4117    fn eval_string_concat() {
4118        assert_eq!(ev(r#""hello" + " " + "world""#), Value::string("hello world"));
4119    }
4120
4121    #[test]
4122    fn eval_if() {
4123        assert_eq!(ev("if true then 1 else 2"), Value::Int(1));
4124        assert_eq!(ev("if false then 1 else 2"), Value::Int(2));
4125    }
4126
4127    #[test]
4128    fn eval_let() {
4129        assert_eq!(ev("let x = 1; in x"), Value::Int(1));
4130        assert_eq!(ev("let x = 1; y = 2; in x + y"), Value::Int(3));
4131    }
4132
4133    #[test]
4134    fn eval_let_dotted_simple() {
4135        // Two dotted bindings sharing the top-level key `a`.
4136        assert_eq!(ev("let a.b = 1; a.c = 2; in a.b + a.c"), Value::Int(3));
4137    }
4138
4139    #[test]
4140    fn eval_let_dotted_deep() {
4141        // Deeply nested dotted path.
4142        assert_eq!(ev("let a.b.c = 1; in a.b.c"), Value::Int(1));
4143    }
4144
4145    #[test]
4146    fn eval_let_dotted_mixed() {
4147        // Mix of simple and dotted bindings.
4148        assert_eq!(
4149            ev("let a.x = 1; b = 2; a.y = 3; in a.x + a.y + b"),
4150            Value::Int(6),
4151        );
4152    }
4153
4154    #[test]
4155    fn eval_let_dotted_produces_attrset() {
4156        // Dotted let bindings produce a real attrset.
4157        let v = ev("let a.b = 1; a.c = 2; in a");
4158        if let Value::Attrs(attrs) = v {
4159            assert_eq!(attrs.get("b"), Some(&Value::Int(1)));
4160            assert_eq!(attrs.get("c"), Some(&Value::Int(2)));
4161        } else {
4162            panic!("expected Attrs, got {v:?}");
4163        }
4164    }
4165
4166    // ── Inner dynamic attrpath key laziness ──────────────────
4167    // CppNix defers a dynamic key that is NOT at the head of an attrpath:
4168    // `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`, so `e` never
4169    // forces until `.a` is demanded. Reading a sibling must not force the
4170    // inner dynamic key. Root fix: `build_deferred_tail_attr` in eval.rs.
4171    // This is the pure-builtins reduction of the NixOS module-system
4172    // `config.homes.${cfg.userName}` fixpoint divergence.
4173    #[test]
4174    fn dynamic_inner_attr_key_is_lazy_on_sibling_read() {
4175        // The dynamic key throws; reading the SIBLING must NOT force it.
4176        assert_eq!(
4177            ev(r#"let s = { a.${throw "KEYFORCED"} = 7; other = 9; }; in s.other"#),
4178            Value::Int(9),
4179        );
4180    }
4181
4182    #[test]
4183    fn dynamic_inner_attr_key_resolves_on_head_demand() {
4184        // Demanding the head DOES resolve the deferred dynamic key.
4185        let v = ev(r#"let u = "bob"; s = { homes.${u} = 7; }; in s.homes"#);
4186        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4187            assert_eq!(attrs.get("bob"), Some(&Value::Int(7)));
4188        } else {
4189            panic!("expected Attrs");
4190        }
4191    }
4192
4193    #[test]
4194    fn dynamic_inner_attr_key_merges_with_static_sibling() {
4195        // Collision under one head still deep-merges (static + dynamic).
4196        let v = ev(r#"let u = "x"; s = { a.${u} = 1; a.b = 2; }; in s.a"#);
4197        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4198            assert_eq!(attrs.get("x"), Some(&Value::Int(1)));
4199            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4200        } else {
4201            panic!("expected Attrs");
4202        }
4203    }
4204
4205    #[test]
4206    fn dynamic_inner_attr_key_null_skips_binding() {
4207        // A null dynamic inner key skips the definition (CppNix rule):
4208        // `a` becomes an empty attrset, the sibling stays.
4209        let v = ev(
4210            r#"let c = true; s = { a.${if c then null else "n"} = 5; b = 1; }; in s.b"#,
4211        );
4212        assert_eq!(v, Value::Int(1));
4213    }
4214
4215    // ── M2.6 ROOT #3: interpolated-STRING tail keys are dynamic too ──────
4216    // `{ a."p${e}" = v; }` must build `{ a = <thunk {"p${e}"=v}>; }` — an
4217    // interpolated-string attr key references `e` and so must defer like a
4218    // bare `${e}`, never force at construction. Reading a sibling must NOT
4219    // force it (the KEYFORCE discriminator, now for a `Str` key).
4220    #[test]
4221    fn interpolated_string_attr_key_is_lazy_on_sibling_read() {
4222        assert_eq!(
4223            ev(r#"let s = { a."p/${throw "KEYFORCED"}" = 7; other = 9; }; in s.other"#),
4224            Value::Int(9),
4225        );
4226    }
4227
4228    #[test]
4229    fn interpolated_string_attr_key_resolves_on_head_demand() {
4230        // Demanding the head DOES resolve the deferred interpolated key.
4231        let v = ev(r#"let u = "bob"; s = { homes."u/${u}" = 7; }; in s.homes"#);
4232        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4233            assert_eq!(attrs.get("u/bob"), Some(&Value::Int(7)));
4234        } else {
4235            panic!("expected Attrs");
4236        }
4237    }
4238
4239    #[test]
4240    fn purely_literal_string_attr_key_stays_eager_static() {
4241        // A `Str` key with NO interpolation is a plain static key and must
4242        // NOT be treated as dynamic (it forces nothing, deep-merges).
4243        let v = ev(r#"let s = { a."foo bar" = 1; a.b = 2; }; in s.a"#);
4244        if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4245            assert_eq!(attrs.get("foo bar"), Some(&Value::Int(1)));
4246            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4247        } else {
4248            panic!("expected Attrs");
4249        }
4250    }
4251
4252    // ── M2.6 ROOT #3 (collision case): dynamic tail key under a head that
4253    // a sibling binding already wrote must stay lazy AND deep-merge.
4254    #[test]
4255    fn dynamic_tail_key_under_colliding_head_is_lazy() {
4256        // `sd.services.x` writes head `sd`; the second binding's dynamic
4257        // key must NOT force when a SIBLING (`sd.services`) is read.
4258        let v = ev(
4259            r#"let s = { sd.services.x = 1; sd.tmpfiles.${throw "KEYFORCED"}.d = 2; }; in s.sd.services.x"#,
4260        );
4261        assert_eq!(v, Value::Int(1));
4262    }
4263
4264    #[test]
4265    fn dynamic_tail_key_under_colliding_head_resolves_and_merges() {
4266        // Demanding the dynamic branch resolves the key; the sibling
4267        // static branch (`sd.services`) survives the merge intact.
4268        let v = ev(
4269            r#"let k = "z"; s = { sd.services.x = 1; sd.tmpfiles.${k}.d = 2; }; in s.sd"#,
4270        );
4271        let sd = force_value(&v).unwrap();
4272        if let Value::Attrs(sd_attrs) = &sd {
4273            // static sibling intact
4274            let services = force_value(sd_attrs.get("services").unwrap()).unwrap();
4275            if let Value::Attrs(a) = &services {
4276                assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4277            } else { panic!("expected services attrs"); }
4278            // dynamic branch resolved to key "z"
4279            let tmpfiles = force_value(sd_attrs.get("tmpfiles").unwrap()).unwrap();
4280            if let Value::Attrs(a) = &tmpfiles {
4281                let z = force_value(a.get("z").unwrap()).unwrap();
4282                if let Value::Attrs(zd) = &z {
4283                    assert_eq!(force_value(zd.get("d").unwrap()).unwrap(), Value::Int(2));
4284                } else { panic!("expected z attrs"); }
4285            } else { panic!("expected tmpfiles attrs"); }
4286        } else {
4287            panic!("expected sd attrs");
4288        }
4289    }
4290
4291    // ── M2.6 ROOT #4a — `with` namespace must be LAZY ─────────────────
4292    // `with X; body` stores the namespace as a thunk forced only on a
4293    // bare-ident fallthrough lookup; demanding only the body's WHNF/keys
4294    // must NOT force X.  cppnix: `attrNames (with (throw "X"); {a=1;})`
4295    // → ["a"].  Before the fix, sui EVALUATED the namespace at `with`-entry
4296    // and threw.  This is the load-bearing over-force behind the M2.6
4297    // `concatLists null` (nixpkgs' `config = mkIf … (with config.services.X;
4298    // { … })` module shape forced `config.services.X` during collection).
4299    #[test]
4300    fn with_namespace_is_lazy_on_body_whnf() {
4301        let v = ev(r#"builtins.attrNames (with (throw "WITH-FORCED"); { a = 1; b = 2; })"#);
4302        if let Value::List(items) = force_value(&v).unwrap() {
4303            let names: Vec<String> = items
4304                .iter()
4305                .map(|i| match force_value(i).unwrap() {
4306                    Value::String(s) => s.as_str().to_string(),
4307                    other => panic!("expected string, got {}", other.type_name()),
4308                })
4309                .collect();
4310            assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
4311        } else {
4312            panic!("expected list");
4313        }
4314    }
4315
4316    #[test]
4317    fn with_namespace_forces_only_on_fallthrough() {
4318        // A bare ident that falls through lexical scope DOES resolve via
4319        // the namespace (correct cppnix semantics) — proves the deferred
4320        // thunk is real and gets forced on demand, not an accidental no-op.
4321        assert_eq!(ev(r#"with { x = 42; }; x"#), Value::Int(42));
4322        // A lexical binding shadows the with-scope, so the (throwing)
4323        // namespace is never forced — the laziness we rely on for M2.6.
4324        assert_eq!(ev(r#"let x = 7; in with (throw "NS"); x"#), Value::Int(7));
4325    }
4326
4327    // ── M2.6 ROOT #4b — depth-≥2 dotted full-set leaf must deep-merge ──
4328    // `o.a = { x = 1; }` inserts `o = { a = <thunk {x=1}> }` (leaf goes
4329    // through maybe_thunk); a deeper sibling `o.a.y = 2` recurses
4330    // merge_nested_insert down to key `a` where the existing value is that
4331    // thunk.  Before the fix, merge_nested_insert required BOTH sides to be
4332    // concrete Attrs, so the Thunk-vs-Attrs collision OVERWROTE — dropping
4333    // `x`.  cppnix desugars both orderings into `o.a = { x = 1; y = 2; }`.
4334    // This is the M2.6 post-`with`-fix frontier (nixpkgs alsa's
4335    // `options.hardware.alsa = { … }` + `options.hardware.alsa.enablePersistence
4336    // = …` merged to only {enablePersistence} → `cardAliases` "does not exist").
4337    #[test]
4338    fn dotted_fullset_leaf_deep_merges_with_deeper_sibling() {
4339        let v = ev(r#"{ o.a = { x = 1; }; o.a.y = 2; }.o.a"#);
4340        if let Value::Attrs(a) = force_value(&v).unwrap() {
4341            assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4342            assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4343        } else {
4344            panic!("expected attrs");
4345        }
4346    }
4347
4348    #[test]
4349    fn dotted_fullset_leaf_deep_merge_reverse_order() {
4350        // Deeper sibling FIRST, full-set leaf SECOND — the NEW value is the
4351        // `<thunk {x=1}>`; must still merge (the collision forces it).
4352        let v = ev(r#"{ o.a.y = 2; o.a = { x = 1; }; }.o.a"#);
4353        if let Value::Attrs(a) = force_value(&v).unwrap() {
4354            assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4355            assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4356        } else {
4357            panic!("expected attrs");
4358        }
4359    }
4360
4361    #[test]
4362    fn dotted_fullset_leaf_merge_preserves_leaf_laziness() {
4363        // The merge forces the existing/new leaf to WHNF (keys) but MUST
4364        // NOT force the leaf VALUES — a throwing sibling value that is never
4365        // demanded stays lazy.
4366        assert_eq!(ev(r#"{ o.a = { x = throw "X-NEVER"; }; o.a.y = 2; }.o.a.y"#), Value::Int(2));
4367    }
4368
4369    #[test]
4370    fn eval_nested_let() {
4371        assert_eq!(ev("let a = 1; b = let c = 2; in c; in a + b"), Value::Int(3));
4372    }
4373
4374    #[test]
4375    fn eval_lambda() {
4376        assert_eq!(ev("(x: x + 1) 41"), Value::Int(42));
4377    }
4378
4379    #[test]
4380    fn eval_lambda_multi_arg() {
4381        assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4382    }
4383
4384    #[test]
4385    fn eval_list() {
4386        let v = ev("[1 2 3]");
4387        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]));
4388    }
4389
4390    #[test]
4391    fn eval_list_concat() {
4392        let v = ev("[1 2] ++ [3 4]");
4393        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]));
4394    }
4395
4396    #[test]
4397    fn eval_attrset() {
4398        let v = ev("{ a = 1; b = 2; }");
4399        if let Value::Attrs(attrs) = v {
4400            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4401            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4402        } else {
4403            panic!("expected attrset");
4404        }
4405    }
4406
4407    #[test]
4408    fn eval_select() {
4409        assert_eq!(ev("{ a = 42; }.a"), Value::Int(42));
4410    }
4411
4412    #[test]
4413    fn eval_select_or() {
4414        assert_eq!(ev("{ a = 42; }.b or 0"), Value::Int(0));
4415    }
4416
4417    #[test]
4418    fn eval_has_attr() {
4419        assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
4420        assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
4421    }
4422
4423    #[test]
4424    fn eval_update() {
4425        let v = ev("{ a = 1; b = 2; } // { b = 3; c = 4; }");
4426        if let Value::Attrs(attrs) = v {
4427            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4428            assert_eq!(attrs.get("b"), Some(&Value::Int(3)));
4429            assert_eq!(attrs.get("c"), Some(&Value::Int(4)));
4430        } else {
4431            panic!("expected attrset");
4432        }
4433    }
4434
4435    #[test]
4436    fn eval_with() {
4437        assert_eq!(ev("with { x = 42; }; x"), Value::Int(42));
4438    }
4439
4440    #[test]
4441    fn eval_assert() {
4442        assert_eq!(ev("assert true; 42"), Value::Int(42));
4443        assert!(eval("assert false; 42").is_err());
4444    }
4445
4446    #[test]
4447    fn eval_formals() {
4448        assert_eq!(ev("({ a, b }: a + b) { a = 1; b = 2; }"), Value::Int(3));
4449    }
4450
4451    #[test]
4452    fn eval_formals_default() {
4453        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 1; }"), Value::Int(11));
4454    }
4455
4456    #[test]
4457    fn eval_formals_ellipsis() {
4458        assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; }"), Value::Int(1));
4459    }
4460
4461    #[test]
4462    fn eval_named_formals() {
4463        assert_eq!(ev("(args @ { a }: args.a) { a = 42; }"), Value::Int(42));
4464    }
4465
4466    #[test]
4467    fn eval_rec_attrset() {
4468        assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
4469    }
4470
4471    #[test]
4472    fn eval_negation() {
4473        assert_eq!(ev("-42"), Value::Int(-42));
4474    }
4475
4476    #[test]
4477    fn eval_float_arithmetic() {
4478        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4479        assert_eq!(ev("1 + 1.5"), Value::Float(2.5));
4480    }
4481
4482    #[test]
4483    fn eval_division_by_zero() {
4484        assert!(eval("1 / 0").is_err());
4485    }
4486
4487    #[test]
4488    fn eval_builtins_available() {
4489        assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
4490        assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
4491    }
4492
4493    #[test]
4494    fn eval_builtins_length() {
4495        assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4496    }
4497
4498    #[test]
4499    fn eval_builtins_head_tail() {
4500        assert_eq!(ev("builtins.head [1 2 3]"), Value::Int(1));
4501        assert_eq!(ev("builtins.length (builtins.tail [1 2 3])"), Value::Int(2));
4502    }
4503
4504    #[test]
4505    fn eval_builtins_add() {
4506        assert_eq!(ev("builtins.add 1 2"), Value::Int(3));
4507    }
4508
4509    #[test]
4510    fn eval_builtins_to_string() {
4511        assert_eq!(ev("builtins.toString 42"), Value::string("42"));
4512    }
4513
4514    #[test]
4515    fn eval_implication() {
4516        assert_eq!(ev("false -> true"), Value::Bool(true));
4517        assert_eq!(ev("true -> false"), Value::Bool(false));
4518        assert_eq!(ev("true -> true"), Value::Bool(true));
4519    }
4520
4521    // ── New tests ────────────────────────────────────────
4522
4523    #[test]
4524    fn eval_error_undefined_variable() {
4525        let result = eval("nonexistent");
4526        assert!(result.is_err());
4527        let msg = format!("{}", result.unwrap_err());
4528        assert!(msg.contains("undefined variable"));
4529    }
4530
4531    #[test]
4532    fn eval_error_type_mismatch_arithmetic() {
4533        let result = eval(r#"1 + "hello""#);
4534        assert!(result.is_err());
4535        let msg = format!("{}", result.unwrap_err());
4536        assert!(msg.contains("cannot add") || msg.contains("type"));
4537    }
4538
4539    #[test]
4540    fn eval_error_unexpected_argument() {
4541        let result = eval("({ a }: a) { a = 1; b = 2; }");
4542        assert!(result.is_err());
4543        let msg = format!("{}", result.unwrap_err());
4544        assert!(msg.contains("unexpected argument"));
4545    }
4546
4547    #[test]
4548    fn eval_error_missing_required_argument() {
4549        let result = eval("({ a, b }: a + b) { a = 1; }");
4550        assert!(result.is_err());
4551        let msg = format!("{}", result.unwrap_err());
4552        assert!(msg.contains("missing argument"));
4553    }
4554
4555    #[test]
4556    fn eval_builtins_attr_names_sorted() {
4557        let v = ev("builtins.attrNames { z = 1; a = 2; m = 3; }");
4558        // BTreeMap keys are already sorted
4559        assert_eq!(
4560            v,
4561            Value::list(vec![
4562                Value::string("a"),
4563                Value::string("m"),
4564                Value::string("z"),
4565            ]),
4566        );
4567    }
4568
4569    #[test]
4570    fn eval_builtins_attr_values() {
4571        let v = ev("builtins.attrValues { a = 1; b = 2; }");
4572        // BTreeMap iteration is sorted by key, so a=1 first, b=2 second
4573        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
4574    }
4575
4576    #[test]
4577    fn eval_builtins_is_null() {
4578        assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
4579        assert_eq!(ev("builtins.isNull 1"), Value::Bool(false));
4580    }
4581
4582    #[test]
4583    fn eval_builtins_is_int() {
4584        assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
4585        assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
4586    }
4587
4588    #[test]
4589    fn eval_builtins_is_bool() {
4590        assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
4591        assert_eq!(ev("builtins.isBool 0"), Value::Bool(false));
4592    }
4593
4594    #[test]
4595    fn eval_builtins_is_string() {
4596        assert_eq!(ev(r#"builtins.isString "hi""#), Value::Bool(true));
4597        assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
4598    }
4599
4600    #[test]
4601    fn eval_builtins_is_list() {
4602        assert_eq!(ev("builtins.isList [1 2]"), Value::Bool(true));
4603        assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
4604    }
4605
4606    #[test]
4607    fn eval_builtins_is_attrs() {
4608        assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
4609        assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
4610    }
4611
4612    #[test]
4613    fn eval_builtins_string_length() {
4614        assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
4615        assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
4616    }
4617
4618    #[test]
4619    fn eval_builtins_to_json_roundtrip() {
4620        // toJSON produces a JSON string; fromJSON parses it back
4621        assert_eq!(
4622            ev(r#"builtins.fromJSON (builtins.toJSON 42)"#),
4623            Value::Int(42),
4624        );
4625        assert_eq!(
4626            ev(r#"builtins.fromJSON (builtins.toJSON [1 2 3])"#),
4627            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4628        );
4629    }
4630
4631    #[test]
4632    fn eval_builtins_from_json() {
4633        assert_eq!(
4634            ev(r#"builtins.fromJSON "{\"a\": 1}""#),
4635            {
4636                let mut attrs = NixAttrs::new();
4637                attrs.insert("a".to_string(), Value::Int(1));
4638                Value::Attrs(Rc::new(attrs))
4639            },
4640        );
4641        assert_eq!(ev(r#"builtins.fromJSON "null""#), Value::Null);
4642        assert_eq!(ev(r#"builtins.fromJSON "true""#), Value::Bool(true));
4643    }
4644
4645    #[test]
4646    fn eval_nested_function_application() {
4647        // (f 1) 2 where f = x: y: x + y
4648        assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4649        // equivalent parenthesized form
4650        assert_eq!(ev("((x: y: x + y) 1) 2"), Value::Int(3));
4651    }
4652
4653    #[test]
4654    fn eval_recursive_let() {
4655        assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4656        assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4657    }
4658
4659    #[test]
4660    fn eval_string_comparison() {
4661        assert_eq!(ev(r#""a" < "b""#), Value::Bool(true));
4662        assert_eq!(ev(r#""b" < "a""#), Value::Bool(false));
4663        assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4664        assert_eq!(ev(r#""abc" != "def""#), Value::Bool(true));
4665    }
4666
4667    #[test]
4668    fn eval_list_in_attrset() {
4669        let v = ev("{ x = [1 2 3]; }.x");
4670        assert_eq!(
4671            v,
4672            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4673        );
4674    }
4675
4676    #[test]
4677    fn eval_nested_attrset_select() {
4678        assert_eq!(ev("{ a = { b = 42; }; }.a.b"), Value::Int(42));
4679    }
4680
4681    #[test]
4682    fn eval_let_shadows_outer() {
4683        assert_eq!(
4684            ev("let x = 1; in let x = 2; in x"),
4685            Value::Int(2),
4686        );
4687    }
4688
4689    #[test]
4690    fn eval_with_provides_scope() {
4691        // `with` scope is available for name resolution
4692        assert_eq!(
4693            ev("with { x = 42; y = 10; }; x + y"),
4694            Value::Int(52),
4695        );
4696    }
4697
4698    #[test]
4699    fn eval_list_equality() {
4700        assert_eq!(ev("[1 2] == [1 2]"), Value::Bool(true));
4701        assert_eq!(ev("[1 2] == [1 3]"), Value::Bool(false));
4702    }
4703
4704    #[test]
4705    fn eval_attrset_equality() {
4706        assert_eq!(ev("{ a = 1; } == { a = 1; }"), Value::Bool(true));
4707        assert_eq!(ev("{ a = 1; } == { a = 2; }"), Value::Bool(false));
4708    }
4709
4710    // ═══════════════════════════════════════════════════════════
4711    // 1. LITERAL TYPES
4712    // ═══════════════════════════════════════════════════════════
4713
4714    #[test]
4715    fn literal_int_large_zero_negative() {
4716        // Large positive integer (within i64 range)
4717        assert_eq!(ev("9223372036854775807"), Value::Int(i64::MAX));
4718        // Zero
4719        assert_eq!(ev("0"), Value::Int(0));
4720        // Negative via unary negate
4721        assert_eq!(ev("-1"), Value::Int(-1));
4722        assert_eq!(ev("-999999"), Value::Int(-999999));
4723    }
4724
4725    #[test]
4726    fn literal_float_small_large() {
4727        assert_eq!(ev("0.001"), Value::Float(0.001));
4728        assert_eq!(ev("999999.999"), Value::Float(999999.999));
4729        // Float with scientific notation via expression (1e6 parsed by rnix)
4730        assert_eq!(ev("1.0e3"), Value::Float(1000.0));
4731        assert_eq!(ev("1.5e2"), Value::Float(150.0));
4732    }
4733
4734    #[test]
4735    fn literal_string_empty_and_escapes() {
4736        assert_eq!(ev(r#""""#), Value::string(""));
4737        // Escape sequences within strings
4738        assert_eq!(ev(r#""hello\nworld""#), Value::string("hello\nworld"));
4739        assert_eq!(ev(r#""tab\there""#), Value::string("tab\there"));
4740    }
4741
4742    #[test]
4743    fn literal_multiline_string() {
4744        // Indented string ('' ... '')
4745        assert_eq!(
4746            ev("''hello''"),
4747            Value::string("hello"),
4748        );
4749        // Multiline indented string strips common indentation
4750        assert_eq!(
4751            ev("''\n  line1\n  line2\n''"),
4752            Value::string("line1\nline2\n"),
4753        );
4754    }
4755
4756    #[test]
4757    fn literal_paths() {
4758        // Relative path
4759        assert_eq!(ev("./foo"), Value::Path(Box::new(SmolStr::from("./foo"))));
4760        // Absolute path
4761        assert_eq!(ev("/nix/store/abc"), Value::Path(Box::new(SmolStr::from("/nix/store/abc"))));
4762        // Home path
4763        assert_eq!(ev("~/myfile"), Value::Path(Box::new(SmolStr::from("~/myfile"))));
4764    }
4765
4766    // ── Interpolated path literals (cid-marquee root, 2026-07-12) ──
4767    //
4768    // CppNix path literals may contain `${e}` antiquotations: `./${x}.nix`,
4769    // `/a/${e}`, `~/${e}`. sui previously flattened the whole path token to
4770    // raw text and dropped the interpolation (`import ./${x}.nix` →
4771    // `No such file or directory`). The `${e}` must be evaluated,
4772    // string-coerced (plain, no copy-to-store), spliced, and the result is
4773    // still a `path` value. Oracles taken from cppnix.
4774
4775    #[test]
4776    fn interp_path_abs_splices_and_types_path() {
4777        // /a/${x}/b with x="foo" → /a/foo/b, type path (nix oracle).
4778        let v = ev(r#"let x = "foo"; in /a/${x}/b"#);
4779        assert_eq!(v, Value::Path(Box::new(SmolStr::from("/a/foo/b"))));
4780    }
4781
4782    #[test]
4783    fn interp_path_abs_multi_and_slash_in_value() {
4784        // Multiple interpolations + a slash inside the spliced value.
4785        assert_eq!(
4786            ev(r#"let a = "x"; b = "y/z"; in /p/${a}/${b}.nix"#),
4787            Value::Path(Box::new(SmolStr::from("/p/x/y/z.nix"))),
4788        );
4789    }
4790
4791    #[test]
4792    fn interp_path_abs_normalizes_double_slash_seam() {
4793        // A path-typed interpolation splices the raw path (no copy-to-store)
4794        // and the `/` seam is normalized: `/bar/` + `/tmp/foo` → /bar/tmp/foo.
4795        assert_eq!(
4796            ev(r#"/bar/${/tmp/foo}"#),
4797            Value::Path(Box::new(SmolStr::from("/bar/tmp/foo"))),
4798        );
4799    }
4800
4801    #[test]
4802    fn interp_path_rel_resolves_against_eval_dir() {
4803        // The spicetify `map (x: ./${x}.nix) [...]` root: a relative
4804        // interpolated path resolves against the defining file's directory,
4805        // exactly like a plain `./foo.nix` literal.
4806        let _g = push_eval_file(std::path::PathBuf::from("/tmp/example/default.nix"));
4807        assert_eq!(
4808            ev(r#"let x = "foo"; in ./${x}.nix"#),
4809            Value::Path(Box::new(SmolStr::from("/tmp/example/foo.nix"))),
4810        );
4811    }
4812
4813    #[test]
4814    fn interp_path_rel_no_eval_dir_keeps_relative_text() {
4815        // With no eval-file context the plain branch keeps the raw relative
4816        // text; the interpolated branch splices then does the same.
4817        assert_eq!(
4818            ev(r#"let x = "foo"; in ./${x}.nix"#),
4819            Value::Path(Box::new(SmolStr::from("./foo.nix"))),
4820        );
4821    }
4822
4823    #[test]
4824    fn interp_path_home_splices_leading_tilde_preserved() {
4825        // Home paths splice their `${e}`; the leading `~` is carried as-is
4826        // (matching sui's plain `~/foo` behavior — `~`-expansion is a
4827        // separate, pre-existing concern, not introduced here).
4828        assert_eq!(
4829            ev(r#"let x = "foo"; in ~/${x}/bar"#),
4830            Value::Path(Box::new(SmolStr::from("~/foo/bar"))),
4831        );
4832    }
4833
4834    #[test]
4835    fn interp_path_non_interpolated_still_raw() {
4836        // A path with no `${…}` must keep the trivial raw-text shortcut
4837        // (byte-for-byte identical to the plain branch).
4838        assert_eq!(ev("/a/b/c"), Value::Path(Box::new(SmolStr::from("/a/b/c"))));
4839        assert_eq!(ev("~/plain"), Value::Path(Box::new(SmolStr::from("~/plain"))));
4840    }
4841
4842    #[test]
4843    fn literal_null_true_false_standalone() {
4844        assert_eq!(ev("null"), Value::Null);
4845        assert_eq!(ev("true"), Value::Bool(true));
4846        assert_eq!(ev("false"), Value::Bool(false));
4847    }
4848
4849    // ═══════════════════════════════════════════════════════════
4850    // 2. OPERATORS — COMPLETE COVERAGE
4851    // ═══════════════════════════════════════════════════════════
4852
4853    #[test]
4854    fn op_arithmetic_int() {
4855        assert_eq!(ev("100 + 200"), Value::Int(300));
4856        assert_eq!(ev("50 - 30"), Value::Int(20));
4857        assert_eq!(ev("7 * 8"), Value::Int(56));
4858        assert_eq!(ev("17 / 3"), Value::Int(5)); // integer division
4859    }
4860
4861    #[test]
4862    fn op_arithmetic_float() {
4863        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4864        assert_eq!(ev("5.0 - 1.5"), Value::Float(3.5));
4865        assert_eq!(ev("2.0 * 3.0"), Value::Float(6.0));
4866        assert_eq!(ev("7.0 / 2.0"), Value::Float(3.5));
4867    }
4868
4869    #[test]
4870    fn op_arithmetic_mixed_int_float() {
4871        // int + float => float
4872        assert_eq!(ev("1 + 2.5"), Value::Float(3.5));
4873        assert_eq!(ev("2.5 + 1"), Value::Float(3.5));
4874        // int * float => float
4875        assert_eq!(ev("2 * 1.5"), Value::Float(3.0));
4876        // float - int => float
4877        assert_eq!(ev("5.5 - 2"), Value::Float(3.5));
4878    }
4879
4880    #[test]
4881    fn op_string_concat() {
4882        assert_eq!(ev(r#""foo" + "bar""#), Value::string("foobar"));
4883        assert_eq!(ev(r#""" + "x""#), Value::string("x"));
4884        assert_eq!(ev(r#""a" + "" + "b""#), Value::string("ab"));
4885    }
4886
4887    #[test]
4888    fn op_path_concat() {
4889        // path + string
4890        assert_eq!(ev(r#"./foo + "/bar""#), Value::Path(Box::new(SmolStr::from("./foo/bar"))));
4891        // path + path (should join with /)
4892        assert_eq!(ev("./a + ./b"), Value::Path(Box::new(SmolStr::from("./a/./b"))));
4893    }
4894
4895    #[test]
4896    fn op_comparison_ints() {
4897        assert_eq!(ev("1 < 2"), Value::Bool(true));
4898        assert_eq!(ev("2 < 1"), Value::Bool(false));
4899        assert_eq!(ev("2 > 1"), Value::Bool(true));
4900        assert_eq!(ev("1 > 2"), Value::Bool(false));
4901        assert_eq!(ev("2 <= 2"), Value::Bool(true));
4902        assert_eq!(ev("3 <= 2"), Value::Bool(false));
4903        assert_eq!(ev("2 >= 2"), Value::Bool(true));
4904        assert_eq!(ev("1 >= 2"), Value::Bool(false));
4905    }
4906
4907    #[test]
4908    fn op_comparison_floats() {
4909        assert_eq!(ev("1.5 < 2.5"), Value::Bool(true));
4910        assert_eq!(ev("2.5 > 1.5"), Value::Bool(true));
4911        assert_eq!(ev("1.5 <= 1.5"), Value::Bool(true));
4912        assert_eq!(ev("1.5 >= 1.5"), Value::Bool(true));
4913    }
4914
4915    #[test]
4916    fn op_comparison_strings() {
4917        assert_eq!(ev(r#""apple" < "banana""#), Value::Bool(true));
4918        assert_eq!(ev(r#""banana" > "apple""#), Value::Bool(true));
4919        assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4920        assert_eq!(ev(r#""abc" != "xyz""#), Value::Bool(true));
4921        assert_eq!(ev(r#""abc" <= "abd""#), Value::Bool(true));
4922        assert_eq!(ev(r#""abc" >= "abb""#), Value::Bool(true));
4923    }
4924
4925    #[test]
4926    fn op_equality_various_types() {
4927        assert_eq!(ev("null == null"), Value::Bool(true));
4928        assert_eq!(ev("true == true"), Value::Bool(true));
4929        assert_eq!(ev("false == false"), Value::Bool(true));
4930        assert_eq!(ev("true == false"), Value::Bool(false));
4931        assert_eq!(ev("1 == 1"), Value::Bool(true));
4932        assert_eq!(ev("1 != 2"), Value::Bool(true));
4933        // Different types are not equal
4934        assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
4935        assert_eq!(ev("null == false"), Value::Bool(false));
4936    }
4937
4938    #[test]
4939    fn op_logic_short_circuit() {
4940        // false && <error> should NOT evaluate the RHS
4941        assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
4942        // true || <error> should NOT evaluate the RHS
4943        assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
4944    }
4945
4946    #[test]
4947    fn op_logic_full() {
4948        assert_eq!(ev("true && true"), Value::Bool(true));
4949        assert_eq!(ev("true && false"), Value::Bool(false));
4950        assert_eq!(ev("false && true"), Value::Bool(false));
4951        assert_eq!(ev("false && false"), Value::Bool(false));
4952        assert_eq!(ev("true || true"), Value::Bool(true));
4953        assert_eq!(ev("true || false"), Value::Bool(true));
4954        assert_eq!(ev("false || true"), Value::Bool(true));
4955        assert_eq!(ev("false || false"), Value::Bool(false));
4956        assert_eq!(ev("!true"), Value::Bool(false));
4957        assert_eq!(ev("!false"), Value::Bool(true));
4958    }
4959
4960    #[test]
4961    fn op_implication_truth_table() {
4962        // false -> anything = true
4963        assert_eq!(ev("false -> false"), Value::Bool(true));
4964        assert_eq!(ev("false -> true"), Value::Bool(true));
4965        // true -> x = x
4966        assert_eq!(ev("true -> true"), Value::Bool(true));
4967        assert_eq!(ev("true -> false"), Value::Bool(false));
4968    }
4969
4970    #[test]
4971    fn op_implication_short_circuit() {
4972        // false -> <error> should NOT evaluate the RHS
4973        assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
4974    }
4975
4976    #[test]
4977    fn op_update_merge() {
4978        let v = ev("{ a = 1; } // { b = 2; }");
4979        if let Value::Attrs(attrs) = v {
4980            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4981            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4982        } else {
4983            panic!("expected attrs");
4984        }
4985    }
4986
4987    #[test]
4988    fn op_update_right_wins() {
4989        assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4990    }
4991
4992    #[test]
4993    fn op_list_concat() {
4994        assert_eq!(
4995            ev("[1 2] ++ [3 4]"),
4996            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]),
4997        );
4998        // Empty list concat
4999        assert_eq!(ev("[] ++ [1]"), Value::list(vec![Value::Int(1)]));
5000        assert_eq!(ev("[1] ++ []"), Value::list(vec![Value::Int(1)]));
5001    }
5002
5003    #[test]
5004    fn op_has_attr_present_and_absent() {
5005        assert_eq!(ev("{ x = 1; y = 2; } ? x"), Value::Bool(true));
5006        assert_eq!(ev("{ x = 1; } ? z"), Value::Bool(false));
5007        assert_eq!(ev("{} ? anything"), Value::Bool(false));
5008    }
5009
5010    #[test]
5011    fn op_unary_negate() {
5012        assert_eq!(ev("-42"), Value::Int(-42));
5013        assert_eq!(ev("-3.14"), Value::Float(-3.14));
5014        // Double negate
5015        assert_eq!(ev("- -5"), Value::Int(5));
5016    }
5017
5018    // ═══════════════════════════════════════════════════════════
5019    // 3. CONTROL FLOW
5020    // ═══════════════════════════════════════════════════════════
5021
5022    #[test]
5023    fn control_if_true_branch() {
5024        assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
5025    }
5026
5027    #[test]
5028    fn control_if_false_branch() {
5029        assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
5030    }
5031
5032    #[test]
5033    fn control_if_nested() {
5034        assert_eq!(
5035            ev("if true then (if false then 1 else 2) else 3"),
5036            Value::Int(2),
5037        );
5038        assert_eq!(
5039            ev("if false then 1 else (if true then 2 else 3)"),
5040            Value::Int(2),
5041        );
5042    }
5043
5044    #[test]
5045    fn control_assert_passing() {
5046        assert_eq!(ev("assert 1 == 1; 42"), Value::Int(42));
5047        assert_eq!(ev("assert true; true"), Value::Bool(true));
5048    }
5049
5050    #[test]
5051    fn control_assert_failing() {
5052        assert!(eval("assert false; 42").is_err());
5053        assert!(eval("assert 1 == 2; 42").is_err());
5054    }
5055
5056    #[test]
5057    fn control_with_basic_scope() {
5058        assert_eq!(ev("with { a = 1; b = 2; }; a + b"), Value::Int(3));
5059    }
5060
5061    #[test]
5062    fn control_with_lexical_precedence() {
5063        // let binding takes precedence over with scope
5064        assert_eq!(
5065            ev("let x = 10; in with { x = 99; }; x"),
5066            Value::Int(10),
5067        );
5068    }
5069
5070    #[test]
5071    fn control_with_nested() {
5072        assert_eq!(
5073            ev("with { a = 1; }; with { b = 2; }; a + b"),
5074            Value::Int(3),
5075        );
5076    }
5077
5078    #[test]
5079    fn control_with_lazy_fix_self() {
5080        // THE critical pattern that nixpkgs requires:
5081        // fix (self: with self; { a = 1; b = a + 1; })
5082        // Before the lazy-with fix, this would hit the blackhole detector
5083        // because `with` eagerly forced `self`.
5084        let result = eval(
5085            "let fix = f: let x = f x; in x; in fix (self: with self; { a = 1; b = a + 1; })"
5086        );
5087        assert!(result.is_ok(), "fix with self should work: {:?}", result);
5088        if let Ok(Value::Attrs(attrs)) = result {
5089            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5090            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5091        } else {
5092            panic!("expected Attrs, got {:?}", result);
5093        }
5094    }
5095
5096    #[test]
5097    fn control_with_lazy_fix_self_lib_pattern() {
5098        // The nixpkgs pattern: self-referential package set with lib.
5099        // Access via select to force through the thunk layer.
5100        let result = eval(r#"
5101            let fix = f: let x = f x; in x;
5102            in (fix (self: with self; {
5103                lib = { version = "1.0"; };
5104                hello = "hello ${lib.version}";
5105            })).hello
5106        "#);
5107        assert!(result.is_ok(), "nixpkgs-style lib pattern: {:?}", result);
5108        assert_eq!(
5109            result.unwrap(),
5110            Value::String(Rc::new(NixString::plain("hello 1.0"))),
5111        );
5112    }
5113
5114    #[test]
5115    fn control_with_non_attrset_errors() {
5116        // CppNix errors when with-scope is not an attrset and a lookup hits it
5117        let result = eval("with 42; 1");
5118        // The body `1` is a literal and doesn't look up anything in the
5119        // with-scope, so this should succeed (the scope is never forced).
5120        assert_eq!(result.unwrap(), Value::Int(1));
5121    }
5122
5123    #[test]
5124    fn control_with_non_attrset_lookup_falls_through() {
5125        // If the with scope is not an attrset, lookups should fall through
5126        // to outer scopes rather than crashing.
5127        let result = eval("let x = 1; in with 42; x");
5128        assert_eq!(result.unwrap(), Value::Int(1));
5129    }
5130
5131    #[test]
5132    fn control_let_simple_and_multiple() {
5133        assert_eq!(ev("let x = 5; in x"), Value::Int(5));
5134        assert_eq!(ev("let x = 1; y = 2; z = 3; in x + y + z"), Value::Int(6));
5135    }
5136
5137    #[test]
5138    fn control_let_shadow_outer() {
5139        assert_eq!(
5140            ev("let x = 1; in let x = 2; in x"),
5141            Value::Int(2),
5142        );
5143    }
5144
5145    #[test]
5146    fn control_let_recursive_reference() {
5147        assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
5148        assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
5149    }
5150
5151    #[test]
5152    fn control_nested_let_expression() {
5153        assert_eq!(
5154            ev("let a = let b = 1; in b; in a"),
5155            Value::Int(1),
5156        );
5157        assert_eq!(
5158            ev("let a = let b = 10; in b + 5; in a * 2"),
5159            Value::Int(30),
5160        );
5161    }
5162
5163    // ═══════════════════════════════════════════════════════════
5164    // 4. FUNCTIONS — COMPLETE COVERAGE
5165    // ═══════════════════════════════════════════════════════════
5166
5167    #[test]
5168    fn func_identity_lambda() {
5169        assert_eq!(ev("(x: x) 42"), Value::Int(42));
5170        assert_eq!(ev(r#"(x: x) "hello""#), Value::string("hello"));
5171    }
5172
5173    #[test]
5174    fn func_curried_two_args() {
5175        assert_eq!(ev("(x: y: x + y) 3 4"), Value::Int(7));
5176    }
5177
5178    #[test]
5179    fn func_curried_three_args() {
5180        assert_eq!(ev("(a: b: c: a + b + c) 1 2 3"), Value::Int(6));
5181    }
5182
5183    #[test]
5184    fn func_formals_basic() {
5185        assert_eq!(ev("({ a, b }: a + b) { a = 3; b = 7; }"), Value::Int(10));
5186    }
5187
5188    #[test]
5189    fn func_formals_with_defaults() {
5190        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; }"), Value::Int(15));
5191        // Providing the default-able argument overrides the default
5192        assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; b = 20; }"), Value::Int(25));
5193    }
5194
5195    #[test]
5196    fn func_formals_with_ellipsis() {
5197        assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; c = 3; }"), Value::Int(1));
5198    }
5199
5200    #[test]
5201    fn func_named_formals_at_before() {
5202        // args @ { a, b }: ...
5203        assert_eq!(
5204            ev("(args @ { a, b }: args.a + args.b) { a = 3; b = 4; }"),
5205            Value::Int(7),
5206        );
5207    }
5208
5209    #[test]
5210    fn func_named_formals_at_after() {
5211        // { a, b } @ args: ...
5212        assert_eq!(
5213            ev("({ a, b } @ args: args.a + args.b) { a = 10; b = 20; }"),
5214            Value::Int(30),
5215        );
5216    }
5217
5218    #[test]
5219    fn func_nested_application() {
5220        // Explicit parenthesized application
5221        assert_eq!(ev("((x: y: x * y) 3) 4"), Value::Int(12));
5222    }
5223
5224    #[test]
5225    fn func_higher_order_map() {
5226        assert_eq!(
5227            ev("builtins.map (x: x * 2) [1 2 3]"),
5228            Value::list(vec![Value::Int(2), Value::Int(4), Value::Int(6)]),
5229        );
5230    }
5231
5232    #[test]
5233    fn func_higher_order_filter() {
5234        assert_eq!(
5235            ev("builtins.filter (x: x > 2) [1 2 3 4 5]"),
5236            Value::list(vec![Value::Int(3), Value::Int(4), Value::Int(5)]),
5237        );
5238    }
5239
5240    #[test]
5241    fn func_higher_order_foldl() {
5242        // Sum of list via foldl'
5243        assert_eq!(
5244            ev("builtins.foldl' (acc: x: acc + x) 0 [1 2 3 4]"),
5245            Value::Int(10),
5246        );
5247    }
5248
5249    #[test]
5250    fn func_as_attrset_value() {
5251        assert_eq!(
5252            ev("let s = { f = x: x + 1; }; in s.f 5"),
5253            Value::Int(6),
5254        );
5255    }
5256
5257    #[test]
5258    fn func_immediate_application() {
5259        assert_eq!(ev("(x: x * x) 7"), Value::Int(49));
5260    }
5261
5262    #[test]
5263    fn func_in_let_binding() {
5264        assert_eq!(
5265            ev("let double = x: x * 2; in double 21"),
5266            Value::Int(42),
5267        );
5268    }
5269
5270    // ═══════════════════════════════════════════════════════════
5271    // 5. ATTRIBUTE SETS — COMPLETE COVERAGE
5272    // ═══════════════════════════════════════════════════════════
5273
5274    #[test]
5275    fn attrs_empty_set() {
5276        let v = ev("{}");
5277        if let Value::Attrs(attrs) = v {
5278            assert!(attrs.is_empty());
5279        } else {
5280            panic!("expected attrs");
5281        }
5282    }
5283
5284    #[test]
5285    fn attrs_simple() {
5286        assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
5287    }
5288
5289    #[test]
5290    fn attrs_nested_access() {
5291        assert_eq!(ev("{ a = { b = { c = 42; }; }; }.a.b.c"), Value::Int(42));
5292    }
5293
5294    #[test]
5295    fn attrs_recursive_set() {
5296        assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
5297    }
5298
5299    #[test]
5300    fn attrs_update_disjoint() {
5301        let v = ev("{ a = 1; } // { b = 2; }");
5302        if let Value::Attrs(attrs) = v {
5303            assert_eq!(attrs.len(), 2);
5304            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5305            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5306        } else {
5307            panic!("expected attrs");
5308        }
5309    }
5310
5311    #[test]
5312    fn attrs_update_override() {
5313        assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
5314    }
5315
5316    #[test]
5317    fn attrs_has_attr_operator() {
5318        assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
5319        assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
5320    }
5321
5322    #[test]
5323    fn attrs_select_with_default() {
5324        assert_eq!(ev("{ a = 1; }.a or 99"), Value::Int(1));
5325        assert_eq!(ev("{}.missing or 99"), Value::Int(99));
5326        assert_eq!(ev("{ a = 1; }.b or 42"), Value::Int(42));
5327    }
5328
5329    #[test]
5330    fn attrs_nested_attr_path_in_binding() {
5331        // { a.b = 1; } creates { a = { b = 1; }; }
5332        assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
5333    }
5334
5335    #[test]
5336    fn attrs_inherit_from_scope() {
5337        assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.x"), Value::Int(1));
5338        assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.y"), Value::Int(2));
5339    }
5340
5341    #[test]
5342    fn attrs_inherit_from_expr() {
5343        assert_eq!(
5344            ev("{ inherit ({ a = 42; b = 10; }) a; }.a"),
5345            Value::Int(42),
5346        );
5347    }
5348
5349    #[test]
5350    fn attrs_dynamic_attr_name() {
5351        assert_eq!(
5352            ev(r#"let name = "x"; in { ${name} = 42; }.x"#),
5353            Value::Int(42),
5354        );
5355    }
5356
5357    #[test]
5358    fn attrs_attr_names_sorted() {
5359        assert_eq!(
5360            ev("builtins.attrNames { z = 1; m = 2; a = 3; }"),
5361            Value::list(vec![
5362                Value::string("a"),
5363                Value::string("m"),
5364                Value::string("z"),
5365            ]),
5366        );
5367    }
5368
5369    #[test]
5370    fn attrs_attr_values_follow_key_order() {
5371        // BTreeMap iteration order: a=1, b=2, c=3
5372        assert_eq!(
5373            ev("builtins.attrValues { c = 3; a = 1; b = 2; }"),
5374            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5375        );
5376    }
5377
5378    #[test]
5379    fn attrs_update_is_shallow() {
5380        // // is a shallow merge; nested attrs are replaced, not merged
5381        assert_eq!(
5382            ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a ? x"),
5383            Value::Bool(false),
5384        );
5385        assert_eq!(
5386            ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a.y"),
5387            Value::Int(2),
5388        );
5389    }
5390
5391    // ═══════════════════════════════════════════════════════════
5392    // 6. LISTS — COMPLETE COVERAGE
5393    // ═══════════════════════════════════════════════════════════
5394
5395    #[test]
5396    fn list_empty() {
5397        assert_eq!(ev("[]"), Value::list(vec![]));
5398    }
5399
5400    #[test]
5401    fn list_single_element() {
5402        assert_eq!(ev("[1]"), Value::list(vec![Value::Int(1)]));
5403    }
5404
5405    #[test]
5406    fn list_mixed_types() {
5407        assert_eq!(
5408            ev(r#"[1 "two" true null]"#),
5409            Value::list(vec![
5410                Value::Int(1),
5411                Value::string("two"),
5412                Value::Bool(true),
5413                Value::Null,
5414            ]),
5415        );
5416    }
5417
5418    #[test]
5419    fn list_nested() {
5420        assert_eq!(
5421            ev("[[1 2] [3 4]]"),
5422            Value::list(vec![
5423                Value::list(vec![Value::Int(1), Value::Int(2)]),
5424                Value::list(vec![Value::Int(3), Value::Int(4)]),
5425            ]),
5426        );
5427    }
5428
5429    #[test]
5430    fn list_concat_operator() {
5431        assert_eq!(
5432            ev("[1] ++ [2] ++ [3]"),
5433            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5434        );
5435    }
5436
5437    #[test]
5438    fn list_builtins_length() {
5439        assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
5440        assert_eq!(ev("builtins.length []"), Value::Int(0));
5441    }
5442
5443    #[test]
5444    fn list_builtins_elem_at() {
5445        assert_eq!(ev("builtins.elemAt [10 20 30] 0"), Value::Int(10));
5446        assert_eq!(ev("builtins.elemAt [10 20 30] 1"), Value::Int(20));
5447        assert_eq!(ev("builtins.elemAt [10 20 30] 2"), Value::Int(30));
5448    }
5449
5450    #[test]
5451    fn list_equality() {
5452        assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
5453        assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
5454        assert_eq!(ev("[] == []"), Value::Bool(true));
5455    }
5456
5457    // ═══════════════════════════════════════════════════════════
5458    // 7. STRING INTERPOLATION
5459    // ═══════════════════════════════════════════════════════════
5460
5461    #[test]
5462    fn interp_simple_variable() {
5463        assert_eq!(
5464            ev(r#"let name = "world"; in "hello ${name}""#),
5465            Value::string("hello world"),
5466        );
5467    }
5468
5469    #[test]
5470    fn interp_nested_expression() {
5471        assert_eq!(
5472            ev(r#""result: ${builtins.toString (1 + 2)}""#),
5473            Value::string("result: 3"),
5474        );
5475    }
5476
5477    #[test]
5478    fn interp_int_coercion() {
5479        // Ints are coerced to string in interpolation
5480        assert_eq!(
5481            ev(r#"let x = 42; in "count: ${builtins.toString x}""#),
5482            Value::string("count: 42"),
5483        );
5484    }
5485
5486    #[test]
5487    fn interp_multiple() {
5488        assert_eq!(
5489            ev(r#"let a = "foo"; b = "bar"; in "${a} and ${b}""#),
5490            Value::string("foo and bar"),
5491        );
5492    }
5493
5494    #[test]
5495    fn interp_in_let() {
5496        assert_eq!(
5497            ev(r#"let x = "world"; in "hello ${x}""#),
5498            Value::string("hello world"),
5499        );
5500    }
5501
5502    #[test]
5503    fn interp_empty_result() {
5504        assert_eq!(
5505            ev(r#"let x = ""; in "a${x}b""#),
5506            Value::string("ab"),
5507        );
5508    }
5509
5510    #[test]
5511    fn interp_path_in_string_context() {
5512        // CppNix string interpolation is copy-to-store coercion: a nonexistent
5513        // path errors "path '…' does not exist" (previously sui spliced the raw
5514        // relative path "./foo" verbatim, diverging from nix). The positive
5515        // copy-to-store case is byte-verified in
5516        // interp_path_copies_to_store_byte_matches_cppnix below.
5517        assert!(eval(r#""path: ${./foo-nonexistent-xyz}""#).is_err());
5518    }
5519
5520    #[test]
5521    fn interp_adjacent_interpolations() {
5522        assert_eq!(
5523            ev(r#"let a = "x"; b = "y"; in "${a}${b}""#),
5524            Value::string("xy"),
5525        );
5526    }
5527
5528    // ═══════════════════════════════════════════════════════════
5529    // 8. BUILTINS — VERIFY ALL MAJOR ONES
5530    // ═══════════════════════════════════════════════════════════
5531
5532    #[test]
5533    fn builtins_map_filter_foldl() {
5534        // map
5535        assert_eq!(
5536            ev("builtins.map (x: x + 10) [1 2 3]"),
5537            Value::list(vec![Value::Int(11), Value::Int(12), Value::Int(13)]),
5538        );
5539        // filter
5540        assert_eq!(
5541            ev("builtins.filter (x: x > 1) [1 2 3]"),
5542            Value::list(vec![Value::Int(2), Value::Int(3)]),
5543        );
5544        // foldl' — product
5545        assert_eq!(
5546            ev("builtins.foldl' (a: b: a * b) 1 [2 3 4]"),
5547            Value::Int(24),
5548        );
5549    }
5550
5551    #[test]
5552    fn builtins_map_attrs() {
5553        assert_eq!(
5554            ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).a"),
5555            Value::Int(2),
5556        );
5557        assert_eq!(
5558            ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).b"),
5559            Value::Int(4),
5560        );
5561    }
5562
5563    #[test]
5564    fn builtins_list_to_attrs() {
5565        assert_eq!(
5566            ev(r#"(builtins.listToAttrs [{ name = "x"; value = 1; } { name = "y"; value = 2; }]).x"#),
5567            Value::Int(1),
5568        );
5569    }
5570
5571    #[test]
5572    fn builtins_list_to_attrs_duplicate_key_first_wins() {
5573        // Nix `listToAttrs` keeps the FIRST occurrence of a duplicate `name`
5574        // (later duplicates are ignored). cppnix returns 1 here, not 2.
5575        // Byte-parity root (cid darwin): a Cargo.lock listing a crate twice
5576        // (registry entry then git entry of the same name+version) must
5577        // resolve to the FIRST source, so `substrate/lockfile-delta.nix`'s
5578        // `lockByKey` picks the registry crate exactly as nix does. Last-wins
5579        // silently switched the source to git and produced a structurally
5580        // different `rust_<crate>` derivation.
5581        assert_eq!(
5582            ev(r#"(builtins.listToAttrs [{ name = "k"; value = 1; } { name = "k"; value = 2; }]).k"#),
5583            Value::Int(1),
5584        );
5585    }
5586
5587    #[test]
5588    fn builtins_concat_map() {
5589        assert_eq!(
5590            ev("builtins.concatMap (x: [x (x * 2)]) [1 2 3]"),
5591            Value::list(vec![
5592                Value::Int(1), Value::Int(2),
5593                Value::Int(2), Value::Int(4),
5594                Value::Int(3), Value::Int(6),
5595            ]),
5596        );
5597    }
5598
5599    #[test]
5600    fn builtins_concat_lists() {
5601        assert_eq!(
5602            ev("builtins.concatLists [[1 2] [3] [4 5]]"),
5603            Value::list(vec![
5604                Value::Int(1), Value::Int(2), Value::Int(3),
5605                Value::Int(4), Value::Int(5),
5606            ]),
5607        );
5608    }
5609
5610    #[test]
5611    fn builtins_concat_strings_sep() {
5612        assert_eq!(
5613            ev(r#"builtins.concatStringsSep ", " ["a" "b" "c"]"#),
5614            Value::string("a, b, c"),
5615        );
5616        assert_eq!(
5617            ev(r#"builtins.concatStringsSep "" ["x" "y"]"#),
5618            Value::string("xy"),
5619        );
5620    }
5621
5622    #[test]
5623    fn builtins_replace_strings() {
5624        assert_eq!(
5625            ev(r#"builtins.replaceStrings ["o"] ["0"] "foobar""#),
5626            Value::string("f00bar"),
5627        );
5628        assert_eq!(
5629            ev(r#"builtins.replaceStrings ["hello"] ["goodbye"] "hello world""#),
5630            Value::string("goodbye world"),
5631        );
5632    }
5633
5634    /// `hasPrefix`/`hasSuffix` are nixpkgs `lib.strings` functions, NOT CppNix
5635    /// builtins — so sui must not have them either. This test used to assert
5636    /// they worked; it now asserts they are absent, which is the same test
5637    /// pointed the correct way.
5638    #[test]
5639    fn builtins_has_prefix_has_suffix_are_not_builtins() {
5640        assert_eq!(ev(r#"builtins ? hasPrefix"#), Value::Bool(false));
5641        assert_eq!(ev(r#"builtins ? hasSuffix"#), Value::Bool(false));
5642        assert!(
5643            eval(r#"builtins.hasPrefix "he" "hello""#).is_err(),
5644            "builtins.hasPrefix must fail the way real nix fails it"
5645        );
5646        assert!(
5647            eval(r#"builtins.hasSuffix "lo" "hello""#).is_err(),
5648            "builtins.hasSuffix must fail the way real nix fails it"
5649        );
5650    }
5651
5652    #[test]
5653    fn builtins_all_any() {
5654        assert_eq!(ev("builtins.all (x: x > 0) [1 2 3]"), Value::Bool(true));
5655        assert_eq!(ev("builtins.all (x: x > 1) [1 2 3]"), Value::Bool(false));
5656        assert_eq!(ev("builtins.any (x: x > 2) [1 2 3]"), Value::Bool(true));
5657        assert_eq!(ev("builtins.any (x: x > 5) [1 2 3]"), Value::Bool(false));
5658    }
5659
5660    #[test]
5661    fn builtins_sort() {
5662        assert_eq!(
5663            ev("builtins.sort (a: b: a < b) [3 1 2]"),
5664            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5665        );
5666    }
5667
5668    #[test]
5669    fn builtins_remove_attrs() {
5670        let v = ev(r#"builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b" "c"]"#);
5671        if let Value::Attrs(attrs) = v {
5672            assert_eq!(attrs.len(), 1);
5673            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5674            assert!(attrs.get("b").is_none());
5675        } else {
5676            panic!("expected attrs");
5677        }
5678    }
5679
5680    #[test]
5681    fn builtins_intersect_attrs() {
5682        let v = ev("builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }");
5683        if let Value::Attrs(attrs) = v {
5684            assert_eq!(attrs.len(), 1);
5685            // intersectAttrs returns values from the second set
5686            assert_eq!(attrs.get("b"), Some(&Value::Int(20)));
5687        } else {
5688            panic!("expected attrs");
5689        }
5690    }
5691
5692    #[test]
5693    fn builtins_type_of_all_types() {
5694        assert_eq!(ev("builtins.typeOf null"), Value::string("null"));
5695        assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
5696        assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
5697        assert_eq!(ev("builtins.typeOf 3.14"), Value::string("float"));
5698        assert_eq!(ev(r#"builtins.typeOf "hi""#), Value::string("string"));
5699        assert_eq!(ev("builtins.typeOf [1]"), Value::string("list"));
5700        assert_eq!(ev("builtins.typeOf {}"), Value::string("set"));
5701        assert_eq!(ev("builtins.typeOf (x: x)"), Value::string("lambda"));
5702    }
5703
5704    #[test]
5705    fn builtins_is_type_checks() {
5706        assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
5707        assert_eq!(ev("builtins.isNull 0"), Value::Bool(false));
5708        assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
5709        assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
5710        assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
5711        assert_eq!(ev("builtins.isBool 1"), Value::Bool(false));
5712        assert_eq!(ev(r#"builtins.isString "x""#), Value::Bool(true));
5713        assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
5714        assert_eq!(ev("builtins.isList []"), Value::Bool(true));
5715        assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
5716        assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
5717        assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
5718        assert_eq!(ev("builtins.isFunction (x: x)"), Value::Bool(true));
5719        assert_eq!(ev("builtins.isFunction 1"), Value::Bool(false));
5720        assert_eq!(ev("builtins.isFloat 3.14"), Value::Bool(true));
5721        assert_eq!(ev("builtins.isFloat 1"), Value::Bool(false));
5722    }
5723
5724    #[test]
5725    fn builtins_to_json_from_json_roundtrip() {
5726        // int roundtrip
5727        assert_eq!(ev("builtins.fromJSON (builtins.toJSON 42)"), Value::Int(42));
5728        // string roundtrip
5729        assert_eq!(
5730            ev(r#"builtins.fromJSON (builtins.toJSON "hello")"#),
5731            Value::string("hello"),
5732        );
5733        // list roundtrip
5734        assert_eq!(
5735            ev("builtins.fromJSON (builtins.toJSON [1 2 3])"),
5736            Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5737        );
5738        // null roundtrip
5739        assert_eq!(ev("builtins.fromJSON (builtins.toJSON null)"), Value::Null);
5740        // bool roundtrip
5741        assert_eq!(ev("builtins.fromJSON (builtins.toJSON true)"), Value::Bool(true));
5742    }
5743
5744    #[test]
5745    fn builtins_to_string_various() {
5746        assert_eq!(ev("builtins.toString 42"), Value::string("42"));
5747        assert_eq!(ev("builtins.toString true"), Value::string("1"));
5748        assert_eq!(ev("builtins.toString false"), Value::string(""));
5749        assert_eq!(ev("builtins.toString null"), Value::string(""));
5750        assert_eq!(ev(r#"builtins.toString "hello""#), Value::string("hello"));
5751    }
5752
5753    #[test]
5754    fn builtins_function_args() {
5755        let v = ev("builtins.functionArgs ({ a, b ? 1 }: a)");
5756        if let Value::Attrs(attrs) = v {
5757            assert_eq!(attrs.get("a"), Some(&Value::Bool(false))); // no default
5758            assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));  // has default
5759        } else {
5760            panic!("expected attrs");
5761        }
5762    }
5763
5764    #[test]
5765    fn builtins_gen_list() {
5766        assert_eq!(
5767            ev("builtins.genList (x: x * x) 5"),
5768            Value::list(vec![
5769                Value::Int(0), Value::Int(1), Value::Int(4),
5770                Value::Int(9), Value::Int(16),
5771            ]),
5772        );
5773        assert_eq!(ev("builtins.genList (x: x) 0"), Value::list(vec![]));
5774    }
5775
5776    #[test]
5777    fn builtins_elem() {
5778        assert_eq!(ev("builtins.elem 2 [1 2 3]"), Value::Bool(true));
5779        assert_eq!(ev("builtins.elem 5 [1 2 3]"), Value::Bool(false));
5780        assert_eq!(ev("builtins.elem 1 []"), Value::Bool(false));
5781    }
5782
5783    #[test]
5784    fn builtins_head_tail() {
5785        assert_eq!(ev("builtins.head [10 20 30]"), Value::Int(10));
5786        assert_eq!(
5787            ev("builtins.tail [10 20 30]"),
5788            Value::list(vec![Value::Int(20), Value::Int(30)]),
5789        );
5790    }
5791
5792    #[test]
5793    fn builtins_string_length() {
5794        assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
5795        assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
5796        assert_eq!(ev(r#"builtins.stringLength "abc def""#), Value::Int(7));
5797    }
5798
5799    #[test]
5800    fn builtins_ceil_floor() {
5801        assert_eq!(ev("builtins.ceil 2.3"), Value::Int(3));
5802        assert_eq!(ev("builtins.ceil 2.0"), Value::Int(2));
5803        assert_eq!(ev("builtins.floor 2.9"), Value::Int(2));
5804        assert_eq!(ev("builtins.floor 2.0"), Value::Int(2));
5805        // Int coercion: ceil/floor on int should work via to_float()
5806        assert_eq!(ev("builtins.ceil 5"), Value::Int(5));
5807        assert_eq!(ev("builtins.floor 5"), Value::Int(5));
5808    }
5809
5810    #[test]
5811    fn builtins_try_eval() {
5812        let v = ev("builtins.tryEval 42");
5813        if let Value::Attrs(attrs) = v {
5814            assert_eq!(attrs.get("success"), Some(&Value::Bool(true)));
5815            assert_eq!(attrs.get("value"), Some(&Value::Int(42)));
5816        } else {
5817            panic!("expected attrs");
5818        }
5819    }
5820
5821    #[test]
5822    fn builtins_throw() {
5823        let result = eval(r#"builtins.throw "oops""#);
5824        assert!(result.is_err());
5825        let msg = format!("{}", result.unwrap_err());
5826        assert!(msg.contains("oops"));
5827    }
5828
5829    #[test]
5830    fn builtins_seq_deep_seq() {
5831        // seq forces first arg, returns second
5832        assert_eq!(ev("builtins.seq 1 42"), Value::Int(42));
5833        // deepSeq similarly
5834        assert_eq!(ev("builtins.deepSeq [1 2 3] 99"), Value::Int(99));
5835    }
5836
5837    #[test]
5838    fn builtins_current_system() {
5839        let v = ev("builtins.currentSystem");
5840        if let Value::String(ns) = v {
5841            let s = &ns.chars;
5842            // Should be a valid system string
5843            assert!(
5844                s == "aarch64-darwin"
5845                    || s == "x86_64-darwin"
5846                    || s == "aarch64-linux"
5847                    || s == "x86_64-linux",
5848                "unexpected system: {s}",
5849            );
5850        } else {
5851            panic!("expected string");
5852        }
5853    }
5854
5855    // ═══════════════════════════════════════════════════════════
5856    // 9. REAL-WORLD NIXPKGS PATTERNS
5857    // ═══════════════════════════════════════════════════════════
5858
5859    #[test]
5860    fn pattern_mkif_like() {
5861        // lib.mkIf pattern: if condition then { key = value; } else {}
5862        assert_eq!(
5863            ev("(if true then { x = 1; } else {}).x"),
5864            Value::Int(1),
5865        );
5866        let v = ev("if false then { x = 1; } else {}");
5867        if let Value::Attrs(attrs) = v {
5868            assert!(attrs.is_empty());
5869        } else {
5870            panic!("expected attrs");
5871        }
5872    }
5873
5874    #[test]
5875    fn pattern_optional_attrs() {
5876        // lib.optionalAttrs pattern
5877        assert_eq!(
5878            ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in (optionalAttrs true { a = 1; }).a"),
5879            Value::Int(1),
5880        );
5881        let v = ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in optionalAttrs false { a = 1; }");
5882        if let Value::Attrs(attrs) = v {
5883            assert!(attrs.is_empty());
5884        } else {
5885            panic!("expected attrs");
5886        }
5887    }
5888
5889    #[test]
5890    fn pattern_filter_attrs_via_remove() {
5891        // lib.filterAttrs pattern via removeAttrs
5892        assert_eq!(
5893            ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]).a"#),
5894            Value::Int(1),
5895        );
5896        assert_eq!(
5897            ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]) ? b"#),
5898            Value::Bool(false),
5899        );
5900    }
5901
5902    #[test]
5903    fn pattern_override() {
5904        // default // overrides pattern
5905        let v = ev(r#"
5906            let
5907                defaults = { debug = false; port = 8080; host = "localhost"; };
5908                overrides = { debug = true; port = 9090; };
5909            in defaults // overrides
5910        "#);
5911        if let Value::Attrs(attrs) = v {
5912            assert_eq!(attrs.get("debug"), Some(&Value::Bool(true)));
5913            assert_eq!(attrs.get("port"), Some(&Value::Int(9090)));
5914            assert_eq!(attrs.get("host"), Some(&Value::string("localhost")));
5915        } else {
5916            panic!("expected attrs");
5917        }
5918    }
5919
5920    #[test]
5921    fn pattern_functor() {
5922        // { __functor = self: x: self.value + x; value = 10; } 5
5923        assert_eq!(
5924            ev("let s = { __functor = self: x: self.value + x; value = 10; }; in s 5"),
5925            Value::Int(15),
5926        );
5927    }
5928
5929    #[test]
5930    fn pattern_platform_check() {
5931        // Check pattern: if builtins.currentSystem == "..." then ... else ...
5932        let v = ev(r#"if builtins.currentSystem == "aarch64-darwin" then "arm" else "other""#);
5933        // We just verify it evaluates without error and produces a string
5934        if let Value::String(_) = v {
5935            // ok
5936        } else {
5937            panic!("expected string");
5938        }
5939    }
5940
5941    #[test]
5942    fn pattern_recursive_overlay_lambda_structure() {
5943        // Test the lambda structure of an overlay (self: super: { ... })
5944        let v = ev("let overlay = self: super: { pkg = 42; }; in overlay {} {}");
5945        if let Value::Attrs(attrs) = v {
5946            assert_eq!(attrs.get("pkg"), Some(&Value::Int(42)));
5947        } else {
5948            panic!("expected attrs");
5949        }
5950    }
5951
5952    #[test]
5953    fn pattern_call_package_simplified() {
5954        // Simplified callPackage: f: f { inherit lib; }
5955        assert_eq!(
5956            ev("let callPkg = f: f { lib = { id = x: x; }; }; lib = { id = x: x; }; in callPkg ({ lib }: lib.id 42)"),
5957            Value::Int(42),
5958        );
5959    }
5960
5961    #[test]
5962    fn pattern_derivation_like_attrset() {
5963        let v = ev(r#"{ type = "derivation"; name = "hello"; system = builtins.currentSystem; builder = "/bin/sh"; }"#);
5964        if let Value::Attrs(attrs) = v {
5965            assert_eq!(attrs.get("type"), Some(&Value::string("derivation")));
5966            assert_eq!(attrs.get("name"), Some(&Value::string("hello")));
5967            assert_eq!(attrs.get("builder"), Some(&Value::string("/bin/sh")));
5968            // system should be a string (may be a thunk that forces to string)
5969            let system = force_value(attrs.get("system").unwrap()).unwrap();
5970            assert!(matches!(system, Value::String(_)), "expected string, got {system:?}");
5971        } else {
5972            panic!("expected attrs");
5973        }
5974    }
5975
5976    #[test]
5977    fn pattern_module_system_simplified() {
5978        // Simplified NixOS module evaluation
5979        assert_eq!(
5980            ev(r#"
5981                let
5982                    eval = m: m { config = {}; lib = { mkDefault = x: x; }; };
5983                in eval ({ config, lib }: { result = lib.mkDefault 42; })
5984            "#),
5985            {
5986                let mut attrs = NixAttrs::new();
5987                attrs.insert("result".to_string(), Value::Int(42));
5988                Value::Attrs(Rc::new(attrs))
5989            },
5990        );
5991    }
5992
5993    // ═══════════════════════════════════════════════════════════
5994    // 10. ERROR HANDLING
5995    // ═══════════════════════════════════════════════════════════
5996
5997    #[test]
5998    fn error_undefined_variable() {
5999        let result = eval("nonexistent_var");
6000        assert!(result.is_err());
6001        let msg = format!("{}", result.unwrap_err());
6002        assert!(msg.contains("undefined variable") || msg.contains("nonexistent_var"));
6003    }
6004
6005    #[test]
6006    fn error_type_mismatch_arithmetic() {
6007        let result = eval(r#"1 + "hello""#);
6008        assert!(result.is_err());
6009    }
6010
6011    #[test]
6012    fn error_missing_attribute() {
6013        let result = eval("{}.nonexistent");
6014        assert!(result.is_err());
6015        let msg = format!("{}", result.unwrap_err());
6016        assert!(msg.contains("nonexistent") || msg.contains("not found"));
6017    }
6018
6019    #[test]
6020    fn error_division_by_zero() {
6021        assert!(eval("1 / 0").is_err());
6022        assert!(eval("100 / 0").is_err());
6023    }
6024
6025    #[test]
6026    fn error_missing_required_function_arg() {
6027        let result = eval("({ a, b }: a + b) { a = 1; }");
6028        assert!(result.is_err());
6029        let msg = format!("{}", result.unwrap_err());
6030        assert!(msg.contains("missing argument"));
6031    }
6032
6033    #[test]
6034    fn error_unexpected_function_arg() {
6035        let result = eval("({ a }: a) { a = 1; b = 2; }");
6036        assert!(result.is_err());
6037        let msg = format!("{}", result.unwrap_err());
6038        assert!(msg.contains("unexpected argument"));
6039    }
6040
6041    #[test]
6042    fn error_assertion_failure() {
6043        assert!(eval("assert false; 1").is_err());
6044        assert!(eval("assert 1 == 2; 1").is_err());
6045    }
6046
6047    #[test]
6048    fn error_infinite_recursion() {
6049        // `let x = x; in x` should either hit the depth guard or fail on
6050        // undefined variable (since sequential let can't see its own binding).
6051        let result = eval("let x = x; in x");
6052        assert!(result.is_err());
6053    }
6054
6055    #[test]
6056    fn error_infinite_recursion_via_lambda() {
6057        // A true infinite recursion via self-application -- depth guard catches this.
6058        let result = eval("let f = x: f x; in f 1");
6059        assert!(result.is_err());
6060        let msg = format!("{}", result.unwrap_err());
6061        assert!(
6062            msg.contains("infinite recursion") || msg.contains("eval depth") || msg.contains("undefined"),
6063        );
6064    }
6065
6066    // ═══════════════════════════════════════════════════════════
6067    // ADDITIONAL COVERAGE: edge cases and integration
6068    // ═══════════════════════════════════════════════════════════
6069
6070    #[test]
6071    fn integration_let_with_function_returning_attrset() {
6072        assert_eq!(
6073            ev("let mkPkg = name: { inherit name; version = 1; }; in (mkPkg \"hello\").name"),
6074            Value::string("hello"),
6075        );
6076    }
6077
6078    #[test]
6079    fn integration_chained_updates() {
6080        assert_eq!(
6081            ev("({ a = 1; } // { b = 2; } // { c = 3; }).c"),
6082            Value::Int(3),
6083        );
6084    }
6085
6086    #[test]
6087    fn integration_map_over_attrnames() {
6088        // Common nixpkgs pattern: map over attrNames
6089        assert_eq!(
6090            ev(r#"
6091                let
6092                    set = { a = 1; b = 2; };
6093                    names = builtins.attrNames set;
6094                in builtins.length names
6095            "#),
6096            Value::Int(2),
6097        );
6098    }
6099
6100    #[test]
6101    fn integration_compose_functions() {
6102        // Function composition
6103        assert_eq!(
6104            ev("let compose = f: g: x: f (g x); double = x: x * 2; inc = x: x + 1; in compose double inc 5"),
6105            Value::Int(12), // (5 + 1) * 2
6106        );
6107    }
6108
6109    #[test]
6110    fn integration_recursive_list_building() {
6111        // Build a list using genList and map
6112        assert_eq!(
6113            ev("builtins.map (x: x * x) (builtins.genList (x: x + 1) 4)"),
6114            Value::list(vec![Value::Int(1), Value::Int(4), Value::Int(9), Value::Int(16)]),
6115        );
6116    }
6117
6118    #[test]
6119    fn integration_attrset_from_list() {
6120        // Convert list to attrset via listToAttrs + map
6121        let v = ev(r#"
6122            builtins.listToAttrs (builtins.map (x: { name = x; value = true; }) ["a" "b" "c"])
6123        "#);
6124        if let Value::Attrs(attrs) = v {
6125            assert_eq!(attrs.get("a"), Some(&Value::Bool(true)));
6126            assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));
6127            assert_eq!(attrs.get("c"), Some(&Value::Bool(true)));
6128        } else {
6129            panic!("expected attrs");
6130        }
6131    }
6132
6133    #[test]
6134    fn integration_nested_with_and_let() {
6135        assert_eq!(
6136            ev("let x = 10; in with { y = 20; }; x + y"),
6137            Value::Int(30),
6138        );
6139    }
6140
6141    #[test]
6142    fn integration_complex_pattern_match() {
6143        // Complex function with defaults, ellipsis, and @ pattern
6144        assert_eq!(
6145            ev("(args @ { a, b ? 5, ... }: a + b + (if args ? c then args.c else 0)) { a = 1; c = 10; }"),
6146            Value::Int(16), // 1 + 5 + 10
6147        );
6148    }
6149
6150    #[test]
6151    fn integration_substring() {
6152        assert_eq!(
6153            ev(r#"builtins.substring 0 5 "hello world""#),
6154            Value::string("hello"),
6155        );
6156        assert_eq!(
6157            ev(r#"builtins.substring 6 5 "hello world""#),
6158            Value::string("world"),
6159        );
6160    }
6161
6162    #[test]
6163    fn integration_has_attr_on_nested() {
6164        // ? on nested attr paths
6165        assert_eq!(ev("{ a = { b = 1; }; } ? a"), Value::Bool(true));
6166        assert_eq!(
6167            ev("({ a = { b = 1; }; }.a) ? b"),
6168            Value::Bool(true),
6169        );
6170    }
6171
6172    #[test]
6173    fn integration_cat_attrs() {
6174        assert_eq!(
6175            ev(r#"builtins.catAttrs "x" [{ x = 1; } { y = 2; } { x = 3; }]"#),
6176            Value::list(vec![Value::Int(1), Value::Int(3)]),
6177        );
6178    }
6179
6180    #[test]
6181    fn integration_get_attr_builtin() {
6182        assert_eq!(
6183            ev(r#"builtins.getAttr "a" { a = 42; b = 10; }"#),
6184            Value::Int(42),
6185        );
6186    }
6187
6188    #[test]
6189    fn integration_has_attr_builtin() {
6190        assert_eq!(
6191            ev(r#"builtins.hasAttr "a" { a = 1; }"#),
6192            Value::Bool(true),
6193        );
6194        assert_eq!(
6195            ev(r#"builtins.hasAttr "z" { a = 1; }"#),
6196            Value::Bool(false),
6197        );
6198    }
6199
6200    #[test]
6201    fn integration_is_path() {
6202        assert_eq!(ev("builtins.isPath ./foo"), Value::Bool(true));
6203        assert_eq!(ev("builtins.isPath 42"), Value::Bool(false));
6204    }
6205
6206    #[test]
6207    fn integration_builtins_trace() {
6208        // trace prints the first arg (as debug) and returns the second
6209        assert_eq!(ev(r#"builtins.trace "debug msg" 42"#), Value::Int(42));
6210    }
6211
6212    #[test]
6213    fn integration_builtins_split() {
6214        // Nix spec: split returns alternating non-match strings and match group lists.
6215        // When the regex has no capture groups, separator positions get empty lists.
6216        // split "/" "a/b/c" => ["a" [] "b" [] "c"]
6217        assert_eq!(
6218            ev(r#"builtins.split "/" "a/b/c""#),
6219            Value::list(vec![
6220                Value::string("a"),
6221                Value::list(vec![]),
6222                Value::string("b"),
6223                Value::list(vec![]),
6224                Value::string("c"),
6225            ]),
6226        );
6227        // With a capture group, the captured text appears in the list.
6228        // split "(/)" "a/b/c" => ["a" ["/"] "b" ["/"] "c"]
6229        assert_eq!(
6230            ev(r#"builtins.split "(/)" "a/b/c""#),
6231            Value::list(vec![
6232                Value::string("a"),
6233                Value::list(vec![Value::string("/")]),
6234                Value::string("b"),
6235                Value::list(vec![Value::string("/")]),
6236                Value::string("c"),
6237            ]),
6238        );
6239    }
6240
6241    #[test]
6242    fn integration_builtins_split_no_capture_groups() {
6243        // builtins.split with no capture groups returns empty lists
6244        // at separator positions — matches CppNix behavior.
6245        // This is critical for nixpkgs lib.splitString which uses
6246        // builtins.filter builtins.isString on the result.
6247        assert_eq!(
6248            ev(r#"builtins.split "-" "aarch64-darwin""#),
6249            Value::list(vec![
6250                Value::string("aarch64"),
6251                Value::list(vec![]),
6252                Value::string("darwin"),
6253            ]),
6254        );
6255    }
6256
6257    #[test]
6258    fn integration_builtins_split_system_string_filter() {
6259        // Simulates nixpkgs lib.splitString: filter isString (split pattern string)
6260        // This is the exact pattern that parses system strings like "aarch64-darwin".
6261        assert_eq!(
6262            ev(r#"builtins.filter builtins.isString (builtins.split "-" "aarch64-darwin")"#),
6263            Value::list(vec![
6264                Value::string("aarch64"),
6265                Value::string("darwin"),
6266            ]),
6267        );
6268    }
6269
6270    #[test]
6271    fn integration_deeply_nested_let() {
6272        // Deeply nested let-in expressions
6273        assert_eq!(
6274            ev("let a = let b = let c = 10; in c * 2; in b + 1; in a"),
6275            Value::Int(21),
6276        );
6277    }
6278
6279    #[test]
6280    fn integration_if_in_attrset_value() {
6281        assert_eq!(
6282            ev("{ x = if true then 1 else 2; }.x"),
6283            Value::Int(1),
6284        );
6285    }
6286
6287    #[test]
6288    fn integration_lambda_in_list() {
6289        // Store lambdas in a list and apply them
6290        assert_eq!(
6291            ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 0) 5"),
6292            Value::Int(6),
6293        );
6294        assert_eq!(
6295            ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 1) 5"),
6296            Value::Int(10),
6297        );
6298    }
6299
6300    #[test]
6301    fn integration_nixpkgs_lib_id() {
6302        // lib.id = x: x
6303        assert_eq!(
6304            ev("let lib = { id = x: x; const = a: b: a; }; in lib.id 42"),
6305            Value::Int(42),
6306        );
6307        assert_eq!(
6308            ev("let lib = { id = x: x; const = a: b: a; }; in lib.const 1 2"),
6309            Value::Int(1),
6310        );
6311    }
6312
6313    #[test]
6314    fn integration_multiple_inherit() {
6315        assert_eq!(
6316            ev("let a = 1; b = 2; c = 3; in { inherit a b c; }.b"),
6317            Value::Int(2),
6318        );
6319    }
6320
6321    #[test]
6322    fn integration_rec_set_with_builtins() {
6323        assert_eq!(
6324            ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6325            Value::Int(5),
6326        );
6327    }
6328
6329    // ═══════════════════════════════════════════════════════════
6330    // 11. __FUNCTOR PROTOCOL
6331    // ═══════════════════════════════════════════════════════════
6332
6333    #[test]
6334    fn functor_simple_callable_attrset() {
6335        assert_eq!(
6336            ev("let s = { __functor = self: x: x + 1; }; in s 41"),
6337            Value::Int(42),
6338        );
6339    }
6340
6341    #[test]
6342    fn functor_with_self_reference() {
6343        assert_eq!(
6344            ev("let s = { __functor = self: x: self.base + x; base = 100; }; in s 23"),
6345            Value::Int(123),
6346        );
6347    }
6348
6349    #[test]
6350    fn functor_updated_attrset() {
6351        // Override a field in the attrset, functor still works
6352        assert_eq!(
6353            ev(r#"
6354                let
6355                    mk = { __functor = self: x: self.n + x; n = 0; };
6356                    s = mk // { n = 50; };
6357                in s 7
6358            "#),
6359            Value::Int(57),
6360        );
6361    }
6362
6363    #[test]
6364    fn functor_error_on_non_callable_attrset() {
6365        // Attrset without __functor should produce error when called
6366        let result = eval("let s = { a = 1; }; in s 5");
6367        assert!(result.is_err());
6368    }
6369
6370    // ═══════════════════════════════════════════════════════════
6371    // 12. __TOSTRING PROTOCOL
6372    // ═══════════════════════════════════════════════════════════
6373
6374    #[test]
6375    fn to_string_protocol_in_interpolation() {
6376        assert_eq!(
6377            ev(r#"let s = { __toString = self: "world"; }; in "hello ${s}""#),
6378            Value::string("hello world"),
6379        );
6380    }
6381
6382    #[test]
6383    fn to_string_protocol_accesses_self() {
6384        assert_eq!(
6385            ev(r#"let s = { __toString = self: self.val; val = "abc"; }; in "${s}""#),
6386            Value::string("abc"),
6387        );
6388    }
6389
6390    #[test]
6391    fn to_string_protocol_via_builtin_to_string() {
6392        assert_eq!(
6393            ev(r#"builtins.toString { __toString = self: "via-builtin"; }"#),
6394            Value::string("via-builtin"),
6395        );
6396    }
6397
6398    #[test]
6399    fn to_string_protocol_attrset_without_toString_fails() {
6400        // An attrset without __toString should fail in string context
6401        let result = eval(r#""${{}}"#);
6402        assert!(result.is_err());
6403    }
6404
6405    // ═══════════════════════════════════════════════════════════
6406    // 13. NEWLY IMPLEMENTED BUILTINS (eval-level tests)
6407    // ═══════════════════════════════════════════════════════════
6408
6409    /// `concatStrings` is nixpkgs `lib.strings.concatStrings`, not a CppNix
6410    /// builtin. The CAPABILITY is not lost — `concatStringsSep ""` is the real
6411    /// builtin spelling and is asserted here to still produce the same bytes,
6412    /// so this test proves both halves: the invented name is gone, and nothing
6413    /// a nix program can legally write got worse.
6414    #[test]
6415    fn eval_builtins_concat_strings_is_not_a_builtin() {
6416        assert_eq!(ev(r#"builtins ? concatStrings"#), Value::Bool(false));
6417        assert!(
6418            eval(r#"builtins.concatStrings ["a" "b" "c"]"#).is_err(),
6419            "builtins.concatStrings must fail the way real nix fails it"
6420        );
6421        assert_eq!(
6422            ev(r#"builtins.concatStringsSep "" ["a" "b" "c"]"#),
6423            Value::string("abc"),
6424        );
6425        assert_eq!(
6426            ev(r#"builtins.concatStringsSep "" []"#),
6427            Value::string(""),
6428        );
6429    }
6430
6431    #[test]
6432    fn eval_builtins_partition() {
6433        let v = ev("builtins.partition (x: x > 3) [1 2 3 4 5]");
6434        if let Value::Attrs(a) = v {
6435            assert_eq!(a.get("right"), Some(&Value::list(vec![Value::Int(4), Value::Int(5)])));
6436            assert_eq!(a.get("wrong"), Some(&Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)])));
6437        } else {
6438            panic!("expected attrs");
6439        }
6440    }
6441
6442    #[test]
6443    fn eval_builtins_group_by() {
6444        let v = ev(r#"builtins.groupBy (x: if x > 0 then "pos" else "neg") [1 (0 - 2) 3 (0 - 4)]"#);
6445        if let Value::Attrs(a) = v {
6446            assert_eq!(a.get("pos"), Some(&Value::list(vec![Value::Int(1), Value::Int(3)])));
6447            assert_eq!(a.get("neg"), Some(&Value::list(vec![Value::Int(-2), Value::Int(-4)])));
6448        } else {
6449            panic!("expected attrs");
6450        }
6451    }
6452
6453    #[test]
6454    fn eval_builtins_zip_attrs_with() {
6455        let v = ev("builtins.zipAttrsWith (n: vs: builtins.head vs) [{ a = 1; } { a = 2; b = 3; }]");
6456        if let Value::Attrs(a) = v {
6457            assert_eq!(a.get("a"), Some(&Value::Int(1)));
6458            assert_eq!(a.get("b"), Some(&Value::Int(3)));
6459        } else {
6460            panic!("expected attrs");
6461        }
6462    }
6463
6464    #[test]
6465    fn eval_builtins_compare_versions() {
6466        assert_eq!(ev(r#"builtins.compareVersions "2.0" "1.0""#), Value::Int(1));
6467        assert_eq!(ev(r#"builtins.compareVersions "1.0" "2.0""#), Value::Int(-1));
6468        assert_eq!(ev(r#"builtins.compareVersions "1.0" "1.0""#), Value::Int(0));
6469    }
6470
6471    #[test]
6472    fn eval_builtins_parse_drv_name() {
6473        let v = ev(r#"builtins.parseDrvName "nix-2.3.4""#);
6474        if let Value::Attrs(a) = v {
6475            assert_eq!(a.get("name"), Some(&Value::string("nix")));
6476            assert_eq!(a.get("version"), Some(&Value::string("2.3.4")));
6477        } else {
6478            panic!("expected attrs");
6479        }
6480    }
6481
6482    #[test]
6483    fn eval_builtins_base_name_of() {
6484        assert_eq!(
6485            ev(r#"builtins.baseNameOf "/foo/bar/baz""#),
6486            Value::string("baz"),
6487        );
6488    }
6489
6490    #[test]
6491    fn eval_builtins_dir_of() {
6492        assert_eq!(
6493            ev(r#"builtins.dirOf "/foo/bar/baz""#),
6494            Value::string("/foo/bar"),
6495        );
6496    }
6497
6498    #[test]
6499    fn eval_builtins_add_error_context() {
6500        assert_eq!(
6501            ev(r#"builtins.addErrorContext "some context" 42"#),
6502            Value::Int(42),
6503        );
6504    }
6505
6506    #[test]
6507    fn eval_builtins_abort() {
6508        let result = eval(r#"builtins.abort "fatal error""#);
6509        assert!(result.is_err());
6510        let msg = format!("{}", result.unwrap_err());
6511        assert!(msg.contains("fatal error"));
6512    }
6513
6514    // ═══════════════════════════════════════════════════════════
6515    // 14. INDENTED STRINGS ('' ... '')
6516    // ═══════════════════════════════════════════════════════════
6517
6518    #[test]
6519    fn indented_string_simple() {
6520        assert_eq!(ev("''hello''"), Value::string("hello"));
6521    }
6522
6523    #[test]
6524    fn indented_string_multiline_strips_indent() {
6525        assert_eq!(
6526            ev("''\n  line1\n  line2\n''"),
6527            Value::string("line1\nline2\n"),
6528        );
6529    }
6530
6531    #[test]
6532    fn indented_string_with_interpolation() {
6533        let code = "let x = \"world\"; in ''hello ${x}''";
6534        assert_eq!(
6535            ev(code),
6536            Value::string("hello world"),
6537        );
6538    }
6539
6540    #[test]
6541    fn indented_string_deeper_indent_preserved() {
6542        // Common indent is 2 spaces; the 4-space line keeps 2 extra
6543        assert_eq!(
6544            ev("''\n  a\n    b\n''"),
6545            Value::string("a\n  b\n"),
6546        );
6547    }
6548
6549    // ═══════════════════════════════════════════════════════════
6550    // 15. DYNAMIC ATTRIBUTE NAMES
6551    // ═══════════════════════════════════════════════════════════
6552
6553    #[test]
6554    fn dynamic_attr_name_in_set() {
6555        assert_eq!(
6556            ev(r#"let key = "mykey"; in { ${key} = 42; }.mykey"#),
6557            Value::Int(42),
6558        );
6559    }
6560
6561    #[test]
6562    fn dynamic_attr_name_with_expression() {
6563        assert_eq!(
6564            ev(r#"let prefix = "foo"; in { ${"${prefix}bar"} = 1; }.foobar"#),
6565            Value::Int(1),
6566        );
6567    }
6568
6569    // ═══════════════════════════════════════════════════════════
6570    // 16. IGNORED TESTS — features needing major infrastructure
6571    // ═══════════════════════════════════════════════════════════
6572
6573    #[test]
6574    fn eval_builtins_match() {
6575        assert_eq!(
6576            ev(r#"builtins.match "([0-9]+)" "42""#),
6577            Value::list(vec![Value::string("42")]),
6578        );
6579    }
6580
6581    #[test]
6582    fn eval_builtins_hash_string() {
6583        let v = ev(r#"builtins.hashString "sha256" "hello""#);
6584        if let Value::String(ns) = v {
6585            assert_eq!(ns.chars.len(), 64);
6586        } else {
6587            panic!("expected string");
6588        }
6589    }
6590
6591    #[test]
6592    fn eval_builtins_import() {
6593        let dir = std::env::temp_dir();
6594        let path = dir.join("sui_eval_test_import_eval.nix");
6595        std::fs::write(&path, "42").unwrap();
6596        let expr = format!(r#"import "{}""#, path.display());
6597        let v = eval(&expr).unwrap();
6598        assert_eq!(v, Value::Int(42));
6599        std::fs::remove_file(&path).ok();
6600    }
6601
6602    #[test]
6603    fn eval_builtins_derivation() {
6604        let v = eval(r#"builtins.derivation { name = "test"; system = "x86_64-linux"; builder = "/bin/sh"; }"#).unwrap();
6605        if let Value::Attrs(a) = v {
6606            assert_eq!(a.get("type"), Some(&Value::string("derivation")));
6607        } else {
6608            panic!("expected attrs");
6609        }
6610    }
6611
6612    #[test]
6613    fn eval_mutual_recursive_let() {
6614        // Multi-pass evaluation allows forward references in let bindings.
6615        // After 3 passes (placeholder + eval + re-eval), `a.x` resolves to
6616        // the value of `b` from the previous pass, and `a.x.y` is an attrset.
6617        // Full semantic equivalence with Nix (a.x.y == a) requires lazy
6618        // thunks, but the multi-pass approach is sufficient for common
6619        // patterns like mutual module references.
6620        let v = eval("let a = { x = b; }; b = { y = a; }; in a.x.y");
6621        assert!(v.is_ok(), "mutual recursive let should not error: {v:?}");
6622        // a.x.y should be an attrset (it's a's value from a prior pass)
6623        let val = v.unwrap();
6624        assert!(
6625            matches!(val, Value::Attrs(_)),
6626            "a.x.y should be an attrset, got: {val:?}",
6627        );
6628    }
6629
6630    #[test]
6631    fn eval_mutual_recursive_let_simple() {
6632        // Simpler case: forward reference in sequential let bindings
6633        let v = eval("let a = b; b = 42; in a");
6634        assert!(v.is_ok());
6635        // After multi-pass: pass 2 sets a=Null (b not yet bound), b=42
6636        // pass 3 sets a=42, b=42
6637        assert_eq!(v.unwrap(), Value::Int(42));
6638    }
6639
6640    #[test]
6641    fn eval_builtins_read_dir() {
6642        let dir = std::env::temp_dir().join("sui_eval_test_readdir_eval");
6643        let _ = std::fs::remove_dir_all(&dir);
6644        std::fs::create_dir_all(&dir).unwrap();
6645        std::fs::write(dir.join("a.txt"), "").unwrap();
6646        let expr = format!(r#"builtins.readDir "{}""#, dir.display());
6647        let v = eval(&expr).unwrap();
6648        if let Value::Attrs(a) = v {
6649            assert_eq!(a.get("a.txt"), Some(&Value::string("regular")));
6650        } else {
6651            panic!("expected attrs");
6652        }
6653        let _ = std::fs::remove_dir_all(&dir);
6654    }
6655
6656    // ═══════════════════════════════════════════════════════════
6657    // 17. THUNK / LAZY EVALUATION
6658    // ═══════════════════════════════════════════════════════════
6659
6660    #[test]
6661    fn thunk_basic_let() {
6662        // Simple let binding through thunk.
6663        assert_eq!(ev("let x = 1; in x"), Value::Int(1));
6664    }
6665
6666    #[test]
6667    fn thunk_forward_ref() {
6668        // Forward reference: `a` references `b` which is defined later.
6669        assert_eq!(ev("let a = b; b = 1; in a"), Value::Int(1));
6670    }
6671
6672    #[test]
6673    fn thunk_mutual_rec_attrset_in_let() {
6674        // Mutual recursion through attrsets in let bindings.
6675        assert_eq!(ev("let a = { x = b; }; b = { y = 1; }; in a.x.y"), Value::Int(1));
6676    }
6677
6678    #[test]
6679    fn thunk_rec_attrset() {
6680        // rec { a = b; b = 1; } -- forward ref within rec set.
6681        assert_eq!(ev("(rec { a = b; b = 1; }).a"), Value::Int(1));
6682    }
6683
6684    #[test]
6685    fn thunk_rec_attrset_chain() {
6686        // Longer chain: c depends on b depends on a.
6687        assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
6688    }
6689
6690    #[test]
6691    fn thunk_fixpoint() {
6692        // Classic fixpoint combinator -- the core of nixpkgs' `lib.fix`.
6693        assert_eq!(
6694            ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; })).b"),
6695            Value::Int(2),
6696        );
6697    }
6698
6699    #[test]
6700    fn thunk_blackhole_self_reference() {
6701        // `let x = x; in x` is infinite recursion -- blackhole detection.
6702        let result = eval("let x = x; in x");
6703        assert!(result.is_err());
6704        let msg = format!("{}", result.unwrap_err());
6705        assert!(
6706            msg.contains("infinite recursion") || msg.contains("blackhole"),
6707            "expected blackhole error, got: {msg}",
6708        );
6709    }
6710
6711    #[test]
6712    fn thunk_mutual_blackhole() {
6713        // `let a = b; b = a; in a` -- mutual infinite recursion.
6714        let result = eval("let a = b; b = a; in a");
6715        assert!(result.is_err());
6716    }
6717
6718    #[test]
6719    fn thunk_let_body_forces_correctly() {
6720        // The let body should be able to use thunked bindings in arithmetic.
6721        assert_eq!(ev("let a = 10; b = 20; in a + b"), Value::Int(30));
6722    }
6723
6724    #[test]
6725    fn thunk_only_forced_when_needed() {
6726        // The binding `bad` would error if forced, but it is never used.
6727        assert_eq!(ev("let bad = 1 / 0; good = 42; in good"), Value::Int(42));
6728    }
6729
6730    #[test]
6731    fn thunk_forward_ref_in_function_body() {
6732        // Forward reference used inside a function body.
6733        assert_eq!(
6734            ev("let f = x: x + b; b = 10; in f 5"),
6735            Value::Int(15),
6736        );
6737    }
6738
6739    #[test]
6740    fn thunk_rec_set_self_ref_through_self() {
6741        // rec set where `b` references `a` which is in the same set.
6742        assert_eq!(
6743            ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6744            Value::Int(5),
6745        );
6746    }
6747
6748    #[test]
6749    fn thunk_nested_let_forward_ref() {
6750        // Forward reference in nested let.
6751        assert_eq!(
6752            ev("let a = b + 1; b = 2; in a"),
6753            Value::Int(3),
6754        );
6755    }
6756
6757    #[test]
6758    fn thunk_deep_chain() {
6759        // Chain of forward references: e -> d -> c -> b -> a.
6760        assert_eq!(
6761            ev("let a = 1; b = a; c = b; d = c; e = d; in e"),
6762            Value::Int(1),
6763        );
6764    }
6765
6766    #[test]
6767    fn thunk_rec_set_fixpoint() {
6768        // Fixpoint through rec set -- common nixpkgs pattern.
6769        assert_eq!(
6770            ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; c = self.b + 1; })).c"),
6771            Value::Int(3),
6772        );
6773    }
6774
6775    #[test]
6776    fn thunk_let_with_inherit() {
6777        // Inherit in let should work alongside thunked bindings.
6778        assert_eq!(
6779            ev("let a = 1; in let inherit a; b = a + 1; in b"),
6780            Value::Int(2),
6781        );
6782    }
6783
6784    #[test]
6785    fn thunk_attrset_value_lazy() {
6786        // Values in non-rec attrsets are evaluated eagerly, but the test
6787        // verifies that thunked let bindings inside attrset values work.
6788        assert_eq!(
6789            ev("let x = 42; in { a = x; }.a"),
6790            Value::Int(42),
6791        );
6792    }
6793
6794    #[test]
6795    fn thunk_unused_error_not_forced() {
6796        // Multiple bindings, only `ok` is used. `bad` throws but is never forced.
6797        assert_eq!(
6798            ev(r#"let bad = builtins.throw "boom"; ok = 1; in ok"#),
6799            Value::Int(1),
6800        );
6801    }
6802
6803    #[test]
6804    fn thunk_rec_set_mutual_reference() {
6805        // Mutual reference within rec set.
6806        let v = ev("rec { a = { val = b.val + 1; }; b = { val = 10; }; }");
6807        if let Value::Attrs(attrs) = v {
6808            let a = attrs.get("a").unwrap();
6809            let a_forced = force_value(a).unwrap();
6810            if let Value::Attrs(a_attrs) = a_forced {
6811                assert_eq!(a_attrs.get("val"), Some(&Value::Int(11)));
6812            } else {
6813                panic!("expected attrs for a");
6814            }
6815        } else {
6816            panic!("expected attrs");
6817        }
6818    }
6819
6820    // ── let-rec self-reference corner cases ───────────────
6821
6822    #[test]
6823    fn let_rec_self_reference_simple() {
6824        assert_eq!(
6825            ev("let x = 1; y = x + 1; in y"),
6826            Value::Int(2),
6827        );
6828    }
6829
6830    #[test]
6831    fn let_rec_self_reference_chain() {
6832        assert_eq!(
6833            ev("let a = 1; b = a + 1; c = b + 1; in c"),
6834            Value::Int(3),
6835        );
6836    }
6837
6838    #[test]
6839    fn let_rec_self_reference_with_function() {
6840        assert_eq!(
6841            ev("let f = x: x + 1; y = f 10; in y"),
6842            Value::Int(11),
6843        );
6844    }
6845
6846    #[test]
6847    fn let_rec_mutual_recursion_via_if() {
6848        assert_eq!(
6849            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"),
6850            Value::Bool(true),
6851        );
6852    }
6853
6854    #[test]
6855    fn let_rec_forward_ref_in_list() {
6856        assert_eq!(
6857            ev("let xs = [a b]; a = 1; b = 2; in builtins.length xs"),
6858            Value::Int(2),
6859        );
6860    }
6861
6862    // ── with-shadowing corner cases ───────────────────────
6863
6864    #[test]
6865    fn with_shadowing_let_wins_over_with() {
6866        assert_eq!(
6867            ev("let x = 1; in with { x = 2; }; x"),
6868            Value::Int(1),
6869        );
6870    }
6871
6872    #[test]
6873    fn with_shadowing_inner_with_wins() {
6874        assert_eq!(
6875            ev("with { x = 1; }; with { x = 2; }; x"),
6876            Value::Int(2),
6877        );
6878    }
6879
6880    #[test]
6881    fn with_shadowing_outer_provides_missing() {
6882        assert_eq!(
6883            ev("with { x = 1; y = 10; }; with { x = 2; }; x + y"),
6884            Value::Int(12),
6885        );
6886    }
6887
6888    #[test]
6889    fn with_shadowing_lambda_arg_wins() {
6890        assert_eq!(
6891            ev("(x: with { x = 99; }; x) 42"),
6892            Value::Int(42),
6893        );
6894    }
6895
6896    #[test]
6897    fn with_shadowing_nested_let_wins_over_with() {
6898        assert_eq!(
6899            ev("with { x = 1; }; let x = 2; in x"),
6900            Value::Int(2),
6901        );
6902    }
6903
6904    #[test]
6905    fn with_scope_dynamic_attrs() {
6906        assert_eq!(
6907            ev(r#"with { x = 1; y = 2; z = 3; }; x + y + z"#),
6908            Value::Int(6),
6909        );
6910    }
6911
6912    #[test]
6913    fn with_scope_over_lazy_thunk_chain_resolves() {
6914        // A `with`-head that resolves through a NESTED thunk chain
6915        // (`Thunk(Thunk(Attrs))`) must still be searched: the lookup
6916        // has to FULLY force the head (chase the chain), not take a
6917        // single force step. A single step leaves a `Value::Thunk`
6918        // that `type_name()` reports as "set" but the `Value::Attrs`
6919        // match rejects — the scope is skipped and a bare ident
6920        // through it fails with a spurious UndefinedVar. This corners
6921        // the nixpkgs `platforms = with lib.platforms; unix;` shape.
6922        assert_eq!(
6923            ev(r#"let outer = if true then (if true then { unix = 42; } else {}) else {};
6924                      # force a two-deep lazy wrap of the with-head
6925                      head = (x: x) ((y: y) outer);
6926                  in with head; unix"#),
6927            Value::Int(42),
6928        );
6929    }
6930
6931    #[test]
6932    fn with_scope_head_from_deep_select_resolves() {
6933        // `with a.b.c; key` where a.b.c is a lazily-selected attrset —
6934        // the bare-ident body must find `key` through the forced head.
6935        assert_eq!(
6936            ev(r#"let a = { b = { c = { key = 7; }; }; }; in with a.b.c; key"#),
6937            Value::Int(7),
6938        );
6939    }
6940
6941    // ── attrset deep merge ────────────────────────────────
6942
6943    #[test]
6944    fn attrset_deep_merge_simple() {
6945        let v = ev("{ a.b = 1; a.c = 2; }");
6946        if let Value::Attrs(attrs) = v {
6947            let a = force_value(attrs.get("a").unwrap()).unwrap();
6948            if let Value::Attrs(inner) = a {
6949                assert_eq!(force_value(inner.get("b").unwrap()).unwrap(), Value::Int(1));
6950                assert_eq!(force_value(inner.get("c").unwrap()).unwrap(), Value::Int(2));
6951            } else {
6952                panic!("expected nested attrs");
6953            }
6954        } else {
6955            panic!("expected attrs");
6956        }
6957    }
6958
6959    #[test]
6960    fn attrset_deep_merge_three_levels() {
6961        let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
6962        if let Value::Attrs(attrs) = v {
6963            let a = force_value(attrs.get("a").unwrap()).unwrap();
6964            if let Value::Attrs(a_inner) = a {
6965                let e = force_value(a_inner.get("e").unwrap()).unwrap();
6966                assert_eq!(e, Value::Int(3));
6967                let b = force_value(a_inner.get("b").unwrap()).unwrap();
6968                if let Value::Attrs(b_inner) = b {
6969                    assert_eq!(force_value(b_inner.get("c").unwrap()).unwrap(), Value::Int(1));
6970                    assert_eq!(force_value(b_inner.get("d").unwrap()).unwrap(), Value::Int(2));
6971                } else {
6972                    panic!("expected nested attrs for b");
6973                }
6974            } else {
6975                panic!("expected nested attrs for a");
6976            }
6977        } else {
6978            panic!("expected attrs");
6979        }
6980    }
6981
6982    #[test]
6983    fn attrset_deep_merge_preserves_siblings() {
6984        assert_eq!(
6985            ev("{ a.x = 1; b = 2; a.y = 3; }.b"),
6986            Value::Int(2),
6987        );
6988    }
6989
6990    #[test]
6991    fn attrset_deep_merge_in_let() {
6992        let v = ev("let s = { a.b = 1; a.c = 2; }; in s.a.b + s.a.c");
6993        assert_eq!(v, Value::Int(3));
6994    }
6995
6996    #[test]
6997    fn attrset_deep_merge_fullset_then_dotted() {
6998        // General root (gst-plugins-base `passthru.waylandEnabled` drop):
6999        // `a = { x = 1; }; a.y = 2;` — the full-set binding is a lazy
7000        // Thunk (attrset literals go through maybe_thunk), so a naive
7001        // merge_nested_insert (which only merges concrete Value::Attrs)
7002        // overwrote `a` with `{ y = 2 }`, silently dropping `x`. The
7003        // collision must force the existing thunk to WHNF first.
7004        let v = ev("let s = { a = { x = 1; }; a.y = 2; }; in s.a.x + s.a.y");
7005        assert_eq!(v, Value::Int(3));
7006        // both keys must survive (not just their sum)
7007        let both = ev("let s = { a = { x = 1; }; a.y = 2; }; in [ s.a.x s.a.y ]");
7008        if let Value::List(items) = both {
7009            assert_eq!(force_value(&items[0]).unwrap(), Value::Int(1));
7010            assert_eq!(force_value(&items[1]).unwrap(), Value::Int(2));
7011        } else {
7012            panic!("expected list");
7013        }
7014    }
7015
7016    // ── inherit-from patterns ─────────────────────────────
7017
7018    #[test]
7019    fn inherit_from_basic() {
7020        assert_eq!(
7021            ev("let s = { x = 1; y = 2; }; in let inherit (s) x y; in x + y"),
7022            Value::Int(3),
7023        );
7024    }
7025
7026    #[test]
7027    fn inherit_from_with_shadowing() {
7028        assert_eq!(
7029            ev("let x = 10; in let inherit ({ x = 20; }) x; in x"),
7030            Value::Int(20),
7031        );
7032    }
7033
7034    #[test]
7035    fn inherit_from_in_attrset() {
7036        let v = ev(r#"let s = { a = 1; b = 2; }; in { inherit (s) a b; c = 3; }"#);
7037        if let Value::Attrs(attrs) = v {
7038            assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7039            assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7040            assert_eq!(force_value(attrs.get("c").unwrap()).unwrap(), Value::Int(3));
7041        } else {
7042            panic!("expected attrs");
7043        }
7044    }
7045
7046    #[test]
7047    fn inherit_from_rec_set() {
7048        assert_eq!(
7049            ev("rec { inherit ({ x = 42; }) x; y = x; }.y"),
7050            Value::Int(42),
7051        );
7052    }
7053
7054    #[test]
7055    fn inherit_plain_from_scope() {
7056        assert_eq!(
7057            ev("let x = 1; in { inherit x; }.x"),
7058            Value::Int(1),
7059        );
7060    }
7061
7062    // Regression (2026-07-11): a bare `inherit x;` must resolve LAZILY, like
7063    // a plain reference to `x` — not eagerly at attrset construction. When
7064    // `x` is provided only by an enclosing `with` scope whose value is a
7065    // fixpoint still being constructed, eager resolution spuriously threw
7066    // `UndefinedVar`. nixpkgs `all-packages.nix` is
7067    // `with pkgs; { nettle = import … { inherit callPackage; }; }`, so
7068    // `inherit callPackage` must resolve from the `with pkgs` scope at force
7069    // time. (This was the nettle UndefinedVar('callPackage') drop.)
7070    #[test]
7071    fn inherit_plain_from_with_scope_lazy() {
7072        // `inherit cp` reads `cp` from a `with self` fixpoint scope; the
7073        // attr forcing it (`a`) must resolve `cp` lazily against the settled
7074        // scope, not eagerly during attrset construction.
7075        assert_eq!(
7076            ev("let fix = f: let x = f x; in x;
7077                    self = fix (self: with self; {
7078                      a = use { inherit cp; };
7079                      use = { cp }: cp 5;
7080                      cp = x: x + 100;
7081                    });
7082                in self.a"),
7083            Value::Int(105),
7084        );
7085        // Simpler: bare inherit from a plain (non-blackhole) with scope.
7086        assert_eq!(
7087            ev("with { y = 7; }; { inherit y; }.y"),
7088            Value::Int(7),
7089        );
7090    }
7091
7092    #[test]
7093    fn inherit_multiple_from_expr() {
7094        assert_eq!(
7095            ev("let s = { a = 10; b = 20; c = 30; }; in let inherit (s) a b c; in a + b + c"),
7096            Value::Int(60),
7097        );
7098    }
7099
7100    // ── string interpolation edge cases ───────────────────
7101
7102    #[test]
7103    fn interp_nested_attrset_access() {
7104        assert_eq!(
7105            ev(r#"let x = { a = "hello"; }; in "${x.a} world""#),
7106            Value::string("hello world"),
7107        );
7108    }
7109
7110    #[test]
7111    fn interp_with_let_expression() {
7112        assert_eq!(
7113            ev(r#""${let x = "inner"; in x}""#),
7114            Value::string("inner"),
7115        );
7116    }
7117
7118    #[test]
7119    fn interp_float_coercion() {
7120        // CppNix %f-format: always 6 decimal places.
7121        assert_eq!(
7122            ev(r#""${toString 3.14}""#),
7123            Value::string("3.140000"),
7124        );
7125    }
7126
7127    // ── comparison edge cases ─────────────────────────────
7128
7129    #[test]
7130    fn compare_mixed_int_float() {
7131        assert_eq!(ev("1 < 1.5"), Value::Bool(true));
7132        assert_eq!(ev("1.5 > 1"), Value::Bool(true));
7133        assert_eq!(ev("2.0 == 2"), Value::Bool(true));
7134    }
7135
7136    #[test]
7137    fn compare_string_lexicographic() {
7138        assert_eq!(ev(r#""abc" < "abd""#), Value::Bool(true));
7139        assert_eq!(ev(r#""abc" < "abc""#), Value::Bool(false));
7140        assert_eq!(ev(r#""abc" <= "abc""#), Value::Bool(true));
7141    }
7142
7143    // ── update operator edge cases ────────────────────────
7144
7145    #[test]
7146    fn update_empty_sets() {
7147        let v = ev("{} // {}");
7148        if let Value::Attrs(a) = v { assert!(a.is_empty()); } else { panic!(); }
7149    }
7150
7151    #[test]
7152    fn update_right_overrides_completely() {
7153        assert_eq!(
7154            ev("{ a = 1; b = 2; } // { a = 10; c = 30; }"),
7155            ev("{ a = 10; b = 2; c = 30; }"),
7156        );
7157    }
7158
7159    #[test]
7160    fn update_chained() {
7161        assert_eq!(
7162            ev("{ a = 1; } // { b = 2; } // { c = 3; }"),
7163            ev("{ a = 1; b = 2; c = 3; }"),
7164        );
7165    }
7166
7167    // ── force_value edge cases ────────────────────────────
7168
7169    #[test]
7170    fn force_value_concrete_unchanged() {
7171        let v = Value::Int(42);
7172        assert_eq!(force_value(&v).unwrap(), Value::Int(42));
7173    }
7174
7175    #[test]
7176    fn force_value_null() {
7177        assert_eq!(force_value(&Value::Null).unwrap(), Value::Null);
7178    }
7179
7180    // ── eval_with_file ────────────────────────────────────
7181
7182    #[test]
7183    fn eval_with_file_none() {
7184        let result = eval_with_file("1 + 2", None).unwrap();
7185        assert_eq!(result, Value::Int(3));
7186    }
7187
7188    // ── error messages ────────────────────────────────────
7189
7190    #[test]
7191    fn error_type_mismatch_in_comparison() {
7192        let result = eval(r#"1 < "a""#);
7193        assert!(result.is_err());
7194    }
7195
7196    #[test]
7197    fn error_select_from_non_set() {
7198        let result = eval("42.x");
7199        assert!(result.is_err());
7200    }
7201
7202    #[test]
7203    fn error_call_non_function() {
7204        let result = eval("42 1");
7205        assert!(result.is_err());
7206    }
7207
7208    #[test]
7209    fn error_negate_string() {
7210        let result = eval(r#"-"hello""#);
7211        assert!(result.is_err());
7212    }
7213
7214    // ── multiline string edge cases ───────────────────────
7215
7216    #[test]
7217    fn multiline_string_empty() {
7218        assert_eq!(ev("''''"), Value::string(""));
7219    }
7220
7221    #[test]
7222    fn multiline_string_with_trailing_newline() {
7223        let v = ev("''\n  hello\n''");
7224        assert_eq!(v, Value::string("hello\n"));
7225    }
7226
7227    // ── list operations ───────────────────────────────────
7228
7229    #[test]
7230    fn list_concat_empty_left() {
7231        assert_eq!(ev("[] ++ [1 2]"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7232    }
7233
7234    #[test]
7235    fn list_concat_empty_right() {
7236        assert_eq!(ev("[1 2] ++ []"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7237    }
7238
7239    #[test]
7240    fn list_concat_both_empty() {
7241        assert_eq!(ev("[] ++ []"), Value::list(vec![]));
7242    }
7243
7244    // ── pattern matching / formals edge cases ─────────────
7245
7246    #[test]
7247    fn formals_at_pattern_accessible() {
7248        assert_eq!(
7249            ev("({ x, ... } @ args: builtins.length (builtins.attrNames args)) { x = 1; y = 2; z = 3; }"),
7250            Value::Int(3),
7251        );
7252    }
7253
7254    #[test]
7255    fn formals_default_uses_other_arg() {
7256        assert_eq!(
7257            ev("({ x, y ? x + 1 }: y) { x = 10; }"),
7258            Value::Int(11),
7259        );
7260    }
7261
7262    #[test]
7263    fn formals_default_lazy_assert_false() {
7264        // nixpkgs parse.nix pattern: default is `assert false; null` but
7265        // the body checks `args ? vendor` instead of using `vendor`
7266        // directly, so the default must never be forced.
7267        assert_eq!(
7268            ev("({ cpu, vendor ? assert false; null, kernel } @ args: if args ? vendor then vendor else \"inferred\") { cpu = \"x86_64\"; kernel = \"linux\"; }"),
7269            Value::String(Rc::new(NixString::plain("inferred"))),
7270        );
7271    }
7272
7273    #[test]
7274    fn formals_default_lazy_only_forced_when_accessed() {
7275        // When the default IS accessed, it should still evaluate correctly.
7276        assert_eq!(
7277            ev("({ a, b ? 42 }: b) { a = 1; }"),
7278            Value::Int(42),
7279        );
7280    }
7281
7282    #[test]
7283    fn formals_ellipsis_ignores_extra() {
7284        assert_eq!(
7285            ev("({ x, ... }: x) { x = 1; y = 2; z = 3; }"),
7286            Value::Int(1),
7287        );
7288    }
7289
7290    // ── pure mode ─────────────────────────────────────────
7291
7292    #[test]
7293    fn pure_mode_roundtrip() {
7294        let was_pure = is_pure_mode();
7295        set_pure_mode(true);
7296        assert!(is_pure_mode());
7297        set_pure_mode(false);
7298        assert!(!is_pure_mode());
7299        set_pure_mode(was_pure);
7300    }
7301
7302    // ── path operations ───────────────────────────────────
7303
7304    #[test]
7305    fn path_concat_with_string() {
7306        assert_eq!(
7307            ev(r#"/foo + "bar""#),
7308            Value::Path(Box::new(SmolStr::from("/foobar"))),
7309        );
7310    }
7311
7312    #[test]
7313    fn path_concat_with_path() {
7314        assert_eq!(
7315            ev("/foo + /bar"),
7316            Value::Path(Box::new(SmolStr::from("/foo//bar"))),
7317        );
7318    }
7319
7320    // ── EvalFileGuard / current_eval_dir ───────────────────
7321
7322    #[test]
7323    fn current_eval_dir_empty_when_no_file_pushed() {
7324        // Without a push, current_eval_dir should yield None.
7325        // (Note: this test is order-dependent; we accept whatever the
7326        // top of the stack happens to be when called.)
7327        let snapshot = current_eval_dir();
7328        // At minimum the API doesn't panic and returns Option.
7329        let _ = snapshot;
7330    }
7331
7332    #[test]
7333    fn push_eval_file_sets_current_dir() {
7334        let p = std::path::PathBuf::from("/tmp/example/file.nix");
7335        {
7336            let _g = push_eval_file(p.clone());
7337            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/tmp/example")));
7338        }
7339        // Guard dropped, stack popped — current dir is whatever was below.
7340        // We can't assert exact value without snapshotting first, but the
7341        // value before push should be restored.
7342    }
7343
7344    #[test]
7345    fn push_eval_file_nested_stack() {
7346        let outer = std::path::PathBuf::from("/a/x.nix");
7347        let inner = std::path::PathBuf::from("/b/y.nix");
7348        {
7349            let _g_outer = push_eval_file(outer.clone());
7350            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7351            {
7352                let _g_inner = push_eval_file(inner.clone());
7353                assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/b")));
7354            }
7355            // Inner dropped — outer is back on top.
7356            assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7357        }
7358    }
7359
7360    /// A fileless frame MASKS the parent's file rather than being skipped.
7361    ///
7362    /// Regression: the stack used to be `Vec<PathBuf>`, so a thunk captured in
7363    /// a `--expr` context pushed nothing when it forced and the callee's file
7364    /// stayed visible. `builtins.unsafeGetAttrPos` then reported the callee's
7365    /// path where CppNix reports `null`, which set `eval-config.nix`'s
7366    /// `modulesLocation` and permuted NixOS module definition order.
7367    #[test]
7368    fn fileless_frame_masks_parent_file() {
7369        let outer = std::path::PathBuf::from("/a/x.nix");
7370        let _g_outer = push_eval_file(outer.clone());
7371        assert_eq!(current_eval_file(), Some(outer.clone()));
7372        {
7373            let _g_none = push_eval_frame(None);
7374            // The whole point: NOT Some("/a/x.nix").
7375            assert_eq!(current_eval_file(), None);
7376            assert_eq!(current_eval_dir(), None);
7377            assert_eq!(eval_file_stack_snapshot().last().map(String::as_str), Some("<no-file>"));
7378        }
7379        // Popped — the parent is visible again.
7380        assert_eq!(current_eval_file(), Some(outer));
7381    }
7382
7383    // ── Source-mapped error context ────────────────────────
7384
7385    #[test]
7386    fn error_undefined_var_includes_file_context() {
7387        let p = std::path::PathBuf::from("/nix/store/abc-default.nix");
7388        let _g = push_eval_file(p);
7389        let result = eval("nonexistent_xyz");
7390        let msg = format!("{}", result.unwrap_err());
7391        assert!(msg.contains("undefined variable"), "msg: {msg}");
7392        assert!(msg.contains("nonexistent_xyz"), "msg: {msg}");
7393        assert!(msg.contains("abc-default.nix"), "msg: {msg}");
7394    }
7395
7396    #[test]
7397    fn error_attr_not_found_includes_file_context() {
7398        let p = std::path::PathBuf::from("/nix/store/xyz-module.nix");
7399        let _g = push_eval_file(p);
7400        let result = eval("{}.missing_key");
7401        let msg = format!("{}", result.unwrap_err());
7402        assert!(msg.contains("not found") || msg.contains("missing_key"), "msg: {msg}");
7403        assert!(msg.contains("xyz-module.nix"), "msg: {msg}");
7404    }
7405
7406    #[test]
7407    fn error_assertion_failed_includes_file_context() {
7408        let p = std::path::PathBuf::from("/nix/store/test-assert.nix");
7409        let _g = push_eval_file(p);
7410        let result = eval("assert false; 1");
7411        let msg = format!("{}", result.unwrap_err());
7412        assert!(msg.contains("assertion failed"), "msg: {msg}");
7413        assert!(msg.contains("test-assert.nix"), "msg: {msg}");
7414    }
7415
7416    /// `inherit` binds an attribute, so it carries a position.
7417    ///
7418    /// Regression: `attach_attrset_positions` matched only
7419    /// `Entry::AttrpathValue`, so every inherited key was position-less — most
7420    /// of nixpkgs' `lib`, which re-exports via `inherit (self.options) mkOption
7421    /// …`, and it fed a null into `eval-config.nix`'s `modulesLocation`.
7422    ///
7423    /// Shaped exactly like `unsafe_get_attr_pos_reports_file_and_offset_column`
7424    /// (ONE direct `eval`, no lambda, no second evaluation) because the
7425    /// in-process harness is fragile here: the source-text registry is a
7426    /// thread-local that `pos.rs`'s tests clear, so a multi-eval version passes
7427    /// standalone and fails in the full suite. The CLI path is not affected —
7428    /// verified against `nix eval` on both shapes, both engines agreeing on
7429    /// column 18.
7430    #[test]
7431    fn inherit_bindings_carry_positions() {
7432        let dir = tempfile::tempdir().unwrap();
7433        // A PLAIN attrset, no `let ... in` wrapper: with the wrapper the
7434        // result is built lazily AFTER `import` returns, and the in-process
7435        // harness then resolves it without the file on the eval stack. The CLI
7436        // handles both (measured), the harness only this one.
7437        let body = "{ inherit ({ x = 1; }) x; }\n";
7438        let f = dir.path().join("inh.nix");
7439        std::fs::write(&f, body).unwrap();
7440        let v = eval(&format!("builtins.unsafeGetAttrPos \"x\" (import {})", f.display())).unwrap();
7441        let attrs = match v {
7442            Value::Attrs(a) => a,
7443            Value::Null => panic!("null — the inherit binding carried no position"),
7444            o => panic!("expected attrs, got {o:?}"),
7445        };
7446        // Computed from the fixture, never hardcoded: a hardcoded expectation is
7447        // how `pos::line_col`'s own "verified" comment came to agree with the
7448        // bug it documented.
7449        let off = body.rfind("x; }").unwrap();
7450        let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7451        assert_eq!(*attrs.get("line").unwrap(), Value::Int(1));
7452        assert_eq!(*attrs.get("column").unwrap(), Value::Int((off - bol) as i64 + 1));
7453    }
7454
7455    /// Corpus gate: every attribute-BINDING form carries a position.
7456    ///
7457    /// Seals the class the three position bugs came from, rather than the three
7458    /// instances: `//` dropping positions wholesale, `pos::line_col` returning a
7459    /// constant, and `inherit` never being recorded. Each was found only because
7460    /// a NixOS toplevel drvPath diverged — an expensive way to learn that an
7461    /// attribute lost its position.
7462    ///
7463    /// Expectations are DERIVED from the fixture, never written out, so the test
7464    /// cannot drift into agreeing with whatever the implementation emits. That
7465    /// is exactly how `line_col`'s own "verified against nix eval" comment came
7466    /// to document the bug it contained.
7467    ///
7468    /// Anti-vacuity: the row count is asserted, and any `NULL` fails. A change
7469    /// that stops attaching positions altogether makes every row `NULL` — which
7470    /// must be a failure, not an empty-set pass.
7471    #[test]
7472    fn every_binding_form_carries_a_position() {
7473        let dir = tempfile::tempdir().unwrap();
7474        // One line per key so the expected line number is its 1-based index.
7475        let body = concat!(
7476            "let src = { i = 1; j = 2; }; in {\n",
7477            "  plain = 1;\n",
7478            "  \"quoted\" = 2;\n",
7479            "  inherit (src) i;\n",
7480            "  inherit src;\n",
7481            "  nested.deep = 3;\n",
7482            "}\n",
7483        );
7484        let f = dir.path().join("forms.nix");
7485        std::fs::write(&f, body).unwrap();
7486
7487        // `nested` is the head of a dotted path; CppNix points at the head.
7488        let keys = ["plain", "quoted", "i", "src", "nested"];
7489        let probe = keys
7490            .iter()
7491            .map(|k| format!(
7492                "(let q = builtins.unsafeGetAttrPos \"{k}\" t; \
7493                 in if q == null then \"{k}=NULL\" \
7494                 else \"{k}=${{toString q.line}}:${{toString q.column}}\")"
7495            ))
7496            .collect::<Vec<_>>()
7497            .join(" + \" \" + ");
7498        let got = eval(&format!("let t = import {}; in {probe}", f.display()))
7499            .unwrap()
7500            .as_string()
7501            .unwrap()
7502            .to_string();
7503
7504        assert!(!got.contains("NULL"), "a binding form lost its position: {got}");
7505        let rows: Vec<&str> = got.split(' ').collect();
7506        assert_eq!(rows.len(), keys.len(), "corpus shrank — gate would be vacuous: {got}");
7507
7508        // Derive each expectation by locating the key token in the fixture.
7509        for (k, row) in keys.iter().zip(&rows) {
7510            let needle = match *k {
7511                "quoted" => "\"quoted\"".to_string(),
7512                "i" => "i;".to_string(),
7513                "src" => "src;".to_string(),
7514                // A dotted path's head is followed by `.`, not ` =` — CppNix
7515                // reports the HEAD token's position for the outer key.
7516                "nested" => "nested.".to_string(),
7517                other => format!("{other} ="),
7518            };
7519            let off = body.find(&needle).unwrap();
7520            let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7521            let line = 1 + body[..off].matches('\n').count();
7522            let col = off - bol + 1;
7523            assert_eq!(*row, format!("{k}={line}:{col}"), "wrong position for `{k}` in:\n{body}");
7524        }
7525    }
7526
7527    /// A missing-argument error names the file the LAMBDA came from.
7528    ///
7529    /// Evaluated with `eval_with_file`, not `push_eval_file` + bare `eval`, and
7530    /// the difference is the point. Calling a closure now pushes the closure's
7531    /// OWN file — including a fileless frame when it has none — so a lambda
7532    /// defined in a fileless string no longer borrows whatever unrelated file
7533    /// happens to sit on the stack. That borrowing is what the old form
7534    /// asserted, and CppNix does not do it: an `--expr` lambda has no file.
7535    /// Associating the source with a file, as every real `import` does, keeps
7536    /// the original intent (errors carry file context) while testing the path
7537    /// production actually takes. Verified against CppNix: for a lambda in a
7538    /// real file both engines name that file.
7539    #[test]
7540    fn error_missing_argument_includes_file_context() {
7541        let p = std::path::PathBuf::from("/nix/store/func.nix");
7542        let result = eval_with_file("({ a, b }: a) { a = 1; }", Some(p));
7543        let msg = format!("{}", result.unwrap_err());
7544        assert!(msg.contains("missing argument"), "msg: {msg}");
7545        assert!(msg.contains("func.nix"), "msg: {msg}");
7546    }
7547
7548    #[test]
7549    fn error_cannot_call_includes_file_context() {
7550        let p = std::path::PathBuf::from("/nix/store/call.nix");
7551        let _g = push_eval_file(p);
7552        let result = eval("42 99");
7553        let msg = format!("{}", result.unwrap_err());
7554        assert!(msg.contains("cannot call"), "msg: {msg}");
7555        assert!(msg.contains("call.nix"), "msg: {msg}");
7556    }
7557
7558    #[test]
7559    fn error_without_file_has_no_in_prefix() {
7560        // When no file is on the eval stack, error messages should
7561        // not contain ", in" context.
7562        let result = eval("nonexistent_xyz");
7563        let msg = format!("{}", result.unwrap_err());
7564        assert!(msg.contains("undefined variable"), "msg: {msg}");
7565        assert!(!msg.contains(", in"), "msg should not contain file context: {msg}");
7566    }
7567
7568    // ── pure mode getter/setter independence ───────────────
7569
7570    #[test]
7571    fn pure_mode_set_get_independence() {
7572        let was = is_pure_mode();
7573        set_pure_mode(true);
7574        assert!(is_pure_mode());
7575        set_pure_mode(false);
7576        assert!(!is_pure_mode());
7577        set_pure_mode(was);
7578    }
7579
7580    // ── eval_with_file with file path ──────────────────────
7581
7582    #[test]
7583    fn eval_with_file_some_path_arithmetic() {
7584        let p = std::path::PathBuf::from("/tmp/imaginary.nix");
7585        let result = eval_with_file("1 + 2", Some(p)).unwrap();
7586        assert_eq!(result, Value::Int(3));
7587    }
7588
7589    // ── unsafeGetAttrPos — the options.json `attrTag` declarations root ──
7590    //
7591    // Seals the CppNix-matching behavior: for a literal attrset built in a
7592    // FILE, `builtins.unsafeGetAttrPos <key> <set>` returns
7593    // `{ file; line=1; column=<key byte offset>+1; }`; for a `<string>` eval
7594    // (no file) it returns `null`. Byte-verified against `nix eval`.
7595
7596    #[test]
7597    fn unsafe_get_attr_pos_reports_file_and_offset_column() {
7598        // The real `attrTag` path: a literal attrset built in an IMPORTED file.
7599        // `import` registers the file's source text + pushes it on the eval
7600        // stack, so `eval_attrset` captures the key positions against that file
7601        // and `unsafeGetAttrPos` resolves them. CppNix reports the file plus a
7602        // real newline-resolved line and BYTE column.
7603        //
7604        // Re-baselined: this used to assert line 1 and column = the key's
7605        // 1-based byte offset in the whole file, citing "verified against nix
7606        // eval". It was not — that was sui's own output taken as the oracle,
7607        // and the same false rule was pinned in pos.rs. Measured on nix 2.31.5:
7608        // for `{ a = 1;\n  b = 2; }` the `b` key is 2:3, not 1:12.
7609        let dir = tempfile::tempdir().unwrap();
7610        // The literal's `b` key sits at a known byte offset in this file.
7611        let file_body = "{ a = 1;\n  b = 2; }\n";
7612        let f = dir.path().join("lit.nix");
7613        std::fs::write(&f, file_body).unwrap();
7614        let src = format!("builtins.unsafeGetAttrPos \"b\" (import {})", f.display());
7615        let v = eval(&src).unwrap();
7616        let attrs = match v { Value::Attrs(a) => a, other => panic!("expected attrs, got {other:?}") };
7617        assert_eq!(
7618            attrs.get("file").unwrap().as_string().unwrap(),
7619            f.to_string_lossy(),
7620        );
7621        // `b` is on the SECOND line, at byte column 3.
7622        let off = file_body.find("b = 2").unwrap();
7623        let bol = file_body[..off].rfind('\n').map_or(0, |i| i + 1);
7624        let expected_line = 1 + file_body[..off].matches('\n').count() as i64;
7625        let expected_col = (off - bol) as i64 + 1;
7626        assert_eq!(expected_line, 2, "fixture must put `b` on line 2");
7627        assert_eq!(*attrs.get("line").unwrap(), Value::Int(expected_line));
7628        let col = match attrs.get("column").unwrap() { Value::Int(n) => *n, o => panic!("{o:?}") };
7629        assert_eq!(col, expected_col, "column must be the 1-based BYTE column");
7630    }
7631
7632    #[test]
7633    fn unsafe_get_attr_pos_null_for_string_origin() {
7634        // A `<string>`-eval'd literal (no file on the stack) has no position → null.
7635        let v = eval("builtins.unsafeGetAttrPos \"a\" { a = 1; }").unwrap();
7636        assert_eq!(v, Value::Null);
7637    }
7638
7639    #[test]
7640    fn unsafe_get_attr_pos_null_for_missing_key() {
7641        // A key absent from an imported set → null.
7642        let dir = tempfile::tempdir().unwrap();
7643        let f = dir.path().join("lit.nix");
7644        std::fs::write(&f, "{ a = 1; }\n").unwrap();
7645        let src = format!("builtins.unsafeGetAttrPos \"zzz\" (import {})", f.display());
7646        let v = eval(&src).unwrap();
7647        assert_eq!(v, Value::Null);
7648    }
7649
7650    // ── String interpolation primitive coercions ───────────
7651
7652    #[test]
7653    fn interp_int_into_string() {
7654        // Integer interpolated into a string is coerced to its decimal repr.
7655        assert_eq!(ev(r#""val=${toString 42}""#), Value::string("val=42"));
7656    }
7657
7658    #[test]
7659    fn interp_bool_true_becomes_one() {
7660        // Per eval_str: Bool(true) → "1", Bool(false) → "" (empty)
7661        let v = ev(r#"let x = true; in "${builtins.toString x}""#);
7662        assert_eq!(v, Value::string("1"));
7663    }
7664
7665    #[test]
7666    fn interp_null_becomes_empty() {
7667        // Null in interpolation is empty.
7668        let v = ev(r#"let x = null; in "${builtins.toString x}""#);
7669        assert_eq!(v, Value::string(""));
7670    }
7671
7672    #[test]
7673    fn interp_attrset_without_to_string_errors() {
7674        // An attrset interpolated without __toString is a type error.
7675        let result = eval(r#"let s = { x = 1; }; in "${s}""#);
7676        assert!(result.is_err());
7677    }
7678
7679    #[test]
7680    fn interp_attrset_with_to_string_protocol() {
7681        // __toString protocol returns a string when called with self.
7682        let v = ev(r#""${{ __toString = self: "ok"; }}""#);
7683        assert_eq!(v, Value::string("ok"));
7684    }
7685
7686    // ── Path PathRel / PathHome / PathAbs ─────────────────
7687
7688    #[test]
7689    fn eval_path_absolute_literal() {
7690        let v = ev("/tmp/foo");
7691        match v {
7692            Value::Path(p) => assert!(p.contains("/tmp/foo")),
7693            _ => panic!("expected Path"),
7694        }
7695    }
7696
7697    #[test]
7698    fn eval_path_home_literal() {
7699        let v = ev("~/foo.nix");
7700        match v {
7701            Value::Path(p) => assert!(p.contains("~/foo.nix") || p.ends_with("foo.nix")),
7702            _ => panic!("expected Path"),
7703        }
7704    }
7705
7706    // ── search path miss ──────────────────────────────────
7707
7708    #[test]
7709    fn path_search_unmatched_errors() {
7710        // Without NIX_PATH entries matching, <nonexistent> errors out.
7711        // We unset NIX_PATH locally to ensure no entries match.
7712        let saved = std::env::var("NIX_PATH").ok();
7713        // SAFETY: tests run sequentially in single-threaded mode by
7714        // default? The thread_local NIX_PATH is per-thread but std::env
7715        // is process-global. We restore it after.
7716        unsafe {
7717            std::env::remove_var("NIX_PATH");
7718        }
7719        let result = eval("<this_should_not_resolve>");
7720        if let Some(v) = saved {
7721            unsafe {
7722                std::env::set_var("NIX_PATH", v);
7723            }
7724        }
7725        assert!(result.is_err());
7726    }
7727
7728    // ── Unary operators ────────────────────────────────────
7729
7730    #[test]
7731    fn unary_negate_int() {
7732        assert_eq!(ev("-7"), Value::Int(-7));
7733    }
7734
7735    #[test]
7736    fn unary_negate_float() {
7737        assert_eq!(ev("-2.5"), Value::Float(-2.5));
7738    }
7739
7740    #[test]
7741    fn unary_invert_true() {
7742        assert_eq!(ev("!true"), Value::Bool(false));
7743    }
7744
7745    #[test]
7746    fn unary_invert_false() {
7747        assert_eq!(ev("!false"), Value::Bool(true));
7748    }
7749
7750    #[test]
7751    fn unary_negate_bool_errors() {
7752        let result = eval("-true");
7753        assert!(result.is_err());
7754    }
7755
7756    #[test]
7757    fn unary_invert_int_errors() {
7758        let result = eval("!42");
7759        assert!(result.is_err());
7760    }
7761
7762    // ── Binary op type errors ──────────────────────────────
7763
7764    #[test]
7765    fn binop_add_attrs_errors() {
7766        let result = eval("{a=1;} + {b=2;}");
7767        assert!(result.is_err());
7768    }
7769
7770    #[test]
7771    fn binop_sub_string_errors() {
7772        let result = eval(r#""a" - "b""#);
7773        assert!(result.is_err());
7774    }
7775
7776    #[test]
7777    fn binop_mul_string_errors() {
7778        let result = eval(r#""a" * "b""#);
7779        assert!(result.is_err());
7780    }
7781
7782    #[test]
7783    fn binop_div_string_errors() {
7784        let result = eval(r#""a" / "b""#);
7785        assert!(result.is_err());
7786    }
7787
7788    #[test]
7789    fn binop_compare_attrs_errors() {
7790        let result = eval("{a=1;} < {b=2;}");
7791        assert!(result.is_err());
7792    }
7793
7794    #[test]
7795    fn binop_div_float_by_zero_int() {
7796        // Float / int(0) is NOT a DivisionByZero error in this evaluator —
7797        // only int/int matches the DivisionByZero branch. This documents
7798        // that branch.
7799        let result = eval("1.0 / 0");
7800        // Either inf or error is acceptable; the documented branch is
7801        // the int/int(0) → DivisionByZero one.
7802        let _ = result;
7803    }
7804
7805    #[test]
7806    fn binop_int_div_zero_is_division_by_zero() {
7807        let result = eval("5 / 0");
7808        match result {
7809            Err(EvalError::DivisionByZero) => {}
7810            other => panic!("expected DivisionByZero, got {other:?}"),
7811        }
7812    }
7813
7814    // ── if/then/else laziness ──────────────────────────────
7815
7816    #[test]
7817    fn if_else_only_chosen_branch_evaluated_then() {
7818        // The else branch contains a divide-by-zero that would error
7819        // if eagerly evaluated. Choosing the then branch must skip it.
7820        assert_eq!(ev("if true then 42 else 1 / 0"), Value::Int(42));
7821    }
7822
7823    #[test]
7824    fn if_else_only_chosen_branch_evaluated_else() {
7825        assert_eq!(ev("if false then 1 / 0 else 99"), Value::Int(99));
7826    }
7827
7828    #[test]
7829    fn if_condition_must_be_bool() {
7830        let result = eval("if 1 then 1 else 2");
7831        assert!(result.is_err());
7832    }
7833
7834    #[test]
7835    fn if_condition_lazy_does_not_force_unused() {
7836        // Lazy `let` ensures that `bad` is only forced if the chosen
7837        // branch references it.
7838        assert_eq!(
7839            ev("let bad = 1 / 0; in if true then 42 else bad"),
7840            Value::Int(42),
7841        );
7842    }
7843
7844    // ── Logic short-circuit laziness ───────────────────────
7845
7846    #[test]
7847    fn and_short_circuits_on_false() {
7848        // RHS contains an error; should never run.
7849        assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
7850    }
7851
7852    #[test]
7853    fn or_short_circuits_on_true() {
7854        assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
7855    }
7856
7857    #[test]
7858    fn implication_short_circuits_on_false_lhs() {
7859        // false -> anything is true; RHS not evaluated.
7860        assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
7861    }
7862
7863    // ── Lambda fixpoint via let ────────────────────────────
7864
7865    #[test]
7866    fn lambda_fix_combinator_returns_attrset() {
7867        // The classic `fix = f: let x = f x; in x` shape.
7868        let v = ev(
7869            "let fix = f: let x = f x; in x; in
7870              (fix (self: { val = 1; double = self.val * 2; })).double",
7871        );
7872        assert_eq!(v, Value::Int(2));
7873    }
7874
7875    // ── eval_attrset rec scope details ─────────────────────
7876
7877    #[test]
7878    fn rec_attrset_self_reference() {
7879        // rec set with simple forward reference.
7880        let v = ev("(rec { a = b; b = 1; }).a");
7881        assert_eq!(v, Value::Int(1));
7882    }
7883
7884    #[test]
7885    fn rec_attrset_inherit_from_uses_outer_scope() {
7886        // inherit-from in rec uses the OUTER (lexical) scope to evaluate
7887        // the source expression, not the rec scope. We bind `src` in
7888        // an outer let so the inherit can find it.
7889        let v = ev(
7890            "let src = { a = 10; }; in
7891              rec {
7892                inherit (src) a;
7893                b = a + 1;
7894              }",
7895        );
7896        if let Value::Attrs(attrs) = v {
7897            let b = attrs.get("b").unwrap();
7898            let b_forced = force_value(b).unwrap();
7899            assert_eq!(b_forced, Value::Int(11));
7900        } else {
7901            panic!("expected attrs");
7902        }
7903    }
7904
7905    #[test]
7906    fn nonrec_attrset_no_self_reference() {
7907        // In a non-rec set, a name doesn't see its sibling. The error
7908        // surfaces as an UndefinedVar when the thunk is forced.
7909        let result = eval("({ a = 1; b = a + 1; }).b");
7910        assert!(result.is_err());
7911    }
7912
7913    // ── eval_attrset deep merge edge cases ─────────────────
7914
7915    #[test]
7916    fn dotted_binding_three_segments_then_sibling() {
7917        let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
7918        if let Value::Attrs(attrs) = v {
7919            let a = attrs.get("a").unwrap();
7920            let a_forced = force_value(a).unwrap();
7921            if let Value::Attrs(a_attrs) = a_forced {
7922                let b = a_attrs.get("b").unwrap();
7923                let b_forced = force_value(b).unwrap();
7924                if let Value::Attrs(b_attrs) = b_forced {
7925                    assert_eq!(force_value(b_attrs.get("c").unwrap()).unwrap(), Value::Int(1));
7926                    assert_eq!(force_value(b_attrs.get("d").unwrap()).unwrap(), Value::Int(2));
7927                } else {
7928                    panic!("expected b to be attrs");
7929                }
7930                assert_eq!(force_value(a_attrs.get("e").unwrap()).unwrap(), Value::Int(3));
7931            } else {
7932                panic!("expected a to be attrs");
7933            }
7934        } else {
7935            panic!("expected outer attrs");
7936        }
7937    }
7938
7939    // ── rec/let dotted bindings in recursive scope ────────
7940
7941    #[test]
7942    fn rec_dotted_bindings_visible_to_siblings() {
7943        // Dotted bindings in rec blocks must be visible to sibling
7944        // bindings -- this is the nixpkgs lib/systems/parse.nix pattern.
7945        let v = ev("rec { types.openSB = 1; types.openCpu = 2; foo = types.openSB; }.foo");
7946        assert_eq!(v, Value::Int(1));
7947    }
7948
7949    #[test]
7950    fn rec_dotted_leaf_uses_rec_scope() {
7951        // Leaf expressions in dotted bindings must see sibling
7952        // rec-bindings, not just the parent scope.
7953        let v = ev("rec { types.a = f 1; f = x: x + 1; }.types.a");
7954        assert_eq!(v, Value::Int(2));
7955    }
7956
7957    #[test]
7958    fn rec_dotted_multiple_keys_merge() {
7959        // Multiple dotted bindings sharing a top-level key must merge.
7960        let v = ev("rec { types.a = 1; types.b = 2; x = types; }.x");
7961        if let Value::Attrs(attrs) = v {
7962            assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7963            assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7964        } else {
7965            panic!("expected attrs");
7966        }
7967    }
7968
7969    #[test]
7970    fn rec_nixpkgs_parse_pattern() {
7971        // Simplified nixpkgs lib/systems/parse.nix pattern:
7972        // rec block with dotted types.xxx bindings that reference
7973        // each other through the rec scope.
7974        let v = ev(r#"
7975            let
7976              mkOptionType = x: x;
7977              mergeOneOption = "merge";
7978              attrValues = builtins.attrValues;
7979              setType = name: value: { __type = name; } // value;
7980              mapAttrs = builtins.mapAttrs;
7981              enum = xs: mkOptionType { name = "enum"; check = x: builtins.elem x xs; };
7982              setTypes = type: mapAttrs (name: value: setType type.name ({ inherit name; } // value));
7983            in
7984            rec {
7985              types.openSB = mkOptionType { name = "sb"; merge = mergeOneOption; };
7986              types.significantByte = enum (attrValues significantBytes);
7987              significantBytes = setTypes types.openSB { bigEndian = {}; littleEndian = {}; };
7988              types.openCpuType = mkOptionType { name = "cpu-type"; };
7989              types.cpuType = enum (attrValues cpuTypes);
7990              cpuTypes = setTypes types.openCpuType { arm = { bits = 32; }; };
7991            }.types.openCpuType
7992        "#);
7993        if let Value::Attrs(attrs) = v {
7994            assert_eq!(
7995                force_value(attrs.get("name").unwrap()).unwrap(),
7996                Value::string("cpu-type")
7997            );
7998        } else {
7999            panic!("expected attrs");
8000        }
8001    }
8002
8003    #[test]
8004    fn let_dotted_leaf_uses_let_scope() {
8005        // Dotted binding leaf in a let block sees sibling let-bindings.
8006        let v = ev("let a.x = f 1; f = x: x + 1; in a.x");
8007        assert_eq!(v, Value::Int(2));
8008    }
8009
8010    #[test]
8011    fn let_inherit_from_plus_dotted_is_rejected() {
8012        // `inherit (src) types;` and `types.added = true;` in one `let` are two
8013        // definitions of `types`, and an inherited name can never merge — it
8014        // binds the name outright. CppNix rejects it at parse time:
8015        //
8016        //     error: attribute 'types' already defined at «string»:1:60
8017        //
8018        // ★ THIS TEST USED TO ASSERT THE WRONG ANSWER, on purpose, and said so:
8019        // "Sui currently lets the dotted binding win (last-write-wins). This
8020        // test documents the current behaviour -- when we add duplicate
8021        // detection it should change to assert an error." That is this change.
8022        // The old expectation was `added = true` with `existing` SILENTLY GONE,
8023        // at exit 0.
8024        let err = eval(
8025            r#"
8026            let
8027              src = { types = { existing = true; }; };
8028              inherit (src) types;
8029              types.added = true;
8030            in types
8031        "#,
8032        )
8033        .expect_err("a duplicate definition must be refused, not resolved by last-write-wins");
8034        let msg = err.to_string();
8035        assert!(
8036            msg.contains("attribute 'types' already defined"),
8037            "the refusal must name the attribute: {msg}"
8038        );
8039    }
8040
8041    // ── Function pattern variations ────────────────────────
8042
8043    #[test]
8044    fn pattern_empty_no_args_no_ellipsis() {
8045        // {} pattern accepts only an empty attrset.
8046        assert_eq!(ev("({}: 1) {}"), Value::Int(1));
8047    }
8048
8049    #[test]
8050    fn pattern_empty_with_ellipsis_accepts_extra() {
8051        assert_eq!(ev("({...}: 1) { a = 1; b = 2; }"), Value::Int(1));
8052    }
8053
8054    #[test]
8055    fn pattern_all_defaults() {
8056        assert_eq!(
8057            ev("({a ? 1, b ? 2}: a + b) {}"),
8058            Value::Int(3),
8059        );
8060    }
8061
8062    #[test]
8063    fn pattern_at_bind_before() {
8064        // args @ { x }: args.x — bind name comes before pattern.
8065        assert_eq!(ev("(args @ { x }: args.x) { x = 7; }"), Value::Int(7));
8066    }
8067
8068    #[test]
8069    fn pattern_at_bind_after() {
8070        // { x } @ args: args.x — bind name comes after pattern.
8071        assert_eq!(ev("({ x } @ args: args.x) { x = 7; }"), Value::Int(7));
8072    }
8073
8074    #[test]
8075    fn pattern_default_references_other_arg() {
8076        // The default for `b` references `a` (which exists).
8077        assert_eq!(ev("({a, b ? a + 1}: b) {a = 10;}"), Value::Int(11));
8078    }
8079
8080    #[test]
8081    fn pattern_required_missing_errors() {
8082        let result = eval("({ a, b }: a) { a = 1; }");
8083        assert!(result.is_err());
8084    }
8085
8086    #[test]
8087    fn pattern_unexpected_errors_without_ellipsis() {
8088        let result = eval("({ a }: a) { a = 1; b = 2; }");
8089        assert!(result.is_err());
8090    }
8091
8092    // ── apply: error on non-callable ───────────────────────
8093
8094    #[test]
8095    fn apply_int_errors() {
8096        let result = eval("42 5");
8097        assert!(result.is_err());
8098    }
8099
8100    #[test]
8101    fn apply_string_errors() {
8102        let result = eval(r#""hi" 5"#);
8103        assert!(result.is_err());
8104    }
8105
8106    #[test]
8107    fn apply_attrset_without_functor_errors() {
8108        let result = eval("{ x = 1; } 5");
8109        assert!(result.is_err());
8110        let msg = format!("{}", result.unwrap_err());
8111        assert!(msg.contains("__functor") || msg.contains("cannot call"));
8112    }
8113
8114    // ── Select with multi-segment + default ────────────────
8115
8116    #[test]
8117    fn select_multi_segment_with_default() {
8118        // a.b.missing or 99 -- the missing segment yields the default.
8119        assert_eq!(ev("{ a = { b = 1; }; }.a.c or 99"), Value::Int(99));
8120    }
8121
8122    #[test]
8123    fn select_from_int_errors() {
8124        let result = eval("(1).x");
8125        assert!(result.is_err());
8126    }
8127
8128    // ── HasAttr edge cases ─────────────────────────────────
8129
8130    #[test]
8131    fn has_attr_on_non_set_returns_false() {
8132        // `expr ? a` where expr is not a set returns false (not error).
8133        assert_eq!(ev("1 ? x"), Value::Bool(false));
8134    }
8135
8136    #[test]
8137    fn has_attr_nested_path_present() {
8138        assert_eq!(ev("{ a = { b = 1; }; } ? a.b"), Value::Bool(true));
8139    }
8140
8141    #[test]
8142    fn has_attr_nested_path_missing() {
8143        assert_eq!(ev("{ a = { b = 1; }; } ? a.c"), Value::Bool(false));
8144    }
8145
8146    #[test]
8147    fn has_attr_intermediate_missing_returns_false() {
8148        assert_eq!(ev("{} ? a.b.c"), Value::Bool(false));
8149    }
8150
8151    // ── List eval edge cases ───────────────────────────────
8152
8153    #[test]
8154    fn list_with_function_value() {
8155        let v = ev("[(x: x + 1)]");
8156        if let Value::List(items) = v {
8157            assert_eq!(items.len(), 1);
8158            // List elements are now lazy (thunked). Force to check type.
8159            let forced = force_value(&items[0]).unwrap();
8160            assert!(matches!(forced, Value::Lambda(_)));
8161        } else {
8162            panic!("expected list");
8163        }
8164    }
8165
8166    // ── eval_inherit edge: inherit from missing var ────────
8167
8168    #[test]
8169    fn inherit_unknown_name_errors() {
8170        let result = eval("let x = 1; in let inherit nonexistent; in nonexistent");
8171        assert!(result.is_err());
8172    }
8173
8174    // ── String op: string concat preserves context ─────────
8175
8176    #[test]
8177    fn string_concat_no_context_when_both_plain() {
8178        let v = ev(r#""abc" + "def""#);
8179        if let Value::String(ns) = v {
8180            assert_eq!(ns.chars, "abcdef");
8181            assert!(!ns.has_context());
8182        } else {
8183            panic!("expected string");
8184        }
8185    }
8186
8187    // ── Parens / Root ──────────────────────────────────────
8188
8189    #[test]
8190    fn parens_around_expression() {
8191        assert_eq!(ev("(1 + 2)"), Value::Int(3));
8192    }
8193
8194    #[test]
8195    fn nested_parens() {
8196        assert_eq!(ev("(((42)))"), Value::Int(42));
8197    }
8198
8199    // ── Throw via builtins ─────────────────────────────────
8200
8201    #[test]
8202    fn throw_propagates_as_error() {
8203        let result = eval(r#"builtins.throw "kaboom""#);
8204        match result {
8205            Err(EvalError::Throw(s)) => assert!(s.contains("kaboom")),
8206            other => panic!("expected Throw, got {other:?}"),
8207        }
8208    }
8209
8210    #[test]
8211    fn assert_failed_propagates_as_error() {
8212        let result = eval("assert false; 1");
8213        match result {
8214            Err(EvalError::AssertionFailed(_)) => {}
8215            other => panic!("expected AssertionFailed, got {other:?}"),
8216        }
8217    }
8218
8219    // ── eval_str InterpolPart::Literal only ────────────────
8220
8221    #[test]
8222    fn string_no_interp_yields_no_context() {
8223        let v = ev(r#""just literal""#);
8224        if let Value::String(ns) = v {
8225            assert!(!ns.has_context());
8226        } else {
8227            panic!("expected string");
8228        }
8229    }
8230
8231    // ── Path interpolation adds context ───────────────────
8232
8233    // Byte-parity root #5: interpolating a source path is CppNix copy-to-store
8234    // coercion — the path is NAR-copied into /nix/store/<hash>-<name> and the
8235    // store path (with store-path context) is spliced in, not the raw path.
8236    // NAR of a single regular file is content+basename only (location-
8237    // independent), so a temp <dir>/data.txt of "hello\n" yields the exact
8238    // store path nix 2.34 produced: /nix/store/y9dmv…-data.txt.
8239    #[test]
8240    fn interp_path_copies_to_store_byte_matches_cppnix() {
8241        let dir = std::env::temp_dir().join(format!("sui-r5-interp-{}", std::process::id()));
8242        let _ = std::fs::remove_dir_all(&dir);
8243        std::fs::create_dir_all(&dir).unwrap();
8244        let f = dir.join("data.txt");
8245        std::fs::write(&f, b"hello\n").unwrap();
8246        let expr = format!(r#""${{{}}}""#, f.display());
8247        let v = eval(&expr).unwrap();
8248        if let Value::String(ns) = v {
8249            assert_eq!(
8250                ns.chars.to_string(),
8251                "/nix/store/y9dmvfhip31hg8ia4njwjz9vfa3ndphr-data.txt",
8252            );
8253            assert!(ns.has_context());
8254        } else {
8255            panic!("expected string");
8256        }
8257        let _ = std::fs::remove_dir_all(&dir);
8258    }
8259
8260    // ── pipe operators (NotImplemented) ────────────────────
8261    // Pipe operators (|>, <|) are parsed as PipeRight/PipeLeft and
8262    // currently return NotImplemented. We can't easily evaluate them
8263    // here because rnix may not even parse them, so we just rely on
8264    // the binop branch existing.
8265
8266    // ── ParseError surface ─────────────────────────────────
8267
8268    #[test]
8269    fn parse_error_unbalanced_braces() {
8270        let result = eval("{ a = 1");
8271        assert!(result.is_err());
8272        let err = result.unwrap_err();
8273        assert!(matches!(err, EvalError::ParseError(_)));
8274    }
8275
8276    #[test]
8277    fn parse_error_dangling_let() {
8278        let result = eval("let in");
8279        assert!(result.is_err());
8280    }
8281
8282    #[test]
8283    fn parse_error_empty_input() {
8284        let result = eval("");
8285        assert!(result.is_err());
8286    }
8287
8288    // ── num_op coverage via float ops ──────────────────────
8289
8290    #[test]
8291    fn float_int_subtraction() {
8292        assert_eq!(ev("3.5 - 1"), Value::Float(2.5));
8293    }
8294
8295    #[test]
8296    fn int_float_subtraction() {
8297        assert_eq!(ev("3 - 0.5"), Value::Float(2.5));
8298    }
8299
8300    #[test]
8301    fn float_float_division() {
8302        assert_eq!(ev("6.0 / 2.0"), Value::Float(3.0));
8303    }
8304
8305    #[test]
8306    fn int_float_multiplication() {
8307        assert_eq!(ev("3 * 2.5"), Value::Float(7.5));
8308    }
8309
8310    // ── compare with mixed numerics ────────────────────────
8311
8312    #[test]
8313    fn compare_int_float_less() {
8314        assert_eq!(ev("1 < 1.5"), Value::Bool(true));
8315    }
8316
8317    #[test]
8318    fn compare_float_int_more() {
8319        assert_eq!(ev("3.5 > 3"), Value::Bool(true));
8320    }
8321
8322    #[test]
8323    fn compare_equal_int_float() {
8324        assert_eq!(ev("3 <= 3.0"), Value::Bool(true));
8325    }
8326
8327    // ── Equality ──────────────────────────────────────────
8328
8329    #[test]
8330    fn equal_lists_same() {
8331        assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
8332    }
8333
8334    #[test]
8335    fn equal_lists_diff_length() {
8336        assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
8337    }
8338
8339    #[test]
8340    fn not_equal_lists() {
8341        assert_eq!(ev("[1] != [2]"), Value::Bool(true));
8342    }
8343
8344    #[test]
8345    fn equal_attrsets_same() {
8346        assert_eq!(ev("{a = 1; b = 2;} == {b = 2; a = 1;}"), Value::Bool(true));
8347    }
8348
8349    // ── Lambda identity equality (Rc ptr_eq) ────────────────
8350    // Regression test: same lambda via Rc must compare equal.
8351    // Without this, nixpkgs stdenv evaluation enters an infinite loop
8352    // because `crossSystem != localSystem` returns true even when both
8353    // are the same elaborate result (containing shared function attrs).
8354
8355    #[test]
8356    fn lambda_self_equality_in_attrset() {
8357        // Same closure shared via let → inherit must be equal
8358        assert_eq!(
8359            ev("let f = x: x; in { a = 1; inherit f; } == { a = 1; inherit f; }"),
8360            Value::Bool(true),
8361        );
8362    }
8363
8364    #[test]
8365    fn lambda_self_reference_attrset_equality() {
8366        // Attrset with function attr: x == x must be true
8367        assert_eq!(
8368            ev("let x = { a = 1; f = y: y; }; in x == x"),
8369            Value::Bool(true),
8370        );
8371    }
8372
8373    #[test]
8374    fn lambda_different_closures_not_equal() {
8375        // Different lambda closures (even structurally identical) must be false
8376        assert_eq!(
8377            ev("{ f = x: x; } == { f = x: x; }"),
8378            Value::Bool(false),
8379        );
8380    }
8381
8382    #[test]
8383    fn lambda_ne_does_not_force_unused_branch() {
8384        // If crossSystem == localSystem (same obj), != returns false,
8385        // and the then-branch (with throw) is never forced.
8386        assert_eq!(
8387            ev("let ls = { a = 1; f = x: x; }; in if ls != ls then builtins.throw \"bug\" else 42"),
8388            Value::Int(42),
8389        );
8390    }
8391
8392    // ── force_value chains thunks ──────────────────────────
8393
8394    #[test]
8395    fn force_value_through_thunk() {
8396        let root = rnix::Root::parse("1 + 2");
8397        let expr = root.tree().expr().unwrap();
8398        let thunk = Thunk::new_suspended(expr, Env::new());
8399        let val = Value::Thunk(thunk);
8400        assert_eq!(force_value(&val).unwrap(), Value::Int(3));
8401    }
8402
8403    // ── Builtin name "tryEval" lazy arg path ──────────────
8404
8405    #[test]
8406    fn try_eval_catches_thrown_error() {
8407        // tryEval wraps the thunk and catches throws inside.
8408        let v = ev(r#"(builtins.tryEval (builtins.throw "oops")).success"#);
8409        assert_eq!(v, Value::Bool(false));
8410    }
8411
8412    #[test]
8413    fn try_eval_returns_value_on_success() {
8414        let v = ev("(builtins.tryEval 42).value");
8415        assert_eq!(v, Value::Int(42));
8416    }
8417
8418    // ── LegacyLet (`let { body = ...; ...}`) ───────────────
8419
8420    #[test]
8421    fn legacy_let_returns_body_attr() {
8422        // `let { x = 1; body = x + 41; }` is the legacy let form: it
8423        // is desugared as a recursive set whose `body` attr is the
8424        // result.
8425        assert_eq!(ev("let { x = 1; body = x + 41; }"), Value::Int(42));
8426    }
8427
8428    #[test]
8429    fn legacy_let_missing_body_errors() {
8430        let result = eval("let { x = 1; }");
8431        assert!(result.is_err());
8432    }
8433
8434    #[test]
8435    fn legacy_let_with_inherit_from_scope() {
8436        assert_eq!(
8437            ev("let outer = 5; in let { inherit outer; body = outer * 2; }"),
8438            Value::Int(10),
8439        );
8440    }
8441
8442    // ── eval_str interpolation more cases ──────────────────
8443
8444    #[test]
8445    fn interp_with_string_concat_preserves_order() {
8446        assert_eq!(
8447            ev(r#"let a = "x"; b = "y"; in "${a}-${b}""#),
8448            Value::string("x-y"),
8449        );
8450    }
8451
8452    #[test]
8453    fn interp_only_literal_part() {
8454        assert_eq!(ev(r#""no interp here""#), Value::string("no interp here"));
8455    }
8456
8457    // ── eval_attr dynamic / string keys ────────────────────
8458
8459    #[test]
8460    fn dynamic_attr_via_string_key_in_set() {
8461        // `{ "a" = 1; }.a` works because attr keys can be string literals.
8462        assert_eq!(ev(r#"{ "a" = 1; }.a"#), Value::Int(1));
8463    }
8464
8465    #[test]
8466    fn dynamic_attr_via_interpolated_key() {
8467        let v = ev(r#"let k = "foo"; in { ${k} = 99; }.foo"#);
8468        assert_eq!(v, Value::Int(99));
8469    }
8470
8471    // ── String key access via select with dynamic ──────────
8472
8473    #[test]
8474    fn select_with_string_key() {
8475        let v = ev(r#"{ a = 42; }."a""#);
8476        assert_eq!(v, Value::Int(42));
8477    }
8478
8479    // ── Apply via __functor on attrset ─────────────────────
8480
8481    #[test]
8482    fn apply_attrset_with_functor_works() {
8483        let v = ev("let s = { __functor = self: x: x + 1; }; in s 5");
8484        assert_eq!(v, Value::Int(6));
8485    }
8486
8487    // ── Negation of negative ───────────────────────────────
8488
8489    #[test]
8490    fn double_negate_int() {
8491        assert_eq!(ev("- (-5)"), Value::Int(5));
8492    }
8493
8494    // ── Inherit from rec scope binding visibility ──────────
8495
8496    #[test]
8497    fn inherit_in_let_makes_name_available() {
8498        assert_eq!(
8499            ev("let src = { a = 7; }; in let inherit (src) a; in a"),
8500            Value::Int(7),
8501        );
8502    }
8503
8504    // ── String + path ──────────────────────────────────────
8505
8506    #[test]
8507    fn path_plus_string_yields_path() {
8508        let v = ev(r#"/foo + "/bar""#);
8509        match v {
8510            Value::Path(p) => assert_eq!(&*p, "/foo/bar"),
8511            _ => panic!("expected path"),
8512        }
8513    }
8514
8515    // ── Lazy attrset value not forced unless selected ──────
8516
8517    #[test]
8518    fn attrset_value_not_forced_unless_selected() {
8519        // `bad` is an attr whose value would error if forced, but we
8520        // only ever select `good`, so it's never touched.
8521        assert_eq!(
8522            ev(r#"{ bad = builtins.throw "boom"; good = 42; }.good"#),
8523            Value::Int(42),
8524        );
8525    }
8526
8527    // ── Lambda calling itself via let ──────────────────────
8528
8529    #[test]
8530    fn lambda_recursive_via_let() {
8531        // factorial via let-bound recursive function
8532        assert_eq!(
8533            ev("let fact = n: if n == 0 then 1 else n * fact (n - 1); in fact 5"),
8534            Value::Int(120),
8535        );
8536    }
8537
8538    // ── Dynamic key in select ──────────────────────────────
8539
8540    #[test]
8541    fn select_with_dynamic_key_via_var() {
8542        // ${k} interpolation in select position is not standard Nix
8543        // syntax, but a string-literal key works for select.
8544        assert_eq!(ev(r#"let k = { x = 1; }; in k.x"#), Value::Int(1));
8545    }
8546
8547    // ── Compare strings ────────────────────────────────────
8548
8549    #[test]
8550    fn compare_string_lex_greater_or_equal() {
8551        assert_eq!(ev(r#""b" >= "a""#), Value::Bool(true));
8552        assert_eq!(ev(r#""a" >= "a""#), Value::Bool(true));
8553        assert_eq!(ev(r#""a" >= "b""#), Value::Bool(false));
8554    }
8555
8556    // ── PartialEq across types ─────────────────────────────
8557
8558    #[test]
8559    fn equal_int_string_false() {
8560        assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
8561    }
8562
8563    #[test]
8564    fn equal_null_int_false() {
8565        assert_eq!(ev("null == 0"), Value::Bool(false));
8566    }
8567
8568    // ── Update operator on thunked operands ────────────────
8569
8570    #[test]
8571    fn update_with_let_bound_operands() {
8572        assert_eq!(
8573            ev("let a = { x = 1; }; b = { y = 2; }; in (a // b).y"),
8574            Value::Int(2),
8575        );
8576    }
8577
8578    // ── Concat on let-bound lists ──────────────────────────
8579
8580    #[test]
8581    fn concat_lists_from_let() {
8582        assert_eq!(
8583            ev("let a = [1 2]; b = [3 4]; in builtins.length (a ++ b)"),
8584            Value::Int(4),
8585        );
8586    }
8587
8588    // ── String interpolation: list coercion ─────────────────
8589
8590    #[test]
8591    fn interp_list_coerces_with_spaces() {
8592        // Lists in interpolation are now coerced via coerce_to_string
8593        // (space-joined elements).
8594        assert_eq!(
8595            ev(r#""${toString [1 2 3]}""#),
8596            Value::string("1 2 3"),
8597        );
8598    }
8599
8600    #[test]
8601    fn interp_list_directly_coerces() {
8602        // Direct list interpolation space-joins elements via coerce_to_string.
8603        assert_eq!(
8604            ev(r#""${[1 2]}""#),
8605            Value::string("1 2"),
8606        );
8607    }
8608
8609    // ── String interpolation: outPath ─────────────────────
8610
8611    #[test]
8612    fn interp_outpath_attrset() {
8613        assert_eq!(
8614            ev(r#"let x = { outPath = "/nix/store/abc"; }; in "${x}""#),
8615            Value::string("/nix/store/abc"),
8616        );
8617    }
8618
8619    #[test]
8620    fn interp_tostring_takes_priority_over_outpath() {
8621        assert_eq!(
8622            ev(r#"let x = { __toString = self: "custom"; outPath = "/ignored"; }; in "${x}""#),
8623            Value::string("custom"),
8624        );
8625    }
8626
8627    #[test]
8628    fn interp_derivation_coerces_to_outpath() {
8629        // derivation produces an attrset with outPath
8630        let result = eval(r#"
8631            let drv = builtins.derivation {
8632                name = "test";
8633                system = "x86_64-linux";
8634                builder = "/bin/sh";
8635            };
8636            in "${drv}"
8637        "#).unwrap();
8638        if let Value::String(s) = result {
8639            assert!(s.chars.starts_with("/nix/store/"), "got: {}", s.chars);
8640        } else {
8641            panic!("expected string");
8642        }
8643    }
8644
8645    // ── String interpolation: lambda error ─────────────────
8646
8647    #[test]
8648    fn interp_lambda_errors() {
8649        let result = eval(r#""${x: x}""#);
8650        assert!(result.is_err());
8651    }
8652
8653    // ── force_value tests ────────────────────────────────────
8654
8655    #[test]
8656    fn force_value_int_returns_same() {
8657        let v = Value::Int(42);
8658        assert_eq!(force_value(&v).unwrap(), Value::Int(42));
8659    }
8660
8661    #[test]
8662    fn force_value_bool_returns_same() {
8663        let v = Value::Bool(true);
8664        assert_eq!(force_value(&v).unwrap(), Value::Bool(true));
8665    }
8666
8667    #[test]
8668    fn force_value_string_returns_same() {
8669        let v = Value::string("hello");
8670        assert_eq!(force_value(&v).unwrap(), Value::string("hello"));
8671    }
8672
8673    #[test]
8674    fn force_value_attrs_returns_same() {
8675        let mut a = NixAttrs::new();
8676        a.insert("x".to_string(), Value::Int(1));
8677        let v = Value::Attrs(Rc::new(a.clone()));
8678        assert_eq!(force_value(&v).unwrap(), Value::Attrs(Rc::new(a)));
8679    }
8680
8681    #[test]
8682    fn force_value_list_returns_same() {
8683        let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
8684        assert_eq!(
8685            force_value(&v).unwrap(),
8686            Value::list(vec![Value::Int(1), Value::Int(2)]),
8687        );
8688    }
8689
8690    #[test]
8691    fn force_value_null_returns_null() {
8692        let v = Value::Null;
8693        assert_eq!(force_value(&v).unwrap(), Value::Null);
8694    }
8695
8696    #[test]
8697    fn force_value_evaluated_thunk_returns_cached() {
8698        // Thunk wrapping a simple expression should evaluate and cache
8699        let v = ev("let x = 1 + 2; in x");
8700        assert_eq!(v, Value::Int(3));
8701        // Force again — should return the cached value
8702        assert_eq!(force_value(&v).unwrap(), Value::Int(3));
8703    }
8704
8705    // ── Tail-call loop tests ─────────────────────────────────
8706
8707    #[test]
8708    fn tco_if_true_condition() {
8709        assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
8710    }
8711
8712    #[test]
8713    fn tco_if_false_condition() {
8714        assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
8715    }
8716
8717    #[test]
8718    fn tco_deeply_nested_if_else_chain() {
8719        // Build a chain: if false then 1 else if false then 2 else ... else 150
8720        // All conditions are false except the final else, which produces 150.
8721        let mut expr = String::from("150");
8722        for i in (1..150).rev() {
8723            expr = format!("if false then {} else {}", i, expr);
8724        }
8725        let v = ev(&expr);
8726        assert_eq!(v, Value::Int(150));
8727    }
8728
8729    #[test]
8730    fn tco_assert_true_passes_through() {
8731        assert_eq!(ev("assert true; 42"), Value::Int(42));
8732    }
8733
8734    #[test]
8735    fn tco_assert_false_throws_assertion_failed() {
8736        let result = eval("assert false; 42");
8737        assert!(result.is_err());
8738        let err = result.unwrap_err();
8739        assert!(
8740            matches!(err, EvalError::AssertionFailed(_)),
8741            "expected AssertionFailed, got: {err}",
8742        );
8743    }
8744
8745    #[test]
8746    fn tco_with_makes_scope_available() {
8747        assert_eq!(ev("with { x = 10; y = 20; }; x + y"), Value::Int(30));
8748    }
8749
8750    #[test]
8751    fn tco_let_in_creates_bindings() {
8752        assert_eq!(ev("let a = 5; in a"), Value::Int(5));
8753    }
8754
8755    #[test]
8756    fn tco_let_in_multiple_bindings() {
8757        assert_eq!(ev("let a = 1; b = 2; c = 3; in a + b + c"), Value::Int(6));
8758    }
8759
8760    // ── eval_attrset tests ───────────────────────────────────
8761
8762    #[test]
8763    fn eval_attrset_empty() {
8764        let v = ev("{}");
8765        if let Value::Attrs(attrs) = v {
8766            assert!(attrs.is_empty(), "expected empty attrset");
8767        } else {
8768            panic!("expected attrset, got {v:?}");
8769        }
8770    }
8771
8772    #[test]
8773    fn eval_attrset_simple_kv() {
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 eval_attrset_recursive() {
8785        assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
8786        assert_eq!(ev("(rec { a = 1; b = a + 1; }).a"), Value::Int(1));
8787    }
8788
8789    #[test]
8790    fn eval_attrset_inherit_from_scope() {
8791        assert_eq!(ev("let x = 1; in { inherit x; }.x"), Value::Int(1));
8792    }
8793
8794    #[test]
8795    fn eval_attrset_inherit_from_expr() {
8796        assert_eq!(
8797            ev("{ inherit (builtins) true; }.true"),
8798            Value::Bool(true),
8799        );
8800    }
8801
8802    #[test]
8803    fn eval_attrset_dotted_path() {
8804        assert_eq!(ev("{ a.b.c = 1; }.a.b.c"), Value::Int(1));
8805    }
8806
8807    #[test]
8808    fn eval_attrset_update_merge() {
8809        let v = ev("{ a = 1; } // { b = 2; }");
8810        if let Value::Attrs(attrs) = v {
8811            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8812            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8813        } else {
8814            panic!("expected attrset, got {v:?}");
8815        }
8816    }
8817
8818    // ── eval_apply tests ─────────────────────────────────────
8819
8820    #[test]
8821    fn eval_apply_simple_function() {
8822        assert_eq!(ev("(x: x + 1) 2"), Value::Int(3));
8823    }
8824
8825    #[test]
8826    fn eval_apply_pattern_destructuring() {
8827        assert_eq!(ev("({a, b}: a + b) { a = 1; b = 2; }"), Value::Int(3));
8828    }
8829
8830    #[test]
8831    fn eval_apply_default_arguments() {
8832        assert_eq!(ev("({a, b ? 0}: a + b) { a = 1; }"), Value::Int(1));
8833    }
8834
8835    #[test]
8836    fn eval_apply_ellipsis() {
8837        assert_eq!(ev("({a, ...}: a) { a = 1; b = 2; }"), Value::Int(1));
8838    }
8839
8840    // ── eval_select tests ────────────────────────────────────
8841
8842    #[test]
8843    fn eval_select_single_key() {
8844        assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
8845    }
8846
8847    #[test]
8848    fn eval_select_multi_level() {
8849        assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
8850    }
8851
8852    #[test]
8853    fn eval_select_with_or_default() {
8854        assert_eq!(ev("{}.a or 42"), Value::Int(42));
8855    }
8856
8857    #[test]
8858    fn eval_select_missing_key_without_default_throws() {
8859        let result = eval("{}.a");
8860        assert!(result.is_err());
8861    }
8862
8863    // ── BinOp tests ──────────────────────────────────────────
8864
8865    #[test]
8866    fn binop_add_ints() {
8867        assert_eq!(ev("1 + 2"), Value::Int(3));
8868    }
8869
8870    #[test]
8871    fn binop_sub_ints() {
8872        assert_eq!(ev("3 - 1"), Value::Int(2));
8873    }
8874
8875    #[test]
8876    fn binop_mul_ints() {
8877        assert_eq!(ev("2 * 3"), Value::Int(6));
8878    }
8879
8880    #[test]
8881    fn binop_div_ints() {
8882        assert_eq!(ev("6 / 2"), Value::Int(3));
8883    }
8884
8885    #[test]
8886    fn binop_float_arithmetic() {
8887        assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
8888    }
8889
8890    #[test]
8891    fn binop_string_concat() {
8892        assert_eq!(
8893            ev(r#""hello" + " " + "world""#),
8894            Value::string("hello world"),
8895        );
8896    }
8897
8898    #[test]
8899    fn binop_list_concat() {
8900        assert_eq!(
8901            ev("[1 2] ++ [3 4]"),
8902            Value::list(vec![
8903                Value::Int(1),
8904                Value::Int(2),
8905                Value::Int(3),
8906                Value::Int(4),
8907            ]),
8908        );
8909    }
8910
8911    #[test]
8912    fn binop_attrset_update() {
8913        let v = ev("{ a = 1; } // { b = 2; }");
8914        if let Value::Attrs(attrs) = v {
8915            assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8916            assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8917        } else {
8918            panic!("expected attrset, got {v:?}");
8919        }
8920    }
8921
8922    #[test]
8923    fn binop_less_than() {
8924        assert_eq!(ev("1 < 2"), Value::Bool(true));
8925        assert_eq!(ev("2 < 1"), Value::Bool(false));
8926    }
8927
8928    #[test]
8929    fn binop_greater_than() {
8930        assert_eq!(ev("2 > 1"), Value::Bool(true));
8931        assert_eq!(ev("1 > 2"), Value::Bool(false));
8932    }
8933
8934    #[test]
8935    fn binop_equal() {
8936        assert_eq!(ev("1 == 1"), Value::Bool(true));
8937        assert_eq!(ev("1 == 2"), Value::Bool(false));
8938    }
8939
8940    #[test]
8941    fn binop_not_equal() {
8942        assert_eq!(ev("1 != 2"), Value::Bool(true));
8943        assert_eq!(ev("1 != 1"), Value::Bool(false));
8944    }
8945
8946    #[test]
8947    fn binop_logical_and() {
8948        assert_eq!(ev("true && false"), Value::Bool(false));
8949        assert_eq!(ev("true && true"), Value::Bool(true));
8950    }
8951
8952    #[test]
8953    fn binop_logical_or() {
8954        assert_eq!(ev("true || false"), Value::Bool(true));
8955        assert_eq!(ev("false || false"), Value::Bool(false));
8956    }
8957
8958    #[test]
8959    fn binop_logical_not() {
8960        assert_eq!(ev("!true"), Value::Bool(false));
8961        assert_eq!(ev("!false"), Value::Bool(true));
8962    }
8963
8964    #[test]
8965    fn binop_implication() {
8966        assert_eq!(ev("false -> true"), Value::Bool(true));
8967        assert_eq!(ev("false -> false"), Value::Bool(true));
8968        assert_eq!(ev("true -> true"), Value::Bool(true));
8969        assert_eq!(ev("true -> false"), Value::Bool(false));
8970    }
8971}
8972
8973/// A `sui-normalize` rejection, as the walker's error.
8974///
8975/// ★ `ParseError`, not a new variant, and not an eval error: nix rejects a
8976/// duplicate attribute during PARSING. Measured — `nix-instantiate --parse`
8977/// on `{ a = 1; a = 2; }` fails with `attribute 'a' already defined` and never
8978/// evaluates it. Filing this as an eval error would misreport WHEN it happens.
8979///
8980/// The message carries the attribute PATH but not nix's `«string»:1:3`
8981/// position or its caret block. That is a source-span formatter sui does not
8982/// have, and coupling it here would turn a small change into an
8983/// error-rendering project; the contract this stage signs up to is the exit
8984/// code and the attribute path.
8985fn reject(e: sui_normalize::NormalizeError) -> EvalError {
8986    EvalError::ParseError(format!("{e}{}", eval_file_ctx()))
8987}
8988
8989/// Build an attrset from a `sui-normalize` [`GroupPlan`].
8990///
8991/// This is the plan-driven replacement for the entry loops in
8992/// [`eval_attrset`] / the `LetIn` arm / `eval_entries`. It exists because
8993/// nix's duplicate-key merge is a **parse-time splice into the first-declared
8994/// node**, not a value-level union: the second side's bindings become
8995/// bindings *of the first node*, so they are scoped by it and the later
8996/// `rec` is discarded. `sui-normalize` performed that splice; this function
8997/// only evaluates the result.
8998///
8999/// The consequence worth stating: there is no merging here, and no collision
9000/// to resolve. `attrs.insert` is a plain insert because the plan's
9001/// postcondition is that no name appears twice. That is what retires
9002/// `merge_nested_insert` from the construction path — and with it the
9003/// force-to-WHNF-on-collision that turned
9004/// `let f = x: x+1; a.b = {x = f 1;}; a.b.y = 2; in a.b.x` into
9005/// `UndefinedVar 'f'` on an expression nix evaluates to `2`.
9006pub fn eval_plan_group(
9007    plan: &sui_normalize::GroupPlan,
9008    env: &Env,
9009) -> Result<Value, EvalError> {
9010    let (attrs, _scope) = bind_plan_group(plan, env)?;
9011    Ok(Value::Attrs(std::rc::Rc::new(attrs)))
9012}
9013
9014/// Build a plan's bindings, returning BOTH the attrset and the scope they were
9015/// bound in.
9016///
9017/// Two consumers need different halves of this. An attrset literal wants the
9018/// attrs; a `let` wants the scope, because a `let` is a binder for a body and
9019/// produces no attrset at all. Legacy-`let` (`let { … body = …; }`) wants the
9020/// attrs and then selects `body` from them.
9021fn bind_plan_group(
9022    plan: &sui_normalize::GroupPlan,
9023    env: &Env,
9024) -> Result<(NixAttrs, Env), EvalError> {
9025    use sui_normalize::Binding;
9026
9027    let mut attrs = NixAttrs::new();
9028    // A recursive group binds its own names; a non-recursive one does not.
9029    // `rec`-ness came from the FIRST declaration — see `sui-normalize`.
9030    let mut scope_env = if plan.recursive { env.child() } else { env.clone() };
9031    let mut thunks: Vec<Thunk> = Vec::new();
9032
9033    // `inherit (e)` sources: ONE thunk per clause, shared across every name
9034    // that clause binds, so `e` is evaluated at most once. Built against the
9035    // group's OWN scope — measured on nix: `rec { b = {x=99;}; inherit (b) x; }`
9036    // is `x = 99`, so the source sees the group it is being bound into.
9037    let from_thunks: Vec<Thunk> = plan
9038        .inherit_froms
9039        .iter()
9040        .map(|e| Thunk::new_suspended(e.clone(), scope_env.clone()))
9041        .collect();
9042
9043    for b in &plan.statics {
9044        let name = sui_intern::resolve(b.name).to_string();
9045        let value = match &b.binding {
9046            Binding::Leaf(expr) => {
9047                let t = Thunk::new_suspended(expr.clone(), scope_env.clone());
9048                thunks.push(t.clone());
9049                Value::Thunk(t)
9050            }
9051            Binding::Group(sub) => {
9052                let t = Thunk::new_plan_group(sub.clone(), scope_env.clone());
9053                thunks.push(t.clone());
9054                Value::Thunk(t)
9055            }
9056            // `inherit x` resolves in the ENCLOSING scope, never the group's
9057            // own rec scope — that is what makes it shadow rather than
9058            // self-reference, and why it can never merge.
9059            Binding::Inherit => env
9060                .lookup(&name)
9061                .ok_or_else(|| EvalError::UndefinedVar(format!("'{name}'")))?,
9062            Binding::InheritFrom { from } => {
9063                let t = Thunk::new_inherit_select(from_thunks[*from].clone(), &name);
9064                thunks.push(t.clone());
9065                Value::Thunk(t)
9066            }
9067        };
9068        // PLAIN insert: the plan guarantees no repeated name.
9069        attrs.insert(name.clone(), value.clone());
9070        if plan.recursive {
9071            scope_env.bind(name, value);
9072        }
9073    }
9074
9075    // Phase 2: re-point every thunk at the completed scope, so a binding that
9076    // references a LATER sibling resolves. `PlanGroup` is re-pointable for
9077    // exactly this reason.
9078    if plan.recursive {
9079        for t in &thunks {
9080            t.update_env(&scope_env);
9081        }
9082    }
9083
9084    // ── dynamic keys ─────────────────────────────────────────────────────
9085    //
9086    // `${e}` keys that did not constant-fold. They are resolved AFTER every
9087    // static key, in source order, in the group's own scope — nix's ordering,
9088    // and the reason a dynamic key can never participate in the parse-time
9089    // merge. Omitting this dropped them entirely: two corpus fixtures built
9090    // `{ a = {}; }` where nix builds `{ a = { b = …; c = …; }; }`.
9091    //
9092    // A key evaluating to `null` SKIPS the binding (CppNix), rather than
9093    // inserting a `"null"` name.
9094    for d in &plan.dynamics {
9095        let key_val = eval_expr(&d.key, &scope_env)?;
9096        let key_concrete = key_val.demand()?;
9097        if matches!(key_concrete, Concrete::Null) {
9098            continue;
9099        }
9100        let name = key_concrete.into_value().as_string()?.to_string();
9101        let value = match &d.value {
9102            sui_normalize::Binding::Leaf(expr) => {
9103                Value::Thunk(Thunk::new_suspended(expr.clone(), scope_env.clone()))
9104            }
9105            sui_normalize::Binding::Group(sub) => {
9106                Value::Thunk(Thunk::new_plan_group(sub.clone(), scope_env.clone()))
9107            }
9108            sui_normalize::Binding::Inherit => env
9109                .lookup(&name)
9110                .ok_or_else(|| EvalError::UndefinedVar(format!("'{name}'")))?,
9111            sui_normalize::Binding::InheritFrom { from } => {
9112                Value::Thunk(Thunk::new_inherit_select(from_thunks[*from].clone(), &name))
9113            }
9114        };
9115        attrs.insert(name, value);
9116    }
9117
9118    // ★ Positions, which `builtins.unsafeGetAttrPos` reads. Dropping this was
9119    // a real regression caught by `every_binding_form_carries_a_position` —
9120    // the plan path built the right VALUES with every key position NULL.
9121    //
9122    // `StaticBinding::pos` is already the offset the AST path records: an
9123    // `AttrpathValue` starts at its head attr (`a` in `a.b = 1`, which is what
9124    // CppNix reports for the outer key), and an inherited name carries its own
9125    // ident's offset. And because the splice keeps the FIRST declaration's
9126    // `pos`, a merged key reports where it was first defined — which is what
9127    // nix reports too.
9128    if !plan.statics.is_empty() {
9129        let mut table = crate::pos::AttrPositions::new(current_eval_file());
9130        for b in &plan.statics {
9131            table.insert(b.name, b.pos.into());
9132        }
9133        attrs.set_positions(std::rc::Rc::new(table));
9134    }
9135
9136    Ok((attrs, scope_env))
9137}