Skip to main content

sui_eval/
value.rs

1//! Nix value types and environments.
2//!
3//! The evaluator is single-threaded: `Env` and `NixAttrs` contain
4//! `Rc<UnsafeCell<ThunkRepr>>` thunks.  All shared pointers use `Rc`
5//! (not `Arc`) because the values are never sent across threads.
6
7use std::cell::{Cell, OnceCell, RefCell, UnsafeCell};
8
9use std::fmt;
10pub use std::rc::Rc;
11
12use rustc_hash::FxBuildHasher;
13use smallvec::SmallVec;
14pub use smol_str::SmolStr;
15
16use rowan::ast::AstNode;
17
18use sui_intern::Symbol;
19
20/// Type alias for the persistent hash map used by `NixAttrs` and `Env`.
21///
22/// Uses `FxBuildHasher` (fast multiplication-based hash) instead of the
23/// default `RandomState`. This is optimal for `Symbol(u32)` keys where
24/// the hash is a single multiply-shift — no SipHash overhead.
25pub type FxHashMap<K, V> = im_rc::HashMap<K, V, FxBuildHasher>;
26
27/// Compact attrset map — a real `hashbrown` (std) `HashMap` with `FxBuildHasher`.
28///
29/// Used ONLY for `NixAttrs` (attribute sets), which are immutable-after-
30/// construction. Unlike `FxHashMap` (the persistent `im_rc` HAMT, retained for
31/// `Env` where `child()`/scope-push relies on O(1) structural sharing), this is a
32/// flat open-addressing table with ~0.875 load factor and NO branch-node
33/// allocations — a symbolicated dhat profile proved the `im_rc` HAMT branch nodes
34/// dominate eval heap, and the attrset slice is the safe one to compact.
35///
36/// BYTE-NEUTRAL: attrset observation order (which feeds drvPath hashing) comes
37/// from `NixAttrs::sorted_entries()` — it resolves each `Symbol` to its `String`
38/// and string-sorts on observation — NOT from this map's internal iteration
39/// order. Both `im_rc::HashMap` and `std::HashMap` are unordered, so swapping the
40/// implementation cannot change any observed order → drvPaths are unchanged.
41pub type AttrsMap<K, V> = std::collections::HashMap<K, V, FxBuildHasher>;
42
43/// Env-gated LIVE-OBJECT CENSUS.
44///
45/// A permanent, zero-cost-when-off diagnostic answering the question:
46/// when sui's eval peak is ~2× nix's, is the overhead (a) cyclic/lingering
47/// producer garbage sui retains, or (b) a uniform per-object representation
48/// overhead? These need different fixes, so we MEASURE.
49///
50/// Gated behind `SUI_LIVE_CENSUS=1`. The atomics are always compiled but the
51/// `_MADE`/`_LIVE` bookkeeping and the RSS/dump thread only run when enabled.
52/// All counters use `Relaxed` — we want a cheap high-water snapshot, not a
53/// linearizable total.
54///
55/// `_MADE` + `_LIVE` are incremented in the INNER heap type's constructor;
56/// `_LIVE` is decremented in the inner type's `Drop` so it fires exactly once
57/// when the last `Rc` drops. Counters live on the inner heap types
58/// (`NixAttrs`, `ThunkInner`, `EnvInner`, `NixString`, the list `Vec`) so we
59/// count distinct heap allocations, not `Rc` clones.
60pub mod census {
61    use std::sync::atomic::{AtomicI64, Ordering::Relaxed};
62    use std::sync::OnceLock;
63
64    pub static ATTRS_LIVE: AtomicI64 = AtomicI64::new(0);
65    pub static ATTRS_MADE: AtomicI64 = AtomicI64::new(0);
66    pub static THUNK_LIVE: AtomicI64 = AtomicI64::new(0);
67    pub static THUNK_MADE: AtomicI64 = AtomicI64::new(0);
68    pub static THUNK_EVALUATED: AtomicI64 = AtomicI64::new(0);
69    pub static ENV_LIVE: AtomicI64 = AtomicI64::new(0);
70    pub static ENV_MADE: AtomicI64 = AtomicI64::new(0);
71    pub static NIXSTR_LIVE: AtomicI64 = AtomicI64::new(0);
72    pub static NIXSTR_MADE: AtomicI64 = AtomicI64::new(0);
73    pub static LIST_LIVE: AtomicI64 = AtomicI64::new(0);
74    pub static LIST_MADE: AtomicI64 = AtomicI64::new(0);
75
76    /// True iff `SUI_LIVE_CENSUS=1`. Cached — read once.
77    #[inline]
78    pub fn enabled() -> bool {
79        static ON: OnceLock<bool> = OnceLock::new();
80        *ON.get_or_init(|| std::env::var("SUI_LIVE_CENSUS").as_deref() == Ok("1"))
81    }
82
83    #[inline(always)]
84    pub fn made(made: &AtomicI64, live: &AtomicI64) {
85        if enabled() {
86            made.fetch_add(1, Relaxed);
87            live.fetch_add(1, Relaxed);
88        }
89    }
90
91    #[inline(always)]
92    pub fn dropped(live: &AtomicI64) {
93        if enabled() {
94            live.fetch_sub(1, Relaxed);
95        }
96    }
97
98    #[inline(always)]
99    pub fn evaluated() {
100        if enabled() {
101            THUNK_EVALUATED.fetch_add(1, Relaxed);
102        }
103    }
104
105    /// Resident set size of this process, in bytes (macOS + Linux).
106    pub fn rss_bytes() -> u64 {
107        #[cfg(target_os = "macos")]
108        unsafe {
109            let mut info: libc::mach_task_basic_info = std::mem::zeroed();
110            let mut count = (std::mem::size_of::<libc::mach_task_basic_info>()
111                / std::mem::size_of::<libc::natural_t>()) as libc::mach_msg_type_number_t;
112            let kr = libc::task_info(
113                libc::mach_task_self(),
114                libc::MACH_TASK_BASIC_INFO,
115                std::ptr::addr_of_mut!(info).cast(),
116                &mut count,
117            );
118            if kr == libc::KERN_SUCCESS {
119                return info.resident_size;
120            }
121            0
122        }
123        #[cfg(not(target_os = "macos"))]
124        {
125            std::fs::read_to_string("/proc/self/statm")
126                .ok()
127                .and_then(|s| s.split_whitespace().nth(1).map(String::from))
128                .and_then(|pages| pages.parse::<u64>().ok())
129                .map(|pages| pages * 4096)
130                .unwrap_or(0)
131        }
132    }
133
134    /// Print all live/made counts + RSS to stderr, tagged.
135    ///
136    /// No-op unless `SUI_LIVE_CENSUS=1`. The counters only accumulate when the
137    /// census is enabled, so dumping while disabled emits an all-zeros
138    /// `[census exit] …` line to stderr — pure noise that pollutes any tool
139    /// parsing sui's stderr. Concretely it regressed the `derivation show→add`
140    /// ATerm round-trip parity row: that probe collects every non-`#` stderr
141    /// line from `derivation add` as the round-tripped ATerm, and the
142    /// exit-guard's unconditional dump appended the census line to it. Gating
143    /// here makes census-pollution-when-disabled unrepresentable at EVERY call
144    /// site (the process-exit guard AND the periodic poller), not just the one
145    /// that regressed.
146    pub fn dump(tag: &str) {
147        if !enabled() {
148            return;
149        }
150        let rss = rss_bytes();
151        eprintln!(
152            "[census {tag}] rss={rss_mb:.1}MB \
153attrs_live={al} attrs_made={am} \
154thunk_live={tl} thunk_made={tm} thunk_eval={te} \
155env_live={el} env_made={em} \
156nixstr_live={sl} nixstr_made={sm} \
157list_live={ll} list_made={lm}",
158            rss_mb = rss as f64 / (1024.0 * 1024.0),
159            al = ATTRS_LIVE.load(Relaxed),
160            am = ATTRS_MADE.load(Relaxed),
161            tl = THUNK_LIVE.load(Relaxed),
162            tm = THUNK_MADE.load(Relaxed),
163            te = THUNK_EVALUATED.load(Relaxed),
164            el = ENV_LIVE.load(Relaxed),
165            em = ENV_MADE.load(Relaxed),
166            sl = NIXSTR_LIVE.load(Relaxed),
167            sm = NIXSTR_MADE.load(Relaxed),
168            ll = LIST_LIVE.load(Relaxed),
169            lm = LIST_MADE.load(Relaxed),
170        );
171        let (src_files, src_bytes) = crate::pos::source_text_census();
172        eprintln!(
173            "[census {tag}] src_files={src_files} src_bytes={src_mb:.1}MB",
174            src_mb = src_bytes as f64 / (1024.0 * 1024.0),
175        );
176    }
177
178    /// Spawn the periodic-dump thread (only when enabled). Dumps every 2s so a
179    /// 30s+ eval captures the high-water region. Also usable as an at-exit
180    /// hook via the returned guard.
181    pub fn spawn_poller() {
182        if !enabled() {
183            return;
184        }
185        std::thread::spawn(|| loop {
186            std::thread::sleep(std::time::Duration::from_millis(2000));
187            dump("periodic");
188        });
189    }
190}
191
192// -- String interner (shared with sui-bytecode via sui-intern's thread-local) --
193//
194// Previously this module owned its own `thread_local! INTERNER`. That
195// diverged from `sui-bytecode`'s `sui_intern::*` thread-local — Symbols
196// were NOT portable across the tree-walker ↔ VM fallback boundary,
197// which only worked because both paths happened to re-intern strings
198// from the same source text. Delegating both to the same thread-local
199// closes the gap and makes `sui_intern::prewarm()` affect this crate
200// too.
201
202/// Intern a string key, returning a Symbol handle.
203/// Used for NixAttrs keys and Env binding names.
204pub fn intern(s: &str) -> Symbol {
205    sui_intern::intern(s)
206}
207
208/// Resolve a Symbol back to its string content. Allocates a fresh
209/// `String`. For hot paths prefer [`resolve_rc`] or [`with_resolved`]
210/// — `Rc::clone` is ~20x cheaper than `String::from` for identifier-
211/// sized inputs.
212pub fn resolve(sym: Symbol) -> String {
213    sui_intern::resolve(sym)
214}
215
216/// Resolve a Symbol to a shared `Rc<str>`. Zero-copy.
217pub fn resolve_rc(sym: Symbol) -> std::rc::Rc<str> {
218    sui_intern::resolve_rc(sym)
219}
220
221/// Borrow the resolved string inside a closure without allocating.
222pub fn with_resolved<F, R>(sym: Symbol, f: F) -> R
223where
224    F: FnOnce(&str) -> R,
225{
226    sui_intern::with_resolved(sym, f)
227}
228
229// -- Identifier symbol cache --
230//
231// Caches the interned Symbol for each AST identifier by (source_id, text_offset).
232// Avoids re-hashing identifier strings on repeated evaluations of the
233// same expression (common in loops, recursion, overlay fixpoints).
234//
235// The source_id discriminates different parse trees (main file vs imports)
236// so that identifiers at the same byte offset in different files don't
237// collide in the cache.
238
239thread_local! {
240    /// Monotonically increasing counter — bumped on each `rnix::Root::parse`.
241    // STARTS AT 1, NOT 0 — 0 is the reserved "untagged env" sentinel.
242    //
243    // `Env::new()` defaults `source_id: 0`. While the generator also started at
244    // 0, the FIRST file parsed shared key-space with every untagged env, so an
245    // identifier in that file could collide with one from an untagged context at
246    // the same byte offset. That is the same aliasing class as the
247    // CURRENT_SOURCE_ID bug fixed alongside this (see eval.rs's Ident arms) —
248    // reserving 0 costs nothing and removes the overlap by construction.
249    static SOURCE_GEN: Cell<u32> = const { Cell::new(1) };
250
251    /// Maps `(source_id, text_offset)` → interned `Symbol`.
252    static IDENT_CACHE: RefCell<rustc_hash::FxHashMap<u64, Symbol>> =
253        RefCell::new(rustc_hash::FxHashMap::default());
254}
255
256/// Allocate a new source ID for a freshly parsed AST tree.
257///
258/// Call once per `rnix::Root::parse` invocation. The returned ID is
259/// used as the high 32 bits of the `IDENT_CACHE` key, ensuring that
260/// identifiers from different source texts never collide.
261pub fn next_source_id() -> u32 {
262    SOURCE_GEN.with(|g| {
263        let id = g.get();
264        g.set(id.wrapping_add(1));
265        id
266    })
267}
268
269/// Intern a string with caching by source ID and AST text offset.
270///
271/// First call for a given `(source_id, text_offset)`: hash + intern
272/// (same cost as [`intern`]).
273/// Subsequent calls: `FxHashMap` u64 lookup (~5 ns) — no string hashing.
274pub fn intern_cached(name: &str, source_id: u32, text_offset: u32) -> Symbol {
275    intern_cached_with(source_id, text_offset, || intern(name))
276}
277
278/// Cache an interned `Symbol` by `(source_id, text_offset)`, computing it
279/// lazily via `cold` only on a cache miss.
280///
281/// Steady-state hit: `FxHashMap` u64 lookup — no string materialization, no
282/// string hashing. This lets the identifier-eval hot path avoid the
283/// per-lookup `ident_text().to_string()` heap allocation entirely, since the
284/// `&str` is only needed to intern on the (once-per-offset) cold miss.
285pub fn intern_cached_with<F>(source_id: u32, text_offset: u32, cold: F) -> Symbol
286where
287    F: FnOnce() -> Symbol,
288{
289    let key = (u64::from(source_id) << 32) | u64::from(text_offset);
290    IDENT_CACHE.with(|c| {
291        let mut cache = c.borrow_mut();
292        *cache.entry(key).or_insert_with(cold)
293    })
294}
295
296/// Clear the identifier symbol cache.
297///
298/// Call between independent top-level evaluations to reclaim memory.
299/// The cache grows unboundedly during a single evaluation pass.
300pub fn clear_ident_cache() {
301    IDENT_CACHE.with(|c| c.borrow_mut().clear());
302}
303
304// ── Nix string context ─────────────────────────────────────────
305
306/// An element of a Nix string's context set.
307#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
308pub enum ContextElement {
309    /// Store path reference (e.g., "/nix/store/abc-hello").
310    Plain(SmolStr),
311    /// Derivation output reference.
312    Output { drv: SmolStr, output: SmolStr },
313    /// Entire derivation closure.
314    DrvDeep(SmolStr),
315}
316
317impl fmt::Display for ContextElement {
318    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319        match self {
320            ContextElement::Plain(p) => write!(f, "{p}"),
321            ContextElement::Output { drv, output } => write!(f, "{drv}!{output}"),
322            ContextElement::DrvDeep(d) => write!(f, "={d}"),
323        }
324    }
325}
326
327/// The context attached to a Nix string: a set of store-path references that
328/// the string depends on. Plain string literals have an empty context.
329///
330/// Uses a `Vec` with linear deduplication instead of `BTreeSet`.  Most strings
331/// have 0-2 context elements where linear search is faster than tree overhead,
332/// and `Vec` has the same size as `BTreeSet` (3 words) without per-node heap
333/// allocations for small sets.
334#[derive(Debug, Clone, PartialEq, Eq, Default)]
335pub struct StringContext(SmallVec<[ContextElement; 2]>);
336
337impl StringContext {
338    /// Create an empty context.
339    pub fn new() -> Self {
340        Self(SmallVec::new())
341    }
342
343    /// Merge another context into this one.
344    pub fn merge(&mut self, other: &StringContext) {
345        for elem in &other.0 {
346            if !self.0.contains(elem) {
347                self.0.push(elem.clone());
348            }
349        }
350    }
351
352    /// Add a plain store-path reference.
353    pub fn add_plain(&mut self, path: impl Into<SmolStr>) {
354        let elem = ContextElement::Plain(path.into());
355        if !self.0.contains(&elem) {
356            self.0.push(elem);
357        }
358    }
359
360    /// Add a derivation output reference.
361    pub fn add_output(&mut self, drv: impl Into<SmolStr>, output: impl Into<SmolStr>) {
362        let elem = ContextElement::Output { drv: drv.into(), output: output.into() };
363        if !self.0.contains(&elem) {
364            self.0.push(elem);
365        }
366    }
367
368    /// Add a derivation-deep reference.
369    pub fn add_drv_deep(&mut self, drv: impl Into<SmolStr>) {
370        let elem = ContextElement::DrvDeep(drv.into());
371        if !self.0.contains(&elem) {
372            self.0.push(elem);
373        }
374    }
375
376    /// Whether this context set is empty.
377    #[must_use]
378    pub fn is_empty(&self) -> bool {
379        self.0.is_empty()
380    }
381
382    /// Return the number of context elements.
383    #[must_use]
384    pub fn len(&self) -> usize {
385        self.0.len()
386    }
387
388    /// Iterate over all context elements.
389    pub fn iter(&self) -> impl Iterator<Item = &ContextElement> {
390        self.0.iter()
391    }
392
393    /// Insert a raw context element (deduplicating).
394    pub fn insert(&mut self, elem: ContextElement) {
395        if !self.0.contains(&elem) {
396            self.0.push(elem);
397        }
398    }
399
400    /// Return the elements as a slice.
401    pub fn elements(&self) -> &[ContextElement] {
402        &self.0
403    }
404}
405
406/// A Nix string value with associated context (store-path references).
407#[derive(Debug, PartialEq, Eq)]
408pub struct NixString {
409    /// The character data.
410    pub chars: SmolStr,
411    /// The context set (empty for plain string literals).
412    pub context: StringContext,
413}
414
415// `Clone` is hand-written so the census counts every NixString that comes
416// into existence (a clone is a fresh heap object once Rc-wrapped), keeping
417// `NIXSTR_MADE`/`NIXSTR_LIVE` consistent with the `Drop` below.
418impl Clone for NixString {
419    fn clone(&self) -> Self {
420        census::made(&census::NIXSTR_MADE, &census::NIXSTR_LIVE);
421        Self {
422            chars: self.chars.clone(),
423            context: self.context.clone(),
424        }
425    }
426}
427
428impl Drop for NixString {
429    fn drop(&mut self) {
430        census::dropped(&census::NIXSTR_LIVE);
431    }
432}
433
434impl NixString {
435    /// Create a context-free string.
436    pub fn plain(s: impl Into<SmolStr>) -> Self {
437        census::made(&census::NIXSTR_MADE, &census::NIXSTR_LIVE);
438        Self {
439            chars: s.into(),
440            context: StringContext::default(),
441        }
442    }
443
444    /// Create a string with an explicit context.
445    pub fn with_context(s: impl Into<SmolStr>, ctx: StringContext) -> Self {
446        census::made(&census::NIXSTR_MADE, &census::NIXSTR_LIVE);
447        Self {
448            chars: s.into(),
449            context: ctx,
450        }
451    }
452
453    /// Borrow the string content.
454    #[must_use]
455    pub fn as_str(&self) -> &str {
456        &self.chars
457    }
458
459    /// Whether this string carries any context (store path references).
460    #[must_use]
461    pub fn has_context(&self) -> bool {
462        !self.context.is_empty()
463    }
464}
465
466impl AsRef<str> for NixString {
467    fn as_ref(&self) -> &str {
468        &self.chars
469    }
470}
471
472/// Census wrapper around a list's backing `Vec<Value>`.
473///
474/// `#[repr(transparent)]` + `Deref`/`DerefMut` to `Vec<Value>` so nearly every
475/// existing call site (`.len()`, `.iter()`, indexing, `.as_slice()`, `.clone()`
476/// → produces a `NixList`) works unchanged. Its sole job is to carry the census
477/// hooks (`LIST_MADE`/`LIST_LIVE`) on the inner heap allocation.
478#[repr(transparent)]
479#[derive(Debug, PartialEq)]
480pub struct NixList(pub Vec<Value>);
481
482impl NixList {
483    #[inline]
484    pub fn new(v: Vec<Value>) -> Self {
485        census::made(&census::LIST_MADE, &census::LIST_LIVE);
486        NixList(v)
487    }
488
489    /// Consume into the backing `Vec<Value>`. `mem::take` because `NixList`
490    /// has a `Drop` impl (can't move the field out); the emptied husk's Drop
491    /// still fires, decrementing LIVE — correct, the list is consumed.
492    #[inline]
493    pub fn into_vec(mut self) -> Vec<Value> {
494        std::mem::take(&mut self.0)
495    }
496}
497
498impl From<Vec<Value>> for NixList {
499    #[inline]
500    fn from(v: Vec<Value>) -> Self {
501        NixList::new(v)
502    }
503}
504
505// Slice/array comparison so `assert_eq!(nixlist, [..])` in tests keeps working.
506impl<T: AsRef<[Value]>> PartialEq<T> for NixList {
507    #[inline]
508    fn eq(&self, other: &T) -> bool {
509        self.0.as_slice() == other.as_ref()
510    }
511}
512
513impl Clone for NixList {
514    fn clone(&self) -> Self {
515        census::made(&census::LIST_MADE, &census::LIST_LIVE);
516        NixList(self.0.clone())
517    }
518}
519
520impl Drop for NixList {
521    fn drop(&mut self) {
522        census::dropped(&census::LIST_LIVE);
523    }
524}
525
526impl FromIterator<Value> for NixList {
527    #[inline]
528    fn from_iter<I: IntoIterator<Item = Value>>(iter: I) -> Self {
529        NixList::new(iter.into_iter().collect())
530    }
531}
532
533impl std::ops::Deref for NixList {
534    type Target = Vec<Value>;
535    #[inline]
536    fn deref(&self) -> &Vec<Value> {
537        &self.0
538    }
539}
540
541impl std::ops::DerefMut for NixList {
542    #[inline]
543    fn deref_mut(&mut self) -> &mut Vec<Value> {
544        &mut self.0
545    }
546}
547
548impl<'a> IntoIterator for &'a NixList {
549    type Item = &'a Value;
550    type IntoIter = std::slice::Iter<'a, Value>;
551    #[inline]
552    fn into_iter(self) -> Self::IntoIter {
553        self.0.iter()
554    }
555}
556
557impl IntoIterator for NixList {
558    type Item = Value;
559    type IntoIter = std::vec::IntoIter<Value>;
560    #[inline]
561    fn into_iter(mut self) -> Self::IntoIter {
562        // Move the Vec out. `NixList`'s Drop still fires on the emptied husk,
563        // decrementing LIVE — correct, since the elements move to the iterator
564        // and the list allocation is consumed.
565        std::mem::take(&mut self.0).into_iter()
566    }
567}
568
569impl std::ops::Deref for NixString {
570    type Target = str;
571
572    fn deref(&self) -> &str {
573        &self.chars
574    }
575}
576
577impl fmt::Display for NixString {
578    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579        write!(f, "{}", self.chars)
580    }
581}
582
583// ── Value enum ────────────────────────────────────────────────
584
585/// A Nix value — potentially lazy (may be a Thunk).
586///
587/// To get a guaranteed-concrete value, call `.demand()` which returns
588/// `Concrete`. The `Concrete` type has thunk-free accessors that the
589/// compiler enforces — you cannot accidentally skip forcing.
590#[derive(Debug, Clone)]
591#[derive(Default)]
592pub enum Value {
593    #[default]
594    Null,
595    Bool(bool),
596    Int(i64),
597    Float(f64),
598    String(Rc<NixString>),
599    Path(Box<SmolStr>),
600    List(Rc<NixList>),
601    Attrs(Rc<NixAttrs>),
602    Lambda(Rc<Closure>),
603    Builtin(Box<BuiltinFn>),
604    /// A lazy value (thunk) with memoization and blackhole detection.
605    Thunk(Thunk),
606}
607
608// ── Concrete: construction-guaranteed non-thunk ──────────────
609
610/// A demanded Nix value. Guaranteed NOT a Thunk at the TYPE level.
611///
612/// Unlike `Value` (which has a `Thunk` variant), `Concrete` is a separate
613/// enum that DOES NOT HAVE a Thunk variant. The compiler rejects any attempt
614/// to construct a `Concrete` from a thunk — the variant simply doesn't exist.
615///
616/// The ONLY way to obtain a `Concrete` is through `Value::demand()`.
617///
618/// ```rust,ignore
619/// let val: Value = eval_expr(expr, env)?;  // might be Thunk
620/// let c: Concrete = val.demand()?;          // NOW guaranteed concrete
621/// let n: i64 = c.as_int()?;                // type-safe, thunk-free
622/// ```
623#[derive(Debug, Clone)]
624pub enum Concrete {
625    Null,
626    Bool(bool),
627    Int(i64),
628    Float(f64),
629    String(Rc<NixString>),
630    Path(Box<SmolStr>),
631    List(Rc<NixList>),      // elements may be lazy (correct for Nix)
632    Attrs(Rc<NixAttrs>),       // values may be lazy (correct for Nix)
633    Lambda(Rc<Closure>),
634    Builtin(Box<BuiltinFn>),
635    // NO Thunk variant. The compiler enforces this.
636}
637
638impl Concrete {
639    /// Convert back to a Value (for APIs that still take Value).
640    #[inline]
641    pub fn into_value(self) -> Value {
642        match self {
643            Concrete::Null => Value::Null,
644            Concrete::Bool(b) => Value::Bool(b),
645            Concrete::Int(n) => Value::Int(n),
646            Concrete::Float(f) => Value::Float(f),
647            Concrete::String(s) => Value::String(s),
648            Concrete::Path(p) => Value::Path(p),
649            Concrete::List(l) => Value::List(l),
650            Concrete::Attrs(a) => Value::Attrs(a),
651            Concrete::Lambda(c) => Value::Lambda(c),
652            Concrete::Builtin(b) => Value::Builtin(b),
653        }
654    }
655
656    /// Borrow as a Value reference. Constructs a temporary Value.
657    /// Prefer specific accessors (as_bool, as_int, etc.) when possible.
658    pub fn to_value(&self) -> Value {
659        self.clone().into_value()
660    }
661
662    /// Extract bool — guaranteed no thunk.
663    pub fn as_bool(&self) -> Result<bool, EvalError> {
664        match self {
665            Concrete::Bool(b) => Ok(*b),
666            other => Err(EvalError::TypeMismatch { expected: "bool", got: other.type_name() }),
667        }
668    }
669
670    /// Extract int — guaranteed no thunk.
671    pub fn as_int(&self) -> Result<i64, EvalError> {
672        match self {
673            Concrete::Int(n) => Ok(*n),
674            other => Err(EvalError::TypeMismatch { expected: "int", got: other.type_name() }),
675        }
676    }
677
678    /// Extract string ref — guaranteed no thunk.
679    pub fn as_str(&self) -> Result<&str, EvalError> {
680        match self {
681            Concrete::String(s) => Ok(&s.chars),
682            other => Err(EvalError::TypeMismatch { expected: "string", got: other.type_name() }),
683        }
684    }
685
686    /// Extract NixString ref — guaranteed no thunk.
687    pub fn as_nix_string(&self) -> Result<&NixString, EvalError> {
688        match self {
689            Concrete::String(s) => Ok(s),
690            other => Err(EvalError::TypeMismatch { expected: "string", got: other.type_name() }),
691        }
692    }
693
694    /// Extract list ref — guaranteed no thunk at this level.
695    /// Note: list ELEMENTS may still be lazy (Value, not Concrete).
696    pub fn as_list(&self) -> Result<&[Value], EvalError> {
697        match self {
698            Concrete::List(l) => Ok(l.as_slice()),
699            other => Err(EvalError::TypeMismatch { expected: "list", got: other.type_name() }),
700        }
701    }
702
703    /// Extract attrs ref — guaranteed no thunk at this level.
704    /// Note: attr VALUES may still be lazy (Value, not Concrete).
705    pub fn as_attrs(&self) -> Result<&NixAttrs, EvalError> {
706        match self {
707            Concrete::Attrs(a) => Ok(a),
708            other => Err(EvalError::TypeMismatch { expected: "set", got: other.type_name() }),
709        }
710    }
711
712    /// Extract float — guaranteed no thunk.
713    pub fn as_float(&self) -> Result<f64, EvalError> {
714        match self {
715            Concrete::Float(f) => Ok(*f),
716            Concrete::Int(n) => Ok(*n as f64),
717            other => Err(EvalError::TypeMismatch { expected: "float", got: other.type_name() }),
718        }
719    }
720
721    /// Check the value type name.
722    pub fn type_name(&self) -> &'static str {
723        match self {
724            Concrete::Null => "null",
725            Concrete::Bool(_) => "bool",
726            Concrete::Int(_) => "int",
727            Concrete::Float(_) => "float",
728            Concrete::String(_) => "string",
729            Concrete::Path(_) => "path",
730            Concrete::List(_) => "list",
731            Concrete::Attrs(_) => "set",
732            Concrete::Lambda(_) | Concrete::Builtin(_) => "lambda",
733        }
734    }
735
736    /// Alias for `as_str()` — API parity with Value::as_string().
737    pub fn as_string(&self) -> Result<&str, EvalError> {
738        self.as_str()
739    }
740
741    /// Extract owned NixAttrs — guaranteed no thunk at this level.
742    pub fn to_attrs(&self) -> Result<NixAttrs, EvalError> {
743        match self {
744            Concrete::Attrs(a) => Ok((**a).clone()),
745            other => Err(EvalError::TypeMismatch { expected: "set", got: other.type_name() }),
746        }
747    }
748
749    /// Extract owned list — guaranteed no thunk at this level.
750    pub fn to_list(&self) -> Result<Vec<Value>, EvalError> {
751        match self {
752            Concrete::List(l) => Ok((**l).0.clone()),
753            other => Err(EvalError::TypeMismatch { expected: "list", got: other.type_name() }),
754        }
755    }
756
757    /// Extract a filesystem path from Path or String.
758    pub fn coerce_to_path(&self, context: &str) -> Result<String, EvalError> {
759        match self {
760            Concrete::Path(p) => Ok(p.to_string()),
761            Concrete::String(ns) => Ok(ns.chars.to_string()),
762            Concrete::Attrs(attrs) => {
763                if let Some(out_path) = attrs.get("outPath") {
764                    let forced = crate::eval::force_value(out_path)?;
765                    forced.coerce_to_path(context)
766                } else {
767                    Err(EvalError::type_error(format!(
768                        "{context}: expected path or string, got set without outPath"
769                    )))
770                }
771            }
772            other => Err(EvalError::type_error(format!(
773                "{context}: expected path or string, got {}", other.type_name()
774            ))),
775        }
776    }
777
778    /// Extract owned string.
779    pub fn to_str(&self) -> Result<String, EvalError> {
780        match self {
781            Concrete::String(s) => Ok(s.chars.to_string()),
782            other => Err(EvalError::TypeMismatch { expected: "string", got: other.type_name() }),
783        }
784    }
785
786    /// Extract owned NixString (with context).
787    pub fn to_nix_string(&self) -> Result<NixString, EvalError> {
788        match self {
789            Concrete::String(s) => Ok((**s).clone()),
790            other => Err(EvalError::TypeMismatch { expected: "string", got: other.type_name() }),
791        }
792    }
793
794    /// Check if value is a function (lambda or builtin).
795    pub fn is_function(&self) -> bool {
796        matches!(self, Concrete::Lambda(_) | Concrete::Builtin(_))
797    }
798}
799
800// Type-safe conversion: Concrete → Value (infallible)
801impl From<Concrete> for Value {
802    fn from(c: Concrete) -> Value {
803        c.into_value()
804    }
805}
806
807impl PartialEq for Concrete {
808    fn eq(&self, other: &Self) -> bool {
809        match (self, other) {
810            (Concrete::Null, Concrete::Null) => true,
811            (Concrete::Bool(a), Concrete::Bool(b)) => a == b,
812            (Concrete::Int(a), Concrete::Int(b)) => a == b,
813            (Concrete::Float(a), Concrete::Float(b)) => a == b,
814            (Concrete::Int(a), Concrete::Float(b)) | (Concrete::Float(b), Concrete::Int(a)) => (*a as f64) == *b,
815            (Concrete::String(a), Concrete::String(b)) => Rc::ptr_eq(a, b) || a.chars == b.chars,
816            (Concrete::Path(a), Concrete::Path(b)) => a == b,
817            (Concrete::List(a), Concrete::List(b)) => Rc::ptr_eq(a, b) || a == b,
818            (Concrete::Attrs(a), Concrete::Attrs(b)) => {
819                if Rc::ptr_eq(a, b) {
820                    return true;
821                }
822                // cppnix `EvalState::eqValues` derivation short-circuit:
823                // two attrsets that are BOTH derivations (each has
824                // `type == "derivation"`) AND each carry an `outPath`
825                // compare by their `outPath` string ONLY — never by deep
826                // structural equality.  This is load-bearing: derivations
827                // hold thunks/functions (`meta`, `override`, …) that never
828                // compare structurally-equal even when the two describe the
829                // same store output, and forcing every attr can throw.
830                // (Empirically characterized against the live nix oracle:
831                //  `hello == (hello // { x = 5; })` ⇒ true.)
832                if let (Some(pa), Some(pb)) =
833                    (derivation_out_path(a), derivation_out_path(b))
834                {
835                    return pa == pb;
836                }
837                // Structural compare by BORROW, not by clone. The prior
838                // `a.inner() == b.inner()` flattened AND cloned *both* backing
839                // `AttrsMap`s (`inner()` = `as_flat().clone()`) purely to feed
840                // `HashMap::eq` — the clone is dead work. `as_flat()` returns a
841                // borrow into the (memoized-if-overlay) map, so
842                // `a.as_flat() == b.as_flat()` runs the *identical*
843                // `HashMap::eq`: same keys, same per-value `Value::eq` calls.
844                // `HashMap::eq` is ORDER-INDEPENDENT by construction (it iterates
845                // one map and looks each key up in the other), so this holds
846                // regardless of the map's internal iteration order — true for the
847                // std `AttrsMap` exactly as it was for the old `im_rc` map.
848                // PROVABLY-NEUTRAL
849                // on the demand axis: cloning a `Value` is an `Rc`-bump that
850                // forces NOTHING; the only `.demand()` calls in this arm are (1)
851                // the derivation short-circuit above (unchanged) and (2) inside
852                // `Value::eq` (unchanged — same values, same order). Removing the
853                // clone cannot move which thunk forces, when, or whether a throw
854                // surfaces. See docs/PERF-ARSENAL.md C-A.
855                let (fa, fb) = (a.as_flat(), b.as_flat());
856                if crate::perf::enabled() {
857                    crate::perf::inc(crate::perf::Counter::AttrsEqStructuralCalls);
858                    // Combined entry count of the two maps the old `inner()`
859                    // path would have cloned before comparing.
860                    crate::perf::add(
861                        crate::perf::Counter::AttrsEqEntriesCloneElided,
862                        (fa.len() + fb.len()) as u64,
863                    );
864                }
865                fa == fb
866            }
867            (Concrete::Lambda(a), Concrete::Lambda(b)) => Rc::ptr_eq(a, b),
868            _ => false,
869        }
870    }
871}
872
873/// Concatenate two Nix lists: `left ++ right_elems`.
874///
875/// `left` must be a `Value::List`; `right_elems` is the right list's element
876/// slice. When `left`'s backing `Rc<Vec>` is uniquely owned (a fresh
877/// temporary, as in a left-associative `acc ++ [x]` fold), the right elements
878/// are appended IN PLACE — amortized O(1) instead of the O(n) full clone that
879/// `left.to_vec()` would cost. When the `Rc` is shared, the shared list is
880/// left untouched and a fresh clone-extended Vec is built (identical to the
881/// prior `to_vec()` + `extend_from_slice` path).
882///
883/// # Byte-neutrality
884/// PROVABLY-NEUTRAL. Both paths produce the identical ordered sequence of the
885/// same `Rc`-shared lazy `Value` thunks — no element is forced, reordered, or
886/// re-identified. The only observable difference is heap allocation reuse,
887/// which is not a Nix-observable property. See `docs/PERF-ARSENAL.md`.
888pub fn concat_lists(left: Value, right_elems: &[Value]) -> Result<Value, EvalError> {
889    // Take ownership of the left backing Vec, reusing its allocation when the
890    // Rc is unique. `Rc::try_unwrap` returns the inner Vec on refcount 1;
891    // otherwise it clones (identical bytes to the old `to_vec()`).
892    let mut la = match left {
893        Value::List(rc) => {
894            let reused = Rc::strong_count(&rc) == 1;
895            let vec: Vec<Value> = match Rc::try_unwrap(rc) {
896                Ok(v) => v.into_vec(), // uniquely owned: allocation reused
897                Err(rc) => (*rc).0.clone(), // shared: clone the left (unchanged)
898            };
899            if crate::perf::enabled() {
900                crate::perf::inc(crate::perf::Counter::ListConcatCalls);
901                if reused {
902                    // Left elements appended in place — copy elided.
903                    crate::perf::add(
904                        crate::perf::Counter::ListConcatElemsReused,
905                        vec.len() as u64,
906                    );
907                } else {
908                    // Left elements cloned into a fresh Vec (the storm).
909                    crate::perf::add(
910                        crate::perf::Counter::ListConcatElemsCopied,
911                        vec.len() as u64,
912                    );
913                }
914            }
915            vec
916        }
917        other => {
918            return Err(EvalError::TypeMismatch {
919                expected: "list",
920                got: other.type_name(),
921            });
922        }
923    };
924    // Right elements are always copied (appended); their thunks are Rc-shared.
925    la.extend_from_slice(right_elems);
926    Ok(Value::list(la))
927}
928
929/// If `attrs` is a derivation — an attrset whose `type` forces to the string
930/// `"derivation"` AND which carries a forceable `outPath` — return that
931/// `outPath` string.  Otherwise `None` (caller falls back to structural
932/// equality).  A force error on `type`/`outPath` yields `None`, so a broken
933/// derivation degrades to structural compare rather than a spurious match.
934fn derivation_out_path(attrs: &NixAttrs) -> Option<String> {
935    match attrs.get("type")?.demand().ok()? {
936        Concrete::String(s) if s.chars == "derivation" => {}
937        _ => return None,
938    }
939    match attrs.get("outPath")?.demand().ok()? {
940        Concrete::String(s) => Some(s.chars.to_string()),
941        _ => None,
942    }
943}
944
945/// If `attrs` is a **derivation** (`type` forces to `"derivation"`) carrying a
946/// forceable `drvPath` AND `outPath`, return `Ok(Some((drv_path, out_path)))`.
947///
948/// Returns `Ok(None)` when `attrs` is not a derivation (no `type ==
949/// "derivation"`, or missing `drvPath`) — e.g. a plain attrset that merely
950/// carries an `outPath` (a `{ outPath = "…"; }` path-like), which has nothing
951/// to realize. Returns `Err` only if forcing `drvPath`/`outPath` itself fails
952/// (a genuinely broken derivation), so the caller surfaces the eval error
953/// rather than silently treating a broken drv as "not a derivation".
954///
955/// This is the import-from-derivation sibling of [`derivation_out_path`]: that
956/// helper only needs `outPath` for equality; realize also needs `drvPath` to
957/// know *what* to build.
958fn derivation_drv_and_out(
959    attrs: &NixAttrs,
960) -> Result<Option<(String, String)>, EvalError> {
961    // Not a derivation unless `type` forces to exactly "derivation".
962    match attrs.get("type") {
963        Some(t) => match crate::eval::force_value(t)? {
964            Value::String(s) if s.chars == "derivation" => {}
965            _ => return Ok(None),
966        },
967        None => return Ok(None),
968    }
969    // A derivation without a drvPath cannot be realized — treat as non-drv so
970    // the caller falls back to plain coercion (the outPath arm).
971    let drv_path = match attrs.get("drvPath") {
972        Some(d) => crate::eval::force_value(d)?.coerce_to_path("drvPath")?,
973        None => return Ok(None),
974    };
975    let out_path = match attrs.get("outPath") {
976        Some(o) => crate::eval::force_value(o)?.coerce_to_path("outPath")?,
977        None => return Ok(None),
978    };
979    Ok(Some((drv_path, out_path)))
980}
981
982/// Given a store-path STRING (produced by interpolating a derivation) and its
983/// string context, return the producing `.drv` path IF this store path is a
984/// derivation output that should be realized on a filesystem read.
985///
986/// Returns `Some(drv_path)` only when the context carries a
987/// `ContextElement::Output { drv, output }` whose `output` store path matches
988/// `out_path` — i.e. this string IS the output of a derivation named by the
989/// context. `Plain`/`DrvDeep`-only contexts (a plain store-path reference, or a
990/// `.drv` self-reference) don't name an output to realize, and an
991/// empty-context string is a literal path with nothing to build.
992///
993/// This is how cppnix decides IFD across interpolation: the derivation-ness of
994/// `"${drv}"` survives as string context, not as a value shape.
995fn out_path_needs_realize(out_path: &str, ctx: &StringContext) -> Option<String> {
996    // Only store-path strings can be derivation outputs.
997    if !out_path.starts_with("/nix/store/") {
998        return None;
999    }
1000    for elem in ctx.iter() {
1001        if let ContextElement::Output { drv, output } = elem {
1002            // The context stores the OUTPUT NAME (e.g. "out"/"dev"), while the
1003            // string IS the output's store path. cppnix's `Output.outputName`
1004            // matches the string it decorates; sui's tree-walker builds the
1005            // interpolated string FROM this output's store path, so a single
1006            // `Output` element on a store-path string is the producing drv.
1007            let _ = output; // output name is not needed to build the closure
1008            return Some(drv.to_string());
1009        }
1010    }
1011    None
1012}
1013
1014impl Value {
1015    /// Convert a known-concrete Value to Concrete. Panics if Thunk.
1016    /// Only use when the caller guarantees the value is not a thunk.
1017    pub(crate) fn demand_unchecked(self) -> Concrete {
1018        match self {
1019            Value::Null => Concrete::Null,
1020            Value::Bool(b) => Concrete::Bool(b),
1021            Value::Int(n) => Concrete::Int(n),
1022            Value::Float(f) => Concrete::Float(f),
1023            Value::String(s) => Concrete::String(s),
1024            Value::Path(p) => Concrete::Path(p),
1025            Value::List(l) => Concrete::List(l),
1026            Value::Attrs(a) => Concrete::Attrs(a),
1027            Value::Lambda(c) => Concrete::Lambda(c),
1028            Value::Builtin(b) => Concrete::Builtin(b),
1029            Value::Thunk(_) => panic!("demand_unchecked called on Thunk"),
1030        }
1031    }
1032}
1033
1034impl Value {
1035    /// Demand a concrete value. Forces if Thunk, returns as-is if concrete.
1036    ///
1037    /// This is the TYPED forcing API. The returned `Concrete` is guaranteed
1038    /// non-Thunk — enforced by the Concrete enum having NO Thunk variant.
1039    pub fn demand(&self) -> Result<Concrete, EvalError> {
1040        let v = match self {
1041            Value::Thunk(_) => crate::eval::force_value(self)?,
1042            other => other.clone(),
1043        };
1044        // Convert Value → Concrete. Thunk is impossible after force_value.
1045        match v {
1046            Value::Null => Ok(Concrete::Null),
1047            Value::Bool(b) => Ok(Concrete::Bool(b)),
1048            Value::Int(n) => Ok(Concrete::Int(n)),
1049            Value::Float(f) => Ok(Concrete::Float(f)),
1050            Value::String(s) => Ok(Concrete::String(s)),
1051            Value::Path(p) => Ok(Concrete::Path(p)),
1052            Value::List(l) => Ok(Concrete::List(l)),
1053            Value::Attrs(a) => Ok(Concrete::Attrs(a)),
1054            Value::Lambda(c) => Ok(Concrete::Lambda(c)),
1055            Value::Builtin(b) => Ok(Concrete::Builtin(b)),
1056            Value::Thunk(_) => {
1057                // force_value returned a Thunk — chase it.
1058                // This can happen when the transitive unwrap loop hits
1059                // a depth limit. Re-force to resolve.
1060                let re_forced = crate::eval::force_value(&v)?;
1061                match re_forced {
1062                    Value::Null => Ok(Concrete::Null),
1063                    Value::Bool(b) => Ok(Concrete::Bool(b)),
1064                    Value::Int(n) => Ok(Concrete::Int(n)),
1065                    Value::Float(f) => Ok(Concrete::Float(f)),
1066                    Value::String(s) => Ok(Concrete::String(s)),
1067                    Value::Path(p) => Ok(Concrete::Path(p)),
1068                    Value::List(l) => Ok(Concrete::List(l)),
1069                    Value::Attrs(a) => Ok(Concrete::Attrs(a)),
1070                    Value::Lambda(c) => Ok(Concrete::Lambda(c)),
1071                    Value::Builtin(b) => Ok(Concrete::Builtin(b)),
1072                    Value::Thunk(_) => Err(EvalError::InfiniteRecursion(
1073                        "demand: thunk chain could not be resolved".to_string(),
1074                    )),
1075                }
1076            }
1077        }
1078    }
1079}
1080
1081#[cfg(target_pointer_width = "64")]
1082const _: () = assert!(std::mem::size_of::<Value>() <= 16);
1083
1084/// Runaway backstop for overlay-fixpoint promotion.  A genuine fixpoint
1085/// re-entry converges in a bounded number of nested promotions (the
1086/// nixpkgs `libxcrypt`/`self:super:` overlay needs ≤18 concurrent
1087/// promotions).  A non-converging demand (e.g. a cross-system stdenv
1088/// fixpoint that keeps re-entering the empty partial) climbs the nesting
1089/// without bound.  When the active concurrent-promotion nesting
1090/// (`IN_PROMISE_EVAL`) reaches this cap we STOP promoting and fall through
1091/// to `InfiniteRecursion` — which `eval_select`'s `x.y or default` arm
1092/// recovers exactly like nix's lazy fall-through, converting a would-be
1093/// native stack overflow into the recoverable error nix itself raises.
1094///
1095/// This is the runaway half of the same discipline the release-build
1096/// `MAX_EVAL_DEPTH` guard provides (which is `usize::MAX` in release to
1097/// admit nixpkgs' legitimately-deep fixpoints); scoping the bound to
1098/// *promotions* keeps ordinary deep evaluation unbounded while still
1099/// catching a non-terminating fixpoint before the OS stack does.
1100const FIXPOINT_PROMOTE_NEST_CAP: u32 = 32;
1101
1102/// Force-stack-depth backstop that arms once a fixpoint promotion has fired
1103/// (`promotion_occurred()`).  A converging fixpoint (`libxcrypt`) bottoms
1104/// out at a force depth of a few dozen; a non-converging promoted partial
1105/// recurses without bound.  This cap (10× any observed real fixpoint's force
1106/// depth) converts a force-stack runaway into a recoverable
1107/// `InfiniteRecursion` before the native OS stack aborts, without touching
1108/// ordinary (non-promotion) deep evaluation.  Paired with the eval-depth
1109/// backstop (`eval::PROMOTION_RUNAWAY_EVAL_DEPTH`) for runaways that don't
1110/// climb the force stack.
1111const PROMOTION_RUNAWAY_FORCE_DEPTH: usize = 500;
1112
1113thread_local! {
1114    /// Depth counter for "currently evaluating the body of a Promise-state
1115    /// thunk".  Incremented before the body of a `ThunkRepr::Promise`
1116    /// runs, decremented after.  Used by `eval_select` to treat missing
1117    /// attribute lookups on the Promise's sentinel value as `null`
1118    /// instead of erroring with `AttrNotFound`.  Scoped to Promise
1119    /// evaluation so unrelated user code retains cppnix-strict semantics.
1120    pub(crate) static IN_PROMISE_EVAL: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
1121
1122    /// Set once a fixpoint promotion has occurred anywhere in the current
1123    /// top-level evaluation.  Arms the release-active force-depth runaway
1124    /// backstop for the REST of the eval (not just while `IN_PROMISE_EVAL`
1125    /// is non-zero) — a corrupted promoted partial can send a DOWNSTREAM
1126    /// fixpoint (`makeOverridable`/`commonAttrs`) into unbounded recursion
1127    /// AFTER the promoting force has already returned, so the backstop must
1128    /// outlive the promotion's own softening scope.
1129    pub(crate) static PROMOTION_OCCURRED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1130}
1131
1132/// `true` if any overlay-fixpoint promotion has fired in this eval.
1133#[inline(always)]
1134pub fn promotion_occurred() -> bool {
1135    PROMOTION_OCCURRED.with(|c| c.get())
1136}
1137
1138/// `true` if the evaluator is currently inside the body of a
1139/// `ThunkRepr::Promise` (used by `eval_select` to relax
1140/// `AttrNotFound` errors during fix-point construction).
1141#[inline(always)]
1142pub fn in_promise_eval() -> bool {
1143    IN_PROMISE_EVAL.with(|c| c.get() > 0)
1144}
1145
1146/// Internal representation of a thunk's state machine.
1147///
1148/// Transitions: `Suspended` → `Blackhole` → `Evaluated` (on success),
1149/// or `Suspended` → `Blackhole` → `Suspended` (on failure, to allow retry).
1150pub enum ThunkRepr {
1151    /// Not yet evaluated. Holds the AST expression and captured environment.
1152    Suspended {
1153        expr: rnix::ast::Expr,
1154        env: Env,
1155    },
1156    /// Pending `inherit (source) name` selection. When forced,
1157    /// forces the shared `source_thunk` and pulls out `name`.
1158    ///
1159    /// The `source_thunk` is created once per `inherit (source) a b c`
1160    /// clause and shared (via `Rc` clone) across all inherited names.
1161    /// This means N names share one source evaluation instead of N
1162    /// independent evaluations — the source thunk's own memoization
1163    /// ensures it is evaluated at most once.
1164    ///
1165    /// This is its own variant (rather than synthesizing a Select AST
1166    /// node) because rnix doesn't expose a public AST builder, and
1167    /// we want each inherited name to defer evaluation of the source
1168    /// expression so that `inherit (lib.trivial) ...` at the top of
1169    /// trivial.nix doesn't blackhole on the still-being-constructed
1170    /// `lib.trivial`.
1171    InheritSelect {
1172        source_thunk: Thunk,
1173        name: SmolStr,
1174    },
1175    /// A lazy value backed by a Rust closure.  Used for flake input
1176    /// evaluation: the closure calls `evaluate_flake` on first access
1177    /// instead of eagerly during flake setup, matching CppNix semantics
1178    /// where each input's outputs function is wrapped in a thunk.
1179    Native(Box<dyn FnOnce() -> Result<Value, EvalError>>),
1180    /// A deferred with-scope ident lookup.  Stores a direct reference to the
1181    /// with-scope's shared cache and the ident name.  When forced, checks the
1182    /// cache for the resolved attrset and looks up the name — O(1) hash lookup,
1183    /// no Env traversal, no fixpoint re-forcing.
1184    ///
1185    /// This is the construction-guarantee solution for the with-scope fixpoint
1186    /// problem: instead of creating 80K+ Env-capturing thunks (each doing a
1187    /// full lookup on force), we create 80K lightweight cache-referencing thunks
1188    /// that share the same resolved attrset.
1189    WithIdent {
1190        /// The ident name to look up
1191        name: SmolStr,
1192        /// Direct reference to the with-scope's cached attrset.
1193        /// Shared via Rc<RefCell> — all idents from the same `with` scope
1194        /// reference the same cache.  When ANY lookup forces the scope,
1195        /// the cache is populated and all subsequent WithIdent forces are O(1).
1196        scope_cache: Rc<RefCell<Option<NixAttrs>>>,
1197        /// The scope value (for initial force if cache is empty)
1198        scope_value: Value,
1199        /// Fallback: the full env for lexical+outer-scope lookup if the
1200        /// with-scope doesn't contain this name
1201        env: Env,
1202    },
1203    /// Currently being evaluated -- detects infinite recursion.
1204    Blackhole,
1205    /// Currently being evaluated, but the thunk is known to be
1206    /// self-recursive (its RHS references the bound name).  Inner
1207    /// re-entrance returns the partial value from the cell instead
1208    /// of erroring with `InfiniteRecursion` — matches cppnix's
1209    /// `let x = f x; in x` semantics where inner accesses to `x`
1210    /// see the not-yet-complete attrset under construction.
1211    ///
1212    /// The cell starts as `Value::Attrs(empty)` (the cheapest
1213    /// sentinel that propagates through `mapAttrs` / `attrNames` /
1214    /// `concatMap` without further type errors).  When the body
1215    /// completes, the cell is replaced with the final value and
1216    /// the repr transitions to `Evaluated`.
1217    Promise(Rc<RefCell<Value>>),
1218    /// A `Native` (`FnOnce`) thunk whose closure already ran and
1219    /// FAILED.  The closure is consumed and cannot be retried, so we
1220    /// memoize the error itself and re-raise it on every subsequent
1221    /// force.  This is the correctness-preserving replacement for the
1222    /// old `Evaluated(Null)` poisoning: a thunk that threw on its first
1223    /// force MUST NOT silently become `null` on a second read (which
1224    /// turned a swallowed transient flake-input error into a bogus
1225    /// `AttrNotFound`/`cannot select from set` far downstream — the
1226    /// stylix `darwinModules` marquee root).  A re-force re-throws the
1227    /// original error, exactly as cppnix re-throws a thunk that failed.
1228    Failed(EvalError),
1229    /// Already evaluated and memoized as a THUNK value.  The `cache`
1230    /// `OnceCell` is intentionally empty for this variant (caching a thunk
1231    /// would spin `force_value`), so the boxed `Value` is the sole store.
1232    Evaluated(Box<Value>),
1233    /// Already evaluated and memoized as a CONCRETE (non-thunk) value.
1234    /// The value lives ONLY in the `cache` `OnceCell` (`Box<Concrete>`);
1235    /// this variant is a valueless terminal marker that collapses the
1236    /// former double-store (a redundant `Evaluated(Box<Value>)` alongside
1237    /// the cache).  Any reader that finds this marker reconstructs the
1238    /// `Value` from `cache` via `Concrete::into_value()`, which is a
1239    /// byte-identical, lossless inverse of `demand_unchecked` (same enum
1240    /// shape, moves the inner `Rc`/`Box` — preserving string context and
1241    /// list/attrs `Rc` identity).  In practice the `cache` fast path in
1242    /// `force`/`force_inner` returns before this arm is ever matched.
1243    EvaluatedConcrete,
1244}
1245
1246/// Inner storage for a thunk: a fast-path `OnceCell` cache plus the
1247/// full `UnsafeCell` state machine.  Reads of already-evaluated thunks
1248/// hit the `OnceCell` and never touch the `UnsafeCell`, eliminating
1249/// all runtime overhead on the hot path (~150M+ cache hits per nixpkgs
1250/// eval).  The cold path (1.8M forces) uses `UnsafeCell` directly —
1251/// safe because the evaluator is single-threaded (`Rc`, not `Arc`) and
1252/// the state machine ensures no overlapping mutable access
1253/// (`Suspended` → `Blackhole` → `Evaluated` transitions are sequential).
1254struct ThunkInner {
1255    /// Fast-path cache for already-evaluated thunks.
1256    /// Set once when `Evaluated` is stored, never cleared.
1257    /// Reads bypass the `UnsafeCell` entirely.
1258    cache: OnceCell<Box<Concrete>>,
1259    /// Full state machine for the thunk lifecycle.
1260    repr: UnsafeCell<ThunkRepr>,
1261    /// `true` when the thunk's RHS references its own bound name
1262    /// (a fix-point pattern like `let x = f x; in x`).  On force,
1263    /// transitions to `ThunkRepr::Promise` instead of `Blackhole`
1264    /// so inner re-entrance sees the partial value rather than
1265    /// erroring.  Detected at thunk-construction time via AST
1266    /// text search.
1267    recursive: bool,
1268}
1269
1270impl Drop for ThunkInner {
1271    fn drop(&mut self) {
1272        census::dropped(&census::THUNK_LIVE);
1273    }
1274}
1275
1276/// A lazy value with memoization and blackhole detection.
1277#[derive(Clone)]
1278pub struct Thunk(pub(crate) Rc<ThunkInner>);
1279
1280impl Thunk {
1281    /// Create a thunk that will evaluate `expr` in `env` when forced.
1282    pub fn new_suspended(expr: rnix::ast::Expr, env: Env) -> Self {
1283        crate::trace::inc_thunks_created();
1284        census::made(&census::THUNK_MADE, &census::THUNK_LIVE);
1285        Self(Rc::new(ThunkInner {
1286            cache: OnceCell::new(),
1287            repr: UnsafeCell::new(ThunkRepr::Suspended { expr, env }),
1288            recursive: false,
1289        }))
1290    }
1291
1292    /// Like [`new_suspended`] but marks the thunk as self-recursive.
1293    /// On force, inner re-entrance returns the partial value from the
1294    /// promise cell instead of erroring with `InfiniteRecursion`,
1295    /// matching cppnix's `let x = f x; in x` semantics.  Use this for
1296    /// let-bindings whose RHS textually references the bound name
1297    /// (see `eval::is_self_recursive_binding`).
1298    pub fn new_suspended_recursive(expr: rnix::ast::Expr, env: Env) -> Self {
1299        crate::trace::inc_thunks_created();
1300        census::made(&census::THUNK_MADE, &census::THUNK_LIVE);
1301        crate::perf::inc(crate::perf::Counter::ThunkSiteLetForward);
1302        Self(Rc::new(ThunkInner {
1303            cache: OnceCell::new(),
1304            repr: UnsafeCell::new(ThunkRepr::Suspended { expr, env }),
1305            recursive: true,
1306        }))
1307    }
1308
1309    /// Create a thunk that, when forced, forces the shared
1310    /// `source_thunk` and pulls out the attribute named `name`.
1311    ///
1312    /// The caller creates ONE `Thunk::new_suspended(source_expr, env)`
1313    /// per `inherit (source)` clause and passes clones (Rc bump) to
1314    /// each inherited name.  This way the source is evaluated at most
1315    /// once regardless of how many names are inherited.
1316    pub fn new_inherit_select(source_thunk: Thunk, name: impl Into<SmolStr>) -> Self {
1317        crate::trace::inc_thunks_created();
1318        census::made(&census::THUNK_MADE, &census::THUNK_LIVE);
1319        crate::perf::inc(crate::perf::Counter::ThunkSiteInheritSrc);
1320        Self(Rc::new(ThunkInner {
1321            cache: OnceCell::new(),
1322            repr: UnsafeCell::new(ThunkRepr::InheritSelect {
1323                source_thunk,
1324                name: name.into(),
1325            }),
1326            recursive: false,
1327        }))
1328    }
1329
1330    /// Create a WithIdent thunk — a deferred with-scope ident lookup.
1331    /// Stores a direct reference to the shared with-scope cache.
1332    /// When forced: O(1) hash lookup in the cache, no Env traversal.
1333    pub fn new_with_ident(
1334        name: SmolStr,
1335        scope_cache: Rc<RefCell<Option<NixAttrs>>>,
1336        scope_value: Value,
1337        env: Env,
1338    ) -> Self {
1339        crate::trace::inc_thunks_created();
1340        census::made(&census::THUNK_MADE, &census::THUNK_LIVE);
1341        crate::perf::inc(crate::perf::Counter::ThunkSiteOther);
1342        Self(Rc::new(ThunkInner {
1343            cache: OnceCell::new(),
1344            repr: UnsafeCell::new(ThunkRepr::WithIdent {
1345                name,
1346                scope_cache,
1347                scope_value,
1348                env,
1349            }),
1350            recursive: false,
1351        }))
1352    }
1353
1354    /// Create a thunk backed by a Rust closure.  When forced, the
1355    /// closure is called exactly once and its result is memoized.
1356    /// This is used for lazy flake input evaluation.
1357    pub fn new_native(f: impl FnOnce() -> Result<Value, EvalError> + 'static) -> Self {
1358        crate::trace::inc_thunks_created();
1359        census::made(&census::THUNK_MADE, &census::THUNK_LIVE);
1360        crate::perf::inc(crate::perf::Counter::ThunkSiteNative);
1361        Self(Rc::new(ThunkInner {
1362            cache: OnceCell::new(),
1363            repr: UnsafeCell::new(ThunkRepr::Native(Box::new(f))),
1364            recursive: false,
1365        }))
1366    }
1367
1368    /// Create a thunk that is already evaluated (an optimization).
1369    /// Pre-populates the `OnceCell` cache so the fast path is
1370    /// immediately available.
1371    pub fn new_evaluated(value: Value) -> Self {
1372        crate::trace::inc_thunks_created();
1373        census::made(&census::THUNK_MADE, &census::THUNK_LIVE);
1374        crate::perf::inc(crate::perf::Counter::ThunkSiteEvaluated);
1375        let cache = OnceCell::new();
1376        // Collapse the double-store: a concrete value lives ONLY in the
1377        // cache with an `EvaluatedConcrete` marker repr; a thunk value
1378        // keeps the boxed `Evaluated` repr and an empty cache.
1379        let repr = if matches!(value, Value::Thunk(_)) {
1380            ThunkRepr::Evaluated(Box::new(value))
1381        } else {
1382            let _ = cache.set(Box::new(value.demand_unchecked()));
1383            ThunkRepr::EvaluatedConcrete
1384        };
1385        Self(Rc::new(ThunkInner {
1386            cache,
1387            repr: UnsafeCell::new(repr),
1388            recursive: false,
1389        }))
1390    }
1391
1392    /// Check whether this thunk has already been forced.
1393    /// Uses the `OnceCell` cache for a fast, borrow-free check.
1394    pub fn is_evaluated(&self) -> bool {
1395        self.0.cache.get().is_some()
1396    }
1397
1398    /// Check whether this thunk is a native (Rust closure) thunk.
1399    ///
1400    /// Native thunks are used for lazy flake input evaluation and can
1401    /// be very expensive to force (e.g., evaluating all of nixpkgs).
1402    /// This lets callers skip them in eager conversion paths.
1403    pub fn is_native(&self) -> bool {
1404        // SAFETY: Single-threaded evaluator (Rc, not Arc). Read-only access,
1405        // no mutable reference exists at this point.
1406        matches!(unsafe { &*self.0.repr.get() }, ThunkRepr::Native(_))
1407    }
1408
1409    /// Peek at the cached value WITHOUT forcing.
1410    /// Returns Some(&Value) if the thunk has been evaluated, None otherwise.
1411    /// This is used by with-scope lookup to check if the fixpoint thunk
1412    /// has already been resolved (by another evaluation path) without
1413    /// entering the force state machine.
1414    pub fn peek(&self) -> Option<&Concrete> {
1415        self.0.cache.get().map(|v| &**v)
1416    }
1417
1418    /// Replace the environment captured in a suspended thunk.
1419    /// For `InheritSelect`, delegates to the shared source thunk's
1420    /// `update_env` (which updates the source's captured env).
1421    /// No-op if the thunk is already evaluated or a blackhole.
1422    pub fn update_env(&self, new_env: &Env) {
1423        // SAFETY: Single-threaded evaluator. No other reference to repr
1424        // exists during env replacement.
1425        let repr = unsafe { &mut *self.0.repr.get() };
1426        match repr {
1427            ThunkRepr::Suspended { env, .. } => {
1428                *env = new_env.clone();
1429            }
1430            ThunkRepr::InheritSelect { source_thunk, .. } => {
1431                source_thunk.update_env(new_env);
1432            }
1433            _ => {}
1434        }
1435    }
1436
1437    /// Store a forced result into this thunk's terminal state, collapsing
1438    /// the former thunk double-store.
1439    ///
1440    /// - A CONCRETE (non-thunk) result is stored ONLY in the `cache`
1441    ///   `OnceCell` (`Box<Concrete>`), and `repr` becomes the valueless
1442    ///   `EvaluatedConcrete` marker — freeing the redundant
1443    ///   `Box<Value>` that `Evaluated` used to hold. Reconstruction via
1444    ///   `Concrete::into_value()` is a byte-identical inverse of the
1445    ///   `demand_unchecked()` used to fill the cache.
1446    /// - A THUNK result keeps `repr = Evaluated(Box<Value>)` and leaves
1447    ///   the cache empty (caching a thunk would spin `force_value`).
1448    ///
1449    /// SAFETY: single-threaded evaluator (`Rc`, not `Arc`); the caller
1450    /// must hold no other borrow of `repr` — every call site here is on
1451    /// the sequential `Suspended → Blackhole/Promise → Evaluated`
1452    /// transition, so no overlapping mutable access exists.
1453    ///
1454    /// Takes `&Value` and clones exactly as the former open-coded stores
1455    /// did (`Box::new(value.clone())` for the thunk repr,
1456    /// `Box::new(value.clone().demand_unchecked())` for the cache) — so the
1457    /// clone count is identical to the pre-collapse code and the change is
1458    /// byte-neutral by construction.
1459    #[inline]
1460    unsafe fn store_evaluated(&self, value: &Value) {
1461        census::evaluated();
1462        if matches!(value, Value::Thunk(_)) {
1463            *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Evaluated(Box::new(value.clone()));
1464        } else {
1465            let _ = self.0.cache.set(Box::new(value.clone().demand_unchecked()));
1466            *unsafe { &mut *self.0.repr.get() } = ThunkRepr::EvaluatedConcrete;
1467        }
1468    }
1469
1470    /// Owned-value variant of the concrete branch of [`store_evaluated`],
1471    /// for the `force_inner` early-return that OWNS `value` and does not
1472    /// need it afterward.
1473    ///
1474    /// `store_evaluated(&value)` clones the whole `Value` to fill the
1475    /// cache (`Box::new(value.clone().demand_unchecked())`) and the caller
1476    /// then returns the owned `value` separately — an extra *outer*
1477    /// `Value` clone. Here we MOVE `value` into the cache (no outer clone)
1478    /// and clone the cheaper inner `Concrete` for the return, trading one
1479    /// `Value::clone` for one `Concrete::clone` (the same inner `Rc` bumps,
1480    /// one fewer throwaway `Value` temporary).
1481    ///
1482    /// Content-, order-, and census-neutral versus the
1483    /// `store_evaluated(&value); return Ok(value)` it replaces: the cache
1484    /// holds the identical `Box<Concrete>`, `repr` becomes the identical
1485    /// `EvaluatedConcrete` marker, `census::evaluated()` fires exactly
1486    /// once, and the returned `Value` is a byte-identical reconstruction
1487    /// of `value`.
1488    ///
1489    /// Panics (via `demand_unchecked`) if `value` is a `Thunk` — the sole
1490    /// call site only reaches it on the `!was_thunk_before_loop` branch,
1491    /// where `value` is guaranteed non-`Thunk`.
1492    ///
1493    /// SAFETY: same contract as [`store_evaluated`] — single-threaded,
1494    /// no overlapping `repr` borrow.
1495    #[inline]
1496    unsafe fn store_evaluated_owned(&self, value: Value) -> Value {
1497        census::evaluated();
1498        let concrete = value.demand_unchecked();
1499        let ret = concrete.clone().into_value();
1500        let _ = self.0.cache.set(Box::new(concrete));
1501        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::EvaluatedConcrete;
1502        ret
1503    }
1504
1505    /// Force this thunk using the given evaluator function.
1506    ///
1507    /// On first force: transitions Suspended -> Blackhole -> Evaluated.
1508    /// Re-entering a Blackhole signals infinite recursion.
1509    /// If the evaluated result is itself a thunk, it is forced transitively.
1510    ///
1511    /// Uses `stacker::maybe_grow` to ensure sufficient stack space for
1512    /// deeply nested thunk chains (e.g., nixpkgs overlay fixpoints).
1513    pub fn force(
1514        &self,
1515        evaluator: &dyn Fn(&rnix::ast::Expr, &Env) -> Result<Value, EvalError>,
1516    ) -> Result<Value, EvalError> {
1517        // Ultra-fast path: if already evaluated, return cached value
1518        // WITHOUT entering stacker::maybe_grow. This avoids the stack
1519        // check overhead on ~150M cache hits during nixpkgs evaluation.
1520        if let Some(cached) = self.0.cache.get() {
1521            crate::perf::inc(crate::perf::Counter::ThunkHit);
1522            return Ok((**cached).clone().into_value());
1523        }
1524        // Cold path: evaluation may recurse deeply, so use stacker.
1525        stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
1526            self.force_inner(evaluator)
1527        })
1528    }
1529
1530    /// Inner implementation of [`Thunk::force`] — called from the
1531    /// `stacker` trampoline.
1532    fn force_inner(
1533        &self,
1534        evaluator: &dyn Fn(&rnix::ast::Expr, &Env) -> Result<Value, EvalError>,
1535    ) -> Result<Value, EvalError> {
1536        // SAFETY (all `unsafe` blocks in this method): The evaluator is
1537        // single-threaded (`Rc`, not `Arc`).  `ThunkInner` is `!Send`/`!Sync`.
1538        // The `OnceCell` fast path handles all concurrent-safe reads (150M+
1539        // hits).  Only the cold path (1.8M forces) touches the `UnsafeCell`.
1540        // The state machine guarantees no overlapping mutable access:
1541        // Suspended → Blackhole → Evaluated transitions are sequential.
1542
1543        // Ultra-fast path: check OnceCell cache (no borrow).
1544        if let Some(cached) = self.0.cache.get() {
1545            crate::perf::inc(crate::perf::Counter::ThunkHit);
1546            return Ok((**cached).clone().into_value());
1547        }
1548
1549        let thunk_id = Rc::as_ptr(&self.0) as usize;
1550
1551        // Promise fast-path: if this thunk is currently in `Promise`
1552        // state (a self-recursive fix-point whose outer body is still
1553        // running, and *this* call is an inner re-entrance), return
1554        // the cell's current partial value without consuming the
1555        // repr.  Matches cppnix's `let x = f x; in x` semantics:
1556        // inner accesses to `x` during f's evaluation see the not-
1557        // yet-complete value instead of erroring with
1558        // `InfiniteRecursion`.
1559        //
1560        // SAFETY: Single-threaded evaluator. The immutable borrow
1561        // is scoped to this `if let` block; the early return exits
1562        // before any further access to `repr`.
1563        if let ThunkRepr::Promise(cell) = unsafe { &*self.0.repr.get() } {
1564            return Ok(cell.borrow().clone());
1565        }
1566
1567        // Take the current repr.  Replace with `Promise(cell)` if the
1568        // thunk is self-recursive (so inner re-entrance during body
1569        // evaluation hits the fast-path above), otherwise classic
1570        // `Blackhole` (so inner re-entrance errors with
1571        // `InfiniteRecursion`, which is the correct behaviour for
1572        // non-recursive bindings like `let r = r; in r`).
1573        // SAFETY: Single-threaded evaluator. State machine ensures no
1574        // overlapping mutable access: Suspended->Blackhole/Promise->Evaluated.
1575        let new_repr_on_force = if self.0.recursive {
1576            ThunkRepr::Promise(Rc::new(RefCell::new(
1577                Value::Attrs(Rc::new(NixAttrs::new())),
1578            )))
1579        } else {
1580            ThunkRepr::Blackhole
1581        };
1582        let is_promise = self.0.recursive;
1583        let repr = std::mem::replace(unsafe { &mut *self.0.repr.get() }, new_repr_on_force);
1584
1585        match repr {
1586            ThunkRepr::Suspended { expr, env } => {
1587                crate::perf::inc(crate::perf::Counter::ThunkForce);
1588                crate::trace::inc_thunks_forced_unique();
1589                let tracing = crate::trace::trace_enabled();
1590                // Always push a force frame — `pop_force` is matched in every
1591                // exit path below.  This keeps the cycle chain on
1592                // `EvalError::InfiniteRecursion` populated WITHOUT requiring
1593                // the operator to set `SUI_TRACE_EVAL=verbose` first.  In
1594                // tracing mode we also capture the (expensive) source-text
1595                // description; otherwise we keep the frame cheap (just the
1596                // file + thunk id) so the always-on overhead stays bounded.
1597                let desc: String = if tracing {
1598                    expr.syntax().text().to_string().chars().take(60).collect()
1599                } else {
1600                    String::new()
1601                };
1602                crate::trace::push_force(crate::trace::ForceFrame {
1603                    defined_in: env.eval_file().cloned(),
1604                    description: desc.clone(),
1605                    thunk_id,
1606                });
1607                // Runaway backstop #1 (force-stack depth) for overlay-fixpoint
1608                // promotion (release-active; belt-and-suspenders with the
1609                // eval-depth backstop in `eval::DepthGuard::enter`).
1610                //
1611                // A promoted empty-attrs partial is byte-correct for the
1612                // native-system stdenv fixpoint (`libxcrypt` — the actual
1613                // byte-parity root; its promotions bottom out at a force depth
1614                // ≤ ~50), but is the WRONG partial for a demand that indexes it
1615                // as a list / non-attrs (the cross-system Darwin `apple-sdk`
1616                // path `hello` hits when `builtins.currentSystem` is macOS).
1617                // There the empty partial feeds a downstream `makeOverridable`
1618                // fixpoint that recurses without bound.  Release disables the
1619                // general `MAX_EVAL_DEPTH` guard (`usize::MAX`) to admit
1620                // nixpkgs' legitimately-deep fixpoints, so nothing else stops
1621                // that recursion before the OS stack aborts.
1622                //
1623                // Armed only once a promotion has fired (`promotion_occurred()`)
1624                // and for the REST of the eval — a corrupted partial can send a
1625                // downstream fixpoint runaway AFTER the promoting force returns,
1626                // so the backstop must outlive the promotion's own softening
1627                // scope.  A runaway that climbs the force stack is caught here;
1628                // one that climbs `eval_expr` without pushing force frames is
1629                // caught by the eval-depth backstop.  Either converts the
1630                // would-be native abort into a recoverable `InfiniteRecursion`
1631                // (which `x.y or default` recovers exactly like nix).
1632                if crate::value::promotion_occurred()
1633                    && crate::trace::current_force_depth() as usize
1634                        > PROMOTION_RUNAWAY_FORCE_DEPTH
1635                {
1636                    crate::trace::pop_force();
1637                    *unsafe { &mut *self.0.repr.get() } =
1638                        ThunkRepr::Suspended { expr, env };
1639                    return Err(EvalError::InfiniteRecursion(
1640                        "overlay-fixpoint promotion runaway (force depth exceeded)".into(),
1641                    ));
1642                }
1643                if tracing {
1644                    crate::trace::trace_force_enter(
1645                        env.eval_file().map(|p| p.as_path()),
1646                        &desc,
1647                    );
1648                    if let Err(msg) = crate::trace::check_force_depth() {
1649                        crate::trace::dump_trace_on_error();
1650                        crate::trace::pop_force();
1651                        crate::trace::trace_force_exit();
1652                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Suspended {
1653                            expr,
1654                            env,
1655                        };
1656                        return Err(EvalError::InfiniteRecursion(msg));
1657                    }
1658                }
1659                // Push the thunk's captured eval_file onto the thread-local
1660                // stack so PathRel literals and relative imports inside the
1661                // thunk body resolve against the file where the thunk was
1662                // *defined*, not where it is forced from. The RAII guard
1663                // pops on drop (including on error paths).
1664                let _file_guard = env.eval_file().cloned().map(crate::eval::push_eval_file);
1665                // Restore the thunk's DEFINING source_id in lockstep with
1666                // eval_file above, so idents evaluated in the thunk body key
1667                // the `(source_id, offset)` symbol cache against the file the
1668                // thunk was defined in — not the ambient source at force time.
1669                // Without this a cross-file force (a lazy thunk from an
1670                // imported file, forced after `eval_with_file` restored the
1671                // top-level source_id) collides on a reused offset and returns
1672                // a wrong Symbol (`parse.nix` `cannot select from null`).
1673                let _srcid_guard = crate::eval::push_source_id(env.source_id());
1674                // M2.6 Promise scope: bump the thread-local counter so
1675                // downstream `eval_select` can soften `AttrNotFound`
1676                // errors on the Promise's sentinel value to `null`.
1677                // Scoped strictly to Promise-thunk body evaluation;
1678                // non-recursive thunks retain cppnix-strict semantics.
1679                if is_promise {
1680                    IN_PROMISE_EVAL.with(|c| c.set(c.get() + 1));
1681                }
1682                let result = evaluator(&expr, &env);
1683                if is_promise {
1684                    IN_PROMISE_EVAL.with(|c| c.set(c.get().saturating_sub(1)));
1685                }
1686                // A `Blackhole` thunk (non-recursive at construction) may have
1687                // been PROMOTED to `Promise` mid-body by a same-thunk fixpoint
1688                // re-entry (the overlay-fixpoint path in the Blackhole arm
1689                // below).  That promotion bumped `IN_PROMISE_EVAL` once; balance
1690                // it here, and populate its cell exactly like a
1691                // recursive-at-construction Promise.  `is_promise` covers the
1692                // construction-time case; `became_promise` covers the mid-body
1693                // semantic-promotion case.  They're mutually exclusive (a
1694                // construction-time Promise never re-enters the Blackhole arm).
1695                let became_promise = !is_promise
1696                    && matches!(unsafe { &*self.0.repr.get() }, ThunkRepr::Promise(_));
1697                if became_promise {
1698                    IN_PROMISE_EVAL.with(|c| c.set(c.get().saturating_sub(1)));
1699                }
1700                match result {
1701                    Ok(mut value) => {
1702                        crate::perf::inc(crate::perf::Counter::ThunkStoreWrites);
1703                        // M2.6 Promise update: if this thunk transitioned
1704                        // through Promise(cell), populate the cell with the
1705                        // final value BEFORE setting Evaluated.  Any
1706                        // outstanding Rc clones of the cell (held by inner
1707                        // thunks whose bodies haven't yet run) will see the
1708                        // complete value when they later force.
1709                        if is_promise || became_promise {
1710                            if let ThunkRepr::Promise(cell) = unsafe { &*self.0.repr.get() } {
1711                                *cell.borrow_mut() = value.clone();
1712                            }
1713                        }
1714                        // Whether the body returned a Thunk decides the store
1715                        // shape.  Computed BEFORE the store so the non-thunk
1716                        // path can MOVE `value` into the cache (owned store)
1717                        // instead of cloning it (see `store_evaluated_owned`).
1718                        //
1719                        // C-store PROVABLY-NEUTRAL narrow win (M2, byte-verified):
1720                        // when `value` is NOT a Thunk, the collapse loop below
1721                        // does not execute (its guard is `while let Value::Thunk`),
1722                        // so the second store (in the thunk branch) would rewrite
1723                        // BYTE-IDENTICAL repr content and re-attempt a no-op
1724                        // OnceCell `cache.set`.  Skipping it is content-AND-order-
1725                        // neutral: the single store already established the
1726                        // terminal (cache=concrete, repr=EvaluatedConcrete);
1727                        // nothing between the stores observes `self.0.repr` (the
1728                        // body has returned — no re-entrant force of self is in
1729                        // flight; the loop only `peek()`s OTHER thunks' OnceCell
1730                        // caches, never self's repr), and no code observes the
1731                        // `Box`'s pointer identity (repr is only ever read by
1732                        // value — grep-confirmed). Only when `value` IS a Thunk
1733                        // (the loop may collapse it to a different concrete) do we
1734                        // re-store the unwrapped result.
1735                        let was_thunk_before_loop = matches!(value, Value::Thunk(_));
1736                        if !was_thunk_before_loop {
1737                            // Non-thunk: single owned store (no outer Value clone),
1738                            // return the reconstruction. Byte-, order-, and census-
1739                            // identical to `store_evaluated(&value); return Ok(value)`
1740                            // (Store#2 is pure redundant and skipped, as before).
1741                            crate::perf::inc(crate::perf::Counter::ThunkStoreRedundant);
1742                            let ret = unsafe { self.store_evaluated_owned(value) };
1743                            crate::trace::pop_force();
1744                            if tracing { crate::trace::trace_force_exit(); }
1745                            return Ok(ret);
1746                        }
1747                        // Thunk path (unchanged): Store#1, collapse loop, Store#2.
1748                        unsafe { self.store_evaluated(&value) };
1749                        // Transitively unwrap thunk-in-thunk chains, with a
1750                        // depth limit to catch `let x = x; in x` cycles.
1751                        // Chase already-resolved thunks only (peek).
1752                        // force_value handles full transitive resolution.
1753                        while let Value::Thunk(ref inner) = value {
1754                            match inner.peek() {
1755                                Some(cached) => value = cached.clone().into_value(),
1756                                None => break,
1757                            }
1758                        }
1759                        if !matches!(value, Value::Thunk(_)) {
1760                            crate::perf::inc(crate::perf::Counter::ThunkStoreLoopMutated);
1761                        }
1762                        unsafe { self.store_evaluated(&value) };
1763                        crate::trace::pop_force();
1764                        if tracing { crate::trace::trace_force_exit(); }
1765                        Ok(value)
1766                    }
1767                    Err(e) => {
1768                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Suspended { expr, env };
1769                        if tracing { crate::trace::dump_trace_on_error(); }
1770                        crate::trace::pop_force();
1771                        if tracing { crate::trace::trace_force_exit(); }
1772                        Err(e)
1773                    }
1774                }
1775            }
1776            ThunkRepr::InheritSelect { source_thunk, name } => {
1777                let tracing = crate::trace::trace_enabled();
1778                let desc = if tracing { format!("inherit (..) {name}") } else { String::new() };
1779                crate::trace::push_force(crate::trace::ForceFrame {
1780                    defined_in: None,
1781                    description: desc.clone(),
1782                    thunk_id,
1783                });
1784                if tracing {
1785                    crate::trace::trace_force_enter(None, &desc);
1786                }
1787                crate::trace::inc_thunks_forced_unique();
1788                if tracing {
1789                    if let Err(msg) = crate::trace::check_force_depth() {
1790                        crate::trace::dump_trace_on_error();
1791                        crate::trace::pop_force();
1792                        crate::trace::trace_force_exit();
1793                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::InheritSelect {
1794                            source_thunk,
1795                            name,
1796                        };
1797                        return Err(EvalError::InfiniteRecursion(msg));
1798                    }
1799                }
1800                let attempt = (|| -> Result<Value, EvalError> {
1801                    let mut forced = source_thunk.force(evaluator)?;
1802                    while let Value::Thunk(inner) = forced {
1803                        forced = inner.force(evaluator)?;
1804                    }
1805                    let attrs = match &forced {
1806                        Value::Attrs(a) => a,
1807                        _ => {
1808                            return Err(EvalError::TypeError(format!(
1809                                "inherit (source) {name}: source is {}, not a set",
1810                                forced.type_name()
1811                            )))
1812                        }
1813                    };
1814                    attrs
1815                        .get(&name)
1816                        .cloned()
1817                        .ok_or_else(|| EvalError::AttrNotFound(name.to_string()))
1818                })();
1819                match attempt {
1820                    Ok(mut value) => {
1821                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Evaluated(Box::new(value.clone()));
1822                        while let Value::Thunk(ref inner) = value {
1823                            match inner.peek() { Some(c) => value = c.clone().into_value(), None => break }
1824                        }
1825                        unsafe { self.store_evaluated(&value) };
1826                        crate::trace::pop_force();
1827                        if tracing { crate::trace::trace_force_exit(); }
1828                        Ok(value)
1829                    }
1830                    Err(e) => {
1831                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::InheritSelect { source_thunk, name };
1832                        if tracing { crate::trace::dump_trace_on_error(); }
1833                        crate::trace::pop_force();
1834                        if tracing { crate::trace::trace_force_exit(); }
1835                        Err(e)
1836                    }
1837                }
1838            }
1839            ThunkRepr::Native(f) => {
1840                let tracing = crate::trace::trace_enabled();
1841                crate::trace::push_force(crate::trace::ForceFrame {
1842                    defined_in: None,
1843                    description: if tracing { "<native-thunk>".into() } else { String::new() },
1844                    thunk_id,
1845                });
1846                if tracing {
1847                    crate::trace::trace_force_enter(None, "<native-thunk>");
1848                }
1849                crate::trace::inc_thunks_forced_unique();
1850                // The closure is consumed (FnOnce).  On success we
1851                // memoize the result.  On failure we leave Blackhole
1852                // — unlike Suspended thunks the closure cannot be
1853                // retried because it has been consumed.
1854                match f() {
1855                    Ok(mut value) => {
1856                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Evaluated(Box::new(value.clone()));
1857                        while let Value::Thunk(ref inner) = value {
1858                            match inner.peek() { Some(c) => value = c.clone().into_value(), None => break }
1859                        }
1860                        unsafe { self.store_evaluated(&value) };
1861                        crate::trace::pop_force();
1862                        if tracing { crate::trace::trace_force_exit(); }
1863                        Ok(value)
1864                    }
1865                    Err(e) => {
1866                        // The `FnOnce` closure is consumed and cannot be
1867                        // retried.  Memoize the ERROR (not `Null`): a
1868                        // re-force must re-raise, never silently return a
1869                        // value the first force did not produce.  The old
1870                        // `Evaluated(Null)` here poisoned a flake-input
1871                        // thunk whose first force failed transiently
1872                        // (e.g. a not-yet-cached transitive source) so a
1873                        // later re-read saw `null` — surfacing as a bogus
1874                        // downstream `AttrNotFound` /
1875                        // `cannot select from set` (the stylix
1876                        // `darwinModules` marquee root).  Do NOT populate
1877                        // the OnceCell (there is no correct concrete value
1878                        // to cache); the `Failed` repr arm re-raises.
1879                        *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Failed(e.clone());
1880                        if tracing { crate::trace::dump_trace_on_error(); }
1881                        crate::trace::pop_force();
1882                        if tracing { crate::trace::trace_force_exit(); }
1883                        Err(e)
1884                    }
1885                }
1886            }
1887            ThunkRepr::WithIdent { name, scope_cache, scope_value, env } => {
1888                crate::perf::inc(crate::perf::Counter::ThunkForce);
1889                crate::trace::inc_thunks_forced_unique();
1890                // Fast path: check the shared with-scope cache.
1891                // All WithIdent thunks from the same `with` scope share
1892                // this cache. Once ANY lookup populates it, all others
1893                // are O(1) hash lookups.
1894                {
1895                    let cache = scope_cache.borrow();
1896                    if let Some(ref attrs) = *cache {
1897                        if let Some(v) = attrs.get(&name) {
1898                            let value = v.clone();
1899                            unsafe { self.store_evaluated(&value) };
1900                            return Ok(value);
1901                        }
1902                        // Name not in cached attrset — fall through to env lookup
1903                    }
1904                }
1905                // Cache not populated yet — force the scope value to populate it
1906                if let Ok(forced) = crate::eval::force_value(&scope_value) {
1907                    if let Value::Attrs(ref attrs) = forced {
1908                        *scope_cache.borrow_mut() = Some((**attrs).clone());
1909                        if let Some(v) = attrs.get(&name) {
1910                            let value = v.clone();
1911                            unsafe { self.store_evaluated(&value) };
1912                            return Ok(value);
1913                        }
1914                    }
1915                }
1916                // Name not in with-scope — fall back to full env lookup.
1917                //
1918                // The cache-first with-scope search may have skipped a scope
1919                // whose CACHE is a stale mid-fixpoint PARTIAL — e.g. `f self`
1920                // cached BEFORE makeScope's `self = f self // { callPackage = …; }`
1921                // merged the scope infra in, so `callPackage` is absent from
1922                // the stale partial yet present in the COMPLETED `self`. On any
1923                // lexical-scope miss (both inside and outside a Promise body),
1924                // re-resolve by force_value-ing each with-scope FRESH (bypassing
1925                // the cache) via `lookup_fresh`. It catches errors, so a
1926                // genuinely mid-fixpoint / throwing scope simply skips and
1927                // returns None — leaving the Promise-body null softening (below)
1928                // for the case where the with-source really IS the empty-attrset
1929                // sentinel. A completed value always wins over the null sentinel.
1930                //
1931                // This is the SAME class as the neovim/python27
1932                // `with self; with super; callPackage` root, but reached through
1933                // the resholve `python27' = (…).override { self = python27'; }`
1934                // recursive-fixpoint hooks scope, where the miss lands inside a
1935                // Promise body (`in_promise_eval()` true) and was previously
1936                // softened to `null` BEFORE `lookup_fresh` ran — silently
1937                // dropping `pip = callPackage …` (→ empty `propagatedBuildInputs`
1938                // on `pip-install-hook.drv`). Trying the completed-`self`
1939                // resolution first restores the drop.
1940                //
1941                // Byte-neutral: `lookup_fresh` only ever returns a value nix's
1942                // single lazy `self` would ALSO expose; when it misses (genuine
1943                // empty-partial sentinel) the softening / error behavior below is
1944                // exactly as before.
1945                let result = match env.lookup(&name) {
1946                    Some(v) => v,
1947                    None => match env.lookup_fresh(&name) {
1948                        Some(v) => v,
1949                        None if in_promise_eval() => Value::Null,
1950                        None => return Err(EvalError::UndefinedVar(format!("'{name}'"))),
1951                    },
1952                };
1953                unsafe { self.store_evaluated(&result) };
1954                Ok(result)
1955            }
1956            ThunkRepr::Blackhole => {
1957                // M2.6 bridge: when an inner force re-enters a thunk
1958                // that's currently being evaluated, cppnix's effective
1959                // behavior is to expose the not-yet-complete value
1960                // (typically a partial attrset).  Without the proper
1961                // `Promise(NixAttrs)` thunk variant (see
1962                // docs/M2.6-MODULE-SYSTEM-FIXPOINT.md::Genuine fix),
1963                // we approximate by returning a sentinel of the
1964                // operator's choice:
1965                //
1966                //   SUI_BLACKHOLE_AS_NULL=1         → Value::Null
1967                //   SUI_BLACKHOLE_AS_EMPTY_ATTRS=1  → Value::Attrs({})
1968                //   SUI_BLACKHOLE_AS_EMPTY_LIST=1   → Value::List([])
1969                //
1970                // `EMPTY_ATTRS` is the closest approximation for the
1971                // NixOS module-system fix-point because the cppnix
1972                // partial is itself an attrset — downstream
1973                // `mapAttrs`/`attrNames`/`concatMap` on the sentinel
1974                // see "no keys to map" rather than a type error.
1975                //
1976                // Default-off for all variants because each silently
1977                // hides legitimate cycles in user code (`let r = r;
1978                // in r.x` would return missing-attr or 0 instead of
1979                // erroring).
1980                if std::env::var_os("SUI_BLACKHOLE_AS_NULL").is_some() {
1981                    return Ok(Value::Null);
1982                }
1983                if std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_LIST").is_some() {
1984                    return Ok(Value::List(Rc::new(NixList::new(Vec::new()))));
1985                }
1986                if std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_ATTRS").is_some() {
1987                    return Ok(Value::Attrs(Rc::new(NixAttrs::new())));
1988                }
1989                if std::env::var_os("SUI_DEBUG_CYCLE").is_some() {
1990                    let same = crate::trace::force_stack_contains(thunk_id);
1991                    eprintln!(
1992                        "[SUI_DEBUG_CYCLE] blackhole re-entry thunk_id={thunk_id:#x} same_thunk_on_stack={same} recursive_flag={}",
1993                        self.0.recursive
1994                    );
1995                    crate::trace::dump_force_stack_ids();
1996                }
1997                // OVERLAY-FIXPOINT SEMANTIC PROMOTION (2026-07-10, default-ON).
1998                //
1999                // When the re-entered thunk is the SAME thunk currently
2000                // mid-evaluation on the force stack, this is a genuine fixpoint
2001                // self-reference — the nixpkgs `self:super:` overlay / `lib.fix`
2002                // pattern threading through `callPackage`/`self`/`super` across
2003                // file boundaries.  `is_self_recursive_binding` (syntactic RHS
2004                // name search) MISSES this because the binding's RHS never
2005                // textually names itself, so the thunk was classified
2006                // `recursive=false` and installed a hard `Blackhole` where nix
2007                // exposes the not-yet-complete value.  That misclassification is
2008                // exactly the byte-parity defect (`sui-spec/src/laziness.rs`
2009                // `RecursionKind::Fixpoint` ⇒ MUST be recursive + Promise): the
2010                // dropped perl `nativeBuildInput` on `pkgs.libxcrypt` (sui
2011                // q9b9v7a9… vs nix jb9k6090…).
2012                //
2013                // The FIX is the Blackhole↔Promise machinery, not a sentinel:
2014                // retroactively PROMOTE this Blackhole to a real `Promise(cell)`
2015                // and return the cell's in-progress partial.  Unlike the earlier
2016                // blank-empty-attrs sentinel (which left the thunk in Blackhole
2017                // forever and stack-overflowed `hello`), the promoted cell is a
2018                // first-class fixpoint cell:
2019                //   * the outer body populates it on completion (the
2020                //     `is_promise || became_promise` branch below), so any inner
2021                //     Rc clones that already read the empty partial converge, and
2022                //     the repr transitions cleanly to `Evaluated`;
2023                //   * `IN_PROMISE_EVAL` is bumped so downstream `eval_select`
2024                //     softens `AttrNotFound`/`cannot-select` on the partial to
2025                //     `null` (the `x.y or default` fall-through nix relies on),
2026                //     which is what stops the `hello` overflow.
2027                //
2028                // Genuine NON-terminating cycles (`let r = r; in r`) remain
2029                // errors: the promoted partial cannot make progress, so the
2030                // force-depth backstop (`check_force_depth`, ~2048/100 in
2031                // test/release) still fires `InfiniteRecursion` — nix's own
2032                // behaviour.  This is the semantic (fixpoint) classification the
2033                // typed discipline demands, done in the demand-order engine
2034                // instead of at syntactic construction time.
2035                if crate::trace::force_stack_contains(thunk_id)
2036                    && IN_PROMISE_EVAL.with(|c| c.get()) < FIXPOINT_PROMOTE_NEST_CAP
2037                {
2038                    if std::env::var_os("SUI_DEBUG_CYCLE").is_some() {
2039                        let chain = crate::trace::capture_cycle(thunk_id);
2040                        let nest = IN_PROMISE_EVAL.with(|c| c.get());
2041                        let fdepth = crate::trace::current_force_depth();
2042                        eprintln!("[SUI_PROMOTE] thunk_id={thunk_id:#x} cycle_len={} nest={nest} fdepth={fdepth}", chain.0.len());
2043                    }
2044                    let cell = Rc::new(RefCell::new(
2045                        Value::Attrs(Rc::new(NixAttrs::new())),
2046                    ));
2047                    // SAFETY: single-threaded evaluator; we hold no other borrow
2048                    // of `repr` here (the outer match consumed it, we replace it).
2049                    *unsafe { &mut *self.0.repr.get() } =
2050                        ThunkRepr::Promise(cell.clone());
2051                    // Enable Promise-body softening for the remainder of the
2052                    // outer force.  Decremented once by the outer force's
2053                    // post-body reconciliation (`became_promise`).
2054                    IN_PROMISE_EVAL.with(|c| c.set(c.get() + 1));
2055                    // Arm the release runaway backstop for the rest of the eval.
2056                    PROMOTION_OCCURRED.with(|c| c.set(true));
2057                    return Ok(cell.borrow().clone());
2058                }
2059                let chain = crate::trace::capture_cycle(thunk_id);
2060                crate::trace::dump_trace_on_error();
2061                Err(EvalError::InfiniteRecursion(chain.to_string()))
2062            }
2063            ThunkRepr::Promise(cell) => {
2064                // Inner re-entrance into a self-recursive thunk that's
2065                // currently being evaluated.  Return the partial value
2066                // the body has constructed so far (the cell starts as
2067                // `Value::Attrs(empty)` and gets updated on body return).
2068                // This is sui's cppnix-equivalent for `let x = f x; in x`
2069                // — the inner reference to `x` during f's evaluation
2070                // sees a partial attrset instead of the original cycle's
2071                // `InfiniteRecursion`.
2072                Ok(cell.borrow().clone())
2073            }
2074            ThunkRepr::Evaluated(v) => {
2075                // Reached when OnceCell wasn't populated (value was a thunk
2076                // when first evaluated). Cache only concrete values — caching
2077                // a thunk would cause force_value's loop to spin.
2078                crate::perf::inc(crate::perf::Counter::ThunkHit);
2079                let cloned = (*v).clone();
2080                if !matches!(cloned, Value::Thunk(_)) {
2081                    if !matches!(cloned, Value::Thunk(_)) { let _ = self.0.cache.set(Box::new(cloned.clone().demand_unchecked())); }
2082                }
2083                *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Evaluated(v);
2084                Ok(cloned)
2085            }
2086            ThunkRepr::EvaluatedConcrete => {
2087                // The concrete value lives in `cache`; the repr is a valueless
2088                // marker (the collapsed former double-store). In practice this
2089                // arm is unreachable: `force`/`force_inner` check the `cache`
2090                // fast path BEFORE the `mem::replace` that consumes the repr,
2091                // and `EvaluatedConcrete` always co-occurs with a populated
2092                // cache — so the fast path returns first. Handle it faithfully
2093                // anyway: reconstruct the `Value` from the cache (a byte-
2094                // identical inverse of `demand_unchecked`) and restore the
2095                // marker (the outer `mem::replace` swapped in Blackhole/Promise).
2096                crate::perf::inc(crate::perf::Counter::ThunkHit);
2097                let value = self
2098                    .0
2099                    .cache
2100                    .get()
2101                    .expect("EvaluatedConcrete implies a populated cache")
2102                    .as_ref()
2103                    .clone()
2104                    .into_value();
2105                *unsafe { &mut *self.0.repr.get() } = ThunkRepr::EvaluatedConcrete;
2106                Ok(value)
2107            }
2108            ThunkRepr::Failed(e) => {
2109                // A previously-forced `Native` thunk whose closure threw.
2110                // Re-raise the memoized error — never fall through to a
2111                // silent value.  Restore the repr (the outer
2112                // `mem::replace` swapped in Blackhole/Promise).
2113                let err = e.clone();
2114                *unsafe { &mut *self.0.repr.get() } = ThunkRepr::Failed(e);
2115                Err(err)
2116            }
2117        }
2118    }
2119}
2120
2121impl fmt::Debug for Thunk {
2122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2123        // SAFETY: Single-threaded evaluator, read-only access during formatting.
2124        match unsafe { &*self.0.repr.get() } {
2125            ThunkRepr::Suspended { .. } => write!(f, "<thunk>"),
2126            ThunkRepr::InheritSelect { name, .. } => write!(f, "<inherit-select {name}>"),
2127            ThunkRepr::Native(_) => write!(f, "<native-thunk>"),
2128            ThunkRepr::WithIdent { name, .. } => write!(f, "<with-ident {name}>"),
2129            ThunkRepr::Blackhole => write!(f, "<blackhole>"),
2130            ThunkRepr::Promise(_) => write!(f, "<promise>"),
2131            ThunkRepr::Failed(e) => write!(f, "<failed-thunk: {e}>"),
2132            ThunkRepr::Evaluated(v) => write!(f, "{v:?}"),
2133            ThunkRepr::EvaluatedConcrete => match self.0.cache.get() {
2134                Some(c) => write!(f, "{:?}", c.as_ref().clone().into_value()),
2135                None => write!(f, "<evaluated-concrete>"),
2136            },
2137        }
2138    }
2139}
2140
2141/// A Nix attribute set with lazy overlay support.
2142///
2143/// Internally uses either a concrete compact `AttrsMap` or a lazy overlay chain.
2144/// The `//` operator creates O(1) overlay nodes instead of O(m log n) merges.
2145/// Attribute access walks the chain right-to-left in O(depth).
2146/// Full iteration (attrNames, attrValues) flattens on demand.
2147///
2148/// The second tuple field is an OPTIONAL source-position table (`None` for
2149/// the vast majority of attrsets — merges, overlays, builtin-built, dynamic
2150/// keys). `eval_attrset` attaches it for a literal with static keys so
2151/// `builtins.unsafeGetAttrPos` can report a key's file/line/column (the
2152/// `attrTag` `declarations` — options.json dock root). It is behind `Rc`, so
2153/// a clone is a refcount bump; `None` costs one pointer-sized word.
2154pub struct NixAttrs(AttrsInner, Option<Rc<crate::pos::AttrPositions>>);
2155
2156// Hand-written `Clone`/`Drop` so the census counts every NixAttrs value that
2157// comes into existence (a clone is a fresh heap object once Rc-wrapped),
2158// keeping `ATTRS_MADE`/`ATTRS_LIVE` consistent. Fresh (non-clone)
2159// constructions bump the counter at each `NixAttrs(...)` tuple-construct site.
2160impl Clone for NixAttrs {
2161    fn clone(&self) -> Self {
2162        census::made(&census::ATTRS_MADE, &census::ATTRS_LIVE);
2163        NixAttrs(self.0.clone(), self.1.clone())
2164    }
2165}
2166
2167impl Drop for NixAttrs {
2168    fn drop(&mut self) {
2169        census::dropped(&census::ATTRS_LIVE);
2170    }
2171}
2172
2173/// Internal representation: either a flat map or an overlay chain.
2174#[derive(Clone)]
2175enum AttrsInner {
2176    /// Concrete attribute set — compact flat `AttrsMap` (std hashbrown).
2177    Flat(AttrsMap<Symbol, Value>),
2178    /// Lazy overlay: right overrides left. O(1) construction.
2179    /// `cache` is populated on first full iteration (attrNames, etc.).
2180    ///
2181    /// `left`/`right` are interior-mutable so they can be RELEASED (swapped to an
2182    /// empty attrs) once `cache` is populated: after flatten the merged `cache`
2183    /// is the complete answer and every reader (`get_sym`/`contains_key`/
2184    /// `is_empty`) routes through `as_flat()` (the cache), so the un-merged
2185    /// parents are dead weight. Releasing them cascade-frees the intermediate
2186    /// overlay chain (the 50+-deep module-fixpoint retention — `EVAL-MEMORY.md`).
2187    /// Byte-neutral: the cache is the same map nix's flatten yields.
2188    Overlay {
2189        left: RefCell<Rc<NixAttrs>>,
2190        right: RefCell<Rc<NixAttrs>>,
2191        cache: Rc<OnceCell<AttrsMap<Symbol, Value>>>,
2192    },
2193}
2194
2195impl fmt::Debug for NixAttrs {
2196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2197        write!(f, "NixAttrs({})", self.len())
2198    }
2199}
2200
2201impl Default for NixAttrs {
2202    fn default() -> Self {
2203        census::made(&census::ATTRS_MADE, &census::ATTRS_LIVE);
2204        Self(AttrsInner::Flat(AttrsMap::default()), None)
2205    }
2206}
2207
2208impl NixAttrs {
2209    pub fn new() -> Self {
2210        Self::default()
2211    }
2212
2213    pub fn with_capacity(_capacity: usize) -> Self {
2214        Self::default()
2215    }
2216
2217    /// Attach a source-position table (the static keys' byte offsets of the
2218    /// literal that built this attrset). Called by `eval_attrset`; consumed
2219    /// by `builtins.unsafeGetAttrPos`. Never affects any observed value.
2220    pub fn set_positions(&mut self, pos: Rc<crate::pos::AttrPositions>) {
2221        self.1 = Some(pos);
2222    }
2223
2224    /// The source-position table, if this attrset carries one (a literal with
2225    /// static keys). `None` for merges/overlays/builtin-built/dynamic-key
2226    /// attrsets.
2227    #[must_use]
2228    pub fn positions(&self) -> Option<&Rc<crate::pos::AttrPositions>> {
2229        self.1.as_ref()
2230    }
2231
2232    /// Resolve the source position of `key` in this attrset — the file/line/
2233    /// column `builtins.unsafeGetAttrPos` returns. `None` when the attrset
2234    /// has no position table, the key is absent from it, or the source has
2235    /// no file (a `<string>`-eval'd literal).
2236    #[must_use]
2237    pub fn pos_for(&self, key: &str) -> Option<crate::pos::ResolvedPos> {
2238        let sym = intern(key);
2239        let (file, offset) = self.pos_entry(sym)?;
2240        crate::pos::resolve(file.as_deref(), offset)
2241    }
2242
2243    /// Find `sym`'s (file, offset) — walking an `//` overlay RIGHT first, then
2244    /// LEFT, so a key's reported position follows the same precedence `//`
2245    /// itself gives the key's VALUE.
2246    ///
2247    /// Why this walks instead of reading `self.1`: `overlay` builds the
2248    /// `AttrsInner::Overlay` node with an empty position slot (it is O(1) and
2249    /// lazy by construction — eagerly merging two tables on every `//` would
2250    /// cost on a very hot path). Reading only the slot therefore reported
2251    /// `null` for EVERY key of every `//` result.
2252    ///
2253    /// That was not cosmetic. nixpkgs' `lib.nixosSystem` ends in
2254    /// `{ …; modules = …; } // removeAttrs args [ "modules" ]`, and
2255    /// `nixos/lib/eval-config.nix:28` derives `modulesLocation` from
2256    /// `unsafeGetAttrPos "modules"` on exactly that attrset. A `null` there
2257    /// skips `setDefaultModuleLocation`, which skips wrapping every user
2258    /// module in `{ _file; imports = [ m ]; }` — and since `collectModules`
2259    /// walks breadth-first via `genericClosure`, the missing wrapper leaves
2260    /// each user module one level SHALLOWER than CppNix puts it, permuting
2261    /// NixOS option definition order and diverging the toplevel drvPath.
2262    fn pos_entry(&self, sym: Symbol) -> Option<(Option<std::path::PathBuf>, u32)> {
2263        if let Some(table) = self.1.as_ref() {
2264            if let Some(offset) = table.keys.get(&sym) {
2265                return Some((table.file.clone(), *offset));
2266            }
2267        }
2268        match &self.0 {
2269            AttrsInner::Overlay { left, right, .. } => {
2270                let r = right.borrow().pos_entry(sym);
2271                if r.is_some() {
2272                    return r;
2273                }
2274                let l = left.borrow().pos_entry(sym);
2275                l
2276            }
2277            _ => None,
2278        }
2279    }
2280
2281    /// Borrow the underlying map. Flattens if overlay.
2282    #[must_use]
2283    pub fn inner(&self) -> AttrsMap<Symbol, Value> {
2284        self.as_flat().clone()
2285    }
2286
2287    /// Get a reference to a flat `AttrsMap`, populating cache if overlay.
2288    fn as_flat(&self) -> &AttrsMap<Symbol, Value> {
2289        match &self.0 {
2290            AttrsInner::Flat(m) => m,
2291            AttrsInner::Overlay { left, right, cache } => {
2292                crate::perf::inc(crate::perf::Counter::OverlayFlattenAttempt);
2293                let flat = cache.get_or_init(|| {
2294                    // Cache MISS: this Overlay node is being flattened for the
2295                    // first time — real O(left+right) merge work.
2296                    crate::perf::inc(crate::perf::Counter::OverlayFlattenBuild);
2297                    let timed = crate::perf::enabled();
2298                    let t0 = if timed { Some(std::time::Instant::now()) } else { None };
2299                    let mut result = left.borrow().as_flat().clone();
2300                    for (k, v) in right.borrow().as_flat().iter() {
2301                        result.insert(*k, v.clone());
2302                    }
2303                    crate::perf::add(
2304                        crate::perf::Counter::OverlayFlattenEntries,
2305                        result.len() as u64,
2306                    );
2307                    if let Some(t0) = t0 {
2308                        crate::trace::add_overlay_flatten_nanos(t0.elapsed().as_nanos());
2309                    }
2310                    result
2311                });
2312                // RELEASE the parents now that `cache` is the complete answer —
2313                // cascade-frees the intermediate overlay chain + their caches
2314                // (nothing else references them). Byte-neutral: every reader now
2315                // routes through this `cache`. Only swaps a still-held parent; a
2316                // second as_flat sees them already empty and skips. The closure's
2317                // borrows above are dropped by here, so these borrow_muts can't
2318                // conflict (single-threaded, sequential).
2319                // Release VALUES but keep the POSITION SKELETON. Swapping in a
2320                // bare `NixAttrs::new()` also discarded every `AttrPositions`
2321                // table in the released subtree, so `pos_entry`'s overlay walk
2322                // found nothing and `unsafeGetAttrPos` returned null for any
2323                // `//` result that had been TOUCHED — measured:
2324                //   fresh overlay        nix line 1, sui line 1
2325                //   after one attr read  nix line 1, sui NULL
2326                // which is every real use, since nixpkgs reads from an attrset
2327                // before anyone asks for a position. `position_husk` keeps the
2328                // same tree shape and the position tables (small, and only
2329                // present on literals) while dropping the values, so the
2330                // cascade-free still reclaims the expensive part.
2331                {
2332                    let mut l = left.borrow_mut();
2333                    if !l.is_empty() { *l = Rc::new(l.position_husk()); }
2334                }
2335                {
2336                    let mut r = right.borrow_mut();
2337                    if !r.is_empty() { *r = Rc::new(r.position_husk()); }
2338                }
2339                flat
2340            }
2341        }
2342    }
2343
2344    /// A value-free copy carrying only what `pos_entry` reads: this node's own
2345    /// position table and, for an overlay, the same shape recursively.
2346    ///
2347    /// Used when `as_flat` releases a flattened overlay's parents. The values
2348    /// are what cost memory; the `AttrPositions` tables are small and exist
2349    /// only on attrset LITERALS with static keys, so keeping the skeleton
2350    /// preserves `unsafeGetAttrPos` at negligible cost. Returns an empty
2351    /// position-less set when the subtree carries no positions at all, so the
2352    /// common case allocates no more than the old `NixAttrs::new()` did.
2353    fn position_husk(&self) -> NixAttrs {
2354        match &self.0 {
2355            AttrsInner::Overlay { left, right, .. } => {
2356                let (l, r) = (left.borrow().position_husk(), right.borrow().position_husk());
2357                if l.1.is_none() && r.1.is_none() && !matches!(l.0, AttrsInner::Overlay { .. })
2358                    && !matches!(r.0, AttrsInner::Overlay { .. })
2359                {
2360                    // Nothing below carries a position — collapse to the cheap
2361                    // empty set rather than rebuilding a pointless spine.
2362                    return NixAttrs(AttrsInner::Flat(AttrsMap::default()), self.1.clone());
2363                }
2364                NixAttrs(
2365                    AttrsInner::Overlay {
2366                        left: RefCell::new(Rc::new(l)),
2367                        right: RefCell::new(Rc::new(r)),
2368                        cache: Rc::new(OnceCell::new()),
2369                    },
2370                    self.1.clone(),
2371                )
2372            }
2373            AttrsInner::Flat(_) => NixAttrs(AttrsInner::Flat(AttrsMap::default()), self.1.clone()),
2374        }
2375    }
2376
2377    fn sorted_entries(&self) -> Vec<(String, &Value)> {
2378        crate::perf::inc(crate::perf::Counter::SortedEntriesCalls);
2379        let m = self.as_flat();
2380        crate::perf::add(crate::perf::Counter::SortedEntriesRows, m.len() as u64);
2381        let timed = crate::perf::enabled();
2382        let t0 = if timed { Some(std::time::Instant::now()) } else { None };
2383        let mut pairs: Vec<(String, &Value)> = m.iter()
2384            .map(|(sym, v)| (resolve(*sym), v))
2385            .collect();
2386        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
2387        if let Some(t0) = t0 {
2388            crate::trace::add_sorted_entries_nanos(t0.elapsed().as_nanos());
2389        }
2390        pairs
2391    }
2392
2393    /// Look up an attribute by name. Walks overlay chain right-to-left.
2394    #[must_use]
2395    pub fn get(&self, key: &str) -> Option<&Value> {
2396        let sym = intern(key);
2397        self.get_sym(&sym)
2398    }
2399
2400    /// Look up by pre-interned Symbol.
2401    ///
2402    /// Fast path: if the overlay's flat cache has been populated (by any
2403    /// prior full iteration — `attrNames`, `attrValues`, `//` merge that
2404    /// needed key enumeration, etc.), read directly from it in O(1). This
2405    /// matters in real Nix workloads where an attrset is first iterated
2406    /// (module eval, `with` desugaring) and then hit many times by dotted
2407    /// access — CppNix has no such structure and pays O(1) always; we want
2408    /// to match that whenever the cache is warm.
2409    ///
2410    /// Slow path: walk the overlay chain right-to-left in O(depth). Not
2411    /// populating the cache on cold lookups is deliberate — the cache
2412    /// costs O(n) to build and the chain is usually short (1–3 overlays).
2413    #[must_use]
2414    pub fn get_sym(&self, sym: &Symbol) -> Option<&Value> {
2415        match &self.0 {
2416            AttrsInner::Flat(m) => m.get(sym),
2417            // Route through `as_flat()` (the memoized cache) rather than borrowing
2418            // into `left`/`right` — this is what lets the parents be released
2419            // post-flatten. `as_flat` returns the cached map in O(1) when warm and
2420            // flattens+caches on the first cold lookup; the returned `&Value`
2421            // borrows the stable `cache`, never a `RefCell`.
2422            AttrsInner::Overlay { .. } => self.as_flat().get(sym),
2423        }
2424    }
2425
2426    /// Insert or overwrite an attribute. Flattens overlay if needed.
2427    pub fn insert(&mut self, key: String, value: Value) {
2428        self.ensure_flat();
2429        if let AttrsInner::Flat(ref mut m) = self.0 {
2430            m.insert(intern(&key), value);
2431        }
2432    }
2433
2434    /// Ensure the inner representation is Flat (for mutation).
2435    fn ensure_flat(&mut self) {
2436        if matches!(self.0, AttrsInner::Overlay { .. }) {
2437            self.0 = AttrsInner::Flat(self.as_flat().clone());
2438        }
2439    }
2440
2441    #[must_use]
2442    pub fn contains_key(&self, key: &str) -> bool {
2443        let sym = intern(key);
2444        self.contains_key_sym(&sym)
2445    }
2446
2447    #[must_use]
2448    pub fn contains_key_sym(&self, sym: &Symbol) -> bool {
2449        match &self.0 {
2450            AttrsInner::Flat(m) => m.contains_key(sym),
2451            // Route through the cache (see get_sym) so left/right stay releasable.
2452            AttrsInner::Overlay { .. } => self.as_flat().contains_key(sym),
2453        }
2454    }
2455
2456    pub fn keys(&self) -> impl Iterator<Item = String> {
2457        self.sorted_entries().into_iter().map(|(k, _)| k)
2458    }
2459
2460    pub fn iter(&self) -> impl Iterator<Item = (String, &Value)> {
2461        self.sorted_entries().into_iter()
2462    }
2463
2464    pub fn iter_unsorted(&self) -> impl Iterator<Item = (String, &Value)> {
2465        self.as_flat().iter().map(|(sym, v)| (resolve(*sym), v)).collect::<Vec<_>>().into_iter()
2466    }
2467
2468    /// Sym-keyed unsorted iteration — ZERO interner traffic, zero allocation.
2469    ///
2470    /// This exists because live-sampling the cid marquee eval (2026-07-21,
2471    /// release-profiling binary) showed the **interner round-trip as the #1 CPU
2472    /// sink — 27–39% of the eval thread, sustained**: `iter_unsorted` above
2473    /// materializes a fresh heap `String` per key via `resolve` AND collects
2474    /// the whole map into a `Vec` on every call, and callers like
2475    /// `intersectAttrs` then re-intern each of those Strings straight back to
2476    /// the `Symbol` they started as (`contains_key(&str)` → `intern`), with a
2477    /// third intern inside `insert`. Sym→String→hash+memcmp→Sym, three times
2478    /// per key per call, at nixpkgs scale.
2479    ///
2480    /// `Symbol` is `Copy(u32)` and `as_flat()` hands back a real borrow (the
2481    /// Overlay case populates its cache), so this iterator borrows instead of
2482    /// collecting. Byte-neutral by the same argument already sealed for the
2483    /// unsorted-iteration change: the observable order of any *result* attrset
2484    /// is re-derived at observation time via `sorted_entries`.
2485    pub fn iter_syms(&self) -> impl Iterator<Item = (Symbol, &Value)> {
2486        self.as_flat().iter().map(|(sym, v)| (*sym, v))
2487    }
2488
2489    /// Sym-keyed insert — the zero-intern sibling of `insert`, for callers
2490    /// that already hold the `Symbol` (every `iter_syms` consumer).
2491    pub fn insert_sym(&mut self, sym: Symbol, value: Value) {
2492        self.ensure_flat();
2493        if let AttrsInner::Flat(ref mut m) = self.0 {
2494            m.insert(sym, value);
2495        }
2496    }
2497
2498    pub fn values(&self) -> impl Iterator<Item = &Value> {
2499        self.sorted_entries().into_iter().map(|(_, v)| v)
2500    }
2501
2502
2503    pub fn remove(&mut self, key: &str) -> Option<Value> {
2504        self.ensure_flat();
2505        if let AttrsInner::Flat(ref mut m) = self.0 {
2506            m.remove(&intern(key))
2507        } else {
2508            None
2509        }
2510    }
2511
2512    #[must_use]
2513    pub fn len(&self) -> usize {
2514        match &self.0 {
2515            AttrsInner::Flat(m) => m.len(),
2516            AttrsInner::Overlay { .. } => {
2517                // Must flatten to count unique keys, but `as_flat()` already
2518                // returns a borrow into the memoized map — cloning it just to
2519                // read `.len()` was pure O(n) waste on every overlay `len()`.
2520                self.as_flat().len()
2521            }
2522        }
2523    }
2524
2525    #[must_use]
2526    pub fn is_empty(&self) -> bool {
2527        match &self.0 {
2528            AttrsInner::Flat(m) => m.is_empty(),
2529            // Cache-first (see get_sym): a released-parent overlay is NOT empty —
2530            // its content lives in the flattened cache. Reading left/right here
2531            // (which post-release are empty) would wrongly report empty.
2532            AttrsInner::Overlay { .. } => self.as_flat().is_empty(),
2533        }
2534    }
2535
2536    /// O(1) lazy overlay: `self // other`. Does NOT merge eagerly.
2537    #[must_use]
2538    pub fn overlay(self, other: NixAttrs) -> NixAttrs {
2539        if other.is_empty() { return self; }
2540        if self.is_empty() { return other; }
2541        crate::perf::inc(crate::perf::Counter::OverlayCreated);
2542        census::made(&census::ATTRS_MADE, &census::ATTRS_LIVE);
2543        NixAttrs(AttrsInner::Overlay {
2544            left: RefCell::new(Rc::new(self)),
2545            right: RefCell::new(Rc::new(other)),
2546            cache: Rc::new(OnceCell::new()),
2547        }, None)
2548    }
2549
2550    /// Eager merge (legacy API — prefer `overlay` for `//`).
2551    #[must_use]
2552    pub fn update(&self, other: &NixAttrs) -> NixAttrs {
2553        match (&self.0, &other.0) {
2554            (AttrsInner::Flat(l), AttrsInner::Flat(r)) => {
2555                let mut result = l.clone();
2556                for (k, v) in r.iter() {
2557                    result.insert(*k, v.clone());
2558                }
2559                census::made(&census::ATTRS_MADE, &census::ATTRS_LIVE);
2560                NixAttrs(AttrsInner::Flat(result), None)
2561            }
2562            _ => {
2563                // For overlay inputs, flatten then merge
2564                let mut result = self.as_flat().clone();
2565                let other_flat = other.as_flat();
2566                for (k, v) in other_flat.iter() {
2567                    result.insert(*k, v.clone());
2568                }
2569                census::made(&census::ATTRS_MADE, &census::ATTRS_LIVE);
2570                NixAttrs(AttrsInner::Flat(result), None)
2571            }
2572        }
2573    }
2574}
2575
2576impl FromIterator<(String, Value)> for NixAttrs {
2577    fn from_iter<I: IntoIterator<Item = (String, Value)>>(iter: I) -> Self {
2578        census::made(&census::ATTRS_MADE, &census::ATTRS_LIVE);
2579        NixAttrs(AttrsInner::Flat(iter.into_iter().map(|(k, v)| (intern(&k), v)).collect()), None)
2580    }
2581}
2582
2583impl IntoIterator for NixAttrs {
2584    type Item = (String, Value);
2585    type IntoIter = Box<dyn Iterator<Item = (String, Value)>>;
2586
2587    fn into_iter(self) -> Self::IntoIter {
2588        let flat = self.as_flat().clone();
2589        Box::new(flat.into_iter().map(|(sym, v)| (resolve(sym), v)))
2590    }
2591}
2592
2593/// A closure — lambda + captured environment.
2594///
2595/// Stores rnix AST nodes so we can re-evaluate the body in the captured env.
2596///
2597/// The environment is `Rc`-wrapped so that cloning a closure (e.g., once per
2598/// element in `map`/`filter`) is a refcount bump instead of a deep copy of the
2599/// entire binding map.
2600#[derive(Debug, Clone)]
2601pub struct Closure {
2602    pub param: rnix::ast::Param,
2603    pub body: rnix::ast::Expr,
2604    pub env: Env,
2605}
2606
2607/// The function signature stored inside a [`BuiltinFn`].
2608pub type BuiltinFunc = dyn Fn(&[Value]) -> Result<Value, EvalError>;
2609
2610/// A builtin function.
2611///
2612/// Not `Send`/`Sync` because `Value` contains rnix AST nodes (rowan `SyntaxNode`)
2613/// which use `NonNull` internally. The evaluator is single-threaded.
2614#[derive(Clone)]
2615pub struct BuiltinFn {
2616    /// Name used for display and debug printing.
2617    pub name: &'static str,
2618    /// The implementation closure.
2619    pub func: Rc<BuiltinFunc>,
2620}
2621
2622impl fmt::Debug for BuiltinFn {
2623    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2624        write!(f, "<builtin {}>", self.name)
2625    }
2626}
2627
2628/// A `with` scope with optional cached forced attrset.
2629///
2630/// On first lookup, the scope value is forced and the resulting attrset
2631/// is cached.  Subsequent lookups skip forcing entirely.
2632///
2633/// The cache is wrapped in `Rc<RefCell<…>>` so that child environments
2634/// (which clone the `Vec<WithScope>`) share the same cache cell —
2635/// once any environment forces a scope, every related environment
2636/// benefits.
2637#[derive(Clone)]
2638struct WithScope {
2639    value: Value,
2640    /// Cached forced attrset.  Shared via Rc so child environments
2641    /// benefit from a parent having already forced the scope.
2642    cached: Rc<RefCell<Option<NixAttrs>>>,
2643}
2644
2645impl fmt::Debug for WithScope {
2646    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2647        f.debug_struct("WithScope")
2648            .field("value", &self.value)
2649            .field("cached", &self.cached.borrow().is_some())
2650            .finish()
2651    }
2652}
2653
2654/// Inner data for an evaluation environment.
2655///
2656/// Wrapped in `Rc` by [`Env`] so that cloning an `Env` is always a
2657/// refcount bump — never a deep copy of the binding map.
2658///
2659/// Uses a flattened `FxHashMap` for bindings: `child()` clones
2660/// the parent's map with O(1) structural sharing instead of building
2661/// a linked parent chain. Lookups are a single O(log32 n) probe
2662/// instead of walking a chain.
2663#[derive(Debug, Clone, Default)]
2664struct EnvInner {
2665    bindings: FxHashMap<Symbol, Value>,
2666    /// Dynamic `with` scopes, innermost last.
2667    with_scopes: Vec<WithScope>,
2668    /// Source file currently being evaluated, for relative path
2669    /// literals (`./foo.nix`) inside function defaults that get
2670    /// evaluated *after* control has left the file scope.
2671    eval_file: Option<std::path::PathBuf>,
2672    /// The `source_id` of the parse tree this env belongs to. Restored
2673    /// on thunk force (in lockstep with `eval_file`) so a lazily-forced
2674    /// thunk's idents key `IDENT_CACHE` against the file where the thunk
2675    /// was DEFINED, not the ambient source at force time. Without this, a
2676    /// cross-file force collides on `(source_id, text_offset)` and returns
2677    /// a wrong Symbol (the `parse.nix` `cannot select from null` bug).
2678    source_id: u32,
2679}
2680
2681/// Evaluation environment — flattened binding map with structural sharing.
2682///
2683/// Internally an `Rc<EnvInner>`, so cloning is always O(1) (refcount
2684/// bump).  `child()` clones the `FxHashMap` (O(1) structural
2685/// sharing) instead of building a parent chain.  `bind()` uses
2686/// `Rc::make_mut` for copy-on-write: if the Rc is shared, only then
2687/// does it clone the inner data.
2688#[derive(Clone, Default)]
2689pub struct Env(Rc<EnvInner>);
2690
2691impl fmt::Debug for Env {
2692    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2693        self.0.fmt(f)
2694    }
2695}
2696
2697/// Decrements `ENV_LIVE` so the census reports a live COUNT rather than a
2698/// monotonic total. One relaxed atomic, and only when `SUI_LIVE_CENSUS=1` —
2699/// `census::dropped` checks `enabled()` first.
2700///
2701/// `EnvInner` derives `Clone`, so a clone is a NEW allocation and must be
2702/// counted; `Env` itself is `Rc`-cloned and must not be. That is why the
2703/// increments sit in `Env::new`/`Env::child` (the two `Rc::new(EnvInner …)`
2704/// sites) rather than in a `Clone` impl.
2705impl Drop for EnvInner {
2706    fn drop(&mut self) {
2707        census::dropped(&census::ENV_LIVE);
2708    }
2709}
2710
2711impl Env {
2712    /// Create a root environment with no bindings.
2713    #[must_use]
2714    pub fn new() -> Self {
2715        census::made(&census::ENV_MADE, &census::ENV_LIVE);
2716        Self(Rc::new(EnvInner {
2717            bindings: FxHashMap::default(),
2718            with_scopes: Vec::new(),
2719            eval_file: None,
2720            source_id: 0,
2721        }))
2722    }
2723
2724    /// Create a child environment that inherits from this one.
2725    ///
2726    /// O(1) — the `FxHashMap` clone is structural sharing (refcount
2727    /// bump on internal tree nodes), not a deep copy.
2728    #[must_use]
2729    pub fn child(&self) -> Self {
2730        crate::perf::inc(crate::perf::Counter::EnvClone);
2731        // `ENV_MADE`/`ENV_LIVE` existed but nothing ever incremented them, so
2732        // `census dump` reported `env_live=0 env_made=0` — and `Env` is the
2733        // leading suspect for sui's 12.3x footprint over CppNix, since every
2734        // suspended thunk holds one and nixpkgs makes them constantly. The one
2735        // structure most worth counting was the one the census could not see.
2736        census::made(&census::ENV_MADE, &census::ENV_LIVE);
2737        Self(Rc::new(EnvInner {
2738            bindings: self.0.bindings.clone(), // O(1) structural sharing
2739            with_scopes: self.0.with_scopes.clone(),
2740            // Children inherit the parent's eval file so that
2741            // path literals nested deep in let-chains still
2742            // resolve against the right directory.
2743            eval_file: self.0.eval_file.clone(),
2744            // Children inherit the parent's source_id — a child scope is
2745            // in the same parse tree as its parent (a new source_id only
2746            // arises on `eval_with_file` for an imported file).
2747            source_id: self.0.source_id,
2748        }))
2749    }
2750
2751    /// Attach a `with` scope to this environment.
2752    ///
2753    /// If the value is a thunk that's ALREADY evaluated (OnceCell cache hit),
2754    /// pre-populate the with-scope cache immediately. This avoids creating
2755    /// deferred WithIdent thunks when the fixpoint is already resolved —
2756    /// critical for the overlay chain where multiple stages access the same
2757    /// fixpoint through different `with self;` scopes.
2758    #[must_use]
2759    pub fn with_scope(mut self, value: Value) -> Self {
2760        // Pre-populate cache if the value is already resolved
2761        let pre_cached = match &value {
2762            Value::Attrs(attrs) => Some((**attrs).clone()),
2763            Value::Thunk(thunk) => thunk.peek().and_then(|v| {
2764                if let Concrete::Attrs(attrs) = v { Some((**attrs).clone()) } else { None }
2765            }),
2766            _ => None,
2767        };
2768        Rc::make_mut(&mut self.0).with_scopes.push(WithScope {
2769            value,
2770            cached: Rc::new(RefCell::new(pre_cached)),
2771        });
2772        self
2773    }
2774
2775    /// Bind a name to a value in this environment's own scope.
2776    ///
2777    /// Uses copy-on-write: if the inner `Rc` is shared, clones the
2778    /// inner data before mutating.
2779    pub fn bind(&mut self, name: String, value: Value) {
2780        Rc::make_mut(&mut self.0).bindings.insert(intern(&name), value);
2781    }
2782
2783    /// Bind many names in ONE copy-on-write step: a single `Rc::make_mut` on the
2784    /// inner env, then N inserts on the owned map — instead of N successive
2785    /// `bind()` calls each re-borrowing + re-`make_mut`-ing `self.0`.
2786    ///
2787    /// Byte-identical to calling [`bind`](Self::bind) once per pair in the same
2788    /// order (same `intern`, same insert sequence, same final HAMT) — a byte-SAFE
2789    /// `RedundantWrite`-class optimization: it removes intermediate re-borrows,
2790    /// not any observable value. Consumed by pattern-lambda binding (`bind_param`),
2791    /// where an N-formal pattern otherwise pays N `make_mut` refcount checks.
2792    pub fn bind_many(&mut self, pairs: impl IntoIterator<Item = (String, Value)>) {
2793        let inner = Rc::make_mut(&mut self.0);
2794        for (name, value) in pairs {
2795            inner.bindings.insert(intern(&name), value);
2796        }
2797    }
2798
2799    /// Get the eval_file for this environment.
2800    #[must_use]
2801    pub fn eval_file(&self) -> Option<&std::path::PathBuf> {
2802        self.0.eval_file.as_ref()
2803    }
2804
2805    /// Set the eval_file for this environment.
2806    pub fn set_eval_file(&mut self, file: Option<std::path::PathBuf>) {
2807        Rc::make_mut(&mut self.0).eval_file = file;
2808    }
2809
2810    /// The `source_id` of the parse tree this env belongs to (0 = top level).
2811    #[must_use]
2812    pub fn source_id(&self) -> u32 {
2813        self.0.source_id
2814    }
2815
2816    /// Set the `source_id` for this environment (called by `eval_with_file`
2817    /// for an imported parse tree).
2818    pub fn set_source_id(&mut self, id: u32) {
2819        Rc::make_mut(&mut self.0).source_id = id;
2820    }
2821
2822    /// Number of direct bindings in this environment (debug).
2823    #[must_use]
2824    pub fn binding_count(&self) -> usize {
2825        self.0.bindings.len()
2826    }
2827
2828    /// First N binding names (debug).
2829    #[must_use]
2830    pub fn binding_names_preview(&self, n: usize) -> Vec<String> {
2831        self.0.bindings.keys().take(n).map(|s| resolve(*s)).collect()
2832    }
2833
2834    /// Number of `with` scopes (debug).
2835    #[must_use]
2836    pub fn with_scope_count(&self) -> usize {
2837        self.0.with_scopes.len()
2838    }
2839
2840    /// Lookup in LEXICAL scope only (no with-scopes).
2841    /// Used by maybe_thunk to avoid forcing with-scope fixpoints during
2842    /// attrset construction.
2843    #[must_use]
2844    pub fn lookup_lexical(&self, name: &str) -> Option<Value> {
2845        let sym = intern(name);
2846        self.0.bindings.get(&sym).cloned()
2847    }
2848
2849    /// Lookup in LEXICAL scope only, by pre-interned [`Symbol`] — the
2850    /// Symbol-keyed sibling of [`lookup_lexical`](Self::lookup_lexical).
2851    ///
2852    /// Probes ONLY the lexical `bindings` map (the first thing
2853    /// [`lookup_fast`](Self::lookup_fast) does, by the same Symbol) — never
2854    /// the `with`-chain. The ENV-RESOLVE M0 fast path uses this: a
2855    /// `Resolution::Lexical{sym}` reference probes here directly with its
2856    /// precomputed Symbol; on a hit the returned value is byte-identical to
2857    /// `lookup_fast`'s (same map, same Symbol); on a miss the caller falls
2858    /// back to today's exact runtime path.
2859    #[must_use]
2860    pub fn lookup_lexical_sym(&self, sym: Symbol) -> Option<Value> {
2861        self.0.bindings.get(&sym).cloned()
2862    }
2863
2864    /// Look up a name using ONLY with-scope caches (no forcing).
2865    /// Returns Some if the name is in a cached with-scope, None otherwise.
2866    /// Used by maybe_thunk to resolve with-scope idents without forcing fixpoints.
2867    #[must_use]
2868    pub fn lookup_with_cache_only(&self, name: &str) -> Option<Value> {
2869        for scope in self.0.with_scopes.iter().rev() {
2870            let cache = scope.cached.borrow();
2871            if let Some(ref attrs) = *cache {
2872                if let Some(v) = attrs.get(name) {
2873                    return Some(v.clone());
2874                }
2875            }
2876            // Also check if the thunk is already evaluated (peek)
2877            drop(cache);
2878            if let Value::Thunk(ref thunk) = scope.value {
2879                if let Some(cached_val) = thunk.peek() {
2880                    if let Concrete::Attrs(ref attrs) = *cached_val {
2881                        // Populate the with-scope cache for future lookups
2882                        *scope.cached.borrow_mut() = Some((**attrs).clone());
2883                        if let Some(v) = attrs.get(name) {
2884                            return Some(v.clone());
2885                        }
2886                    }
2887                }
2888            } else if let Value::Attrs(ref attrs) = scope.value {
2889                *scope.cached.borrow_mut() = Some((**attrs).clone());
2890                if let Some(v) = attrs.get(name) {
2891                    return Some(v.clone());
2892                }
2893            }
2894        }
2895        None
2896    }
2897
2898    /// Get the innermost with-scope's cache and value for creating WithIdent thunks.
2899    /// Returns None if there are no with-scopes.
2900    #[must_use]
2901    pub fn innermost_with_scope(&self) -> Option<(Rc<RefCell<Option<NixAttrs>>>, Value)> {
2902        self.0.with_scopes.last().map(|scope| {
2903            (scope.cached.clone(), scope.value.clone())
2904        })
2905    }
2906
2907    /// Lookup matching Nix semantics:
2908    ///
2909    /// 1. Probe the flattened binding map (single O(log32 n) lookup).
2910    ///    Any explicit `let`/`rec`/function-arg binding wins over every
2911    ///    `with` scope.
2912    /// 2. If no lexical binding matched, iterate `with_scopes` in
2913    ///    reverse order (innermost first). So `with X; with Y; x`
2914    ///    finds `x` in Y if Y has it, otherwise in X.
2915    #[must_use]
2916    pub fn lookup(&self, name: &str) -> Option<Value> {
2917        self.lookup_fast(intern(name), name)
2918    }
2919
2920    /// Cache-BYPASSING with-scope lookup: force each `with`-scope value FRESH
2921    /// (through the full thunk chain) and check for `name`, refreshing the
2922    /// per-scope cache on the way. A force that errors (a mid-fixpoint blackhole
2923    /// or a `with (throw …); …` namespace) is caught and the scope skipped.
2924    ///
2925    /// This exists ONLY for the last-ditch retry on the about-to-throw
2926    /// `UndefinedVar` path (see the WithIdent force): the normal cache-first
2927    /// [`lookup_fast`] can trust a stale mid-fixpoint PARTIAL cached for a scope
2928    /// (e.g. `f self` before makeScope merged `callPackage` into `self`) and skip
2929    /// it; a fresh force sees the now-completed scope. Never call this on a hot
2930    /// path — it re-forces every scope.
2931    #[must_use]
2932    pub fn lookup_fresh(&self, name: &str) -> Option<Value> {
2933        let sym = intern(name);
2934        if let Some(v) = self.0.bindings.get(&sym) {
2935            return Some(v.clone());
2936        }
2937        for scope in self.0.with_scopes.iter().rev() {
2938            if let Ok(Value::Attrs(attrs)) = crate::eval::force_value(&scope.value) {
2939                if let Some(v) = attrs.get_sym(&sym) {
2940                    // Refresh the stale cache with the completed scope so a later
2941                    // lookup of a sibling name also sees it.
2942                    *scope.cached.borrow_mut() = Some((*attrs).clone());
2943                    return Some(v.clone());
2944                }
2945            }
2946        }
2947        None
2948    }
2949
2950    /// Lookup by pre-interned Symbol + string name. Avoids re-interning.
2951    #[must_use]
2952    pub fn lookup_fast(&self, sym: Symbol, name: &str) -> Option<Value> {
2953        crate::perf::inc(crate::perf::Counter::EnvLookup);
2954        if let Some(v) = self.0.bindings.get(&sym) {
2955            return Some(v.clone());
2956        }
2957        // 2. With-scope lookup — iterate innermost-first (reverse order).
2958        for scope in self.0.with_scopes.iter().rev() {
2959            // Fast path: use cached forced attrset
2960            {
2961                let cache = scope.cached.borrow();
2962                if let Some(ref attrs) = *cache {
2963                    if let Some(v) = attrs.get_sym(&sym) {
2964                        return Some(v.clone());
2965                    }
2966                    continue;
2967                }
2968            }
2969            // Slow path: force, cache, then check.
2970            // If the value is already concrete (not a thunk), use it directly.
2971            // If it's a thunk, try to force. On blackhole (fixpoint being
2972            // computed), return None so the caller can defer.
2973            let resolved = match &scope.value {
2974                Value::Attrs(attrs) => {
2975                    // Already concrete — cache and use directly
2976                    crate::perf::inc(crate::perf::Counter::WithScopeCacheClone);
2977                    *scope.cached.borrow_mut() = Some((**attrs).clone());
2978                    Some((**attrs).clone())
2979                }
2980                Value::Thunk(thunk) => {
2981                    // Check if the thunk is already evaluated (OnceCell cache)
2982                    // without entering the force state machine
2983                    if let Some(cached_val) = thunk.peek() {
2984                        if let Concrete::Attrs(ref attrs) = *cached_val {
2985                            crate::perf::inc(crate::perf::Counter::WithScopeCacheClone);
2986                            *scope.cached.borrow_mut() = Some((**attrs).clone());
2987                            Some((**attrs).clone())
2988                        } else {
2989                            None
2990                        }
2991                    } else {
2992                        // Thunk not yet evaluated — force it FULLY. Must use
2993                        // `force_value` (which chases the whole thunk chain),
2994                        // NOT `force_value_tracked` (single `force_thunk` step):
2995                        // a with-scope head like `lib.platforms` is often a
2996                        // lazy `Thunk(Thunk(Attrs))`, so one step yields a
2997                        // `Value::Thunk` whose `type_name()` peeks to "set" but
2998                        // which the `if let Value::Attrs` match REJECTS — the
2999                        // scope is then wrongly skipped and every bare-ident
3000                        // lookup through it (`with lib.platforms; unix`) fails
3001                        // with a spurious UndefinedVar.
3002                        match crate::eval::force_value(&scope.value) {
3003                            Ok(forced) => {
3004                                if let Value::Attrs(ref attrs) = forced {
3005                                    crate::perf::inc(crate::perf::Counter::WithScopeCacheClone);
3006                                    *scope.cached.borrow_mut() = Some((**attrs).clone());
3007                                    Some((**attrs).clone())
3008                                } else {
3009                                    None
3010                                }
3011                            }
3012                            Err(_) => None, // blackhole or other error — skip
3013                        }
3014                    }
3015                }
3016                _ => {
3017                    // Same full-chain force as the Thunk arm above.
3018                    match crate::eval::force_value(&scope.value) {
3019                        Ok(forced) => {
3020                            if let Value::Attrs(ref attrs) = forced {
3021                                crate::perf::inc(crate::perf::Counter::WithScopeCacheClone);
3022                                *scope.cached.borrow_mut() = Some((**attrs).clone());
3023                                Some((**attrs).clone())
3024                            } else {
3025                                None
3026                            }
3027                        }
3028                        Err(_) => None,
3029                    }
3030                }
3031            };
3032            if let Some(ref attrs) = resolved {
3033                if let Some(v) = attrs.get(name) {
3034                    return Some(v.clone());
3035                }
3036            }
3037            // If forcing fails or it's not an attrset, try next scope
3038        }
3039        None
3040    }
3041
3042    /// Look up a binding by pre-interned [`Symbol`].
3043    ///
3044    /// Same semantics as [`lookup`](Self::lookup) but skips the
3045    /// `intern()` call — for use when the caller has already cached
3046    /// the symbol (e.g. via [`intern_cached`]).
3047    #[must_use]
3048    pub fn lookup_sym(&self, sym: Symbol) -> Option<Value> {
3049        crate::perf::inc(crate::perf::Counter::EnvLookup);
3050        // 1. Flat lexical lookup — single O(1) hash + O(log32 n) probe.
3051        if let Some(v) = self.0.bindings.get(&sym) {
3052            return Some(v.clone());
3053        }
3054        // 2. With-scope lookup — iterate innermost-first (reverse order).
3055        for scope in self.0.with_scopes.iter().rev() {
3056            // Fast path: use cached forced attrset
3057            {
3058                let cache = scope.cached.borrow();
3059                if let Some(ref attrs) = *cache {
3060                    if let Some(v) = attrs.get_sym(&sym) {
3061                        return Some(v.clone());
3062                    }
3063                    continue;
3064                }
3065            }
3066            // Slow path: force, cache, then check
3067            if let Ok(forced) = crate::eval::force_value_tracked(&scope.value, "with_scope") {
3068                if let Value::Attrs(ref attrs) = forced {
3069                    let result = attrs.get_sym(&sym).cloned();
3070                    crate::perf::inc(crate::perf::Counter::WithScopeCacheClone);
3071                    *scope.cached.borrow_mut() = Some((**attrs).clone());
3072                    if result.is_some() {
3073                        return result;
3074                    }
3075                }
3076            }
3077            // If forcing fails or it's not an attrset, try next scope
3078        }
3079        None
3080    }
3081}
3082
3083/// Evaluation errors produced by the Nix evaluator.
3084#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3085#[non_exhaustive]
3086pub enum EvalError {
3087    /// A variable was referenced but not bound in scope.
3088    #[error("undefined variable: {0}")]
3089    UndefinedVar(String),
3090    /// A type mismatch or coercion failure.
3091    #[error("type error: {0}")]
3092    TypeError(String),
3093    /// An attribute was selected from a set that does not contain it.
3094    #[error("attribute not found: {0}")]
3095    AttrNotFound(String),
3096    /// A type mismatch with structured expected/got information.
3097    #[error("type error: expected {expected}, got {got}")]
3098    TypeMismatch {
3099        expected: &'static str,
3100        got: &'static str,
3101    },
3102    /// An `assert` expression's condition evaluated to false.
3103    #[error("assertion failed{0}")]
3104    AssertionFailed(String),
3105    /// Integer division by zero.
3106    #[error("division by zero")]
3107    DivisionByZero,
3108    /// Infinite recursion detected (thunk blackhole or eval depth).
3109    #[error("infinite recursion ({0})")]
3110    InfiniteRecursion(String),
3111    /// An I/O error from the host filesystem.
3112    #[error("I/O error: {context}: {message}")]
3113    IoError { context: String, message: String },
3114    /// Explicit `throw` from Nix code — CATCHABLE by `builtins.tryEval`.
3115    #[error("{0}")]
3116    Throw(String),
3117    /// An `abort` from Nix code — UNCATCHABLE (CppNix's `abort`/`builtins.abort`
3118    /// is a hard error `tryEval` does NOT catch, unlike `throw`/`assert`).
3119    /// Verified: `nix eval '(builtins.tryEval (abort "x")).success'` errors.
3120    #[error("{0}")]
3121    Abort(String),
3122    /// A language feature that is not yet implemented.
3123    #[error("not yet implemented: {0}")]
3124    NotImplemented(String),
3125    /// A syntax error in the input expression.
3126    #[error("parse error: {0}")]
3127    ParseError(String),
3128    /// Maximum recursion depth exceeded.
3129    #[error("recursion limit: {0}")]
3130    RecursionLimit(String),
3131}
3132
3133impl EvalError {
3134    /// Convenience constructor for a `TypeError` variant.
3135    #[must_use]
3136    pub fn type_error(msg: impl Into<String>) -> Self {
3137        EvalError::TypeError(msg.into())
3138    }
3139
3140    /// Convenience constructor for a `TypeMismatch` variant.
3141    #[must_use]
3142    pub fn type_mismatch(expected: &'static str, got: &'static str) -> Self {
3143        EvalError::TypeMismatch { expected, got }
3144    }
3145
3146    /// Create a type error for a builtin argument type mismatch.
3147    #[must_use]
3148    pub fn builtin_type(builtin: &str, expected: &str, got: &str) -> Self {
3149        EvalError::TypeError(format!("{builtin}: expected {expected}, got {got}"))
3150    }
3151
3152    /// Create a type error for a binary operator type mismatch.
3153    ///
3154    /// CARRIES THE EVAL FILE (added 2026-07-20). Every arithmetic/comparison
3155    /// raise site routes through here, and none of them appended
3156    /// `eval_file_ctx()` — unlike the ~12 sibling raise sites in `eval.rs` that
3157    /// do — so an operator type error named no file at all. Nor could the frame
3158    /// stack help: `NixTraceGuard::drop` pops every frame during unwind, so by
3159    /// the time the error surfaces `attach_trace` has nothing left to attach.
3160    ///
3161    /// The cost of that was concrete. "cannot add string and null" was the sole
3162    /// symptom of the ident-cache aliasing bug that stopped sui evaluating
3163    /// nixpkgs, and it pointed nowhere: four parallel investigations each spent
3164    /// most of their budget just locating it, and the only tool that worked was
3165    /// `SUI_TRACE_EVAL=1` dumping 521k lines to be read backwards. One
3166    /// `format!` argument here would have named `make-derivation.nix`
3167    /// immediately.
3168    ///
3169    /// Fixing it in `op_type` rather than at the `Add` arm means every operator
3170    /// — add, sub, mul, div, comparison, update — gains the context at once,
3171    /// instead of the next one to bite us needing its own patch.
3172    #[must_use]
3173    pub fn op_type(op: &str, lhs: &str, rhs: &str) -> Self {
3174        EvalError::TypeError(format!(
3175            "cannot {op} {lhs} and {rhs}{}",
3176            crate::eval::eval_file_ctx()
3177        ))
3178    }
3179
3180    /// Whether this error was caused by `throw` or `abort`.
3181    #[must_use]
3182    pub fn is_throw(&self) -> bool {
3183        matches!(self, EvalError::Throw(_))
3184    }
3185
3186    /// Whether this error is an infinite recursion.
3187    #[must_use]
3188    pub fn is_infinite_recursion(&self) -> bool {
3189        matches!(self, EvalError::InfiniteRecursion(_))
3190    }
3191}
3192
3193impl Value {
3194    /// Convenience constructor for a context-free string.
3195    #[must_use]
3196    pub fn string(s: impl Into<SmolStr>) -> Self {
3197        Value::String(Rc::new(NixString::plain(s)))
3198    }
3199
3200    /// Convenience constructor that wraps a `Vec<Value>` in `Rc` for the
3201    /// `List` variant.
3202    #[must_use]
3203    pub fn list(items: Vec<Value>) -> Self {
3204        Value::List(Rc::new(NixList::new(items)))
3205    }
3206
3207    /// True when `self` is a `List` whose backing `Rc<Vec>` is uniquely owned
3208    /// (refcount 1). Used by [`concat_lists`] to decide the in-place fast path.
3209    #[must_use]
3210    pub fn is_uniquely_owned_list(&self) -> bool {
3211        matches!(self, Value::List(rc) if Rc::strong_count(rc) == 1)
3212    }
3213
3214    /// Convert a value to JSON for API output.
3215    #[must_use]
3216    pub fn to_json(&self) -> serde_json::Value {
3217        match self {
3218            Value::Null => serde_json::Value::Null,
3219            Value::Bool(b) => serde_json::Value::Bool(*b),
3220            Value::Int(n) => serde_json::json!(n),
3221            Value::Float(f) => serde_json::json!(f),
3222            Value::String(s) => serde_json::Value::String(s.chars.to_string()),
3223            Value::Path(p) => serde_json::Value::String(p.to_string()),
3224            Value::List(items) => {
3225                serde_json::Value::Array(items.iter().map(|v| v.to_json()).collect())
3226            }
3227            Value::Attrs(attrs) => {
3228                // nix-faithful (CppNix value-to-json.cc `tryAttrsToString`):
3229                // a derivation — an attrset carrying `__toString` or `outPath`
3230                // — serializes to THAT STRING, never its own attrs. Without
3231                // this, `to_json` recurses forever on the self-referential
3232                // derivation graph (`drv.out.drv == drv`, `drv.all`, …) and
3233                // overflows the stack. Mirrors `coerce_to_string` below.
3234                if attrs.get("__toString").is_some() || attrs.get("outPath").is_some() {
3235                    if let Ok((s, _ctx)) = self.coerce_to_string() {
3236                        return serde_json::Value::String(s);
3237                    }
3238                }
3239                let map: serde_json::Map<String, serde_json::Value> = attrs
3240                    .iter()
3241                    .map(|(k, v)| (k.clone(), v.to_json()))
3242                    .collect();
3243                serde_json::Value::Object(map)
3244            }
3245            Value::Lambda(_) => serde_json::Value::String("<lambda>".to_string()),
3246            Value::Builtin(b) => serde_json::Value::String(format!("<builtin {}>", b.name)),
3247            Value::Thunk(thunk) => {
3248                // Force the thunk for JSON conversion.
3249                match thunk.force(&|expr, env| crate::eval::eval_expr(expr, env)) {
3250                    Ok(v) => v.to_json(),
3251                    Err(_) => serde_json::Value::String("<thunk:error>".to_string()),
3252                }
3253            }
3254        }
3255    }
3256
3257    /// Like [`to_json`] but threads string context into `ctx`. Used by
3258    /// `__structuredAttrs` derivation-env building: a derivation value
3259    /// serializes to its outPath (a store-path string) and its drv reference
3260    /// must flow into the derivation's `inputDrvs`; a bare path is copy-to-store
3261    /// coerced. (`to_json` drops context, which is fine for `builtins.toJSON`
3262    /// but not for building a derivation's `__json`.)
3263    pub fn to_json_with_context(
3264        &self,
3265        ctx: &mut StringContext,
3266    ) -> Result<serde_json::Value, EvalError> {
3267        Ok(match self {
3268            Value::Null => serde_json::Value::Null,
3269            Value::Bool(b) => serde_json::Value::Bool(*b),
3270            Value::Int(n) => serde_json::json!(n),
3271            Value::Float(f) => serde_json::json!(f),
3272            Value::String(s) => {
3273                ctx.merge(&s.context);
3274                serde_json::Value::String(s.chars.to_string())
3275            }
3276            Value::Path(_) => {
3277                let (str, c) = self.coerce_to_string_copy_to_store()?;
3278                ctx.merge(&c);
3279                serde_json::Value::String(str)
3280            }
3281            Value::List(items) => {
3282                let mut arr = Vec::with_capacity(items.len());
3283                for v in items.iter() {
3284                    let fv = crate::eval::force_value(v)?;
3285                    arr.push(fv.to_json_with_context(ctx)?);
3286                }
3287                serde_json::Value::Array(arr)
3288            }
3289            Value::Attrs(attrs) => {
3290                // A derivation (attrset with `outPath`/`__toString`) serializes
3291                // to that string with its context — never its own attrs.
3292                if attrs.get("__toString").is_some() || attrs.get("outPath").is_some() {
3293                    let (s, c) = self.coerce_to_string_copy_to_store()?;
3294                    ctx.merge(&c);
3295                    return Ok(serde_json::Value::String(s));
3296                }
3297                let mut map = serde_json::Map::new();
3298                for (k, v) in attrs.iter() {
3299                    let fv = crate::eval::force_value(v)?;
3300                    map.insert(k.clone(), fv.to_json_with_context(ctx)?);
3301                }
3302                serde_json::Value::Object(map)
3303            }
3304            Value::Thunk(_) => {
3305                let forced = crate::eval::force_value(self)?;
3306                forced.to_json_with_context(ctx)?
3307            }
3308            other => {
3309                return Err(EvalError::TypeError(format!(
3310                    "cannot serialize {} to JSON (__structuredAttrs)",
3311                    other.type_name()
3312                )));
3313            }
3314        })
3315    }
3316
3317    /// Return the Nix type name for this value (e.g. `"int"`, `"set"`).
3318    #[must_use]
3319    pub fn type_name(&self) -> &'static str {
3320        match self {
3321            Value::Null => "null",
3322            Value::Bool(_) => "bool",
3323            Value::Int(_) => "int",
3324            Value::Float(_) => "float",
3325            Value::String(_) => "string",
3326            Value::Path(_) => "path",
3327            Value::List(_) => "list",
3328            Value::Attrs(_) => "set",
3329            Value::Lambda(_) => "lambda",
3330            Value::Builtin(_) => "lambda",
3331            Value::Thunk(thunk) => {
3332                // Force and delegate.
3333                match thunk.force(&|expr, env| crate::eval::eval_expr(expr, env)) {
3334                    Ok(v) => v.type_name(),
3335                    Err(_) => "thunk",
3336                }
3337            }
3338        }
3339    }
3340
3341    // ── Value coercion methods ──────────────────────────────────
3342    //
3343    // Naming conventions:
3344    //
3345    // • `as_*(&self)` — borrow. Returns a reference or Copy type.
3346    //   Primitives (`as_bool`, `as_int`) force thunks transparently
3347    //   because they return owned Copy values. Reference accessors
3348    //   (`as_string`, `as_nix_string`, `as_attrs`, `as_list`) CANNOT
3349    //   force thunks (the forced value is transient and we can't
3350    //   return a borrow into it), so they error on Thunk inputs.
3351    //
3352    // • `to_*(&self)` — clone / force. Returns an owned value and
3353    //   DOES force thunks. Use when the value may be a thunk and you
3354    //   need an owned result. Examples: `to_float`, `to_string`,
3355    //   `to_attrs`, `to_list`.
3356    //
3357    // • `coerce_to_path` — a Nix-specific coercion that accepts both
3358    //   Path and String values (many builtins accept either).
3359
3360    /// Extract a bool, forcing thunks if needed.
3361    pub fn as_bool(&self) -> Result<bool, EvalError> {
3362        match self {
3363            Value::Bool(b) => Ok(*b),
3364            Value::Thunk(thunk) => {
3365                thunk.force(&|e, env| crate::eval::eval_expr(e, env))?.as_bool()
3366            }
3367            // M2.6 Promise softening: coercion of a sentinel to bool
3368            // inside a fix-point body returns false (the cheapest sentinel
3369            // that lets `if x then … else …` take the else branch).
3370            _ if in_promise_eval() => Ok(false),
3371            _ => Err(EvalError::TypeMismatch { expected: "bool", got: self.type_name() }),
3372        }
3373    }
3374
3375    /// Extract an integer, forcing thunks if needed.
3376    pub fn as_int(&self) -> Result<i64, EvalError> {
3377        match self {
3378            Value::Int(n) => Ok(*n),
3379            Value::Thunk(thunk) => {
3380                thunk.force(&|e, env| crate::eval::eval_expr(e, env))?.as_int()
3381            }
3382            // M2.6 Promise softening: coercion of a sentinel to int
3383            // returns 0.
3384            _ if in_promise_eval() => Ok(0),
3385            _ => Err(EvalError::TypeMismatch { expected: "int", got: self.type_name() }),
3386        }
3387    }
3388
3389    /// Borrow the string content without forcing thunks.
3390    pub fn as_string(&self) -> Result<&str, EvalError> {
3391        match self {
3392            Value::String(s) => Ok(&s.chars),
3393            Value::Thunk(_) => Err(EvalError::TypeError(
3394                "thunk in as_string: force first via force_value()".into(),
3395            )),
3396            _ if in_promise_eval() => Ok(""),
3397            _ => Err(EvalError::TypeMismatch { expected: "string", got: self.type_name() }),
3398        }
3399    }
3400
3401    /// Return a reference to the full `NixString` (with context).
3402    pub fn as_nix_string(&self) -> Result<&NixString, EvalError> {
3403        match self {
3404            Value::String(ns) => Ok(ns),
3405            Value::Thunk(_) => Err(EvalError::TypeError(
3406                "thunk in as_nix_string: force first via force_value()".into(),
3407            )),
3408            _ => Err(EvalError::TypeMismatch { expected: "string", got: self.type_name() }),
3409        }
3410    }
3411
3412    /// Force-aware string extraction. Returns an owned String by forcing
3413    /// thunks if needed. Use this instead of `as_string()` when you may
3414    /// be operating on thunked attrset values.
3415    pub fn to_str(&self) -> Result<String, EvalError> {
3416        match self {
3417            Value::String(s) => Ok(s.chars.to_string()),
3418            Value::Thunk(thunk) => {
3419                let forced = thunk.force(&|e, env| crate::eval::eval_expr(e, env))?;
3420                forced.to_str()
3421            }
3422            _ if in_promise_eval() => Ok(String::new()),
3423            _ => Err(EvalError::TypeMismatch { expected: "string", got: self.type_name() }),
3424        }
3425    }
3426
3427    /// Force-aware `NixString` extraction. Returns an owned `NixString`
3428    /// (with context) by forcing thunks if needed.
3429    pub fn to_nix_string(&self) -> Result<NixString, EvalError> {
3430        match self {
3431            Value::String(s) => Ok((**s).clone()),
3432            Value::Thunk(thunk) => {
3433                let forced = thunk.force(&|e, env| crate::eval::eval_expr(e, env))?;
3434                forced.to_nix_string()
3435            }
3436            _ if in_promise_eval() => Ok(NixString::plain("")),
3437            _ => Err(EvalError::TypeMismatch { expected: "string", got: self.type_name() }),
3438        }
3439    }
3440
3441    /// Borrow the inner attrs without forcing. If the value is a
3442    /// thunk, the caller should have force_value'd it first; we
3443    /// return an error rather than silently mutating the thunk
3444    /// (which would require &mut self).
3445    ///
3446    /// Most call sites should use `to_attrs()` (which forces and
3447    /// clones) unless they're certain the value is already
3448    /// concrete and want to avoid the clone.
3449    pub fn as_attrs(&self) -> Result<&NixAttrs, EvalError> {
3450        match self {
3451            Value::Attrs(a) => Ok(a),
3452            Value::Thunk(_) => Err(EvalError::TypeError(
3453                "thunk in as_attrs: force first via force_value() or use to_attrs()".into(),
3454            )),
3455            _ => Err(EvalError::TypeMismatch { expected: "set", got: self.type_name() }),
3456        }
3457    }
3458
3459    /// Borrow the list content without forcing thunks.
3460    pub fn as_list(&self) -> Result<&[Value], EvalError> {
3461        match self {
3462            Value::List(l) => Ok(l.as_slice()),
3463            Value::Thunk(_) => Err(EvalError::TypeError(
3464                "thunk in as_list: force first via force_value()".into(),
3465            )),
3466            _ => Err(crate::eval::attach_trace(
3467                EvalError::TypeMismatch { expected: "list", got: self.type_name() }
3468            )),
3469        }
3470    }
3471
3472    /// Force-aware attrs extraction. Forces the value if it is a thunk.
3473    pub fn to_attrs(&self) -> Result<NixAttrs, EvalError> {
3474        match self {
3475            Value::Attrs(a) => Ok((**a).clone()),
3476            Value::Thunk(thunk) => {
3477                let forced = thunk.force(&|e, env| crate::eval::eval_expr(e, env))?;
3478                forced.to_attrs()
3479            }
3480            // M2.6 Promise softening: a coercion of null (or any
3481            // non-attrset sentinel) to an attrset inside a fix-point
3482            // body returns an empty attrset, so downstream builtins
3483            // (mapAttrs, attrNames, ...) see "no keys" rather than a
3484            // type error.
3485            _ if in_promise_eval() => Ok(NixAttrs::new()),
3486            _ => Err(EvalError::TypeMismatch { expected: "set", got: self.type_name() }),
3487        }
3488    }
3489
3490    /// Force-aware list extraction. Forces the value if it is a thunk.
3491    pub fn to_list(&self) -> Result<Vec<Value>, EvalError> {
3492        match self {
3493            Value::List(l) => Ok((**l).0.clone()),
3494            Value::Thunk(thunk) => {
3495                let forced = thunk.force(&|e, env| crate::eval::eval_expr(e, env))?;
3496                forced.to_list()
3497            }
3498            // M2.6 Promise softening: coercion of a sentinel to a list
3499            // inside a fix-point body returns an empty list.
3500            _ if in_promise_eval() => Ok(Vec::new()),
3501            _ => Err(EvalError::TypeMismatch { expected: "list", got: self.type_name() }),
3502        }
3503    }
3504
3505    /// Extract a filesystem path from a `Path` or `String` value.
3506    ///
3507    /// Many builtins (`readFile`, `import`, `pathExists`, etc.) accept
3508    /// either `Path` or `String` arguments. This method centralises
3509    /// that coercion so every call-site doesn't repeat the same match.
3510    pub fn coerce_to_path(&self, context: &str) -> Result<String, EvalError> {
3511        match self {
3512            Value::Path(p) => Ok(p.to_string()),
3513            Value::String(ns) => Ok(ns.chars.to_string()),
3514            Value::Attrs(attrs) => {
3515                if let Some(out_path) = attrs.get("outPath") {
3516                    let forced = crate::eval::force_value(out_path)?;
3517                    forced.coerce_to_path(context)
3518                } else {
3519                    Err(EvalError::TypeError(format!(
3520                        "{context}: expected path or string, got set without outPath"
3521                    )))
3522                }
3523            }
3524            _ => Err(EvalError::TypeError(format!(
3525                "{context}: expected path or string, got {}",
3526                self.type_name()
3527            ))),
3528        }
3529    }
3530
3531    /// Coerce to a filesystem path AND, if this value is a **derivation**
3532    /// whose output is not yet materialized on disk, realize that output first
3533    /// (import-from-derivation).
3534    ///
3535    /// Used by the disk-read builtins (`import`, `readFile`, `readDir`,
3536    /// `pathExists`, `builtins.path`) so a read under a derivation's `outPath`
3537    /// triggers a build/substitute of that output, exactly as cppnix does.
3538    ///
3539    /// Semantics:
3540    /// - A `Path`/`String` coerces as usual — no realize (nothing to build).
3541    /// - A derivation attrset (`type == "derivation"` with `drvPath` +
3542    ///   `outPath`) whose `outPath` (after input-source materialization) does
3543    ///   **not** exist on disk invokes the realize hook with `(drvPath,
3544    ///   outPath)`. On success the returned path is the (now-present) `outPath`.
3545    /// - A non-derivation attrset with `outPath` coerces via `outPath` as usual
3546    ///   (no drv to realize).
3547    /// - If no realize hook is installed, this degrades to `coerce_to_path`
3548    ///   (the read that follows will ENOENT — a real error, never a wrong
3549    ///   value).
3550    ///
3551    /// The realize hook mutates no value the evaluator observes; it only makes
3552    /// the bytes at the already-byte-correct `outPath` present on disk (see
3553    /// [`crate::realize`]).
3554    pub fn coerce_to_realized_path(&self, context: &str) -> Result<String, EvalError> {
3555        match self {
3556            // Direct derivation attrset (`import <drv>`): drvPath + outPath are
3557            // right there.
3558            Value::Attrs(attrs) => {
3559                if let Some((drv_path, out_path)) = derivation_drv_and_out(attrs)? {
3560                    self.realize_if_absent(&drv_path, &out_path, context)?;
3561                    return Ok(out_path);
3562                }
3563            }
3564            // A string produced by interpolating a derivation
3565            // (`readFile "${drv}"`) is a store-path STRING that carries a
3566            // `ContextElement::Output { drv, output }` — the derivation-ness
3567            // survives interpolation *as string context*, which is exactly how
3568            // cppnix decides to realize. If the coerced store path is absent and
3569            // the context names the producing `.drv`, realize it.
3570            Value::String(ns) => {
3571                let out_path = ns.chars.to_string();
3572                if let Some(drv_path) = out_path_needs_realize(&out_path, &ns.context) {
3573                    self.realize_if_absent(&drv_path, &out_path, context)?;
3574                }
3575                return Ok(out_path);
3576            }
3577            _ => {}
3578        }
3579        self.coerce_to_path(context)
3580    }
3581
3582    /// If `out_path` (after input-source materialization) is not present on
3583    /// disk, invoke the realize hook to build/substitute `drv_path`. A missing
3584    /// hook is a silent fall-through (the following read ENOENTs — a real error,
3585    /// never a wrong value); a hook error is surfaced as an eval `IoError`.
3586    fn realize_if_absent(
3587        &self,
3588        drv_path: &str,
3589        out_path: &str,
3590        context: &str,
3591    ) -> Result<(), EvalError> {
3592        // The existence probe must consult the REAL tree — a fetched flake
3593        // input's `-source` prefix is redirected — so materialize first.
3594        let read_path = crate::path::materialize_str(out_path);
3595        if std::path::Path::new(&read_path).exists() {
3596            return Ok(());
3597        }
3598        match crate::realize::realize_output(drv_path, out_path) {
3599            Ok(true) | Ok(false) => Ok(()),
3600            Err(msg) => Err(EvalError::IoError {
3601                context: context.to_string(),
3602                message: format!(
3603                    "import-from-derivation: realizing {drv_path} -> {out_path}: {msg}"
3604                ),
3605            }),
3606        }
3607    }
3608
3609    /// Coerce a numeric value to float.
3610    pub fn to_float(&self) -> Result<f64, EvalError> {
3611        match self {
3612            Value::Float(f) => Ok(*f),
3613            Value::Int(n) => Ok(*n as f64),
3614            Value::Thunk(thunk) => {
3615                thunk.force(&|e, env| crate::eval::eval_expr(e, env))?.to_float()
3616            }
3617            _ => Err(EvalError::TypeMismatch { expected: "number", got: self.type_name() }),
3618        }
3619    }
3620
3621    /// Coerce this value to a string following CppNix semantics.
3622    ///
3623    /// This is the single source of truth for string coercion used by
3624    /// string interpolation, `builtins.toString`, and derivation env
3625    /// var construction.
3626    ///
3627    /// Rules (in order):
3628    /// - String → its content (with context)
3629    /// - Path → path string (adds Plain context element)
3630    /// - Int → decimal representation
3631    /// - Float → decimal representation
3632    /// - Bool → "1" for true, "" for false
3633    /// - Null → ""
3634    /// - Attrs with `__toString` → call `__toString(self)` and coerce result
3635    /// - Attrs with `outPath` → coerce outPath recursively
3636    /// - List → space-joined coerced elements
3637    /// - Lambda/Builtin/Thunk → error
3638    pub fn coerce_to_string(&self) -> Result<(String, StringContext), EvalError> {
3639        self.coerce_to_string_impl(false)
3640    }
3641
3642    /// Coerce to string in CppNix **copy-to-store** mode — the coercion used by
3643    /// string interpolation (`"${./foo}"`) and derivation-attribute population.
3644    /// A source path that isn't already in the store is absolutized,
3645    /// canonicalized, required to exist, and NAR-copied into
3646    /// `/nix/store/<hash>-<basename>`; the result string is that store path and
3647    /// it carries store-path context. This is what makes `src = ./.` reference
3648    /// the correct store path (and thus the correct drv hash) instead of a raw
3649    /// filesystem path. `builtins.toString` keeps the plain mode
3650    /// ([`coerce_to_string`]) — it does *not* copy.
3651    pub fn coerce_to_string_copy_to_store(
3652        &self,
3653    ) -> Result<(String, StringContext), EvalError> {
3654        self.coerce_to_string_impl(true)
3655    }
3656
3657    fn coerce_to_string_impl(
3658        &self,
3659        copy_to_store: bool,
3660    ) -> Result<(String, StringContext), EvalError> {
3661        let mut ctx = StringContext::new();
3662        let s = match self {
3663            Value::String(ns) => {
3664                ctx.merge(&ns.context);
3665                ns.chars.to_string()
3666            }
3667            Value::Path(p) => {
3668                let raw: &str = &**p;
3669                if copy_to_store {
3670                    // CppNix copy-to-store coercion: resolve the path to its
3671                    // canonical absolute location (relative literals resolve
3672                    // against the evaluating file's dir, matching CppNix's
3673                    // parse-time absolutization; canonicalize also yields the
3674                    // realpath, e.g. macOS /tmp → /private/tmp), require it to
3675                    // exist (CppNix errors "path '…' does not exist"), NAR-copy
3676                    // it, and reference the resulting store path.
3677                    //
3678                    // A Path VALUE is ALWAYS copied, even one already under
3679                    // /nix/store — CppNix re-NAR-copies a bare path literal
3680                    // (a store subpath like `<nixpkgs-source>/pkgs/…/default-
3681                    // builder.sh` → its own `<hash>-default-builder.sh`, or even
3682                    // a store root) to a fresh basename-named store path,
3683                    // verified against nix 2.34. Store paths that must NOT be
3684                    // re-copied (derivation outputs, fetchurl `src`, storePath)
3685                    // arrive as context-carrying *Strings*, never as Path values,
3686                    // so they never reach this arm. (The earlier `/nix/store/`
3687                    // guard kept stdenv's builder-script subpaths verbatim, which
3688                    // diverged every nixpkgs input-drv hash from nix.)
3689                    let pb = std::path::Path::new(raw);
3690                    let abs = if pb.is_absolute() {
3691                        pb.to_path_buf()
3692                    } else if let Some(dir) = crate::eval::current_eval_dir() {
3693                        dir.join(pb)
3694                    } else {
3695                        std::env::current_dir()
3696                            .map_err(|e| EvalError::IoError {
3697                                context: format!("copy-to-store coercion of {raw}"),
3698                                message: e.to_string(),
3699                            })?
3700                            .join(pb)
3701                    };
3702                    // Redirect the on-disk read to the input's real source
3703                    // tree when `abs` lies under a fetched flake input's
3704                    // `-source` store prefix (sui does not materialize that
3705                    // store path). The resulting store path is NAR-hashed
3706                    // from the tree CONTENT — byte-identical whether read from
3707                    // the store path or the cache — so no value changes.
3708                    let read_abs = crate::path::materialize(&abs);
3709                    let canon = read_abs.canonicalize().map_err(|_| {
3710                        EvalError::TypeError(format!(
3711                            "path '{}' does not exist",
3712                            abs.display()
3713                        ))
3714                    })?;
3715                    // The copied source's STORE-PATH NAME must match CppNix's
3716                    // `baseNameOf` of the input's own `-source` store path when
3717                    // the path being copied IS a fetched flake input's whole
3718                    // tree (the darwin `system-path` root): blx's `src = ./.`
3719                    // copies the blx input tree back into the store, and CppNix
3720                    // names that copy `<inner>-source` (blx's `/nix/store/<h>-
3721                    // source` basename), NOT `blx-<rev>`. sui reads the bytes
3722                    // from the fetcher cache (`canon`, basename `blx-<rev>`), so
3723                    // `canon.file_name()` gave the wrong NAME while the bytes
3724                    // (→ NAR hash) were already correct. Recover the logical
3725                    // `-source` name from the input-source map; fall back to the
3726                    // real dir's basename for a normal local `src = ./.`.
3727                    // `strip_store_hash_prefix`: `canon` may ALREADY be a store
3728                    // path, whose basename is `<hash>-<name>`. Without the strip
3729                    // the copy lands at `<newhash>-<oldhash>-<name>`. See the
3730                    // helper's docs for the measured 2026-08-11 receipt.
3731                    let name = crate::path::source_name_for_read_dir(&canon)
3732                        .or_else(|| {
3733                            canon
3734                                .file_name()
3735                                .map(|n| sui_compat::source::strip_store_hash_prefix(
3736                                    &n.to_string_lossy()).to_string())
3737                        })
3738                        .unwrap_or_else(|| "source".to_string());
3739                    let src = sui_compat::source::nar_hash_source_tree(&canon, &name)
3740                        .map_err(|e| {
3741                            EvalError::TypeError(format!(
3742                                "copy-to-store coercion of '{}': {e}",
3743                                canon.display()
3744                            ))
3745                        })?;
3746                    ctx.add_plain(src.store_path.clone());
3747                    src.store_path
3748                } else {
3749                    ctx.add_plain(raw.to_string());
3750                    raw.to_string()
3751                }
3752            }
3753            Value::Int(n) => n.to_string(),
3754            // CppNix uses C printf "%f" for float → string coercion,
3755            // which always emits 6 decimal places (`1.5` → "1.500000",
3756            // `3.14159` → "3.141590"). Rust's `{}` formatter strips
3757            // trailing zeros. Match CppNix so `lib.strings.floatToString`
3758            // and module-system defaults round-trip identically.
3759            Value::Float(f) => format!("{f:.6}"),
3760            Value::Bool(true) => "1".to_string(),
3761            Value::Bool(false) => String::new(),
3762            Value::Null => String::new(),
3763            Value::Attrs(attrs) => {
3764                if let Some(to_str) = attrs.get("__toString") {
3765                    let result =
3766                        crate::eval::apply(to_str.clone(), Value::Attrs(attrs.clone()))?;
3767                    let forced = crate::eval::force_value(&result)?;
3768                    let (s, c) = forced.coerce_to_string_impl(copy_to_store)?;
3769                    ctx.merge(&c);
3770                    s
3771                } else if let Some(out_path) = attrs.get("outPath") {
3772                    let forced = crate::eval::force_value(out_path)?;
3773                    let (s, c) = forced.coerce_to_string_impl(copy_to_store)?;
3774                    ctx.merge(&c);
3775                    s
3776                } else {
3777                    return Err(EvalError::TypeError(
3778                        "cannot coerce set to string (no __toString or outPath)".into(),
3779                    ));
3780                }
3781            }
3782            Value::List(items) => {
3783                let mut parts = Vec::new();
3784                for item in items.iter() {
3785                    let forced = crate::eval::force_value(item)?;
3786                    let (s, c) = forced.coerce_to_string_impl(copy_to_store)?;
3787                    ctx.merge(&c);
3788                    parts.push(s);
3789                }
3790                parts.join(" ")
3791            }
3792            Value::Thunk(_) => {
3793                // Force thunk then coerce the result.
3794                let forced = crate::eval::force_value(self)?;
3795                let (s, c) = forced.coerce_to_string_impl(copy_to_store)?;
3796                ctx.merge(&c);
3797                s
3798            }
3799            other => {
3800                return Err(EvalError::TypeError(format!(
3801                    "cannot coerce {} to string",
3802                    other.type_name()
3803                )));
3804            }
3805        };
3806        Ok((s, ctx))
3807    }
3808}
3809
3810// ── Conversions from foreign value types ────────────────────
3811
3812impl From<&serde_json::Value> for Value {
3813    fn from(json: &serde_json::Value) -> Self {
3814        match json {
3815            serde_json::Value::Null => Value::Null,
3816            serde_json::Value::Bool(b) => Value::Bool(*b),
3817            serde_json::Value::Number(n) => {
3818                if let Some(i) = n.as_i64() {
3819                    Value::Int(i)
3820                } else {
3821                    Value::Float(n.as_f64().unwrap_or(0.0))
3822                }
3823            }
3824            serde_json::Value::String(s) => Value::string(s.clone()),
3825            serde_json::Value::Array(arr) => {
3826                Value::List(Rc::new(NixList::new(arr.iter().map(Value::from).collect())))
3827            }
3828            serde_json::Value::Object(obj) => {
3829                let mut attrs = NixAttrs::new();
3830                for (k, v) in obj {
3831                    attrs.insert(k.clone(), Value::from(v));
3832                }
3833                Value::Attrs(Rc::new(attrs))
3834            }
3835        }
3836    }
3837}
3838
3839impl From<&toml::Value> for Value {
3840    fn from(v: &toml::Value) -> Self {
3841        match v {
3842            toml::Value::String(s) => Value::string(s.clone()),
3843            toml::Value::Integer(n) => Value::Int(*n),
3844            toml::Value::Float(f) => Value::Float(*f),
3845            toml::Value::Boolean(b) => Value::Bool(*b),
3846            toml::Value::Array(arr) => {
3847                Value::List(Rc::new(NixList::new(arr.iter().map(Value::from).collect())))
3848            }
3849            toml::Value::Table(t) => {
3850                let mut attrs = NixAttrs::new();
3851                for (k, val) in t {
3852                    attrs.insert(k.clone(), Value::from(val));
3853                }
3854                Value::Attrs(Rc::new(attrs))
3855            }
3856            toml::Value::Datetime(dt) => Value::string(dt.to_string()),
3857        }
3858    }
3859}
3860
3861
3862// ── From impls for ergonomic Value construction ─────────────
3863
3864impl From<bool> for Value {
3865    fn from(b: bool) -> Self {
3866        Value::Bool(b)
3867    }
3868}
3869
3870impl From<i64> for Value {
3871    fn from(n: i64) -> Self {
3872        Value::Int(n)
3873    }
3874}
3875
3876impl From<f64> for Value {
3877    fn from(f: f64) -> Self {
3878        Value::Float(f)
3879    }
3880}
3881
3882impl From<NixString> for Value {
3883    fn from(s: NixString) -> Self {
3884        Value::String(Rc::new(s))
3885    }
3886}
3887
3888impl From<NixAttrs> for Value {
3889    fn from(attrs: NixAttrs) -> Self {
3890        Value::Attrs(Rc::new(attrs))
3891    }
3892}
3893
3894impl From<Vec<Value>> for Value {
3895    fn from(list: Vec<Value>) -> Self {
3896        Value::List(Rc::new(NixList::new(list)))
3897    }
3898}
3899
3900impl PartialEq for Value {
3901    fn eq(&self, other: &Self) -> bool {
3902        // Quick path: pointer-equal thunks are always equal.
3903        if let (Value::Thunk(a), Value::Thunk(b)) = (self, other) {
3904            if Rc::ptr_eq(&a.0, &b.0) { return true; }
3905        }
3906        // Force to Concrete, delegate to Concrete::PartialEq.
3907        // Single source of truth — no duplicated comparison logic.
3908        let l = self.demand().unwrap_or(Concrete::Null);
3909        let r = other.demand().unwrap_or(Concrete::Null);
3910        l == r
3911    }
3912}
3913
3914impl fmt::Display for Value {
3915    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3916        match self {
3917            Value::Null => write!(f, "null"),
3918            Value::Bool(b) => write!(f, "{b}"),
3919            Value::Int(n) => write!(f, "{n}"),
3920            Value::Float(n) => write!(f, "{}", sui_compat::versions::cppnix_format_float(*n)),
3921            Value::String(s) => write!(f, "\"{}\"", s.chars.replace('\\', "\\\\").replace('"', "\\\"")),
3922            Value::Path(p) => write!(f, "{p}"),
3923            Value::List(items) => {
3924                write!(f, "[ ")?;
3925                for item in items.iter() {
3926                    write!(f, "{item} ")?;
3927                }
3928                write!(f, "]")
3929            }
3930            Value::Attrs(attrs) => {
3931                write!(f, "{{ ")?;
3932                for (k, v) in attrs.iter() {
3933                    write!(f, "{k} = {v}; ")?;
3934                }
3935                write!(f, "}}")
3936            }
3937            Value::Lambda(_) => write!(f, "<<lambda>>"),
3938            Value::Builtin(b) => write!(f, "<<builtin {}>>" , b.name),
3939            Value::Thunk(thunk) => {
3940                match thunk.force(&|e, env| crate::eval::eval_expr(e, env)) {
3941                    Ok(v) => write!(f, "{v}"),
3942                    Err(_) => write!(f, "<<thunk:error>>"),
3943                }
3944            }
3945        }
3946    }
3947}
3948
3949#[cfg(test)]
3950mod tests {
3951    use super::*;
3952    use std::rc::Rc;
3953
3954    // ── Value size assertion ──────────────────────────────
3955
3956    /// Measures the per-map cost of the persistent HAMT against a flat map at
3957    /// the sizes nixpkgs actually uses. Not an assertion — a measurement, run
3958    /// with `--nocapture`. See docs/COMPLETE-REPLACEMENT.md §V.34.
3959    #[test]
3960    #[ignore = "measurement, not a gate: run with --ignored --nocapture"]
3961    fn measure_hamt_vs_flat_attrset_cost() {
3962        use crate::value::census::rss_bytes;
3963        const N: usize = 300_000;
3964        const ENTRIES: usize = 4; // typical small nixpkgs attrset
3965
3966        let syms: Vec<Symbol> = (0..ENTRIES).map(|i| intern(&format!("k{i}"))).collect();
3967
3968        let base = rss_bytes();
3969        let mut hamts: Vec<FxHashMap<Symbol, Value>> = Vec::with_capacity(N);
3970        for _ in 0..N {
3971            let mut m = FxHashMap::default();
3972            for s in &syms { m.insert(*s, Value::Int(1)); }
3973            hamts.push(m);
3974        }
3975        let after_hamt = rss_bytes();
3976
3977        let mut flats: Vec<std::collections::HashMap<Symbol, Value>> = Vec::with_capacity(N);
3978        for _ in 0..N {
3979            let mut m = std::collections::HashMap::with_capacity(ENTRIES);
3980            for s in &syms { m.insert(*s, Value::Int(1)); }
3981            flats.push(m);
3982        }
3983        let after_flat = rss_bytes();
3984
3985        let hamt_cost = after_hamt.saturating_sub(base);
3986        let flat_cost = after_flat.saturating_sub(after_hamt);
3987        eprintln!("N={N} entries={ENTRIES}");
3988        eprintln!("  im_rc HAMT : {} B total, {} B/map", hamt_cost, hamt_cost / N as u64);
3989        eprintln!("  std flat   : {} B total, {} B/map", flat_cost, flat_cost / N as u64);
3990        if flat_cost > 0 {
3991            eprintln!("  ratio      : {:.2}x", hamt_cost as f64 / flat_cost as f64);
3992        }
3993        std::hint::black_box((&hamts, &flats));
3994    }
3995
3996    #[test]
3997    fn value_is_16_bytes() {
3998        assert_eq!(std::mem::size_of::<Value>(), 16);
3999    }
4000
4001    // ── `//` carries attr positions (regression) ─────────
4002
4003    /// A key's position must survive `//` — from either side, with the RIGHT
4004    /// winning, matching the precedence `//` gives the key's value.
4005    ///
4006    /// Regression: `overlay()` builds its node with an empty position slot, so
4007    /// reading only that slot reported `null` for every key of every `//`
4008    /// result. nixpkgs' `lib.nixosSystem` ends in
4009    /// `{ …; modules = …; } // removeAttrs args [ "modules" ]` and
4010    /// `eval-config.nix:28` reads `unsafeGetAttrPos "modules"` off it to set
4011    /// `modulesLocation`; a null there permutes NixOS definition order and
4012    /// diverges the toplevel drvPath from CppNix.
4013    #[test]
4014    fn overlay_carries_attr_positions_from_both_sides() {
4015        let tbl = |file: &str, key: &str, off: u32| {
4016            let mut t = crate::pos::AttrPositions::new(Some(std::path::PathBuf::from(file)));
4017            t.insert(intern(key), off);
4018            Rc::new(t)
4019        };
4020        // Both operands must be NON-empty: `overlay` short-circuits to the
4021        // other side when either is empty, so an empty operand would never
4022        // build the Overlay node this test exists to walk.
4023        let mk = |file: &str, key: &str, off: u32| {
4024            let mut a = NixAttrs::new();
4025            a.insert(key.to_string(), Value::Int(1));
4026            a.set_positions(tbl(file, key, off));
4027            a
4028        };
4029
4030        // Key only on the LEFT — the shape nixosSystem actually hits, since
4031        // `removeAttrs args [ "modules" ]` strips it from the right.
4032        let left_only = mk("/l.nix", "modules", 11).overlay(mk("/r.nix", "other", 22));
4033        assert_eq!(
4034            left_only.pos_entry(intern("modules")),
4035            Some((Some(std::path::PathBuf::from("/l.nix")), 11)),
4036        );
4037
4038        // Key on BOTH sides — right wins, as it does for the value.
4039        let both = mk("/l.nix", "modules", 11).overlay(mk("/r.nix", "modules", 22));
4040        assert_eq!(
4041            both.pos_entry(intern("modules")),
4042            Some((Some(std::path::PathBuf::from("/r.nix")), 22)),
4043        );
4044
4045        // Absent key stays absent — the walk must not invent a position.
4046        assert_eq!(both.pos_entry(intern("nope")), None);
4047    }
4048
4049    // ── Value::to_json for every variant ─────────────────
4050
4051    #[test]
4052    fn to_json_null() {
4053        assert_eq!(Value::Null.to_json(), serde_json::Value::Null);
4054    }
4055
4056    #[test]
4057    fn to_json_bool() {
4058        assert_eq!(Value::Bool(true).to_json(), serde_json::Value::Bool(true));
4059        assert_eq!(Value::Bool(false).to_json(), serde_json::Value::Bool(false));
4060    }
4061
4062    #[test]
4063    fn to_json_int() {
4064        assert_eq!(Value::Int(42).to_json(), serde_json::json!(42));
4065    }
4066
4067    #[test]
4068    fn to_json_float() {
4069        assert_eq!(Value::Float(3.14).to_json(), serde_json::json!(3.14));
4070    }
4071
4072    #[test]
4073    fn to_json_string() {
4074        assert_eq!(
4075            Value::string("hello").to_json(),
4076            serde_json::Value::String("hello".to_string()),
4077        );
4078    }
4079
4080    #[test]
4081    fn to_json_path() {
4082        assert_eq!(
4083            Value::Path(Box::new(SmolStr::from("/nix/store"))).to_json(),
4084            serde_json::Value::String("/nix/store".to_string()),
4085        );
4086    }
4087
4088    #[test]
4089    fn to_json_list() {
4090        let v = Value::list(vec![Value::Int(1), Value::Bool(true)]);
4091        assert_eq!(v.to_json(), serde_json::json!([1, true]));
4092    }
4093
4094    #[test]
4095    fn to_json_attrs() {
4096        let mut attrs = NixAttrs::new();
4097        attrs.insert("a".to_string(), Value::Int(1));
4098        let v = Value::Attrs(Rc::new(attrs));
4099        assert_eq!(v.to_json(), serde_json::json!({"a": 1}));
4100    }
4101
4102    // ── cppnix derivation-equality short-circuit (curl/git root) ─────────
4103
4104    fn mk_drv_attrs(out_path: &str, extra_key: &str, extra_val: i64) -> Value {
4105        let mut a = NixAttrs::new();
4106        a.insert("type".to_string(), Value::string("derivation"));
4107        a.insert("outPath".to_string(), Value::string(out_path));
4108        a.insert(extra_key.to_string(), Value::Int(extra_val));
4109        Value::Attrs(Rc::new(a))
4110    }
4111
4112    #[test]
4113    fn derivations_same_outpath_differing_attrs_are_equal() {
4114        // The load-bearing rule: two attrsets that are BOTH `type=="derivation"`
4115        // with an `outPath` compare by `outPath` string ONLY — differing extra
4116        // attrs must NOT make them unequal. This is what nixpkgs'
4117        // `isMismatchedPython` (`drv.pythonModule != python`) relies on; a deep
4118        // structural compare here spuriously fired the guard and dropped
4119        // `python` from flit-core's `propagatedBuildInputs` (curl/git root).
4120        let a = mk_drv_attrs("/nix/store/x-foo", "foo", 1);
4121        let b = mk_drv_attrs("/nix/store/x-foo", "bar", 2);
4122        assert!(a == b, "same-outPath derivations must compare equal");
4123        assert!(!(a != b));
4124    }
4125
4126    #[test]
4127    fn derivations_differing_outpath_are_unequal() {
4128        let a = mk_drv_attrs("/nix/store/x-foo", "foo", 1);
4129        let b = mk_drv_attrs("/nix/store/y-foo", "foo", 1);
4130        assert!(a != b, "different-outPath derivations must compare unequal");
4131    }
4132
4133    #[test]
4134    fn non_derivation_attrs_with_outpath_use_structural_eq() {
4135        // `outPath` alone (no `type == "derivation"`) does NOT trigger the
4136        // short-circuit — nix falls back to structural equality.
4137        let mut a = NixAttrs::new();
4138        a.insert("outPath".to_string(), Value::string("/nix/store/x"));
4139        a.insert("foo".to_string(), Value::Int(1));
4140        let mut b = NixAttrs::new();
4141        b.insert("outPath".to_string(), Value::string("/nix/store/x"));
4142        b.insert("foo".to_string(), Value::Int(2));
4143        assert!(
4144            Value::Attrs(Rc::new(a)) != Value::Attrs(Rc::new(b)),
4145            "non-derivation attrs with equal outPath but differing foo must be unequal",
4146        );
4147    }
4148
4149    // ── C-A: attrs-eq structural compare BY BORROW (PERF-ARSENAL) ─────────
4150    // These seal that `Concrete::eq`'s Attrs arm — now `a.as_flat() ==
4151    // b.as_flat()` instead of `a.inner() == b.inner()` — is result- and
4152    // force-identical. The clone the old path did was pure allocation waste.
4153
4154    #[test]
4155    fn attrs_eq_borrow_result_matches_multi_key() {
4156        // Structural equality over a multi-key set with a nested attrset value
4157        // must be unaffected by dropping the pre-compare clone.
4158        let mk = || {
4159            let mut inner = NixAttrs::new();
4160            inner.insert("n".to_string(), Value::Int(7));
4161            let mut a = NixAttrs::new();
4162            a.insert("a".to_string(), Value::Int(1));
4163            a.insert("b".to_string(), Value::string("two"));
4164            a.insert("c".to_string(), Value::Attrs(Rc::new(inner)));
4165            Value::Attrs(Rc::new(a))
4166        };
4167        assert!(mk() == mk(), "equal multi-key attrsets must compare equal (borrow path)");
4168
4169        // Differ in one value → unequal.
4170        let mut b = NixAttrs::new();
4171        b.insert("a".to_string(), Value::Int(1));
4172        b.insert("b".to_string(), Value::string("TWO"));
4173        let mut a2 = NixAttrs::new();
4174        a2.insert("a".to_string(), Value::Int(1));
4175        a2.insert("b".to_string(), Value::string("two"));
4176        assert!(
4177            Value::Attrs(Rc::new(a2)) != Value::Attrs(Rc::new(b)),
4178            "attrsets differing in one value must be unequal (borrow path)",
4179        );
4180
4181        // Differ in key SET → unequal.
4182        let mut a3 = NixAttrs::new();
4183        a3.insert("a".to_string(), Value::Int(1));
4184        let mut b3 = NixAttrs::new();
4185        b3.insert("a".to_string(), Value::Int(1));
4186        b3.insert("extra".to_string(), Value::Int(9));
4187        assert!(
4188            Value::Attrs(Rc::new(a3)) != Value::Attrs(Rc::new(b3)),
4189            "attrsets differing in key set must be unequal (borrow path)",
4190        );
4191    }
4192
4193    #[test]
4194    fn attrs_eq_borrow_does_not_force_or_throw_on_shared_thunk() {
4195        // The demand-order verification obligation, made concrete:
4196        // `Value::eq` swallows force errors to `Null` (unwrap_or), so the
4197        // Attrs arm NEVER throws in the map-value-compare path. Two attrsets
4198        // that carry the SAME `Rc`-shared throwing thunk under a key that is
4199        // NOT decisive for (in)equality must:
4200        //   (a) compare via the structural (borrow) path without panicking, and
4201        //   (b) never let the throw escape.
4202        // If the borrow-compare forced the thunk and propagated the error, this
4203        // test would fail — proving the clone-elision touched no `.demand()`
4204        // behaviour that the old `inner()` clone path did not already exhibit.
4205        let boom = Value::Thunk(Thunk::new_native(|| {
4206            Err(EvalError::Throw("kaboom".to_string()))
4207        }));
4208        let mut a = NixAttrs::new();
4209        a.insert("x".to_string(), Value::Int(1));
4210        a.insert("t".to_string(), boom.clone()); // same Rc-shared throwing thunk
4211        let mut b = NixAttrs::new();
4212        b.insert("x".to_string(), Value::Int(2)); // decisive differ on `x`
4213        b.insert("t".to_string(), boom);
4214        // No panic, no escaped Err: the comparison returns a bool. `x` differs,
4215        // so they are unequal — and crucially the throwing `t` thunk did not
4216        // abort the comparison.
4217        let va = Value::Attrs(Rc::new(a));
4218        let vb = Value::Attrs(Rc::new(b));
4219        assert!(va != vb, "differ on x → unequal, throwing thunk must not abort eq");
4220    }
4221
4222    #[test]
4223    fn attrs_eq_borrow_overlay_still_compares() {
4224        // The `as_flat()` borrow path must also work when one side is an
4225        // Overlay (its cache is populated by `as_flat()`), matching the old
4226        // `inner()` path which flattened via the same `as_flat()`.
4227        let mut base = NixAttrs::new();
4228        base.insert("a".to_string(), Value::Int(1));
4229        let mut over = NixAttrs::new();
4230        over.insert("b".to_string(), Value::Int(2));
4231        // Build an overlay { a = 1; } // { b = 2; } (lazy Overlay variant),
4232        // exercising the `as_flat()` cache-population path on one side.
4233        let merged = base.overlay(over);
4234        let mut flat = NixAttrs::new();
4235        flat.insert("a".to_string(), Value::Int(1));
4236        flat.insert("b".to_string(), Value::Int(2));
4237        assert!(
4238            Value::Attrs(Rc::new(merged)) == Value::Attrs(Rc::new(flat)),
4239            "overlay and equivalent flat attrset must compare equal (borrow path)",
4240        );
4241    }
4242
4243    #[test]
4244    fn to_json_lambda() {
4245        // Build a minimal rnix lambda for testing
4246        let root = rnix::Root::parse("x: x");
4247        let expr = root.tree().expr().unwrap();
4248        let lambda = match expr {
4249            rnix::ast::Expr::Lambda(l) => l,
4250            _ => panic!("expected lambda"),
4251        };
4252        let closure = Closure {
4253            param: lambda.param().unwrap(),
4254            body: lambda.body().unwrap(),
4255            env: Env::new(),
4256        };
4257        assert_eq!(
4258            Value::Lambda(Rc::new(closure)).to_json(),
4259            serde_json::Value::String("<lambda>".to_string()),
4260        );
4261    }
4262
4263    #[test]
4264    fn to_json_builtin() {
4265        let b = BuiltinFn {
4266            name: "test",
4267            func: Rc::new(|_| Ok(Value::Null)),
4268        };
4269        assert_eq!(
4270            Value::Builtin(Box::new(b)).to_json(),
4271            serde_json::Value::String("<builtin test>".to_string()),
4272        );
4273    }
4274
4275    // ── Value::type_name for every variant ───────────────
4276
4277    #[test]
4278    fn type_name_null() { assert_eq!(Value::Null.type_name(), "null"); }
4279
4280    #[test]
4281    fn type_name_bool() { assert_eq!(Value::Bool(false).type_name(), "bool"); }
4282
4283    #[test]
4284    fn type_name_int() { assert_eq!(Value::Int(0).type_name(), "int"); }
4285
4286    #[test]
4287    fn type_name_float() { assert_eq!(Value::Float(0.0).type_name(), "float"); }
4288
4289    #[test]
4290    fn type_name_string() { assert_eq!(Value::string("").type_name(), "string"); }
4291
4292    #[test]
4293    fn type_name_path() { assert_eq!(Value::Path(Box::new(SmolStr::from(""))).type_name(), "path"); }
4294
4295    #[test]
4296    fn type_name_list() { assert_eq!(Value::list(vec![]).type_name(), "list"); }
4297
4298    #[test]
4299    fn type_name_set() { assert_eq!(Value::Attrs(Rc::new(NixAttrs::new())).type_name(), "set"); }
4300
4301    #[test]
4302    fn type_name_lambda() {
4303        let root = rnix::Root::parse("x: x");
4304        let expr = root.tree().expr().unwrap();
4305        let lambda = match expr {
4306            rnix::ast::Expr::Lambda(l) => l,
4307            _ => panic!("expected lambda"),
4308        };
4309        let closure = Closure {
4310            param: lambda.param().unwrap(),
4311            body: lambda.body().unwrap(),
4312            env: Env::new(),
4313        };
4314        assert_eq!(Value::Lambda(Rc::new(closure)).type_name(), "lambda");
4315    }
4316
4317    #[test]
4318    fn type_name_builtin() {
4319        let b = BuiltinFn {
4320            name: "t",
4321            func: Rc::new(|_| Ok(Value::Null)),
4322        };
4323        assert_eq!(Value::Builtin(Box::new(b)).type_name(), "lambda");
4324    }
4325
4326    // ── as_* error on wrong type ─────────────────────────
4327
4328    #[test]
4329    fn as_bool_error_on_non_bool() {
4330        assert!(Value::Int(1).as_bool().is_err());
4331        assert!(Value::string("true").as_bool().is_err());
4332    }
4333
4334    #[test]
4335    fn as_int_error_on_non_int() {
4336        assert!(Value::Bool(true).as_int().is_err());
4337        assert!(Value::Float(1.0).as_int().is_err());
4338    }
4339
4340    #[test]
4341    fn as_string_error_on_non_string() {
4342        assert!(Value::Int(42).as_string().is_err());
4343        assert!(Value::Null.as_string().is_err());
4344    }
4345
4346    #[test]
4347    fn as_attrs_error_on_non_attrs() {
4348        assert!(Value::Int(1).as_attrs().is_err());
4349        assert!(Value::list(vec![]).as_attrs().is_err());
4350    }
4351
4352    #[test]
4353    fn as_list_error_on_non_list() {
4354        assert!(Value::Int(1).as_list().is_err());
4355        assert!(Value::Attrs(Rc::new(NixAttrs::new())).as_list().is_err());
4356    }
4357
4358    // ── concat_lists structural share (byte-neutrality) ──────────
4359
4360    #[test]
4361    fn concat_lists_uniquely_owned_reuses_and_is_correct() {
4362        // A fresh left list (Rc strong_count == 1) hits the in-place path.
4363        let left = Value::list(vec![Value::Int(1), Value::Int(2)]);
4364        assert!(left.is_uniquely_owned_list());
4365        let right = [Value::Int(3), Value::Int(4)];
4366        let out = super::concat_lists(left, &right).unwrap();
4367        assert_eq!(
4368            out.as_list().unwrap(),
4369            &[Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]
4370        );
4371    }
4372
4373    #[test]
4374    fn concat_lists_shared_left_is_left_untouched_and_correct() {
4375        // Keep an outstanding Rc clone so the left is NOT uniquely owned;
4376        // the clone-extend fallback fires and the shared list is unchanged.
4377        let shared = Rc::new(NixList::new(vec![Value::Int(1), Value::Int(2)]));
4378        let left = Value::List(Rc::clone(&shared));
4379        assert!(!left.is_uniquely_owned_list());
4380        let right = [Value::Int(3)];
4381        let out = super::concat_lists(left, &right).unwrap();
4382        assert_eq!(
4383            out.as_list().unwrap(),
4384            &[Value::Int(1), Value::Int(2), Value::Int(3)]
4385        );
4386        // The original shared backing Vec is untouched.
4387        assert_eq!(&*shared, &[Value::Int(1), Value::Int(2)]);
4388    }
4389
4390    #[test]
4391    fn concat_lists_empty_operands() {
4392        let out = super::concat_lists(Value::list(vec![]), &[]).unwrap();
4393        assert!(out.as_list().unwrap().is_empty());
4394        let out2 = super::concat_lists(Value::list(vec![Value::Int(9)]), &[]).unwrap();
4395        assert_eq!(out2.as_list().unwrap(), &[Value::Int(9)]);
4396        let out3 = super::concat_lists(Value::list(vec![]), &[Value::Int(9)]).unwrap();
4397        assert_eq!(out3.as_list().unwrap(), &[Value::Int(9)]);
4398    }
4399
4400    #[test]
4401    fn concat_lists_non_list_left_errors() {
4402        assert!(super::concat_lists(Value::Int(1), &[]).is_err());
4403    }
4404
4405    #[test]
4406    fn concat_lists_preserves_element_identity() {
4407        // The concatenated list must share the SAME element Rc, not deep-copy.
4408        let inner = Rc::new(NixString::plain("x"));
4409        let a = Value::String(Rc::clone(&inner));
4410        let left = Value::list(vec![a]);
4411        let out = super::concat_lists(left, &[]).unwrap();
4412        if let Value::String(rc) = &out.as_list().unwrap()[0] {
4413            assert!(Rc::ptr_eq(rc, &inner), "element Rc identity preserved");
4414        } else {
4415            panic!("expected string element");
4416        }
4417    }
4418
4419    // ── to_float int->float coercion ─────────────────────
4420
4421    #[test]
4422    fn to_float_coerces_int() {
4423        assert_eq!(Value::Int(5).to_float().unwrap(), 5.0);
4424        assert_eq!(Value::Float(2.5).to_float().unwrap(), 2.5);
4425        assert!(Value::string("x").to_float().is_err());
4426    }
4427
4428    // ── PartialEq ────────────────────────────────────────
4429
4430    #[test]
4431    fn partial_eq_int_float_cross() {
4432        assert_eq!(Value::Int(3), Value::Float(3.0));
4433        assert_eq!(Value::Float(3.0), Value::Int(3));
4434        assert_ne!(Value::Int(3), Value::Float(3.5));
4435    }
4436
4437    #[test]
4438    fn partial_eq_different_types_not_equal() {
4439        assert_ne!(Value::Int(1), Value::string("1"));
4440        assert_ne!(Value::Bool(true), Value::Int(1));
4441        assert_ne!(Value::Null, Value::Bool(false));
4442        assert_ne!(Value::list(vec![]), Value::Attrs(Rc::new(NixAttrs::new())));
4443    }
4444
4445    // ── Display for all variants ─────────────────────────
4446
4447    #[test]
4448    fn display_null() { assert_eq!(format!("{}", Value::Null), "null"); }
4449
4450    #[test]
4451    fn display_bool() {
4452        assert_eq!(format!("{}", Value::Bool(true)), "true");
4453        assert_eq!(format!("{}", Value::Bool(false)), "false");
4454    }
4455
4456    #[test]
4457    fn display_int() { assert_eq!(format!("{}", Value::Int(42)), "42"); }
4458
4459    #[test]
4460    fn display_float() {
4461        let s = format!("{}", Value::Float(3.14));
4462        assert!(s.contains("3.14"));
4463    }
4464
4465    #[test]
4466    fn display_string() {
4467        assert_eq!(format!("{}", Value::string("hi")), "\"hi\"");
4468    }
4469
4470    #[test]
4471    fn display_string_with_escapes() {
4472        let v = Value::string("a\"b\\c");
4473        let s = format!("{v}");
4474        assert!(s.contains("\\\""));
4475        assert!(s.contains("\\\\"));
4476    }
4477
4478    #[test]
4479    fn display_path() {
4480        assert_eq!(format!("{}", Value::Path(Box::new(SmolStr::from("/foo")))), "/foo");
4481    }
4482
4483    #[test]
4484    fn display_list() {
4485        let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
4486        assert_eq!(format!("{v}"), "[ 1 2 ]");
4487    }
4488
4489    #[test]
4490    fn display_attrs() {
4491        let mut attrs = NixAttrs::new();
4492        attrs.insert("x".to_string(), Value::Int(1));
4493        let v = Value::Attrs(Rc::new(attrs));
4494        assert_eq!(format!("{v}"), "{ x = 1; }");
4495    }
4496
4497    #[test]
4498    fn display_lambda() {
4499        let root = rnix::Root::parse("x: x");
4500        let expr = root.tree().expr().unwrap();
4501        let lambda = match expr {
4502            rnix::ast::Expr::Lambda(l) => l,
4503            _ => panic!("expected lambda"),
4504        };
4505        let closure = Closure {
4506            param: lambda.param().unwrap(),
4507            body: lambda.body().unwrap(),
4508            env: Env::new(),
4509        };
4510        assert_eq!(format!("{}", Value::Lambda(Rc::new(closure))), "<<lambda>>");
4511    }
4512
4513    #[test]
4514    fn display_builtin() {
4515        let b = BuiltinFn {
4516            name: "add",
4517            func: Rc::new(|_| Ok(Value::Null)),
4518        };
4519        assert_eq!(format!("{}", Value::Builtin(Box::new(b))), "<<builtin add>>");
4520    }
4521
4522    // ── NixAttrs ─────────────────────────────────────────
4523
4524    #[test]
4525    fn nixattrs_update_merging() {
4526        let mut a = NixAttrs::new();
4527        a.insert("x".to_string(), Value::Int(1));
4528        a.insert("y".to_string(), Value::Int(2));
4529        let mut b = NixAttrs::new();
4530        b.insert("y".to_string(), Value::Int(99));
4531        b.insert("z".to_string(), Value::Int(3));
4532        let merged = a.update(&b);
4533        assert_eq!(merged.get("x"), Some(&Value::Int(1)));
4534        assert_eq!(merged.get("y"), Some(&Value::Int(99)));
4535        assert_eq!(merged.get("z"), Some(&Value::Int(3)));
4536        assert_eq!(merged.len(), 3);
4537    }
4538
4539    #[test]
4540    fn nixattrs_contains_key() {
4541        let mut a = NixAttrs::new();
4542        a.insert("foo".to_string(), Value::Null);
4543        assert!(a.contains_key("foo"));
4544        assert!(!a.contains_key("bar"));
4545    }
4546
4547    // ── Env ──────────────────────────────────────────────
4548
4549    #[test]
4550    fn env_lookup_through_parent_chain() {
4551        let mut root = Env::new();
4552        root.bind("a".to_string(), Value::Int(1));
4553        let mut child = root.child();
4554        child.bind("b".to_string(), Value::Int(2));
4555        let grandchild = child.child();
4556        // grandchild can see both a and b through parent chain
4557        assert_eq!(grandchild.lookup("a"), Some(Value::Int(1)));
4558        assert_eq!(grandchild.lookup("b"), Some(Value::Int(2)));
4559        assert_eq!(grandchild.lookup("c"), None);
4560    }
4561
4562    #[test]
4563    fn env_with_scope_lookup() {
4564        let mut attrs = NixAttrs::new();
4565        attrs.insert("x".to_string(), Value::Int(42));
4566        let env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
4567        assert_eq!(env.lookup("x"), Some(Value::Int(42)));
4568        assert_eq!(env.lookup("y"), None);
4569    }
4570
4571    #[test]
4572    fn env_local_shadows_with_scope() {
4573        let mut attrs = NixAttrs::new();
4574        attrs.insert("x".to_string(), Value::Int(1));
4575        let mut env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
4576        env.bind("x".to_string(), Value::Int(99));
4577        assert_eq!(env.lookup("x"), Some(Value::Int(99)));
4578    }
4579
4580    // ── NixString context propagation ─────────────────────
4581
4582    #[test]
4583    fn string_context_merge_combines_elements() {
4584        let mut ctx_a = StringContext::new();
4585        ctx_a.add_plain("/nix/store/aaa".to_string());
4586        let mut ctx_b = StringContext::new();
4587        ctx_b.add_plain("/nix/store/bbb".to_string());
4588        ctx_a.merge(&ctx_b);
4589        assert_eq!(ctx_a.len(), 2);
4590        assert!(ctx_a.elements().contains(&ContextElement::Plain(SmolStr::from("/nix/store/aaa"))));
4591        assert!(ctx_a.elements().contains(&ContextElement::Plain(SmolStr::from("/nix/store/bbb"))));
4592    }
4593
4594    #[test]
4595    fn string_context_merge_deduplicates() {
4596        let mut ctx = StringContext::new();
4597        ctx.add_plain("/nix/store/same".to_string());
4598        ctx.add_plain("/nix/store/same".to_string());
4599        assert_eq!(ctx.len(), 1);
4600    }
4601
4602    #[test]
4603    fn string_context_mixed_element_types() {
4604        let mut ctx = StringContext::new();
4605        ctx.add_plain("/nix/store/foo".to_string());
4606        ctx.add_output("/nix/store/bar.drv".to_string(), "out".to_string());
4607        ctx.add_drv_deep("/nix/store/baz.drv".to_string());
4608        assert_eq!(ctx.len(), 3);
4609        assert!(!ctx.is_empty());
4610    }
4611
4612    #[test]
4613    fn string_context_new_is_empty() {
4614        let ctx = StringContext::new();
4615        assert!(ctx.is_empty());
4616        assert_eq!(ctx.len(), 0);
4617    }
4618
4619    #[test]
4620    fn string_context_merge_zero_elements() {
4621        let mut ctx_a = StringContext::new();
4622        let ctx_b = StringContext::new();
4623        ctx_a.merge(&ctx_b);
4624        assert!(ctx_a.is_empty());
4625    }
4626
4627    #[test]
4628    fn string_context_merge_one_element() {
4629        let mut ctx = StringContext::new();
4630        let mut other = StringContext::new();
4631        other.add_plain("/nix/store/only".to_string());
4632        ctx.merge(&other);
4633        assert_eq!(ctx.len(), 1);
4634        assert!(ctx.elements().contains(&ContextElement::Plain(SmolStr::from("/nix/store/only"))));
4635    }
4636
4637    #[test]
4638    fn string_context_merge_two_elements() {
4639        let mut ctx = StringContext::new();
4640        ctx.add_plain("/nix/store/a".to_string());
4641        let mut other = StringContext::new();
4642        other.add_plain("/nix/store/b".to_string());
4643        ctx.merge(&other);
4644        assert_eq!(ctx.len(), 2);
4645    }
4646
4647    #[test]
4648    fn string_context_merge_five_elements() {
4649        let mut ctx = StringContext::new();
4650        for i in 0..5 {
4651            ctx.add_plain(format!("/nix/store/path-{i}"));
4652        }
4653        assert_eq!(ctx.len(), 5);
4654        for i in 0..5 {
4655            assert!(ctx.elements().contains(&ContextElement::Plain(SmolStr::from(format!("/nix/store/path-{i}").as_str()))));
4656        }
4657    }
4658
4659    #[test]
4660    fn string_context_insert_deduplicates() {
4661        let mut ctx = StringContext::new();
4662        ctx.insert(ContextElement::Plain(SmolStr::from("/nix/store/dup")));
4663        ctx.insert(ContextElement::Plain(SmolStr::from("/nix/store/dup")));
4664        ctx.insert(ContextElement::Output { drv: SmolStr::from("/nix/store/x.drv"), output: SmolStr::from("out") });
4665        ctx.insert(ContextElement::Output { drv: SmolStr::from("/nix/store/x.drv"), output: SmolStr::from("out") });
4666        assert_eq!(ctx.len(), 2);
4667    }
4668
4669    #[test]
4670    fn nix_string_plain_has_no_context() {
4671        let s = NixString::plain("hello");
4672        assert!(!s.has_context());
4673        assert_eq!(s.as_str(), "hello");
4674    }
4675
4676    #[test]
4677    fn nix_string_with_context_reports_context() {
4678        let mut ctx = StringContext::new();
4679        ctx.add_plain("/nix/store/xyz".to_string());
4680        let s = NixString::with_context("hello", ctx);
4681        assert!(s.has_context());
4682        assert_eq!(s.as_str(), "hello");
4683    }
4684
4685    #[test]
4686    fn nix_string_display_shows_chars_only() {
4687        let mut ctx = StringContext::new();
4688        ctx.add_plain("/nix/store/abc".to_string());
4689        let s = NixString::with_context("visible", ctx);
4690        assert_eq!(format!("{s}"), "visible");
4691    }
4692
4693    #[test]
4694    fn nix_string_struct_eq_includes_context() {
4695        let plain = NixString::plain("hello");
4696        let mut ctx = StringContext::new();
4697        ctx.add_plain("/nix/store/xxx".to_string());
4698        let with_ctx = NixString::with_context("hello", ctx);
4699        // NixString's derived PartialEq compares context too
4700        assert_ne!(plain, with_ctx);
4701    }
4702
4703    #[test]
4704    fn value_string_eq_ignores_context() {
4705        let plain = Value::String(Rc::new(NixString::plain("hello")));
4706        let mut ctx = StringContext::new();
4707        ctx.add_plain("/nix/store/xxx".to_string());
4708        let with_ctx = Value::String(Rc::new(NixString::with_context("hello", ctx)));
4709        // Value::PartialEq only compares .chars, ignoring context
4710        assert_eq!(plain, with_ctx);
4711    }
4712
4713    // ── Env deeply nested with-scopes ─────────────────────
4714
4715    #[test]
4716    fn env_nested_with_inner_wins() {
4717        let mut outer_attrs = NixAttrs::new();
4718        outer_attrs.insert("x".to_string(), Value::Int(1));
4719        let outer = Env::new().with_scope(Value::Attrs(Rc::new(outer_attrs)));
4720        let mut inner_attrs = NixAttrs::new();
4721        inner_attrs.insert("x".to_string(), Value::Int(2));
4722        let inner = outer.child().with_scope(Value::Attrs(Rc::new(inner_attrs)));
4723        assert_eq!(inner.lookup("x"), Some(Value::Int(2)));
4724    }
4725
4726    #[test]
4727    fn env_nested_with_fallback_to_outer() {
4728        let mut outer_attrs = NixAttrs::new();
4729        outer_attrs.insert("x".to_string(), Value::Int(1));
4730        let outer = Env::new().with_scope(Value::Attrs(Rc::new(outer_attrs)));
4731        let mut inner_attrs = NixAttrs::new();
4732        inner_attrs.insert("y".to_string(), Value::Int(2));
4733        let inner = outer.child().with_scope(Value::Attrs(Rc::new(inner_attrs)));
4734        assert_eq!(inner.lookup("x"), Some(Value::Int(1)));
4735        assert_eq!(inner.lookup("y"), Some(Value::Int(2)));
4736    }
4737
4738    #[test]
4739    fn env_lexical_binding_wins_over_all_with_scopes() {
4740        let mut outer_attrs = NixAttrs::new();
4741        outer_attrs.insert("x".to_string(), Value::Int(1));
4742        let outer = Env::new().with_scope(Value::Attrs(Rc::new(outer_attrs)));
4743        let mut inner_attrs = NixAttrs::new();
4744        inner_attrs.insert("x".to_string(), Value::Int(2));
4745        let mut inner = outer.child().with_scope(Value::Attrs(Rc::new(inner_attrs)));
4746        inner.bind("x".to_string(), Value::Int(99));
4747        assert_eq!(inner.lookup("x"), Some(Value::Int(99)));
4748    }
4749
4750    #[test]
4751    fn env_parent_lexical_wins_over_child_with_scope() {
4752        let mut root = Env::new();
4753        root.bind("x".to_string(), Value::Int(10));
4754        let mut child_attrs = NixAttrs::new();
4755        child_attrs.insert("x".to_string(), Value::Int(20));
4756        let child = root.child().with_scope(Value::Attrs(Rc::new(child_attrs)));
4757        assert_eq!(child.lookup("x"), Some(Value::Int(10)));
4758    }
4759
4760    #[test]
4761    fn env_deeply_nested_with_scopes_three_levels() {
4762        let mut a = NixAttrs::new();
4763        a.insert("x".to_string(), Value::Int(1));
4764        let env1 = Env::new().with_scope(Value::Attrs(Rc::new(a)));
4765
4766        let mut b = NixAttrs::new();
4767        b.insert("y".to_string(), Value::Int(2));
4768        let env2 = env1.child().with_scope(Value::Attrs(Rc::new(b)));
4769
4770        let mut c = NixAttrs::new();
4771        c.insert("z".to_string(), Value::Int(3));
4772        let env3 = env2.child().with_scope(Value::Attrs(Rc::new(c)));
4773
4774        assert_eq!(env3.lookup("x"), Some(Value::Int(1)));
4775        assert_eq!(env3.lookup("y"), Some(Value::Int(2)));
4776        assert_eq!(env3.lookup("z"), Some(Value::Int(3)));
4777        assert_eq!(env3.lookup("w"), None);
4778    }
4779
4780    #[test]
4781    fn env_with_scope_does_not_pollute_bindings() {
4782        // With-scope values should not appear in the flat binding map.
4783        // They should only be found via the with-scope lookup path.
4784        let mut attrs = NixAttrs::new();
4785        attrs.insert("x".to_string(), Value::Int(42));
4786        let env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
4787        // The binding map itself should not contain "x"
4788        assert!(env.0.bindings.get(&intern("x")).is_none());
4789        // But lookup should find it via with-scope
4790        assert_eq!(env.lookup("x"), Some(Value::Int(42)));
4791    }
4792
4793    #[test]
4794    fn env_lexical_binding_not_in_with_scopes() {
4795        // Lexical bindings are in the flat binding map, not in with_scopes.
4796        let mut env = Env::new();
4797        env.bind("x".to_string(), Value::Int(42));
4798        // with_scopes should be empty
4799        assert!(env.0.with_scopes.is_empty());
4800        // But lookup finds it via the binding map
4801        assert_eq!(env.lookup("x"), Some(Value::Int(42)));
4802    }
4803
4804    #[test]
4805    fn env_child_inherits_eval_file() {
4806        let mut env = Env::new();
4807        env.set_eval_file(Some(std::path::PathBuf::from("/foo/bar.nix")));
4808        let child = env.child();
4809        assert_eq!(child.eval_file().cloned(), Some(std::path::PathBuf::from("/foo/bar.nix")));
4810    }
4811
4812    #[test]
4813    fn env_new_has_no_parent_no_with() {
4814        let env = Env::new();
4815        assert_eq!(env.lookup("anything"), None);
4816        assert!(env.eval_file().is_none());
4817    }
4818
4819    // ── Thunk state machine ───────────────────────────────
4820
4821    #[test]
4822    fn thunk_new_suspended_is_not_evaluated() {
4823        let root = rnix::Root::parse("42");
4824        let expr = root.tree().expr().unwrap();
4825        let thunk = Thunk::new_suspended(expr, Env::new());
4826        assert!(!thunk.is_evaluated());
4827    }
4828
4829    #[test]
4830    fn thunk_new_evaluated_is_evaluated() {
4831        let thunk = Thunk::new_evaluated(Value::Int(42));
4832        assert!(thunk.is_evaluated());
4833    }
4834
4835    #[test]
4836    fn thunk_force_evaluates_suspended() {
4837        let root = rnix::Root::parse("42");
4838        let expr = root.tree().expr().unwrap();
4839        let thunk = Thunk::new_suspended(expr, Env::new());
4840        let result = thunk.force(&|e, env| crate::eval::eval_expr(e, env));
4841        assert!(result.is_ok());
4842        assert_eq!(result.unwrap(), Value::Int(42));
4843        assert!(thunk.is_evaluated());
4844    }
4845
4846    #[test]
4847    fn thunk_force_memoizes_result() {
4848        let root = rnix::Root::parse("1 + 2");
4849        let expr = root.tree().expr().unwrap();
4850        let thunk = Thunk::new_suspended(expr, Env::new());
4851        let r1 = thunk.force(&|e, env| crate::eval::eval_expr(e, env)).unwrap();
4852        let r2 = thunk.force(&|e, env| crate::eval::eval_expr(e, env)).unwrap();
4853        assert_eq!(r1, Value::Int(3));
4854        assert_eq!(r2, Value::Int(3));
4855    }
4856
4857    #[test]
4858    fn thunk_force_already_evaluated_returns_value() {
4859        let thunk = Thunk::new_evaluated(Value::Bool(true));
4860        let result = thunk.force(&|_, _| panic!("should not be called"));
4861        assert_eq!(result.unwrap(), Value::Bool(true));
4862    }
4863
4864    // C-store PROVABLY-NEUTRAL seal (M2): a force whose body returns a
4865    // CONCRETE (non-Thunk) value takes the redundant-Store#2 skip path
4866    // (`!was_thunk_before_loop` early-return). It must still (a) return the
4867    // correct value, (b) be `is_evaluated()`, (c) populate the OnceCell so
4868    // the fast-path returns the identical value on re-force (proving Store#1's
4869    // guarded `cache.set` — NOT the skipped Store#2 — is what seals the cache),
4870    // and (d) `peek()` returns the value (repr holds it). If the skip dropped
4871    // the terminal state, one of these would regress.
4872    #[test]
4873    fn thunk_force_concrete_skips_redundant_store_but_caches() {
4874        // `1 + 2` evaluates directly to a concrete Int (no thunk-chain unwrap),
4875        // so it exercises the `!was_thunk_before_loop` skip branch.
4876        let root = rnix::Root::parse("1 + 2");
4877        let expr = root.tree().expr().unwrap();
4878        let thunk = Thunk::new_suspended(expr, Env::new());
4879
4880        let r1 = thunk.force(&|e, env| crate::eval::eval_expr(e, env)).unwrap();
4881        assert_eq!(r1, Value::Int(3));
4882        assert!(thunk.is_evaluated());
4883
4884        // OnceCell must be populated (peek sees the value) — proves Store#1's
4885        // guarded cache.set fired and the skipped Store#2 was truly redundant.
4886        assert_eq!(thunk.peek().map(|c| c.clone().into_value()), Some(Value::Int(3)));
4887
4888        // Re-force hits the OnceCell ultra-fast path and returns byte-identical.
4889        let r2 = thunk.force(&|_, _| panic!("re-force must hit the cache, not re-eval")).unwrap();
4890        assert_eq!(r2, Value::Int(3));
4891    }
4892
4893    #[test]
4894    fn thunk_blackhole_detects_infinite_recursion() {
4895        let root = rnix::Root::parse("42");
4896        let expr = root.tree().expr().unwrap();
4897        let thunk = Thunk::new_suspended(expr, Env::new());
4898
4899        // Manually set to blackhole to simulate re-entrance
4900        // SAFETY: Test-only, single-threaded.
4901        *unsafe { &mut *thunk.0.repr.get() } = ThunkRepr::Blackhole;
4902
4903        let result = thunk.force(&|_, _| Ok(Value::Null));
4904        assert!(result.is_err());
4905        let err_msg = format!("{}", result.unwrap_err());
4906        assert!(err_msg.contains("infinite recursion"));
4907    }
4908
4909    #[test]
4910    fn thunk_update_env_replaces_suspended_env() {
4911        let root = rnix::Root::parse("x");
4912        let expr = root.tree().expr().unwrap();
4913        let thunk = Thunk::new_suspended(expr, Env::new());
4914
4915        let mut new_env = Env::new();
4916        new_env.bind("x".to_string(), Value::Int(99));
4917        thunk.update_env(&new_env);
4918
4919        let result = thunk.force(&|e, env| crate::eval::eval_expr(e, env));
4920        assert_eq!(result.unwrap(), Value::Int(99));
4921    }
4922
4923    #[test]
4924    fn thunk_update_env_noop_when_evaluated() {
4925        let thunk = Thunk::new_evaluated(Value::Int(1));
4926        let mut new_env = Env::new();
4927        new_env.bind("x".to_string(), Value::Int(99));
4928        thunk.update_env(&new_env);
4929        assert_eq!(
4930            thunk.force(&|_, _| panic!("should not be called")).unwrap(),
4931            Value::Int(1),
4932        );
4933    }
4934
4935    #[test]
4936    fn thunk_debug_suspended() {
4937        let root = rnix::Root::parse("42");
4938        let expr = root.tree().expr().unwrap();
4939        let thunk = Thunk::new_suspended(expr, Env::new());
4940        assert_eq!(format!("{thunk:?}"), "<thunk>");
4941    }
4942
4943    #[test]
4944    fn thunk_debug_evaluated() {
4945        let thunk = Thunk::new_evaluated(Value::Int(42));
4946        let dbg = format!("{thunk:?}");
4947        assert!(dbg.contains("42"));
4948    }
4949
4950    #[test]
4951    fn thunk_error_restores_suspended_state() {
4952        let root = rnix::Root::parse("nonexistent_var");
4953        let expr = root.tree().expr().unwrap();
4954        let thunk = Thunk::new_suspended(expr, Env::new());
4955
4956        let result = thunk.force(&|e, env| crate::eval::eval_expr(e, env));
4957        assert!(result.is_err());
4958        // After error, thunk should be restored to Suspended, not stuck as Blackhole
4959        assert!(!thunk.is_evaluated());
4960        let dbg = format!("{thunk:?}");
4961        assert_eq!(dbg, "<thunk>");
4962    }
4963
4964    #[test]
4965    fn thunk_inherit_select_forces_and_selects() {
4966        let root = rnix::Root::parse(r#"{ x = 42; }"#);
4967        let expr = root.tree().expr().unwrap();
4968        let source = Thunk::new_suspended(expr, Env::new());
4969        let thunk = Thunk::new_inherit_select(source, "x".to_string());
4970        let result = thunk.force(&|e, env| crate::eval::eval_expr(e, env));
4971        assert_eq!(result.unwrap(), Value::Int(42));
4972        assert!(thunk.is_evaluated());
4973    }
4974
4975    #[test]
4976    fn thunk_inherit_select_missing_attr_errors() {
4977        let root = rnix::Root::parse(r#"{ x = 42; }"#);
4978        let expr = root.tree().expr().unwrap();
4979        let source = Thunk::new_suspended(expr, Env::new());
4980        let thunk = Thunk::new_inherit_select(source, "y".to_string());
4981        let result = thunk.force(&|e, env| crate::eval::eval_expr(e, env));
4982        assert!(result.is_err());
4983        // Thunk should restore to InheritSelect, not be stuck as Blackhole
4984        assert!(!thunk.is_evaluated());
4985    }
4986
4987    #[test]
4988    fn thunk_inherit_select_non_attrs_source_errors() {
4989        let root = rnix::Root::parse("42");
4990        let expr = root.tree().expr().unwrap();
4991        let source = Thunk::new_suspended(expr, Env::new());
4992        let thunk = Thunk::new_inherit_select(source, "x".to_string());
4993        let result = thunk.force(&|e, env| crate::eval::eval_expr(e, env));
4994        assert!(result.is_err());
4995        let msg = format!("{}", result.unwrap_err());
4996        assert!(msg.contains("not a set"));
4997    }
4998
4999    #[test]
5000    fn thunk_inherit_select_shares_source_thunk() {
5001        // Two InheritSelect thunks share the same source thunk.
5002        // Forcing one should evaluate the source; the second should
5003        // get a cache hit on the shared source thunk.
5004        let root = rnix::Root::parse(r#"{ a = 1; b = 2; }"#);
5005        let expr = root.tree().expr().unwrap();
5006        let source = Thunk::new_suspended(expr, Env::new());
5007        let thunk_a = Thunk::new_inherit_select(source.clone(), "a".to_string());
5008        let thunk_b = Thunk::new_inherit_select(source.clone(), "b".to_string());
5009        let result_a = thunk_a.force(&|e, env| crate::eval::eval_expr(e, env));
5010        assert_eq!(result_a.unwrap(), Value::Int(1));
5011        // Source thunk should now be evaluated (memoized).
5012        assert!(source.is_evaluated());
5013        // Second force should hit the source thunk's cache.
5014        let result_b = thunk_b.force(&|e, env| crate::eval::eval_expr(e, env));
5015        assert_eq!(result_b.unwrap(), Value::Int(2));
5016    }
5017
5018    // ── NixAttrs additional tests ─────────────────────────
5019
5020    #[test]
5021    fn nixattrs_empty_operations() {
5022        let a = NixAttrs::new();
5023        assert!(a.is_empty());
5024        assert_eq!(a.len(), 0);
5025        assert_eq!(a.get("x"), None);
5026        assert!(!a.contains_key("x"));
5027        assert_eq!(a.keys().count(), 0);
5028        assert_eq!(a.iter().count(), 0);
5029    }
5030
5031    #[test]
5032    fn nixattrs_update_with_empty() {
5033        let mut a = NixAttrs::new();
5034        a.insert("x".to_string(), Value::Int(1));
5035        let b = NixAttrs::new();
5036        let merged = a.update(&b);
5037        assert_eq!(merged.len(), 1);
5038        assert_eq!(merged.get("x"), Some(&Value::Int(1)));
5039    }
5040
5041    #[test]
5042    fn nixattrs_update_empty_with_nonempty() {
5043        let a = NixAttrs::new();
5044        let mut b = NixAttrs::new();
5045        b.insert("x".to_string(), Value::Int(1));
5046        let merged = a.update(&b);
5047        assert_eq!(merged.len(), 1);
5048        assert_eq!(merged.get("x"), Some(&Value::Int(1)));
5049    }
5050
5051    #[test]
5052    fn nixattrs_keys_sorted_order() {
5053        let mut a = NixAttrs::new();
5054        a.insert("c".to_string(), Value::Int(3));
5055        a.insert("a".to_string(), Value::Int(1));
5056        a.insert("b".to_string(), Value::Int(2));
5057        let keys: Vec<String> = a.keys().collect();
5058        assert_eq!(keys, vec!["a", "b", "c"]);
5059    }
5060
5061    // ── Value convenience methods ─────────────────────────
5062
5063    #[test]
5064    fn value_to_str_forces_thunks() {
5065        let root = rnix::Root::parse(r#""hello""#);
5066        let expr = root.tree().expr().unwrap();
5067        let thunk = Thunk::new_suspended(expr, Env::new());
5068        let val = Value::Thunk(thunk);
5069        assert_eq!(val.to_str().unwrap(), "hello");
5070    }
5071
5072    #[test]
5073    fn value_to_nix_string_forces_thunks() {
5074        let root = rnix::Root::parse(r#""world""#);
5075        let expr = root.tree().expr().unwrap();
5076        let thunk = Thunk::new_suspended(expr, Env::new());
5077        let val = Value::Thunk(thunk);
5078        let ns = val.to_nix_string().unwrap();
5079        assert_eq!(ns.as_str(), "world");
5080        assert!(!ns.has_context());
5081    }
5082
5083    #[test]
5084    fn value_to_attrs_forces_thunks() {
5085        let root = rnix::Root::parse("{ x = 1; }");
5086        let expr = root.tree().expr().unwrap();
5087        let thunk = Thunk::new_suspended(expr, Env::new());
5088        let val = Value::Thunk(thunk);
5089        let attrs = val.to_attrs().unwrap();
5090        assert_eq!(attrs.len(), 1);
5091    }
5092
5093    #[test]
5094    fn value_to_list_forces_thunks() {
5095        let root = rnix::Root::parse("[1 2 3]");
5096        let expr = root.tree().expr().unwrap();
5097        let thunk = Thunk::new_suspended(expr, Env::new());
5098        let val = Value::Thunk(thunk);
5099        let list = val.to_list().unwrap();
5100        assert_eq!(list.len(), 3);
5101    }
5102
5103    #[test]
5104    fn value_to_float_on_thunk() {
5105        let root = rnix::Root::parse("3.14");
5106        let expr = root.tree().expr().unwrap();
5107        let thunk = Thunk::new_suspended(expr, Env::new());
5108        let val = Value::Thunk(thunk);
5109        let f = val.to_float().unwrap();
5110        assert!((f - 3.14).abs() < f64::EPSILON);
5111    }
5112
5113    #[test]
5114    fn value_as_bool_on_thunk() {
5115        let root = rnix::Root::parse("true");
5116        let expr = root.tree().expr().unwrap();
5117        let thunk = Thunk::new_suspended(expr, Env::new());
5118        let val = Value::Thunk(thunk);
5119        assert!(val.as_bool().unwrap());
5120    }
5121
5122    #[test]
5123    fn value_as_int_on_thunk() {
5124        let root = rnix::Root::parse("42");
5125        let expr = root.tree().expr().unwrap();
5126        let thunk = Thunk::new_suspended(expr, Env::new());
5127        let val = Value::Thunk(thunk);
5128        assert_eq!(val.as_int().unwrap(), 42);
5129    }
5130
5131    #[test]
5132    fn value_string_constructor() {
5133        let v = Value::string("test");
5134        assert_eq!(v, Value::String(Rc::new(NixString::plain("test"))));
5135    }
5136
5137    #[test]
5138    fn value_partial_eq_null_null() {
5139        assert_eq!(Value::Null, Value::Null);
5140    }
5141
5142    #[test]
5143    fn value_partial_eq_lists_deep() {
5144        let a = Value::list(vec![Value::Int(1), Value::list(vec![Value::Int(2)])]);
5145        let b = Value::list(vec![Value::Int(1), Value::list(vec![Value::Int(2)])]);
5146        assert_eq!(a, b);
5147    }
5148
5149    #[test]
5150    fn value_partial_eq_attrs_deep() {
5151        let mut a = NixAttrs::new();
5152        a.insert("x".to_string(), Value::Int(1));
5153        let mut b = NixAttrs::new();
5154        b.insert("x".to_string(), Value::Int(1));
5155        assert_eq!(Value::Attrs(Rc::new(a)), Value::Attrs(Rc::new(b)));
5156    }
5157
5158    // ── EvalError variants & convenience constructors ────
5159
5160    #[test]
5161    fn eval_error_type_error_constructor() {
5162        let e = EvalError::type_error("oops");
5163        assert!(matches!(e, EvalError::TypeError(ref s) if s == "oops"));
5164    }
5165
5166    #[test]
5167    fn eval_error_type_mismatch_constructor() {
5168        let e = EvalError::type_mismatch("int", "string");
5169        match e {
5170            EvalError::TypeMismatch { expected, got } => {
5171                assert_eq!(expected, "int");
5172                assert_eq!(got, "string");
5173            }
5174            _ => panic!("expected TypeMismatch"),
5175        }
5176    }
5177
5178    #[test]
5179    fn eval_error_is_throw_yes_no() {
5180        assert!(EvalError::Throw("oops".into()).is_throw());
5181        assert!(!EvalError::TypeError("oops".into()).is_throw());
5182        assert!(!EvalError::AssertionFailed(String::new()).is_throw());
5183    }
5184
5185    #[test]
5186    fn eval_error_is_infinite_recursion_yes_no() {
5187        assert!(EvalError::InfiniteRecursion("loop".into()).is_infinite_recursion());
5188        assert!(!EvalError::DivisionByZero.is_infinite_recursion());
5189        assert!(!EvalError::Throw("x".into()).is_infinite_recursion());
5190    }
5191
5192    #[test]
5193    fn eval_error_display_undefined_var() {
5194        let s = format!("{}", EvalError::UndefinedVar("foo".into()));
5195        assert!(s.contains("undefined variable"));
5196        assert!(s.contains("foo"));
5197    }
5198
5199    #[test]
5200    fn eval_error_display_type_error() {
5201        let s = format!("{}", EvalError::TypeError("bad".into()));
5202        assert!(s.contains("type error"));
5203        assert!(s.contains("bad"));
5204    }
5205
5206    #[test]
5207    fn eval_error_display_attr_not_found() {
5208        let s = format!("{}", EvalError::AttrNotFound("x".into()));
5209        assert!(s.contains("attribute not found"));
5210        assert!(s.contains("x"));
5211    }
5212
5213    #[test]
5214    fn eval_error_display_type_mismatch() {
5215        let s = format!(
5216            "{}",
5217            EvalError::TypeMismatch { expected: "int", got: "string" }
5218        );
5219        assert!(s.contains("expected int"));
5220        assert!(s.contains("got string"));
5221    }
5222
5223    #[test]
5224    fn eval_error_display_assertion_failed() {
5225        let s = format!("{}", EvalError::AssertionFailed(String::new()));
5226        assert!(s.contains("assertion"));
5227    }
5228
5229    #[test]
5230    fn eval_error_display_division_by_zero() {
5231        let s = format!("{}", EvalError::DivisionByZero);
5232        assert!(s.contains("division by zero"));
5233    }
5234
5235    #[test]
5236    fn eval_error_display_infinite_recursion() {
5237        let s = format!("{}", EvalError::InfiniteRecursion("loop".into()));
5238        assert!(s.contains("infinite recursion"));
5239        assert!(s.contains("loop"));
5240    }
5241
5242    #[test]
5243    fn eval_error_display_io_error() {
5244        let s = format!(
5245            "{}",
5246            EvalError::IoError {
5247                context: "ctx".into(),
5248                message: "no such file".into(),
5249            }
5250        );
5251        assert!(s.contains("I/O"));
5252        assert!(s.contains("ctx"));
5253        assert!(s.contains("no such file"));
5254    }
5255
5256    #[test]
5257    fn eval_error_display_throw() {
5258        let s = format!("{}", EvalError::Throw("boom".into()));
5259        assert_eq!(s, "boom");
5260    }
5261
5262    #[test]
5263    fn eval_error_display_not_implemented() {
5264        let s = format!("{}", EvalError::NotImplemented("frob".into()));
5265        assert!(s.contains("not yet implemented"));
5266        assert!(s.contains("frob"));
5267    }
5268
5269    #[test]
5270    fn eval_error_display_parse_error() {
5271        let s = format!("{}", EvalError::ParseError("syntax".into()));
5272        assert!(s.contains("parse error"));
5273        assert!(s.contains("syntax"));
5274    }
5275
5276    #[test]
5277    fn eval_error_display_recursion_limit() {
5278        let s = format!(
5279            "{}",
5280            EvalError::RecursionLimit("max depth exceeded".into())
5281        );
5282        assert!(s.contains("recursion limit"));
5283        assert!(s.contains("max depth exceeded"));
5284    }
5285
5286    #[test]
5287    fn eval_error_partial_eq_same_variant() {
5288        assert_eq!(
5289            EvalError::UndefinedVar("x".into()),
5290            EvalError::UndefinedVar("x".into()),
5291        );
5292        assert_ne!(
5293            EvalError::UndefinedVar("x".into()),
5294            EvalError::UndefinedVar("y".into()),
5295        );
5296        assert_ne!(
5297            EvalError::UndefinedVar("x".into()),
5298            EvalError::AttrNotFound("x".into()),
5299        );
5300    }
5301
5302    // ── ContextElement display ───────────────────────────
5303
5304    #[test]
5305    fn context_element_display_plain() {
5306        let e = ContextElement::Plain("/nix/store/xyz".into());
5307        assert_eq!(format!("{e}"), "/nix/store/xyz");
5308    }
5309
5310    #[test]
5311    fn context_element_display_output() {
5312        let e = ContextElement::Output {
5313            drv: "/nix/store/abc.drv".into(),
5314            output: "out".into(),
5315        };
5316        assert_eq!(format!("{e}"), "/nix/store/abc.drv!out");
5317    }
5318
5319    #[test]
5320    fn context_element_display_drv_deep() {
5321        let e = ContextElement::DrvDeep("/nix/store/abc.drv".into());
5322        assert_eq!(format!("{e}"), "=/nix/store/abc.drv");
5323    }
5324
5325    // ── StringContext additional API ─────────────────────
5326
5327    #[test]
5328    fn string_context_iter_yields_all() {
5329        let mut ctx = StringContext::new();
5330        ctx.add_plain("/nix/store/aaa");
5331        ctx.add_plain("/nix/store/bbb");
5332        let count = ctx.iter().count();
5333        assert_eq!(count, 2);
5334    }
5335
5336    #[test]
5337    fn string_context_len_matches_set_size() {
5338        let mut ctx = StringContext::new();
5339        assert_eq!(ctx.len(), 0);
5340        ctx.add_plain("/nix/store/x");
5341        assert_eq!(ctx.len(), 1);
5342        ctx.add_output("/nix/store/y.drv", "out");
5343        assert_eq!(ctx.len(), 2);
5344    }
5345
5346    #[test]
5347    fn string_context_insert_raw_element() {
5348        let mut ctx = StringContext::new();
5349        ctx.insert(ContextElement::Plain("/nix/store/foo".into()));
5350        assert_eq!(ctx.len(), 1);
5351    }
5352
5353    #[test]
5354    fn string_context_default_is_empty() {
5355        let ctx = StringContext::default();
5356        assert!(ctx.is_empty());
5357    }
5358
5359    // ── NixString additional traits ──────────────────────
5360
5361    #[test]
5362    fn nix_string_as_ref_str() {
5363        let s = NixString::plain("hello");
5364        let r: &str = s.as_ref();
5365        assert_eq!(r, "hello");
5366    }
5367
5368    #[test]
5369    fn nix_string_deref_to_str_methods() {
5370        let s = NixString::plain("Hello World");
5371        assert_eq!(s.len(), 11);
5372        assert!(s.starts_with("Hello"));
5373        // Calling &str method via Deref proves Deref impl is wired up.
5374        assert_eq!(s.to_uppercase(), "HELLO WORLD");
5375    }
5376
5377    // ── NixAttrs additional API ──────────────────────────
5378
5379    #[test]
5380    fn nixattrs_remove_returns_value() {
5381        let mut a = NixAttrs::new();
5382        a.insert("x".into(), Value::Int(1));
5383        let removed = a.remove("x");
5384        assert_eq!(removed, Some(Value::Int(1)));
5385        assert!(!a.contains_key("x"));
5386        assert_eq!(a.remove("y"), None);
5387    }
5388
5389    #[test]
5390    fn nixattrs_values_iter() {
5391        let mut a = NixAttrs::new();
5392        a.insert("a".into(), Value::Int(1));
5393        a.insert("b".into(), Value::Int(2));
5394        let mut vs: Vec<&Value> = a.values().collect();
5395        vs.sort_by_key(|v| match v {
5396            Value::Int(n) => *n,
5397            _ => 0,
5398        });
5399        assert_eq!(vs, vec![&Value::Int(1), &Value::Int(2)]);
5400    }
5401
5402    #[test]
5403    fn nixattrs_iter_returns_sorted_pairs() {
5404        let mut a = NixAttrs::new();
5405        a.insert("zeta".into(), Value::Int(3));
5406        a.insert("alpha".into(), Value::Int(1));
5407        a.insert("mu".into(), Value::Int(2));
5408        let pairs: Vec<(String, &Value)> = a.iter().collect();
5409        assert_eq!(pairs[0].0, "alpha");
5410        assert_eq!(pairs[1].0, "mu");
5411        assert_eq!(pairs[2].0, "zeta");
5412    }
5413
5414    #[test]
5415    fn nixattrs_from_iterator() {
5416        let pairs = vec![
5417            ("a".to_string(), Value::Int(1)),
5418            ("b".to_string(), Value::Int(2)),
5419        ];
5420        let attrs: NixAttrs = pairs.into_iter().collect();
5421        assert_eq!(attrs.len(), 2);
5422        assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5423        assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5424    }
5425
5426    #[test]
5427    fn nixattrs_into_iterator_yields_owned() {
5428        let mut a = NixAttrs::new();
5429        a.insert("x".into(), Value::Int(42));
5430        let pairs: Vec<(String, Value)> = a.into_iter().collect();
5431        assert_eq!(pairs.len(), 1);
5432        assert_eq!(pairs[0].0, "x");
5433        assert_eq!(pairs[0].1, Value::Int(42));
5434    }
5435
5436    #[test]
5437    fn nixattrs_default_is_empty() {
5438        let a = NixAttrs::default();
5439        assert!(a.is_empty());
5440    }
5441
5442    // ── Value::From conversions ──────────────────────────
5443
5444    #[test]
5445    fn value_from_bool() {
5446        assert_eq!(Value::from(true), Value::Bool(true));
5447        assert_eq!(Value::from(false), Value::Bool(false));
5448    }
5449
5450    #[test]
5451    fn value_from_i64() {
5452        assert_eq!(Value::from(42_i64), Value::Int(42));
5453        assert_eq!(Value::from(-1_i64), Value::Int(-1));
5454    }
5455
5456    #[test]
5457    fn value_from_f64() {
5458        assert_eq!(Value::from(2.5_f64), Value::Float(2.5));
5459    }
5460
5461    #[test]
5462    fn value_from_nix_string() {
5463        let v: Value = NixString::plain("hi").into();
5464        assert_eq!(v, Value::string("hi"));
5465    }
5466
5467    #[test]
5468    fn value_from_nix_attrs() {
5469        let mut a = NixAttrs::new();
5470        a.insert("x".into(), Value::Int(1));
5471        let v: Value = a.into();
5472        match v {
5473            Value::Attrs(_) => {}
5474            _ => panic!("expected Attrs"),
5475        }
5476    }
5477
5478    #[test]
5479    fn value_from_vec() {
5480        let v: Value = vec![Value::Int(1), Value::Int(2)].into();
5481        assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
5482    }
5483
5484    #[test]
5485    fn value_default_is_null() {
5486        let v: Value = Value::default();
5487        assert_eq!(v, Value::Null);
5488    }
5489
5490    // ── From<&serde_json::Value> ─────────────────────────
5491
5492    #[test]
5493    fn value_from_json_null() {
5494        let v = Value::from(&serde_json::Value::Null);
5495        assert_eq!(v, Value::Null);
5496    }
5497
5498    #[test]
5499    fn value_from_json_bool() {
5500        let v = Value::from(&serde_json::Value::Bool(true));
5501        assert_eq!(v, Value::Bool(true));
5502    }
5503
5504    #[test]
5505    fn value_from_json_int() {
5506        let v = Value::from(&serde_json::json!(42));
5507        assert_eq!(v, Value::Int(42));
5508    }
5509
5510    #[test]
5511    fn value_from_json_float() {
5512        let v = Value::from(&serde_json::json!(3.14));
5513        match v {
5514            Value::Float(f) => assert!((f - 3.14).abs() < f64::EPSILON),
5515            _ => panic!("expected Float"),
5516        }
5517    }
5518
5519    #[test]
5520    fn value_from_json_string() {
5521        let v = Value::from(&serde_json::Value::String("hi".into()));
5522        assert_eq!(v, Value::string("hi"));
5523    }
5524
5525    #[test]
5526    fn value_from_json_array() {
5527        let v = Value::from(&serde_json::json!([1, true, "x"]));
5528        match v {
5529            Value::List(items) => {
5530                assert_eq!(items.len(), 3);
5531                assert_eq!(items[0], Value::Int(1));
5532                assert_eq!(items[1], Value::Bool(true));
5533                assert_eq!(items[2], Value::string("x"));
5534            }
5535            _ => panic!("expected List"),
5536        }
5537    }
5538
5539    #[test]
5540    fn value_from_json_object() {
5541        let v = Value::from(&serde_json::json!({"a": 1, "b": "x"}));
5542        match v {
5543            Value::Attrs(attrs) => {
5544                assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5545                assert_eq!(attrs.get("b"), Some(&Value::string("x")));
5546            }
5547            _ => panic!("expected Attrs"),
5548        }
5549    }
5550
5551    #[test]
5552    fn value_from_json_nested() {
5553        let v = Value::from(&serde_json::json!({"outer": {"inner": [1, 2]}}));
5554        let json_back = v.to_json();
5555        assert_eq!(json_back, serde_json::json!({"outer": {"inner": [1, 2]}}));
5556    }
5557
5558    // ── From<&toml::Value> ──────────────────────────────
5559
5560    #[test]
5561    fn value_from_toml_string() {
5562        let t = toml::Value::String("hi".into());
5563        assert_eq!(Value::from(&t), Value::string("hi"));
5564    }
5565
5566    #[test]
5567    fn value_from_toml_int() {
5568        let t = toml::Value::Integer(42);
5569        assert_eq!(Value::from(&t), Value::Int(42));
5570    }
5571
5572    #[test]
5573    fn value_from_toml_float() {
5574        let t = toml::Value::Float(3.14);
5575        match Value::from(&t) {
5576            Value::Float(f) => assert!((f - 3.14).abs() < f64::EPSILON),
5577            _ => panic!("expected Float"),
5578        }
5579    }
5580
5581    #[test]
5582    fn value_from_toml_bool() {
5583        let t = toml::Value::Boolean(true);
5584        assert_eq!(Value::from(&t), Value::Bool(true));
5585    }
5586
5587    #[test]
5588    fn value_from_toml_array() {
5589        let t = toml::Value::Array(vec![
5590            toml::Value::Integer(1),
5591            toml::Value::Integer(2),
5592        ]);
5593        assert_eq!(
5594            Value::from(&t),
5595            Value::list(vec![Value::Int(1), Value::Int(2)]),
5596        );
5597    }
5598
5599    #[test]
5600    fn value_from_toml_table() {
5601        let mut tbl = toml::map::Map::new();
5602        tbl.insert("k".into(), toml::Value::Integer(7));
5603        let t = toml::Value::Table(tbl);
5604        match Value::from(&t) {
5605            Value::Attrs(attrs) => {
5606                assert_eq!(attrs.get("k"), Some(&Value::Int(7)));
5607            }
5608            _ => panic!("expected Attrs"),
5609        }
5610    }
5611
5612    #[test]
5613    fn value_from_toml_datetime_becomes_string() {
5614        // toml::Value::Datetime serializes via Display.
5615        let dt: toml::value::Datetime = "2024-01-01T00:00:00Z".parse().unwrap();
5616        let t = toml::Value::Datetime(dt);
5617        match Value::from(&t) {
5618            Value::String(_) => {}
5619            other => panic!("expected String, got {other:?}"),
5620        }
5621    }
5622
5623    // ── Value::coerce_to_path ────────────────────────────
5624
5625    #[test]
5626    fn coerce_to_path_from_path() {
5627        let v = Value::Path(Box::new("/foo".into()));
5628        assert_eq!(v.coerce_to_path("ctx").unwrap(), "/foo");
5629    }
5630
5631    #[test]
5632    fn coerce_to_path_from_string() {
5633        let v = Value::string("/bar");
5634        assert_eq!(v.coerce_to_path("ctx").unwrap(), "/bar");
5635    }
5636
5637    // ── Import-from-derivation (IFD) detection + realize seal ──────────
5638    //
5639    // These lock the parity-critical decision "is this coercion an
5640    // import-from-derivation that must realize?" — the same decision cppnix
5641    // makes off a string's `Output` context. Regressing them silently reopens
5642    // the marquee `import ishou.stylix-fonts` root.
5643
5644    #[test]
5645    fn out_path_needs_realize_matches_output_context() {
5646        // A store-path string carrying a derivation `Output` context IS a
5647        // derivation output → its producing `.drv` is returned for realize.
5648        let mut ctx = StringContext::new();
5649        ctx.add_output("/nix/store/aaa-thing.drv", "out");
5650        assert_eq!(
5651            super::out_path_needs_realize("/nix/store/bbb-thing", &ctx),
5652            Some("/nix/store/aaa-thing.drv".to_string()),
5653        );
5654    }
5655
5656    #[test]
5657    fn out_path_needs_realize_ignores_plain_context() {
5658        // A plain store-path reference (not a derivation output) has nothing to
5659        // build — no realize.
5660        let mut ctx = StringContext::new();
5661        ctx.add_plain("/nix/store/ccc-plain");
5662        assert_eq!(super::out_path_needs_realize("/nix/store/ccc-plain", &ctx), None);
5663    }
5664
5665    #[test]
5666    fn out_path_needs_realize_ignores_non_store_path() {
5667        // A non-store path is never a derivation output, even with an (invalid)
5668        // Output context — nothing to realize.
5669        let mut ctx = StringContext::new();
5670        ctx.add_output("/nix/store/ddd.drv", "out");
5671        assert_eq!(super::out_path_needs_realize("/etc/passwd", &ctx), None);
5672    }
5673
5674    #[test]
5675    fn out_path_needs_realize_empty_context_is_none() {
5676        // A bare store-path literal (empty context) is not a derivation output.
5677        let ctx = StringContext::new();
5678        assert_eq!(super::out_path_needs_realize("/nix/store/eee-lit", &ctx), None);
5679    }
5680
5681    #[test]
5682    fn coerce_to_realized_path_present_output_is_passthrough() {
5683        // When the output already exists on disk, no hook is needed and the
5684        // path is returned unchanged (the no-op realize path — proven live on
5685        // the already-built ifd-test derivation).
5686        let dir = std::env::temp_dir().join("sui-ifd-present-test");
5687        std::fs::create_dir_all(&dir).unwrap();
5688        let file = dir.join("out");
5689        std::fs::write(&file, b"present").unwrap();
5690        let present = file.to_string_lossy().to_string();
5691
5692        let mut ctx = StringContext::new();
5693        // Pretend it's a derivation output (Output context) — but it exists,
5694        // so realize must NOT be invoked (no hook installed → would ENOENT if
5695        // it tried). A store-prefix check would skip a temp path, so assert the
5696        // simpler invariant: an existing plain string coerces to itself.
5697        ctx.add_plain(&present);
5698        let v = Value::String(std::rc::Rc::new(NixString::with_context(
5699            present.as_str(),
5700            ctx,
5701        )));
5702        assert_eq!(v.coerce_to_realized_path("readFile").unwrap(), present);
5703    }
5704
5705    #[test]
5706    fn coerce_to_realized_path_absent_output_invokes_hook() {
5707        // A store-path string with an Output context whose output is ABSENT
5708        // invokes the realize hook with the producing drv. The mock hook
5709        // "materializes" nothing (the store path stays absent) but records the
5710        // call — proving the trigger fires end-to-end through coercion.
5711        use std::sync::{Arc, Mutex};
5712        let seen: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
5713        let seen2 = seen.clone();
5714        let _guard = crate::realize::install_realize_hook(Box::new(move |drv, out| {
5715            seen2.lock().unwrap().push((drv.to_string(), out.to_string()));
5716            Ok(())
5717        }));
5718
5719        // An absent store path (unique per run to avoid collision with a real
5720        // build) carrying an Output context.
5721        let out = "/nix/store/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-ifd-absent";
5722        assert!(!std::path::Path::new(out).exists(), "test store path must be absent");
5723        let mut ctx = StringContext::new();
5724        ctx.add_output("/nix/store/qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq-ifd-absent.drv", "out");
5725        let v = Value::String(std::rc::Rc::new(NixString::with_context(out, ctx)));
5726
5727        // Coercion returns the outPath and fires the hook exactly once.
5728        assert_eq!(v.coerce_to_realized_path("readFile").unwrap(), out);
5729        let s = seen.lock().unwrap();
5730        assert_eq!(s.len(), 1, "realize hook should fire once for an absent output");
5731        assert_eq!(s[0].0, "/nix/store/qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq-ifd-absent.drv");
5732        assert_eq!(s[0].1, out);
5733    }
5734
5735    #[test]
5736    fn coerce_to_path_errors_on_int() {
5737        let v = Value::Int(1);
5738        let e = v.coerce_to_path("readFile").unwrap_err();
5739        match e {
5740            EvalError::TypeError(ref msg) => {
5741                assert!(msg.contains("readFile"));
5742                assert!(msg.contains("path or string"));
5743                assert!(msg.contains("int"));
5744            }
5745            _ => panic!("expected TypeError"),
5746        }
5747    }
5748
5749    #[test]
5750    fn coerce_to_path_errors_on_null() {
5751        let v = Value::Null;
5752        assert!(v.coerce_to_path("ctx").is_err());
5753    }
5754
5755    #[test]
5756    fn coerce_to_path_attrs_with_outpath() {
5757        let mut attrs = NixAttrs::new();
5758        attrs.insert("outPath".to_string(), Value::string("/nix/store/test"));
5759        let val = Value::Attrs(Rc::new(attrs));
5760        assert_eq!(val.coerce_to_path("test").unwrap(), "/nix/store/test");
5761    }
5762
5763    #[test]
5764    fn coerce_to_path_attrs_without_outpath_fails() {
5765        let attrs = NixAttrs::new();
5766        let val = Value::Attrs(Rc::new(attrs));
5767        assert!(val.coerce_to_path("test").is_err());
5768    }
5769
5770    // ── Value::coerce_to_string ─────────────────────────
5771
5772    #[test]
5773    fn coerce_to_string_string() {
5774        let v = Value::string("hello");
5775        let (s, _ctx) = v.coerce_to_string().unwrap();
5776        assert_eq!(s, "hello");
5777    }
5778
5779    #[test]
5780    fn coerce_to_string_path() {
5781        let v = Value::Path(Box::new("/foo".into()));
5782        let (s, ctx) = v.coerce_to_string().unwrap();
5783        assert_eq!(s, "/foo");
5784        assert!(!ctx.is_empty()); // should add a Plain context element
5785    }
5786
5787    #[test]
5788    fn coerce_to_string_int() {
5789        let v = Value::Int(42);
5790        let (s, _ctx) = v.coerce_to_string().unwrap();
5791        assert_eq!(s, "42");
5792    }
5793
5794    #[test]
5795    fn coerce_to_string_float() {
5796        // CppNix %f-format: always 6 decimal places.
5797        let v = Value::Float(3.14);
5798        let (s, _ctx) = v.coerce_to_string().unwrap();
5799        assert_eq!(s, "3.140000");
5800    }
5801
5802    #[test]
5803    fn coerce_to_string_bool_true() {
5804        let (s, _ctx) = Value::Bool(true).coerce_to_string().unwrap();
5805        assert_eq!(s, "1");
5806    }
5807
5808    #[test]
5809    fn coerce_to_string_bool_false() {
5810        let (s, _ctx) = Value::Bool(false).coerce_to_string().unwrap();
5811        assert_eq!(s, "");
5812    }
5813
5814    #[test]
5815    fn coerce_to_string_null() {
5816        let (s, _ctx) = Value::Null.coerce_to_string().unwrap();
5817        assert_eq!(s, "");
5818    }
5819
5820    #[test]
5821    fn coerce_to_string_attrs_with_outpath() {
5822        let mut attrs = NixAttrs::new();
5823        attrs.insert("outPath".to_string(), Value::string("/nix/store/abc"));
5824        let val = Value::Attrs(Rc::new(attrs));
5825        let (s, _ctx) = val.coerce_to_string().unwrap();
5826        assert_eq!(s, "/nix/store/abc");
5827    }
5828
5829    #[test]
5830    fn coerce_to_string_attrs_without_outpath_or_tostring_fails() {
5831        let attrs = NixAttrs::new();
5832        let val = Value::Attrs(Rc::new(attrs));
5833        assert!(val.coerce_to_string().is_err());
5834    }
5835
5836    #[test]
5837    fn coerce_to_string_lambda_fails() {
5838        let root = rnix::Root::parse("x: x");
5839        let expr = root.tree().expr().unwrap();
5840        let closure = Closure {
5841            param: match expr {
5842                rnix::ast::Expr::Lambda(ref l) => l.param().unwrap(),
5843                _ => panic!("expected lambda"),
5844            },
5845            body: match expr {
5846                rnix::ast::Expr::Lambda(ref l) => l.body().unwrap(),
5847                _ => panic!("expected lambda"),
5848            },
5849            env: Env::new(),
5850        };
5851        let val = Value::Lambda(Rc::new(closure));
5852        assert!(val.coerce_to_string().is_err());
5853    }
5854
5855    // ── BuiltinFn debug ──────────────────────────────────
5856
5857    #[test]
5858    fn builtin_fn_debug_includes_name() {
5859        let b = BuiltinFn {
5860            name: "myFunc",
5861            func: Rc::new(|_| Ok(Value::Null)),
5862        };
5863        let s = format!("{b:?}");
5864        assert!(s.contains("myFunc"));
5865        assert!(s.contains("builtin"));
5866    }
5867
5868    // ── Thunk additional tests ───────────────────────────
5869
5870    #[test]
5871    fn thunk_force_chains_through_inner_thunks() {
5872        // Build a thunk whose evaluator yields another thunk.
5873        let inner_root = rnix::Root::parse("99");
5874        let inner_expr = inner_root.tree().expr().unwrap();
5875        let inner_thunk = Thunk::new_suspended(inner_expr, Env::new());
5876        let outer = Thunk::new_evaluated(Value::Thunk(inner_thunk));
5877        let result = outer.force(&|e, env| crate::eval::eval_expr(e, env));
5878        // Already-evaluated outer returns the inner thunk; the chain is
5879        // collapsed by the higher-level force_value, not by force() itself
5880        // when starting from Evaluated. So we just check we got a Thunk
5881        // back unchanged.
5882        match result.unwrap() {
5883            Value::Thunk(_) | Value::Int(99) => {}
5884            other => panic!("unexpected: {other:?}"),
5885        }
5886    }
5887
5888    #[test]
5889    fn thunk_inherit_select_debug_format() {
5890        let root = rnix::Root::parse("{ x = 1; }");
5891        let expr = root.tree().expr().unwrap();
5892        let source = Thunk::new_suspended(expr, Env::new());
5893        let thunk = Thunk::new_inherit_select(source, "x");
5894        let s = format!("{thunk:?}");
5895        assert!(s.contains("inherit-select"));
5896        assert!(s.contains("x"));
5897    }
5898
5899    #[test]
5900    fn thunk_blackhole_debug_format() {
5901        let root = rnix::Root::parse("1");
5902        let expr = root.tree().expr().unwrap();
5903        let thunk = Thunk::new_suspended(expr, Env::new());
5904        // SAFETY: Test-only, single-threaded.
5905        *unsafe { &mut *thunk.0.repr.get() } = ThunkRepr::Blackhole;
5906        assert_eq!(format!("{thunk:?}"), "<blackhole>");
5907    }
5908
5909    // ── Value display for thunks ─────────────────────────
5910
5911    #[test]
5912    fn value_display_thunk_evaluates() {
5913        let root = rnix::Root::parse("42");
5914        let expr = root.tree().expr().unwrap();
5915        let thunk = Thunk::new_suspended(expr, Env::new());
5916        let val = Value::Thunk(thunk);
5917        assert_eq!(format!("{val}"), "42");
5918    }
5919
5920    #[test]
5921    fn value_to_json_thunk_forces() {
5922        let root = rnix::Root::parse(r#""world""#);
5923        let expr = root.tree().expr().unwrap();
5924        let thunk = Thunk::new_suspended(expr, Env::new());
5925        let val = Value::Thunk(thunk);
5926        assert_eq!(val.to_json(), serde_json::Value::String("world".into()));
5927    }
5928
5929    #[test]
5930    fn value_type_name_thunk_forces() {
5931        let root = rnix::Root::parse("42");
5932        let expr = root.tree().expr().unwrap();
5933        let thunk = Thunk::new_suspended(expr, Env::new());
5934        let val = Value::Thunk(thunk);
5935        assert_eq!(val.type_name(), "int");
5936    }
5937
5938    // ── as_string / as_nix_string thunk error ────────────
5939
5940    #[test]
5941    fn as_string_errors_on_thunk() {
5942        let root = rnix::Root::parse(r#""x""#);
5943        let expr = root.tree().expr().unwrap();
5944        let thunk = Thunk::new_suspended(expr, Env::new());
5945        let val = Value::Thunk(thunk);
5946        let err = val.as_string().unwrap_err();
5947        match err {
5948            EvalError::TypeError(msg) => assert!(msg.contains("thunk")),
5949            _ => panic!("expected TypeError"),
5950        }
5951    }
5952
5953    #[test]
5954    fn as_nix_string_errors_on_thunk() {
5955        let root = rnix::Root::parse(r#""x""#);
5956        let expr = root.tree().expr().unwrap();
5957        let thunk = Thunk::new_suspended(expr, Env::new());
5958        let val = Value::Thunk(thunk);
5959        assert!(val.as_nix_string().is_err());
5960    }
5961
5962    #[test]
5963    fn as_attrs_errors_on_thunk() {
5964        let root = rnix::Root::parse("{}");
5965        let expr = root.tree().expr().unwrap();
5966        let thunk = Thunk::new_suspended(expr, Env::new());
5967        let val = Value::Thunk(thunk);
5968        assert!(val.as_attrs().is_err());
5969    }
5970
5971    #[test]
5972    fn as_list_errors_on_thunk() {
5973        let root = rnix::Root::parse("[]");
5974        let expr = root.tree().expr().unwrap();
5975        let thunk = Thunk::new_suspended(expr, Env::new());
5976        let val = Value::Thunk(thunk);
5977        assert!(val.as_list().is_err());
5978    }
5979
5980    // ── as_nix_string OK on string ───────────────────────
5981
5982    #[test]
5983    fn as_nix_string_ok_on_string() {
5984        let v = Value::string("hi");
5985        let ns = v.as_nix_string().unwrap();
5986        assert_eq!(ns.as_str(), "hi");
5987    }
5988
5989    #[test]
5990    fn as_nix_string_errors_on_int() {
5991        let v = Value::Int(1);
5992        match v.as_nix_string() {
5993            Err(EvalError::TypeMismatch { expected, got }) => {
5994                assert_eq!(expected, "string");
5995                assert_eq!(got, "int");
5996            }
5997            _ => panic!("expected TypeMismatch"),
5998        }
5999    }
6000
6001    // ════════════════════════════════════════════════════════════
6002    // 1. OnceCell Thunk Cache
6003    // ════════════════════════════════════════════════════════════
6004
6005    #[test]
6006    fn oncecell_cache_populated_after_force() {
6007        let root = rnix::Root::parse("42");
6008        let expr = root.tree().expr().unwrap();
6009        let thunk = Thunk::new_suspended(expr, Env::new());
6010        // Before forcing, cache should be empty.
6011        assert!(thunk.0.cache.get().is_none());
6012        let _ = thunk.force(&|e, env| crate::eval::eval_expr(e, env)).unwrap();
6013        // After forcing, cache should be populated.
6014        assert!(thunk.0.cache.get().is_some());
6015    }
6016
6017    #[test]
6018    fn oncecell_cache_matches_force_result() {
6019        let root = rnix::Root::parse("1 + 2");
6020        let expr = root.tree().expr().unwrap();
6021        let thunk = Thunk::new_suspended(expr, Env::new());
6022        let forced = thunk.force(&|e, env| crate::eval::eval_expr(e, env)).unwrap();
6023        let cached = thunk.0.cache.get().unwrap();
6024        // Cache stores Concrete (thunk-free); force returns Value.
6025        // Compare via Concrete→Value promotion.
6026        assert_eq!((**cached).clone().into_value(), forced);
6027    }
6028
6029    #[test]
6030    fn oncecell_new_evaluated_prepopulates_cache() {
6031        let thunk = Thunk::new_evaluated(Value::Int(77));
6032        // Cache should be set immediately.
6033        let cached = thunk.0.cache.get().expect("cache should be pre-populated");
6034        assert_eq!(**cached, Concrete::Int(77));
6035    }
6036
6037    #[test]
6038    fn oncecell_is_evaluated_uses_cache() {
6039        let thunk = Thunk::new_evaluated(Value::Bool(false));
6040        // is_evaluated() checks the OnceCell cache.
6041        assert!(thunk.is_evaluated());
6042        assert!(thunk.0.cache.get().is_some());
6043    }
6044
6045    #[test]
6046    fn oncecell_already_evaluated_returns_cached_without_repr() {
6047        // Create a thunk already evaluated. Force should return
6048        // the cached value without touching repr (the evaluator
6049        // closure should never be called).
6050        let thunk = Thunk::new_evaluated(Value::Int(55));
6051        let result = thunk.force(&|_, _| panic!("evaluator should not be called"));
6052        assert_eq!(result.unwrap(), Value::Int(55));
6053    }
6054
6055    // ════════════════════════════════════════════════════════════
6056    // 2. WithScope Memoization
6057    // ════════════════════════════════════════════════════════════
6058
6059    #[test]
6060    fn with_scope_created_with_empty_cache() {
6061        // Thunk-valued scopes start with empty cache (thunk not yet forced)
6062        let thunk = Thunk::new_suspended(
6063            rnix::Root::parse("{}").tree().expr().unwrap(),
6064            Env::new(),
6065        );
6066        let env = Env::new().with_scope(Value::Thunk(thunk));
6067        let scope = &env.0.with_scopes[0];
6068        assert!(scope.cached.borrow().is_none());
6069    }
6070
6071    #[test]
6072    fn with_scope_concrete_pre_populates_cache() {
6073        // Concrete attrset scopes pre-populate cache immediately
6074        let mut attrs = NixAttrs::new();
6075        attrs.insert("x".to_string(), Value::Int(1));
6076        let env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6077        let scope = &env.0.with_scopes[0];
6078        assert!(scope.cached.borrow().is_some());
6079    }
6080
6081    #[test]
6082    fn with_scope_first_lookup_populates_cache() {
6083        let mut attrs = NixAttrs::new();
6084        attrs.insert("x".to_string(), Value::Int(42));
6085        let env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6086        // Cache is pre-populated for concrete attrsets.
6087        assert!(env.0.with_scopes[0].cached.borrow().is_some());
6088        // Lookup hits the pre-populated cache.
6089        let _ = env.lookup("x");
6090        assert!(env.0.with_scopes[0].cached.borrow().is_some());
6091    }
6092
6093    #[test]
6094    fn with_scope_second_lookup_uses_cache() {
6095        let mut attrs = NixAttrs::new();
6096        attrs.insert("x".to_string(), Value::Int(10));
6097        let env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6098        // First lookup populates cache.
6099        assert_eq!(env.lookup("x"), Some(Value::Int(10)));
6100        assert!(env.0.with_scopes[0].cached.borrow().is_some());
6101        // Second lookup should still work (reads from cache).
6102        assert_eq!(env.lookup("x"), Some(Value::Int(10)));
6103    }
6104
6105    #[test]
6106    fn with_scope_child_shares_cache_via_rc() {
6107        let mut attrs = NixAttrs::new();
6108        attrs.insert("shared".to_string(), Value::Int(7));
6109        let parent = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6110        let child = parent.child();
6111        // Force via parent lookup.
6112        let _ = parent.lookup("shared");
6113        // Child's with-scope cache should share the same Rc, so
6114        // it should also show cached.
6115        assert!(child.0.with_scopes[0].cached.borrow().is_some());
6116    }
6117
6118    #[test]
6119    fn with_scope_innermost_checked_first() {
6120        let mut outer = NixAttrs::new();
6121        outer.insert("x".to_string(), Value::Int(1));
6122        outer.insert("y".to_string(), Value::Int(100));
6123        let mut inner = NixAttrs::new();
6124        inner.insert("x".to_string(), Value::Int(2));
6125        let env = Env::new()
6126            .with_scope(Value::Attrs(Rc::new(outer)))
6127            .with_scope(Value::Attrs(Rc::new(inner)));
6128        // Innermost scope has x=2, should win.
6129        assert_eq!(env.lookup("x"), Some(Value::Int(2)));
6130        // y only in outer, should fallback.
6131        assert_eq!(env.lookup("y"), Some(Value::Int(100)));
6132    }
6133
6134    // ════════════════════════════════════════════════════════════
6135    // 3. FxHashMap for NixAttrs
6136    // ════════════════════════════════════════════════════════════
6137
6138    #[test]
6139    fn fxhashmap_nixattrs_new_creates_empty() {
6140        let a = NixAttrs::new();
6141        assert!(a.is_empty());
6142        assert_eq!(a.len(), 0);
6143        // Internal map is a FxHashMap (im_rc::HashMap with FxBuildHasher).
6144        assert!(a.inner().is_empty());
6145    }
6146
6147    #[test]
6148    fn fxhashmap_insert_get_roundtrip_with_symbol_keys() {
6149        let mut a = NixAttrs::new();
6150        a.insert("mykey".to_string(), Value::Int(42));
6151        assert_eq!(a.get("mykey"), Some(&Value::Int(42)));
6152    }
6153
6154    #[test]
6155    fn fxhashmap_contains_key_with_interned_keys() {
6156        let mut a = NixAttrs::new();
6157        a.insert("alpha".to_string(), Value::Int(1));
6158        let sym = intern("alpha");
6159        assert!(a.inner().contains_key(&sym));
6160        let missing_sym = intern("beta");
6161        assert!(!a.inner().contains_key(&missing_sym));
6162    }
6163
6164    #[test]
6165    fn fxhashmap_remove_returns_value() {
6166        let mut a = NixAttrs::new();
6167        a.insert("key".to_string(), Value::Int(99));
6168        let removed = a.remove("key");
6169        assert_eq!(removed, Some(Value::Int(99)));
6170        assert!(a.is_empty());
6171    }
6172
6173    #[test]
6174    fn fxhashmap_keys_returns_sorted_strings() {
6175        let mut a = NixAttrs::new();
6176        a.insert("zulu".to_string(), Value::Int(1));
6177        a.insert("alpha".to_string(), Value::Int(2));
6178        a.insert("mike".to_string(), Value::Int(3));
6179        let keys: Vec<String> = a.keys().collect();
6180        assert_eq!(keys, vec!["alpha", "mike", "zulu"]);
6181    }
6182
6183    #[test]
6184    fn fxhashmap_iter_returns_sorted_string_value_pairs() {
6185        let mut a = NixAttrs::new();
6186        a.insert("b".to_string(), Value::Int(2));
6187        a.insert("a".to_string(), Value::Int(1));
6188        let pairs: Vec<(String, &Value)> = a.iter().collect();
6189        assert_eq!(pairs.len(), 2);
6190        assert_eq!(pairs[0].0, "a");
6191        assert_eq!(*pairs[0].1, Value::Int(1));
6192        assert_eq!(pairs[1].0, "b");
6193        assert_eq!(*pairs[1].1, Value::Int(2));
6194    }
6195
6196    #[test]
6197    fn fxhashmap_update_merges_correctly() {
6198        let mut left = NixAttrs::new();
6199        left.insert("a".to_string(), Value::Int(1));
6200        left.insert("b".to_string(), Value::Int(2));
6201        let mut right = NixAttrs::new();
6202        right.insert("b".to_string(), Value::Int(20));
6203        right.insert("c".to_string(), Value::Int(3));
6204        let merged = left.update(&right);
6205        assert_eq!(merged.get("a"), Some(&Value::Int(1)));
6206        assert_eq!(merged.get("b"), Some(&Value::Int(20))); // right overrides
6207        assert_eq!(merged.get("c"), Some(&Value::Int(3)));
6208        assert_eq!(merged.len(), 3);
6209    }
6210
6211    #[test]
6212    fn fxhashmap_from_iterator_collects_with_interning() {
6213        let pairs = vec![
6214            ("x".to_string(), Value::Int(10)),
6215            ("y".to_string(), Value::Int(20)),
6216            ("z".to_string(), Value::Int(30)),
6217        ];
6218        let attrs: NixAttrs = pairs.into_iter().collect();
6219        assert_eq!(attrs.len(), 3);
6220        assert_eq!(attrs.get("x"), Some(&Value::Int(10)));
6221        assert_eq!(attrs.get("y"), Some(&Value::Int(20)));
6222        assert_eq!(attrs.get("z"), Some(&Value::Int(30)));
6223        // Verify internal storage uses Symbol keys.
6224        let sym_x = intern("x");
6225        assert!(attrs.inner().contains_key(&sym_x));
6226    }
6227
6228    // ════════════════════════════════════════════════════════════
6229    // 4. SmallVec StringContext
6230    // ════════════════════════════════════════════════════════════
6231
6232    #[test]
6233    fn smallvec_context_empty() {
6234        let ctx = StringContext::new();
6235        assert!(ctx.is_empty());
6236        assert_eq!(ctx.len(), 0);
6237        assert_eq!(ctx.elements().len(), 0);
6238    }
6239
6240    #[test]
6241    fn smallvec_context_single_element_inline() {
6242        let mut ctx = StringContext::new();
6243        ctx.add_plain("/nix/store/single");
6244        assert_eq!(ctx.len(), 1);
6245        // SmallVec<[ContextElement; 2]> stores up to 2 inline.
6246        assert!(!ctx.is_empty());
6247    }
6248
6249    #[test]
6250    fn smallvec_context_two_elements_still_inline() {
6251        let mut ctx = StringContext::new();
6252        ctx.add_plain("/nix/store/one");
6253        ctx.add_output("/nix/store/two.drv", "out");
6254        assert_eq!(ctx.len(), 2);
6255    }
6256
6257    #[test]
6258    fn smallvec_context_three_plus_spills_to_heap() {
6259        let mut ctx = StringContext::new();
6260        ctx.add_plain("/nix/store/a");
6261        ctx.add_plain("/nix/store/b");
6262        ctx.add_drv_deep("/nix/store/c.drv");
6263        assert_eq!(ctx.len(), 3);
6264        // Verify all elements are accessible.
6265        assert!(ctx.elements().contains(&ContextElement::Plain(SmolStr::from("/nix/store/a"))));
6266        assert!(ctx.elements().contains(&ContextElement::Plain(SmolStr::from("/nix/store/b"))));
6267        assert!(ctx.elements().contains(&ContextElement::DrvDeep(SmolStr::from("/nix/store/c.drv"))));
6268    }
6269
6270    #[test]
6271    fn smallvec_context_merge_deduplicates() {
6272        let mut ctx1 = StringContext::new();
6273        ctx1.add_plain("/nix/store/dup");
6274        ctx1.add_output("/nix/store/x.drv", "out");
6275        let mut ctx2 = StringContext::new();
6276        ctx2.add_plain("/nix/store/dup");      // duplicate
6277        ctx2.add_plain("/nix/store/unique");    // new
6278        ctx1.merge(&ctx2);
6279        assert_eq!(ctx1.len(), 3); // dup not duplicated
6280    }
6281
6282    #[test]
6283    fn smallvec_context_add_plain_output_drv_deep() {
6284        let mut ctx = StringContext::new();
6285        ctx.add_plain("/nix/store/plain");
6286        assert_eq!(ctx.len(), 1);
6287        assert!(ctx.elements().contains(&ContextElement::Plain(SmolStr::from("/nix/store/plain"))));
6288
6289        ctx.add_output("/nix/store/out.drv", "lib");
6290        assert_eq!(ctx.len(), 2);
6291        assert!(ctx.elements().contains(&ContextElement::Output {
6292            drv: SmolStr::from("/nix/store/out.drv"),
6293            output: SmolStr::from("lib"),
6294        }));
6295
6296        ctx.add_drv_deep("/nix/store/deep.drv");
6297        assert_eq!(ctx.len(), 3);
6298        assert!(ctx.elements().contains(&ContextElement::DrvDeep(SmolStr::from("/nix/store/deep.drv"))));
6299    }
6300
6301    // ════════════════════════════════════════════════════════════
6302    // 5. Rc<Vec<Value>> for List
6303    // ════════════════════════════════════════════════════════════
6304
6305    #[test]
6306    fn rc_list_constructor_wraps_in_rc() {
6307        let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
6308        match &v {
6309            Value::List(rc) => {
6310                assert_eq!(rc.len(), 2);
6311                assert_eq!(Rc::strong_count(rc), 1);
6312            }
6313            _ => panic!("expected List"),
6314        }
6315    }
6316
6317    #[test]
6318    fn rc_list_clone_is_refcount_bump() {
6319        let v = Value::list(vec![Value::Int(10)]);
6320        let rc1 = match &v {
6321            Value::List(rc) => rc.clone(),
6322            _ => panic!("expected List"),
6323        };
6324        let v2 = v.clone();
6325        let rc2 = match &v2 {
6326            Value::List(rc) => rc.clone(),
6327            _ => panic!("expected List"),
6328        };
6329        // Both point to the same allocation.
6330        assert!(Rc::ptr_eq(&rc1, &rc2));
6331        // Strong count should be 3: rc1, rc2, and the one inside v or v2.
6332        // Actually: v has one, v2 has one, rc1 has one, rc2 has one = 4.
6333        assert!(Rc::strong_count(&rc1) >= 2);
6334    }
6335
6336    #[test]
6337    fn rc_list_as_list_returns_slice() {
6338        let v = Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]);
6339        let slice = v.as_list().unwrap();
6340        assert_eq!(slice.len(), 3);
6341        assert_eq!(slice[0], Value::Int(1));
6342        assert_eq!(slice[1], Value::Int(2));
6343        assert_eq!(slice[2], Value::Int(3));
6344    }
6345
6346    #[test]
6347    fn rc_list_from_vec_wraps_in_rc() {
6348        let items = vec![Value::Bool(true), Value::Bool(false)];
6349        let v: Value = items.into();
6350        match &v {
6351            Value::List(rc) => {
6352                assert_eq!(rc.len(), 2);
6353                assert_eq!(Rc::strong_count(rc), 1);
6354            }
6355            _ => panic!("expected List"),
6356        }
6357    }
6358
6359    // ════════════════════════════════════════════════════════════
6360    // 6. String Interning
6361    // ════════════════════════════════════════════════════════════
6362
6363    #[test]
6364    fn intern_same_string_returns_same_symbol() {
6365        let s1 = intern("hello_intern_test");
6366        let s2 = intern("hello_intern_test");
6367        assert_eq!(s1, s2);
6368    }
6369
6370    #[test]
6371    fn intern_different_strings_returns_different_symbols() {
6372        let s1 = intern("unique_str_a_9182");
6373        let s2 = intern("unique_str_b_9182");
6374        assert_ne!(s1, s2);
6375    }
6376
6377    #[test]
6378    fn resolve_roundtrips_correctly() {
6379        let sym = intern("roundtrip_test_str");
6380        let resolved = resolve(sym);
6381        assert_eq!(resolved, "roundtrip_test_str");
6382    }
6383
6384    #[test]
6385    fn intern_cached_same_offset_returns_cached_symbol() {
6386        let sid = next_source_id();
6387        let sym1 = intern_cached("cached_ident_aa", sid, 100);
6388        let sym2 = intern_cached("cached_ident_aa", sid, 100);
6389        assert_eq!(sym1, sym2);
6390    }
6391
6392    #[test]
6393    fn intern_cached_different_offset_same_string_returns_same_symbol() {
6394        // Even with different offsets, the same string should intern
6395        // to the same Symbol (interning dedup at the interner level).
6396        let sid = next_source_id();
6397        let sym1 = intern_cached("dedup_test_str_77", sid, 200);
6398        let sym2 = intern_cached("dedup_test_str_77", sid, 300);
6399        // The symbols should be equal because the interner deduplicates.
6400        assert_eq!(sym1, sym2);
6401    }
6402
6403    #[test]
6404    fn clear_ident_cache_clears() {
6405        let sid = next_source_id();
6406        let _sym = intern_cached("to_be_cleared_99", sid, 500);
6407        clear_ident_cache();
6408        // After clearing, the cache is empty, but interning the same
6409        // string again should still return the same Symbol (the interner
6410        // itself is not cleared, just the offset cache).
6411        let sym2 = intern_cached("to_be_cleared_99", sid, 500);
6412        let resolved = resolve(sym2);
6413        assert_eq!(resolved, "to_be_cleared_99");
6414    }
6415
6416    #[test]
6417    fn next_source_id_increments_monotonically() {
6418        let id1 = next_source_id();
6419        let id2 = next_source_id();
6420        let id3 = next_source_id();
6421        assert_eq!(id2, id1 + 1);
6422        assert_eq!(id3, id2 + 1);
6423    }
6424
6425    // ════════════════════════════════════════════════════════════
6426    // 7. Env Operations
6427    // ════════════════════════════════════════════════════════════
6428
6429    #[test]
6430    fn env_new_creates_empty_bindings() {
6431        let env = Env::new();
6432        assert!(env.0.bindings.is_empty());
6433        assert!(env.0.with_scopes.is_empty());
6434        assert!(env.eval_file().is_none());
6435    }
6436
6437    #[test]
6438    fn env_bind_lookup_roundtrip() {
6439        let mut env = Env::new();
6440        env.bind("foo".to_string(), Value::Int(42));
6441        assert_eq!(env.lookup("foo"), Some(Value::Int(42)));
6442        assert_eq!(env.lookup("bar"), None);
6443    }
6444
6445    #[test]
6446    fn env_child_inherits_parent_bindings_flattened() {
6447        let mut parent = Env::new();
6448        parent.bind("a".to_string(), Value::Int(1));
6449        parent.bind("b".to_string(), Value::Int(2));
6450        let child = parent.child();
6451        // Child sees parent's bindings.
6452        assert_eq!(child.lookup("a"), Some(Value::Int(1)));
6453        assert_eq!(child.lookup("b"), Some(Value::Int(2)));
6454        // Verify bindings are in child's own map (flattened).
6455        let sym_a = intern("a");
6456        assert!(child.0.bindings.contains_key(&sym_a));
6457    }
6458
6459    #[test]
6460    fn env_child_inherits_with_scopes() {
6461        let mut attrs = NixAttrs::new();
6462        attrs.insert("ws".to_string(), Value::Int(10));
6463        let parent = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6464        let child = parent.child();
6465        // Child should have the same with_scopes as parent.
6466        assert_eq!(child.0.with_scopes.len(), parent.0.with_scopes.len());
6467        assert_eq!(child.lookup("ws"), Some(Value::Int(10)));
6468    }
6469
6470    #[test]
6471    fn env_lookup_sym_fast_path_matches_lookup() {
6472        let mut env = Env::new();
6473        env.bind("target".to_string(), Value::Int(88));
6474        let sym = intern("target");
6475        let via_lookup = env.lookup("target");
6476        let via_sym = env.lookup_sym(sym);
6477        assert_eq!(via_lookup, via_sym);
6478        assert_eq!(via_sym, Some(Value::Int(88)));
6479    }
6480
6481    #[test]
6482    fn env_lookup_sym_with_scope_fallback() {
6483        let mut attrs = NixAttrs::new();
6484        attrs.insert("sym_ws".to_string(), Value::Int(33));
6485        let env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6486        let sym = intern("sym_ws");
6487        assert_eq!(env.lookup_sym(sym), Some(Value::Int(33)));
6488    }
6489
6490    #[test]
6491    fn env_with_scope_ordering_multiple_innermost_wins() {
6492        let mut a1 = NixAttrs::new();
6493        a1.insert("x".to_string(), Value::Int(1));
6494        let mut a2 = NixAttrs::new();
6495        a2.insert("x".to_string(), Value::Int(2));
6496        let mut a3 = NixAttrs::new();
6497        a3.insert("x".to_string(), Value::Int(3));
6498        let env = Env::new()
6499            .with_scope(Value::Attrs(Rc::new(a1)))
6500            .with_scope(Value::Attrs(Rc::new(a2)))
6501            .with_scope(Value::Attrs(Rc::new(a3)));
6502        // Innermost (a3) should win.
6503        assert_eq!(env.lookup("x"), Some(Value::Int(3)));
6504    }
6505
6506    #[test]
6507    fn env_lookup_sym_not_found_returns_none() {
6508        let env = Env::new();
6509        let sym = intern("nonexistent_sym_99");
6510        assert_eq!(env.lookup_sym(sym), None);
6511    }
6512
6513    #[test]
6514    fn env_lookup_sym_lexical_wins_over_with_scope() {
6515        let mut attrs = NixAttrs::new();
6516        attrs.insert("priority".to_string(), Value::Int(1));
6517        let mut env = Env::new().with_scope(Value::Attrs(Rc::new(attrs)));
6518        env.bind("priority".to_string(), Value::Int(99));
6519        let sym = intern("priority");
6520        assert_eq!(env.lookup_sym(sym), Some(Value::Int(99)));
6521    }
6522}