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