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