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