sui_eval/eval.rs
1//! Tree-walking Nix evaluator using rnix's typed AST.
2//!
3//! Implements Tvix-style lazy evaluation with thunks: let-bindings and
4//! rec-attrset values are wrapped in `Value::Thunk` and only evaluated
5//! when their value is actually needed (call-by-need with memoization).
6
7use std::cell::{Cell, RefCell};
8use std::collections::{HashSet, HashMap, VecDeque};
9use std::path::PathBuf;
10
11use rnix::ast::{self, AstToken, HasEntry, InterpolPart};
12use rowan::ast::AstNode;
13
14use crate::builtins;
15use crate::value::*;
16
17thread_local! { static EVAL_DEPTH: Cell<usize> = const { Cell::new(0) }; }
18
19
20// ── Source ID for identifier symbol cache ─────────────────────
21//
22// Each call to `rnix::Root::parse` produces a distinct AST tree.
23// Identifiers from different trees may share the same byte offset,
24// so we pair offset with a source ID to form a unique cache key.
25// The ID is stored in a thread-local so `eval_expr` can access it
26// without an extra parameter threaded through every call.
27
28thread_local! {
29 static CURRENT_SOURCE_ID: Cell<u32> = const { Cell::new(0) };
30}
31
32// ── Currently-evaluating-file stack ────────────────────────────
33//
34// Real Nix resolves relative path literals (`./foo.nix`) against the
35// directory of the file that *contains* the literal, not against the
36// process cwd. Track the stack of files we're currently evaluating
37// so the `PathRel` handler and `import` builtin can resolve correctly.
38
39thread_local! {
40 /// `None` frame = "evaluating something with no source file" (a `--expr` /
41 /// `<string>` literal). Representing that explicitly is load-bearing: a
42 /// thunk captured in a fileless context used to push NOTHING when it
43 /// forced, so the callee's file stayed on top and `unsafeGetAttrPos`
44 /// stamped the literal with the callee's path where CppNix returns `null`.
45 /// That fed `eval-config.nix`'s `modulesLocation`, which wraps every user
46 /// module in `{ _file; imports = [ m ]; }` — demoting it one
47 /// `genericClosure` level and permuting NixOS definition order.
48 static EVAL_FILE_STACK: RefCell<Vec<Option<PathBuf>>> = const { RefCell::new(Vec::new()) };
49 /// Nix-level error context stack — captures source positions for --show-trace.
50 /// Each entry: (file, expression_snippet). Pushed on function calls, select,
51 /// force, and popped on return. Attached to errors for structured diagnostics.
52 static NIX_TRACE_STACK: RefCell<Vec<NixTraceFrame>> = const { RefCell::new(Vec::new()) };
53}
54
55/// A single frame in the Nix-level error trace.
56///
57/// The frame is only ever *observed* on the cold error path (via
58/// `attach_trace`). To keep the hot lambda-call path allocation-free,
59/// the per-call lambda frame stores the raw ingredients (a cheap
60/// `Rc`-clone of the closure env + the raw current-eval-file `PathBuf`)
61/// and defers the `format!` / path-strip work into `attach_trace`. The
62/// rendered `(description, file)` pair is byte-identical to the eager
63/// form either way (see the `description()` / `file()` accessors).
64#[derive(Debug, Clone)]
65pub enum NixTraceFrame {
66 /// Pre-formatted frame (the builtin-call path — kept eager because
67 /// the builtin name is already a `&'static str`, so there is no
68 /// per-call heap-`String` to defer).
69 Eager {
70 file: Option<String>,
71 description: String,
72 },
73 /// Lazy per-lambda-call frame. The `description` string and the
74 /// stripped `file` string are built on demand in `attach_trace`.
75 ///
76 /// - `closure_env` provides the *description*'s file (from
77 /// `closure.env.eval_file()`) — an O(1) `Rc` refcount bump.
78 /// - `current_file` is the raw `current_eval_file()` snapshot taken
79 /// at push time (the stack top after the file guard pushed the
80 /// closure's file), used verbatim for the frame's `file` field so
81 /// the rendered `loc` matches the eager form byte-for-byte.
82 Lambda {
83 closure_env: Env,
84 current_file: Option<PathBuf>,
85 },
86}
87
88/// Strip the `-source/` store-path prefix from a rendered path exactly
89/// as the eager trace path did (`p.display()...rsplit_once("-source/")`).
90fn strip_source_prefix(p: &std::path::Path) -> String {
91 let s = p.display().to_string();
92 s.rsplit_once("-source/")
93 .map_or_else(|| p.display().to_string(), |(_, tail)| tail.to_string())
94}
95
96impl NixTraceFrame {
97 /// The frame's `file` field (for the trace `loc`), matching the
98 /// eager `frame.file` byte-for-byte.
99 fn file(&self) -> Option<String> {
100 match self {
101 NixTraceFrame::Eager { file, .. } => file.clone(),
102 NixTraceFrame::Lambda { current_file, .. } => {
103 current_file.as_deref().map(strip_source_prefix)
104 }
105 }
106 }
107
108 /// The frame's `description`, matching the eager `frame.description`
109 /// byte-for-byte. Rendered through the `Display` impl (a `write!`
110 /// surface — the description is the frame's canonical serialization,
111 /// per the fleet TYPED-EMISSION rule; no `format!()`).
112 fn description(&self) -> String {
113 self.to_string()
114 }
115}
116
117/// The frame's rendered description IS its `Display` — the typed emission
118/// surface for the trace message (`write!`, never `format!()`). The
119/// `Lambda` arm defers the path-strip to this cold error-path render.
120impl std::fmt::Display for NixTraceFrame {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 match self {
123 NixTraceFrame::Eager { description, .. } => f.write_str(description),
124 NixTraceFrame::Lambda { closure_env, .. } => {
125 let file = closure_env.eval_file().map(|p| strip_source_prefix(p));
126 write!(
127 f,
128 "while calling function defined in {}",
129 file.as_deref().unwrap_or("<eval>")
130 )
131 }
132 }
133 }
134}
135
136/// Push a Nix-level trace frame. Returns a guard that pops on drop.
137fn push_nix_trace(desc: impl Into<String>) -> NixTraceGuard {
138 let frame = NixTraceFrame::Eager {
139 file: current_eval_file().map(|p| {
140 p.display().to_string()
141 .rsplit_once("-source/")
142 .map_or_else(|| p.display().to_string(), |(_, s)| s.to_string())
143 }),
144 description: desc.into(),
145 };
146 NIX_TRACE_STACK.with(|s| s.borrow_mut().push(frame));
147 NixTraceGuard
148}
149
150/// Push a *lazy* Nix-level trace frame for a lambda call. Stores only the
151/// raw ingredients (an O(1) `Rc`-clone of the closure env + the raw
152/// `current_eval_file()` snapshot) — the `format!`/path-strip work is
153/// deferred to the cold `attach_trace` path. Returns a guard that pops on
154/// drop. The rendered frame is byte-identical to the eager form.
155fn push_nix_trace_lambda(closure_env: &Env) -> NixTraceGuard {
156 let frame = NixTraceFrame::Lambda {
157 closure_env: closure_env.clone(),
158 current_file: current_eval_file(),
159 };
160 NIX_TRACE_STACK.with(|s| s.borrow_mut().push(frame));
161 NixTraceGuard
162}
163
164struct NixTraceGuard;
165impl Drop for NixTraceGuard {
166 fn drop(&mut self) {
167 NIX_TRACE_STACK.with(|s| s.borrow_mut().pop());
168 }
169}
170
171/// Capture the current Nix trace and attach it to an error.
172pub fn attach_trace(err: EvalError) -> EvalError {
173 NIX_TRACE_STACK.with(|s| {
174 let stack = s.borrow();
175 if stack.is_empty() {
176 return err;
177 }
178 let max_frames = std::env::var("SUI_M26_MAXFRAMES").ok()
179 .and_then(|s| s.parse::<usize>().ok()).unwrap_or(15);
180 let mut trace = format!("{err}");
181 for (i, frame) in stack.iter().rev().take(max_frames).enumerate() {
182 let file = frame.file();
183 let loc = file.as_deref().unwrap_or("<eval>");
184 trace.push_str(&format!("\n {} ({loc})", frame.description()));
185 if i + 1 >= max_frames && stack.len() > max_frames {
186 trace.push_str(&format!("\n ... ({} more frames)", stack.len() - max_frames));
187 }
188 }
189 // CRITICAL: preserve Throw/AssertionFailed variants so tryEval can catch them.
190 // Converting to TypeError would make tryEval miss them.
191 match err {
192 EvalError::Throw(_) => EvalError::Throw(trace),
193 EvalError::AssertionFailed(_) => EvalError::AssertionFailed(trace),
194 _ => EvalError::TypeError(trace),
195 }
196 })
197}
198
199/// Return the directory of the file currently being evaluated, if any.
200/// Used by the `PathRel` AST handler to resolve relative path literals.
201#[must_use]
202pub fn current_eval_dir() -> Option<PathBuf> {
203 EVAL_FILE_STACK
204 .with(|s| s.borrow().last().cloned())
205 .flatten()
206 .and_then(|p| p.parent().map(PathBuf::from))
207}
208
209/// Push a file onto the eval stack. Returns an RAII guard that pops
210/// it on drop. Use when entering an `import <file>` so subsequent
211/// relative path literals resolve against the right directory.
212pub fn push_eval_file(file: PathBuf) -> EvalFileGuard {
213 push_eval_frame(Some(file))
214}
215
216/// Push a frame that may be fileless. `None` means "this code has no source
217/// file" and MUST still occupy a stack slot — pushing nothing would leave the
218/// caller's file visible to `current_eval_file`, which is exactly the
219/// `unsafeGetAttrPos` divergence documented on `EVAL_FILE_STACK`.
220pub fn push_eval_frame(file: Option<PathBuf>) -> EvalFileGuard {
221 EVAL_FILE_STACK.with(|s| s.borrow_mut().push(file));
222 EvalFileGuard
223}
224
225/// Return the file currently being evaluated, if any.
226/// Used by error sites to attach source location context.
227#[must_use]
228pub fn current_eval_file() -> Option<PathBuf> {
229 EVAL_FILE_STACK.with(|s| s.borrow().last().cloned()).flatten()
230}
231
232
233/// Snapshot the entire eval file stack (debug).
234pub fn eval_file_stack_snapshot() -> Vec<String> {
235 EVAL_FILE_STACK.with(|s| {
236 s.borrow().iter().map(|p| {
237 let Some(p) = p else { return "<no-file>".to_string() };
238 let s = p.display().to_string();
239 s.rsplit_once("-source/").map_or(s.clone(), |(_, r)| r.to_string())
240 }).collect()
241 })
242}
243
244/// Format the current eval file for error context strings.
245/// Returns e.g. `", in '/nix/store/.../default.nix'"` or empty string.
246pub(crate) fn eval_file_ctx() -> String {
247 current_eval_file()
248 .map(|p| format!(", in '{}'", p.display()))
249 .unwrap_or_default()
250}
251
252/// RAII guard that pops the top of the eval-file stack on drop.
253pub struct EvalFileGuard;
254
255impl Drop for EvalFileGuard {
256 fn drop(&mut self) {
257 EVAL_FILE_STACK.with(|s| {
258 s.borrow_mut().pop();
259 });
260 }
261}
262
263/// Set `CURRENT_SOURCE_ID` to `id`, returning an RAII guard that restores
264/// the previous id on drop. Used at thunk force so a cross-file thunk's
265/// idents key the `(source_id, offset)` symbol cache against the file where
266/// the thunk was DEFINED, not the ambient source at force time — the sibling
267/// of the eval-file guard, closing the `parse.nix` cross-file collision.
268pub fn push_source_id(id: u32) -> SourceIdGuard {
269 let prev = CURRENT_SOURCE_ID.with(|s| {
270 let old = s.get();
271 s.set(id);
272 old
273 });
274 SourceIdGuard(prev)
275}
276
277/// RAII guard that restores the previous `CURRENT_SOURCE_ID` on drop.
278pub struct SourceIdGuard(u32);
279
280impl Drop for SourceIdGuard {
281 fn drop(&mut self) {
282 CURRENT_SOURCE_ID.with(|s| s.set(self.0));
283 }
284}
285
286// ── Path normalization ────────────────────────────────────────
287//
288// Normalize a path by removing `.` components and resolving `..`
289// components. Unlike `canonicalize()`, this doesn't require the
290// path to exist on disk — critical for flake evaluation where
291// files may not be materialized yet.
292
293/// Normalize a path by removing `.` and resolving `..` components
294/// without touching the filesystem.
295///
296/// Delegates to [`crate::path::normalize`] — kept as a public re-export
297/// so existing call-sites continue to compile without changes.
298pub fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
299 crate::path::normalize(path)
300}
301
302// ── Pure (hermetic) evaluation mode ────────────────────────────
303//
304// When pure mode is enabled, impure builtins (`storePath`, `fetchurl`/`fetchTarball`
305// without an explicit hash, `currentTime`, `getEnv`, etc.) should refuse to
306// produce non-deterministic results. The flag is thread-local so each evaluator
307// thread can opt in independently.
308
309thread_local! {
310 static PURE_MODE: Cell<bool> = const { Cell::new(false) };
311}
312
313/// Enable or disable hermetic (pure) evaluation mode for the current thread.
314pub fn set_pure_mode(pure: bool) {
315 PURE_MODE.with(|p| p.set(pure));
316}
317
318/// Whether the current thread is in hermetic (pure) evaluation mode.
319#[must_use]
320pub fn is_pure_mode() -> bool {
321 PURE_MODE.with(Cell::get)
322}
323
324/// Maximum evaluation depth before we report infinite recursion.
325///
326/// With `stacker` dynamically growing the call stack, we are no longer
327/// limited by the default 8 MB thread stack.
328///
329/// **Test builds** keep a low limit (2 048) so that infinite-recursion
330/// tests fail quickly instead of spinning for minutes.
331///
332/// **Non-test builds** disable the depth guard entirely (`None`).
333/// nixpkgs uses deeply nested fixpoints (50+ overlay applications, each
334/// creating cascading chains of millions of `eval_expr` calls when
335/// attributes are forced). CppNix has no explicit depth limit — it
336/// relies on the OS stack, which `stacker` now emulates for us. True
337/// infinite recursion is caught by the thunk blackhole detector in
338/// `Thunk::force`, not by this counter.
339///
340/// "No limit" is carried by `None`, NOT by a `usize::MAX` sentinel. The
341/// sentinel form obliged every reader of this constant to re-guard it
342/// (`MAX_EVAL_DEPTH != usize::MAX && depth > MAX_EVAL_DEPTH`), and that
343/// guard did not actually remove the nonsense comparison it was written to
344/// suppress — `depth > usize::MAX` is false for every `usize`, which
345/// `clippy::absurd_extreme_comparisons` reports at deny level. With the
346/// bound typed as an `Option`, the non-test build contains no comparison
347/// at all and the absurd form has no way to be written.
348#[cfg(test)]
349const MAX_EVAL_DEPTH: Option<usize> = Some(2_048);
350#[cfg(not(test))]
351const MAX_EVAL_DEPTH: Option<usize> = None;
352
353/// Lightweight depth guard.
354///
355/// In non-test builds `MAX_EVAL_DEPTH` is `None`, so the guard is a no-op
356/// (the arm never matches). The compiler should be able to elide most of
357/// the overhead.
358struct DepthGuard;
359
360/// Release-active runaway backstop for the overlay-fixpoint promotion.
361///
362/// Release builds set `MAX_EVAL_DEPTH = None` (no eval-depth guard)
363/// so nixpkgs' legitimately-deep fixpoints evaluate. But a promoted
364/// empty-attrs partial that corrupts a downstream `makeOverridable` /
365/// `commonAttrs` fixpoint (the cross-system Darwin `apple-sdk` path `hello`
366/// hits under `builtins.currentSystem = macOS`) recurses through
367/// `eval_expr` without bound — and that recursion does NOT climb the force
368/// stack, so only an `eval_expr`-level bound catches it before the OS stack
369/// aborts. Armed ONLY once a promotion has fired (`promotion_occurred()`),
370/// so ordinary deep evaluation (never after a promotion) is untouched. The
371/// converging native-system fixpoint (`libxcrypt`) peaks well under this
372/// bound and is unaffected; the non-converging cross-system runaway is
373/// caught here, converting a hard native-stack abort into a recoverable
374/// `InfiniteRecursion` that `x.y or default` recovers exactly like nix
375/// (`hello` returns to a clean value-diverge instead of aborting).
376const PROMOTION_RUNAWAY_EVAL_DEPTH: usize = 500;
377
378impl DepthGuard {
379 #[inline(always)]
380 fn enter() -> Result<Self, EvalError> {
381 EVAL_DEPTH.with(|d| {
382 let depth = d.get();
383 if matches!(MAX_EVAL_DEPTH, Some(max) if depth > max) {
384 return Err(EvalError::InfiniteRecursion(
385 "eval depth exceeded".into(),
386 ));
387 }
388 if depth > PROMOTION_RUNAWAY_EVAL_DEPTH
389 && crate::value::promotion_occurred()
390 {
391 return Err(EvalError::InfiniteRecursion(
392 "overlay-fixpoint promotion runaway (eval depth exceeded)".into(),
393 ));
394 }
395 d.set(depth + 1);
396 Ok(DepthGuard)
397 })
398 }
399}
400
401impl Drop for DepthGuard {
402 #[inline(always)]
403 fn drop(&mut self) {
404 EVAL_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
405 }
406}
407
408/// Collect ALL identifier names referenced in an AST expression.
409///
410/// Walks the full expression tree (including inside `with` bodies)
411/// and collects every `Ident` node. This is an OVER-APPROXIMATION:
412/// it includes shadowed names and names inside `with` bodies.
413///
414/// Over-approximation is SAFE for dead binding elimination — we may
415/// keep a binding that's unused (waste) but never skip a binding
416/// that IS used (correctness).
417///
418/// Previous versions bailed out on `with` expressions, disabling
419/// dead binding elimination entirely. The fix: collect idents even
420/// inside `with` bodies. If a binding name doesn't appear as ANY
421/// identifier ANYWHERE in the expression, it's provably dead
422/// regardless of `with` scopes — `with` makes names from the
423/// namespace reachable, not names from the enclosing let-scope.
424fn collect_referenced_names(expr: &ast::Expr) -> HashSet<String> {
425 let mut names = HashSet::new();
426 for node in expr.syntax().descendants() {
427 if let Some(ident) = ast::Ident::cast(node) {
428 names.insert(ident_text(&ident));
429 }
430 }
431 names
432}
433
434/// Compute the set of binding names that are transitively needed
435/// by the body expression in a recursive scope (let-in or rec attrset).
436///
437/// Algorithm:
438/// 1. Collect all ident references from the body → root set
439/// 2. Collect all ident references from each binding's value expression
440/// 3. BFS from root set through binding dependencies
441/// 4. Return the set of reachable binding names
442///
443/// Bindings NOT in the returned set are provably dead and can be skipped.
444/// This is correct even for recursive scopes because the BFS follows
445/// transitive dependencies: if A is needed and A references B, then B
446/// is added to the needed set.
447fn compute_needed_bindings(
448 body: &ast::Expr,
449 binding_info: &[(String, Option<ast::Expr>)], // (name, value_expr) — None for plain inherit
450) -> HashSet<String> {
451 // Step 1: Collect idents from the body
452 let body_refs = collect_referenced_names(body);
453
454 // Build the set of all binding names and their dependencies
455 let mut all_names: HashSet<String> = HashSet::with_capacity(binding_info.len());
456 let mut deps: HashMap<String, HashSet<String>> = HashMap::with_capacity(binding_info.len());
457
458 for (name, value_expr) in binding_info {
459 all_names.insert(name.clone());
460 if let Some(expr) = value_expr {
461 deps.insert(name.clone(), collect_referenced_names(expr));
462 }
463 }
464
465 // Step 2: BFS from body refs through binding dependencies
466 let mut needed: HashSet<String> = body_refs.intersection(&all_names).cloned().collect();
467 let mut queue: VecDeque<String> = needed.iter().cloned().collect();
468
469 while let Some(name) = queue.pop_front() {
470 if let Some(name_deps) = deps.get(&name) {
471 for dep in name_deps {
472 if all_names.contains(dep) && needed.insert(dep.clone()) {
473 queue.push_back(dep.clone());
474 }
475 }
476 }
477 }
478
479 needed
480}
481
482/// Evaluate a Nix expression string.
483#[must_use = "evaluation result should be used"]
484pub fn eval(input: &str) -> Result<Value, EvalError> {
485 eval_with_file(input, None)
486}
487
488// Whether we are inside a top-level eval (used to avoid nested perf reports).
489thread_local! {
490 static EVAL_NESTING: Cell<usize> = const { Cell::new(0) };
491}
492
493/// Evaluate a Nix expression string, optionally tagged with the
494/// path of the source file. The file is stored on the root `Env`
495/// so that any closure created during evaluation captures it and
496/// can resolve relative path literals (`./foo.nix`) in function
497/// defaults that fire after control has left the file's scope.
498
499pub fn eval_with_file(input: &str, file: Option<std::path::PathBuf>) -> Result<Value, EvalError> {
500 let nesting = EVAL_NESTING.with(|n| {
501 let v = n.get();
502 n.set(v + 1);
503 v
504 });
505 if nesting == 0 {
506 crate::perf::init();
507 crate::perf::start();
508 crate::trace::init_trace();
509 // Clear the identifier symbol cache so that offsets from
510 // previous top-level evaluations don't persist.
511 clear_ident_cache();
512 // ENV-RESOLVE M0 (no-op unless `SUI_RESOLVE=1`): clear the per-source
513 // resolution side-table for the same reason — its `(source_id,
514 // offset)` keys must not survive across independent top-level evals.
515 crate::resolve_env::clear();
516 // SOURCE_TEXTS is deliberately NOT cleared here — it is append-only
517 // for the life of the process. Clearing it on a `nesting == 0`
518 // re-entry was a shared-mutable-cell bug: the top-level
519 // `eval_with_file` RETURNS (nesting → 0) BEFORE its caller
520 // deep-forces the result (e.g. `value.to_json()` at the CLI), and
521 // that deep force triggers lazy `import`s which re-enter
522 // `eval_with_file` at nesting == 0 — so clearing here wiped every
523 // registered file's text mid-force. Any `unsafeGetAttrPos` resolved
524 // after the first deep-force import then failed its `text_for()`
525 // existence check and returned null (the cid `options.json` attrTag
526 // `declarations = []` divergence). SOURCE_TEXTS is keyed by canonical
527 // path and `register_source` stores each path's text only once
528 // (identical on re-parse), so append-only is correct — a path always
529 // maps to its own text — and matches CppNix, which never clears its
530 // source registry. The only cost is bounded growth within one process
531 // (a non-issue for a per-invocation CLI). Removing the clearable cell
532 // makes the whole "absent/wrong source text at resolve time" class
533 // unrepresentable rather than merely guarded.
534 }
535 let parse = rnix::Root::parse(input);
536 if !parse.errors().is_empty() {
537 let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
538 EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
539 return Err(EvalError::ParseError(msgs.join("; ")));
540 }
541
542 // Each parse tree gets a unique source ID so that identifiers
543 // at the same byte offset in different files don't collide in
544 // the symbol cache.
545 let src_id = next_source_id();
546 // ENV-RESOLVE M0 (no-op unless `SUI_RESOLVE=1`): run the parse-time
547 // variable resolver over THIS parse tree and merge its `Lexical`
548 // resolutions into the per-source table under `src_id`. Pure + fail-safe
549 // (any uncertainty is left `Dynamic`), so the eval below is byte-identical
550 // — the `Lexical` fast path only shortcuts a lexical-bindings hit, which
551 // `lookup_fast` returns first anyway.
552 if crate::resolve_env::enabled() {
553 let table = sui_resolve::resolve(&parse.tree());
554 crate::resolve_env::populate(src_id, &table);
555 }
556 // The attrset-binding plan (`SUI_NORMALIZE=1`). A rejection here is nix's
557 // PARSE-time duplicate-attribute error, so it surfaces as a parse error
558 // rather than an eval one — but only once the rejection tier lands; for
559 // now a rejected tree simply records no plan and every group keeps its
560 // existing path.
561 if crate::normalize_env::enabled() {
562 if let Ok(table) = sui_normalize::normalize(&parse.tree()) {
563 crate::normalize_env::populate(src_id, &table);
564 }
565 }
566 // Register this parse tree's file + text so a static key's byte offset
567 // (recorded by `eval_attrset`) resolves to a file/line/column for
568 // `builtins.unsafeGetAttrPos`. The file flows through the eval-file
569 // stack (store-path prefixed for imported inputs); the position resolver
570 // lifts a cache-dir path to its `/nix/store/<h>-source` store path.
571 crate::pos::register_source(file.as_deref(), input);
572 let prev_src_id = CURRENT_SOURCE_ID.with(|s| {
573 let old = s.get();
574 s.set(src_id);
575 old
576 });
577
578 let root = parse.tree();
579 let expr = match root.expr() {
580 Some(e) => e,
581 None => {
582 CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
583 EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
584 return Err(EvalError::ParseError("empty expression".to_string()));
585 }
586 };
587 let mut env = Env::new();
588 env.set_eval_file(file);
589 // Tag the env with THIS parse tree's source_id so a thunk created here
590 // and forced later (cross-file) restores this id on force (see the
591 // source-id guard in `Thunk::force`), keying `IDENT_CACHE` against the
592 // file where the thunk was defined.
593 env.set_source_id(src_id);
594 builtins::register(&mut env);
595 let result = eval_expr(&expr, &env).map_err(|e| attach_trace(e))?;
596 // Force the top-level result so callers always see a concrete value.
597 let final_result = force_value(&result).map_err(|e| attach_trace(e));
598 // Restore the previous source ID (matters for nested imports).
599 CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
600 EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
601 if nesting == 0 {
602 crate::perf::report();
603 }
604 final_result
605}
606
607/// Force a value: if it is a thunk, evaluate and memoize the result.
608/// Concrete values are returned unchanged.
609/// Force a value: if it is a thunk, evaluate and memoize the result.
610/// Concrete values are returned unchanged.
611///
612/// Inlined aggressively so the non-thunk fast path compiles to a
613/// simple clone without a function-call boundary.
614#[inline(always)]
615/// Force a value and return a type-safe `Concrete` (guaranteed non-Thunk).
616///
617/// This is the preferred forcing API. The `Concrete` return type makes it
618/// impossible to accidentally use an unforced thunk — the compiler rejects it.
619pub fn force_concrete(value: &Value) -> Result<Concrete, EvalError> {
620 value.demand()
621}
622
623/// Force a value (legacy API — returns `Value` for backward compatibility).
624///
625/// Prefer `force_concrete()` or `Value::demand()` for new code.
626pub fn force_value(value: &Value) -> Result<Value, EvalError> {
627 crate::perf::inc(crate::perf::Counter::ForceValue);
628 // Fast path: non-thunk values are returned immediately (no clone needed
629 // until we actually have work to do).
630 if !matches!(value, Value::Thunk(_)) {
631 return Ok(value.clone());
632 }
633 // Slow path: chase thunk chains.
634 //
635 // A legitimate chain is typically 1–3 links deep (result of lazy
636 // evaluation wrapping an intermediate value in another thunk).
637 // Reaching 100 means either (a) a self-referential cycle like
638 // `let x = x; in x` that bypassed per-thunk Blackhole detection,
639 // or (b) pathological Thunk(Thunk(...)) nesting. Both are errors.
640 //
641 // Previous behavior silently returned `Ok(last_thunk)` at depth
642 // 100, which hid infinite-recursion bugs — the blackhole tests
643 // in the lib suite failed because `result.is_ok()` instead of
644 // `is_err()`. Returning `Err` here makes the silent-bail visible
645 // at the CppNix-compatible call site (real Nix raises "infinite
646 // recursion encountered").
647 let mut v = value.clone();
648 let mut depth = 0u32;
649 loop {
650 match v {
651 Value::Thunk(ref thunk) => {
652 v = force_thunk(thunk)?;
653 depth += 1;
654 if depth > 100 {
655 return Err(EvalError::InfiniteRecursion(
656 "force_value: thunk chain exceeded depth 100 (cycle or runaway lazy wrap)".into(),
657 ));
658 }
659 }
660 _ => return Ok(v),
661 }
662 }
663}
664
665/// Force with call-site tracking (legacy API).
666pub fn force_value_tracked(value: &Value, site: &str) -> Result<Value, EvalError> {
667 crate::perf::inc(crate::perf::Counter::ForceValue);
668 if let Value::Thunk(thunk) = value {
669 FORCE_SITES.with(|sites| {
670 *sites.borrow_mut().entry(site.to_string()).or_insert(0) += 1;
671 });
672 force_thunk(thunk)
673 } else {
674 Ok(value.clone())
675 }
676}
677
678thread_local! {
679 static FORCE_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
680 std::cell::RefCell::new(std::collections::HashMap::new());
681 static APPLY_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
682 std::cell::RefCell::new(std::collections::HashMap::new());
683}
684
685/// Dump force-site counters (call from perf reporting).
686pub fn dump_force_sites() {
687 FORCE_SITES.with(|sites| {
688 let sites = sites.borrow();
689 let mut sorted: Vec<_> = sites.iter().collect();
690 sorted.sort_by(|a, b| b.1.cmp(a.1));
691 eprintln!("[force-sites] top thunk force call sites:");
692 for (site, count) in sorted.iter().take(10) {
693 eprintln!(" {count:>8} {site}");
694 }
695 });
696 APPLY_SITES.with(|sites| {
697 let sites = sites.borrow();
698 let mut sorted: Vec<_> = sites.iter().collect();
699 sorted.sort_by(|a, b| b.1.cmp(a.1));
700 eprintln!("[apply-sites] top lambda call sites by source file:");
701 for (site, count) in sorted.iter().take(15) {
702 // Strip nix store prefix for readability
703 let short = site.rsplit_once("-source/").map_or(site.as_str(), |(_,s)| s);
704 eprintln!(" {count:>8} {short}");
705 }
706 });
707}
708
709/// Force a thunk — split out from [`force_value`] so the fast path
710/// (non-thunk clone) stays fully inlined while this cold path can
711/// be a regular function call with stacker protection.
712fn force_thunk(thunk: &Thunk) -> Result<Value, EvalError> {
713 // Ultra-fast path: if the thunk is already cached, skip stacker overhead.
714 if let Some(cached) = thunk.peek() {
715 crate::perf::inc(crate::perf::Counter::ThunkHit);
716 return Ok(cached.clone().into_value());
717 }
718 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
719 // Force ONE level only — matches CppNix's forceValue which does
720 // not transitively chase thunk-in-thunk chains. The caller will
721 // force again when the value is actually needed. This is the key
722 // optimization: CppNix forces 71 thunks for lib.version while
723 // sui was forcing 180K due to transitive forcing.
724 thunk.force(&|expr, env| eval_expr(expr, env))
725 })
726}
727
728/// Decide whether to thunk an expression or evaluate it directly.
729///
730/// Trivial expressions (literals, paths) are evaluated immediately --
731/// no thunk allocation. For non-recursive scopes, variable lookups
732/// (Ident) and lambdas are also evaluated eagerly. This matches
733/// CppNix's `maybeThunk` optimization which avoids a large fraction
734/// of thunk creations on nixpkgs.
735///
736/// For recursive scopes (let-in, rec attrsets), set `is_rec = true` to
737/// prevent eager evaluation of `Ident` and `Lambda` expressions:
738/// - Ident: sibling bindings may not be defined yet (forward refs).
739/// - Lambda: the closure must capture the *final* env (set in Phase 2)
740/// so that the lambda body can reference sibling bindings.
741///
742/// `defined_so_far`: In recursive scopes, names that have already been
743/// bound in this scope (i.e. earlier bindings). Idents referencing these
744/// are backward references and can be resolved directly without thunking.
745/// Forward references (names not yet defined) must still be thunked.
746/// Detect whether `value_expr`'s source structurally references
747/// the identifier `name` — the signal that this let-binding is a
748/// self-recursive fix-point (`let x = f x; in x` or
749/// `let x = { a = 1; b = x.a; }; in x`). Used at let-binding
750/// thunking time to pick `Thunk::new_suspended_recursive` over the
751/// classic `Thunk::new_suspended`, so inner re-entrance during
752/// force returns the partial value via `ThunkRepr::Promise`
753/// instead of erroring with `InfiniteRecursion`.
754///
755/// Implementation walks the value-expr's rnix syntax tree looking
756/// for `TOKEN_IDENT` whose text equals `name`. This is a
757/// conservative over-approximation:
758/// - shadowing (e.g. `let x = let x = 1; in x; in x`) marks the
759/// outer thunk recursive even though no real cycle exists;
760/// - the resulting Promise behaviour is a strict superset of
761/// Blackhole for non-cyclic forces (the body runs to completion
762/// and the cell gets the final value), so false positives are
763/// semantically safe — they cost only the extra `Rc<RefCell>`
764/// allocation per recursive let-binding.
765///
766/// False negatives (e.g. the bound name appears only inside an
767/// inherit-from-source clause) leave the existing
768/// `InfiniteRecursion` behaviour intact, which is the conservative
769/// fallback.
770/// `SUI_SCOPE_NARROW` — the scope-narrowing latch.
771///
772/// Every `let` / `rec` / pattern-default binding closes an `Rc` cycle today:
773/// the thunk is bound INTO the scope env, then Phase 2's `update_env` puts
774/// that same env back INTO the thunk. `Rc` has no cycle collector and no
775/// `Weak` sits on that edge, so the whole scope — every innocent leaf in it —
776/// is immortal for the life of the process. Narrowing removes the second half
777/// of the cycle for the bindings that provably do not need it.
778///
779/// * unset / `0` — today's behaviour, byte- AND allocation-identical. Not one
780/// extra tree walk runs on this path.
781/// * `1` — D3 (pattern-lambda formal defaults) + D1 (`let` / `rec` bindings
782/// whose RHS reaches no sibling keep their outer-env capture).
783/// * `2` — additionally D2 (bindings that DO need the scope get a *cluster*
784/// env holding only the names they can reach, so one recursive binding
785/// stops pinning its innocent siblings).
786///
787/// Read once through a `OnceLock` one-way latch — the `resolve_env::enabled()`
788/// idiom — so the value cannot change mid-eval and the default path pays a
789/// single relaxed load.
790/// ★ THE DEFAULT IS 2 (flipped 2026-08-17). `0` and `1` remain selectable for
791/// bisecting a suspected narrowing bug — that is the whole reason the latch
792/// survives rather than the code being inlined.
793///
794/// It shipped as `0`, and NOTHING in the tree set it. So the measured result —
795/// 700.0 MB / 1,020,001 live nodes → 22.2 MB / 0 on the gate probe, with the
796/// process RSS floor at 20.5 MB, i.e. *at the floor* — reached nobody. A fix
797/// present but unreached is the same shape as the VM bridges that were
798/// installed two-of-three, and as `vm_fallback_count()` sitting unread since
799/// the day it was written.
800///
801/// Flipped only after byte-parity was proven at every level, because a wrong
802/// drvPath is far worse than a leak:
803/// - the 117-fixture lang corpus: identical at 0, 1 and 2
804/// - the full `sui-eval` suite at level 2: 1685 pass
805/// - `sui eval --raw <expr>.drvPath` byte-identical across 0/1/2 AND equal to
806/// real nix
807///
808/// The narrowing removes the second half of an `Rc` cycle for bindings that
809/// provably do not need the scope env. It is NOT free of judgement: `P2`, a
810/// genuinely-recursive scope, must still pin, and it does — a narrowing that
811/// improved every probe would mean it was discarding something it should keep.
812fn scope_narrow_level() -> u8 {
813 static LEVEL: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
814 *LEVEL.get_or_init(
815 || match std::env::var("SUI_SCOPE_NARROW").ok().as_deref() {
816 Some("0") => 0,
817 Some("1") => 1,
818 _ => 2,
819 },
820 )
821}
822
823/// True at `SUI_SCOPE_NARROW >= 1` — D1 + D3 are on.
824#[inline]
825fn scope_narrow_enabled() -> bool {
826 scope_narrow_level() >= 1
827}
828
829/// True at `SUI_SCOPE_NARROW = 2` — D2 (the cluster env) is on.
830#[inline]
831fn scope_cluster_enabled() -> bool {
832 scope_narrow_level() >= 2
833}
834
835/// The set of variable-reference ident names in `value_expr`'s subtree
836/// (`NODE_IDENT` whose parent is NOT a `NODE_ATTRPATH` — i.e. genuine
837/// variable references, not attribute names/keys). ONE subtree walk.
838///
839/// Kills the O(N²) re-walk storm (Storm A) at the call sites: previously
840/// `is_self_recursive_binding` did a full subtree walk once per
841/// `(binding × sibling-name)` in every `let`/`rec` scope; now each RHS is
842/// walked ONCE to build this set, then every name is an O(1) set lookup.
843/// Byte-neutral: the recursion verdict is unchanged (a name is self/mutually
844/// recursive iff it is in the set).
845///
846/// NOT cross-call memoized: a process-lifetime memo keyed on ephemeral AST
847/// node identity `(source-id, range)` collides when nodes are parsed/dropped
848/// without a per-eval clear (the standalone-predicate case). The call-site
849/// single-walk is the byte-safe win; `ContentMemo` (sui-intern) is reserved
850/// for sites with a STABLE content key (the NAR-hash memo's `(dir,name)`, the
851/// overlay-flatten per-node cache).
852///
853/// The attrpath exclusion matters: without it, `placeholder = if
854/// lhs.placeholder == …` in nixpkgs `lib/types.nix` would be falsely flagged
855/// self-recursive (its RHS mentions the *attribute* `.placeholder`), routing
856/// the binding through the `Promise` fix-point path whose env handling drops
857/// the let-scope — surfacing as a force-order-dependent `null` in the module
858/// system (`concatLists: expected list, got null`).
859fn referenced_idents(value_expr: &ast::Expr) -> HashSet<SmolStr> {
860 use rnix::SyntaxKind;
861 // Storm A instrumentation (byte-neutral, gated on perf::enabled()): count
862 // this walk + the rnix descendants it visits + its walltime, so the
863 // residual per-fixpoint-iteration self/mutual-recursion detection cost is
864 // VISIBLE in the SUI_EVAL_PERF report — symmetric with sorted_entries /
865 // overlay-flatten. The counter reads add zero output-relevant work.
866 let perf_on = crate::perf::enabled();
867 let t0 = if perf_on {
868 Some(std::time::Instant::now())
869 } else {
870 None
871 };
872 crate::perf::inc(crate::perf::Counter::SelfRecWalkCalls);
873 let mut nodes_walked: u64 = 0;
874 let mut set: HashSet<SmolStr> = HashSet::new();
875 for node in value_expr.syntax().descendants() {
876 nodes_walked += 1;
877 if node.kind() == SyntaxKind::NODE_IDENT
878 && node
879 .parent()
880 .is_none_or(|p| p.kind() != SyntaxKind::NODE_ATTRPATH)
881 && let Some(i) = ast::Ident::cast(node)
882 {
883 set.insert(SmolStr::from(ident_text(&i).as_str()));
884 }
885 }
886 crate::perf::add(crate::perf::Counter::SelfRecWalkNodes, nodes_walked);
887 if let Some(t0) = t0 {
888 crate::trace::add_self_rec_walk_nanos(t0.elapsed().as_nanos());
889 }
890 set
891}
892
893/// True iff `value_expr` references `name` as a variable. Now a set lookup
894/// over one subtree walk (see `referenced_idents`). Byte-neutral vs the prior
895/// per-name-walk implementation.
896fn is_self_recursive_binding(value_expr: &ast::Expr, name: &str) -> bool {
897 referenced_idents(value_expr).contains(name)
898}
899
900fn maybe_thunk(
901 expr: &ast::Expr,
902 env: &Env,
903 is_rec: bool,
904 defined_so_far: Option<&HashSet<String>>,
905) -> Value {
906 match expr {
907 // Literals: evaluate directly (no allocation needed).
908 ast::Expr::Literal(lit) => eval_literal(lit).unwrap_or_else(|_| {
909 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
910 }),
911 // Ident resolution: try full lookup (lexical + with-scope cache + force).
912 // On successful lookup → return value directly (most common case).
913 // On blackhole (fixpoint being constructed) → env.lookup returns None
914 // → create WithIdent thunk for deferred O(1) cache-based resolution.
915 // This approach: (1) is fast for resolved with-scopes (no thunk overhead),
916 // (2) handles blackhole fixpoints correctly via WithIdent deferral.
917 ast::Expr::Ident(ident) if !is_rec => {
918 // Cache the interned Symbol by (source_id, text_offset) — same
919 // zero-alloc steady-state path as the strict Ident arm in
920 // `eval_expr`. The ident text is materialized only on the
921 // once-per-offset cold miss and on the (rare) blackhole deferral.
922 // Same cross-file aliasing fix as the strict `eval_expr` Ident arm —
923 // key on the env's source id, not the unmaintained thread-local.
924 // This twin had NO stale-symbol guard at all (the one commit
925 // 2d93e77 added sits only on the strict arm's lookup-MISS path,
926 // after the keyword check), so it was the more exposed of the two.
927 let sym = {
928 let src_id = env.source_id();
929 let offset = u32::from(ident.syntax().text_range().start());
930 crate::value::intern_cached_with(src_id, offset, || {
931 crate::value::intern(&ident_text(ident))
932 })
933 };
934 // Zero-copy keyword check on the resolved Symbol.
935 if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
936 "true" => Some(Value::Bool(true)),
937 "false" => Some(Value::Bool(false)),
938 "null" => Some(Value::Null),
939 _ => None,
940 }) {
941 return kw;
942 }
943 {
944 {
945 // `name` arg to `lookup_fast` is unused (lookup is by
946 // Symbol) — pass "" to skip materializing the ident text on
947 // the hot HIT path.
948 if let Some(v) = env.lookup_fast(sym, "") {
949 return v;
950 }
951 // Failed — either blackhole or missing. Create WithIdent
952 // thunk for deferred resolution (only for the blackhole case).
953 if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
954 return Value::Thunk(Thunk::new_with_ident(
955 SmolStr::from(ident_text(ident).as_str()),
956 scope_cache,
957 scope_value,
958 env.clone(),
959 ));
960 }
961 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
962 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
963 }
964 }
965 }
966 // Identifiers in rec scope: check if it's a backward reference
967 // (name already defined earlier in the same scope). If so, we
968 // can resolve it directly instead of creating a wasteful thunk.
969 ast::Expr::Ident(ident) if is_rec => {
970 let name = ident_text(ident);
971 match name.as_str() {
972 "true" => Value::Bool(true),
973 "false" => Value::Bool(false),
974 "null" => Value::Null,
975 _ => {
976 // If this name was already defined earlier in the
977 // scope, it's a backward reference — resolve directly.
978 if defined_so_far.map_or(false, |d| d.contains(&name)) {
979 env.lookup(&name).unwrap_or_else(|| {
980 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
981 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
982 })
983 } else {
984 // Forward reference — must thunk
985 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
986 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
987 }
988 }
989 }
990 }
991 // Absolute and home paths: trivial text extraction — but ONLY
992 // for the non-interpolated case. An interpolated path (`/a/${e}`,
993 // `~/${e}`) must be thunked so its `${…}` parts are evaluated in
994 // `eval_expr_inner`, never spliced as literal text.
995 ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
996 // CppNix canonicalizes every absolute path literal on eval
997 // (`/.` → `/`, `/a/./b` → `/a/b`, `/a/../b` → `/b`, `..`
998 // clamped at root). A path VALUE carries the canonical form —
999 // the marquee cid root threw in `lib.path.hasStorePathPrefix`
1000 // precisely because sui kept the raw `/.` text.
1001 let text = crate::path::canon_abs(&p.syntax().text().to_string());
1002 Value::Path(Box::new(SmolStr::from(text.as_str())))
1003 }
1004 ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
1005 let text = p.syntax().text().to_string();
1006 Value::Path(Box::new(SmolStr::from(text.as_str())))
1007 }
1008 // Non-interpolated string literal: a constant value with no
1009 // interpolation, so `eval_str` runs no `${…}` force/coerce — it is
1010 // pure, non-throwing, side-effect-free, and produces a
1011 // `String(NixString::with_context(text, EMPTY))`. Evaluating it here is
1012 // therefore byte-identical to forcing a suspended thunk of it (M2
1013 // thunk-waste: a constant Str thunk is always pure overhead — it can
1014 // never observably change eval order because it cannot throw or
1015 // diverge). Only the NON-interpolated case is direct; an interpolated
1016 // `"${e}"` must stay thunked so its parts force lazily in the right
1017 // env/order. `eval_str` on the empty-interpolation input cannot fail,
1018 // but fall back to a thunk on the (unreachable) error to preserve
1019 // exact prior behavior.
1020 ast::Expr::Str(st) if !str_has_interpolation(st) => {
1021 eval_str(st, env).unwrap_or_else(|_| {
1022 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1023 })
1024 }
1025 // Lambda: capture env directly (no computation needed).
1026 // But NOT in recursive scopes -- the closure must capture the
1027 // final env with all sibling bindings (set in Phase 2).
1028 ast::Expr::Lambda(lam) if !is_rec => {
1029 if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1030 Value::Lambda(Rc::new(Closure {
1031 param,
1032 body,
1033 env: env.clone(),
1034 }))
1035 } else {
1036 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1037 }
1038 }
1039 // Select on a variable: CppNix's maybeThunk evaluates these eagerly
1040 // when the base is a simple ident. However, this breaks fixpoints
1041 // where the base (e.g., `config`) is a thunk being computed — eagerly
1042 // evaluating `config.x` during attrset construction triggers blackhole.
1043 //
1044 // The nixpkgs module system relies on `{ ...; default = config.x; }`
1045 // being lazy. Wrap selects in thunks unconditionally.
1046 // The performance cost is minimal (thunk allocation + deferred eval)
1047 // and correctness is critical for fixpoint patterns.
1048 // Everything else: wrap in a thunk for lazy evaluation.
1049 _ => {
1050 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeOther);
1051 if crate::perf::enabled() {
1052 let kind = match expr {
1053 ast::Expr::Select(_) => "Select",
1054 ast::Expr::Apply(_) => "Apply",
1055 ast::Expr::BinOp(_) => "BinOp",
1056 ast::Expr::IfElse(_) => "IfElse",
1057 ast::Expr::Str(_) => "Str",
1058 ast::Expr::List(_) => "List",
1059 ast::Expr::With(_) => "With",
1060 ast::Expr::Assert(_) => "Assert",
1061 ast::Expr::HasAttr(_) => "HasAttr",
1062 ast::Expr::UnaryOp(_) => "UnaryOp",
1063 ast::Expr::Paren(_) => "Paren",
1064 ast::Expr::LetIn(_) => "LetIn",
1065 ast::Expr::AttrSet(_) => "AttrSet",
1066 ast::Expr::Ident(_) => "Ident(rec)",
1067 ast::Expr::Lambda(_) => "Lambda(rec)",
1068 ast::Expr::LegacyLet(_) => "LegacyLet",
1069 ast::Expr::PathAbs(_)
1070 | ast::Expr::PathHome(_)
1071 | ast::Expr::PathRel(_)
1072 | ast::Expr::PathSearch(_) => "Path(interp)",
1073 _ => "Other",
1074 };
1075 crate::trace::inc_maybe_other_kind(kind);
1076 }
1077 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1078 }
1079 }
1080}
1081
1082/// Evaluate an rnix expression in an environment.
1083///
1084/// Uses `stacker::maybe_grow` to dynamically extend the call stack when
1085/// it is close to exhaustion. This prevents stack overflow on deeply
1086/// nested nixpkgs fixpoints (50+ overlay applications each creating
1087/// multiple recursive `eval_expr` / `force_value` frames).
1088///
1089/// **Fast path:** Ident (~32% of all evals), Literal, Paren, and Root
1090/// expressions don't recurse and are handled directly, skipping the
1091/// `stacker::maybe_grow` overhead for ~40% of all `eval_expr` calls.
1092#[inline(always)]
1093pub fn eval_expr(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1094 // Fast path: trivial expressions that don't recurse.
1095 // Skip stacker overhead for ~40% of all eval_expr calls.
1096 match expr {
1097 ast::Expr::Ident(ident) => {
1098 crate::perf::inc(crate::perf::Counter::EvalExpr);
1099 if crate::perf::enabled() {
1100 crate::perf::inc(crate::perf::Counter::ExprIdent);
1101 }
1102 // ── ENV-RESOLVE M0 fast path (no-op unless `SUI_RESOLVE=1`) ──
1103 // A parse-time-`Lexical` reference carries its precomputed
1104 // Symbol; probe the lexical bindings map DIRECTLY, skipping the
1105 // per-lookup `ident_text().to_string()` + `intern()`. This is
1106 // parity-by-construction: `lookup_fast` probes the SAME lexical
1107 // map by the SAME Symbol FIRST, so a hit here is byte-identical
1108 // to what the unchanged path below returns. Any miss (a
1109 // mid-fixpoint blackhole where the binding isn't in scope yet, an
1110 // unrecorded ident, or `Dynamic`) falls through to the EXACT
1111 // unchanged path — including the whole with-chain + WithIdent
1112 // deferral. The resolver never records keywords, so the
1113 // true/false/null handling below is untouched on this path.
1114 if crate::resolve_env::enabled() {
1115 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1116 let offset = u32::from(ident.syntax().text_range().start());
1117 if let sui_resolve::Resolution::Lexical { sym } =
1118 crate::resolve_env::resolution_for(src_id, offset)
1119 {
1120 if let Some(v) = env.lookup_lexical_sym(sym) {
1121 return Ok(v);
1122 }
1123 }
1124 // Miss / Dynamic → fall through to the unchanged path.
1125 }
1126 // Cache the interned Symbol by (source_id, text_offset) so the
1127 // steady-state identifier lookup pays neither a per-lookup
1128 // `ident_text().to_string()` heap alloc nor a string re-hash — the
1129 // ident's text is materialized only on the once-per-offset cold
1130 // miss. The keyword check + the common `lookup_fast` HIT then run
1131 // fully allocation-free; `name` is materialized lazily only on the
1132 // miss/error branches, which need the string anyway.
1133 // KEY ON `env.source_id()`, NOT the thread-local (fixed 2026-07-20).
1134 //
1135 // `CURRENT_SOURCE_ID` is pushed at exactly ONE site —
1136 // `value.rs`'s `ThunkRepr::Suspended` force branch. Lambda
1137 // application and the Native/WithIdent/InheritSelect/Promise force
1138 // branches never push it, so while a callee's body was being
1139 // evaluated the thread-local still named the CALLER's file. The
1140 // `(source_id, offset)` cache key then aliased across files: an
1141 // identifier at byte N in file A could resolve to the Symbol
1142 // interned for a `null`/`true`/`false` token at byte N in file B —
1143 // and the zero-copy keyword check below turned that into a literal
1144 // `Value::Null` for a perfectly well-defined identifier, before any
1145 // environment lookup.
1146 //
1147 // That is what stopped sui evaluating nixpkgs: `hostSuffix` in
1148 // `make-derivation.nix` resolved to `null`, so `attrs.name +
1149 // hostSuffix` raised "cannot add string and null" — observed
1150 // directly as `STALE-KEYWORD ident="hostSuffix" resolvedAs="null"`.
1151 // It is not darwin-specific and has nothing to do with the module
1152 // system; `import <nixpkgs> {}` fails identically on x86_64-linux.
1153 //
1154 // `Env` already carries the correct value: `eval_with_file` sets it
1155 // and `child()` inherits it, and a lambda's `call_env` is
1156 // `closure.env.child()` — so a body's env names its DEFINING file.
1157 // Keying on it fixes every cross-file path at the cause, rather than
1158 // adding a fifth push/pop guard that a sixth path can forget.
1159 let sym = {
1160 let src_id = env.source_id();
1161 let offset = u32::from(ident.syntax().text_range().start());
1162 crate::value::intern_cached_with(src_id, offset, || {
1163 crate::value::intern(&ident_text(ident))
1164 })
1165 };
1166 // Zero-copy keyword check on the resolved Symbol — the resolver
1167 // never records keywords, so this matches the prior `name.as_str()`
1168 // arm exactly.
1169 if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
1170 "true" => Some(Value::Bool(true)),
1171 "false" => Some(Value::Bool(false)),
1172 "null" => Some(Value::Null),
1173 _ => None,
1174 }) {
1175 return Ok(kw);
1176 }
1177 return {
1178 {
1179 // `lookup_fast`'s `name` argument is unused (lookup is by
1180 // Symbol); pass "" to avoid materializing the ident text on
1181 // the hot HIT path.
1182 if let Some(v) = env.lookup_fast(sym, "") {
1183 Ok(v)
1184 } else {
1185 let name = ident_text(ident);
1186 // The `(src_id, text_offset)` identifier-symbol cache
1187 // (`intern_cached_with`) can hand back a STALE Symbol when
1188 // a lazily-forced thunk's identifier is resolved under a
1189 // force-time `CURRENT_SOURCE_ID` that differs from the
1190 // identifier's PARSE-time src_id — a thunk from file A can
1191 // be forced while B is the current source, so
1192 // `(B_src_id, offset)` aliases B's parse tree's identifier
1193 // at that same byte offset and returns ITS Symbol. (Proven
1194 // root: nixpkgs `lib/systems/parse.nix` `mkOptionType` — the
1195 // binding IS present in the env, but the cache returned
1196 // `Symbol(566)` while the binding was interned under
1197 // `Symbol(506)`, so `lookup_fast(566)` missed a defined
1198 // var.) `intern` is deterministic + append-only, so on a
1199 // miss re-intern the name from its text (the authoritative
1200 // Symbol) and retry the lexical lookup BEFORE considering
1201 // with-scopes or undefined. A genuinely undefined variable
1202 // is unaffected — its fresh lookup also misses and falls
1203 // through unchanged.
1204 let fresh = crate::value::intern(name.as_str());
1205 if fresh != sym {
1206 if let Some(v) = env.lookup_fast(fresh, name.as_str()) {
1207 return Ok(v);
1208 }
1209 }
1210 if env.with_scope_count() > 0 {
1211 // With-scope lookup failed (likely blackhole from fixpoint).
1212 // Return a WithIdent thunk for deferred resolution.
1213 // This is the eval_expr equivalent of maybe_thunk's deferral.
1214 if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
1215 Ok(Value::Thunk(Thunk::new_with_ident(
1216 SmolStr::from(name.as_str()),
1217 scope_cache,
1218 scope_value,
1219 env.clone(),
1220 )))
1221 } else if crate::value::in_promise_eval() {
1222 // M2.6 Promise softening: an undefined
1223 // identifier inside Promise body evaluation
1224 // typically means a `with` block sourced
1225 // from the empty-attrset sentinel didn't
1226 // populate the with-scope. Returning null
1227 // lets the eval proceed; the result is
1228 // wrong-but-bounded (no further forces
1229 // happen on null until something downstream
1230 // demands a real value).
1231 Ok(Value::Null)
1232 } else {
1233 Err(EvalError::UndefinedVar(
1234 format!("'{name}'{}", eval_file_ctx()),
1235 ))
1236 }
1237 } else {
1238 if let Ok(dbg_var) = std::env::var("SUI_DEBUG_VAR") {
1239 if dbg_var == name || dbg_var == "*" {
1240 eprintln!(
1241 "[sui-debug] UndefinedVar '{name}' in {}\n\
1242 [sui-debug] env bindings ({} total): {:?}\n\
1243 [sui-debug] with_scopes: {}",
1244 eval_file_ctx(),
1245 env.binding_count(),
1246 env.binding_names_preview(20),
1247 env.with_scope_count(),
1248 );
1249 }
1250 }
1251 if crate::value::in_promise_eval() {
1252 // Same Promise softening as the with-scope
1253 // branch above.
1254 return Ok(Value::Null);
1255 }
1256 Err(EvalError::UndefinedVar(
1257 format!("'{name}'{}", eval_file_ctx()),
1258 ))
1259 }
1260 }
1261 }
1262 };
1263 }
1264 ast::Expr::Literal(lit) => {
1265 crate::perf::inc(crate::perf::Counter::EvalExpr);
1266 if crate::perf::enabled() {
1267 crate::perf::inc(crate::perf::Counter::ExprLiteral);
1268 }
1269 return eval_literal(lit);
1270 }
1271 ast::Expr::Paren(p) => {
1272 if let Some(inner) = p.expr() {
1273 return eval_expr(&inner, env);
1274 }
1275 }
1276 ast::Expr::Root(r) => {
1277 if let Some(inner) = r.expr() {
1278 return eval_expr(&inner, env);
1279 }
1280 }
1281 // Lambda: no recursion — just captures env into a closure.
1282 ast::Expr::Lambda(lam) => {
1283 crate::perf::inc(crate::perf::Counter::EvalExpr);
1284 if crate::perf::enabled() {
1285 crate::perf::inc(crate::perf::Counter::ExprLambda);
1286 }
1287 if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1288 return Ok(Value::Lambda(Rc::new(Closure {
1289 param,
1290 body,
1291 env: env.clone(),
1292 })));
1293 }
1294 }
1295 _ => {}
1296 }
1297 // Complex expressions: need stacker for recursion safety
1298 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
1299 eval_expr_inner(expr, env)
1300 })
1301}
1302
1303/// Inner implementation of [`eval_expr`] — called from the `stacker`
1304/// trampoline so that the stack is guaranteed to have headroom.
1305///
1306/// Uses a tail-call loop: for expressions in tail position (`if/else`,
1307/// `let..in`, `with`, `assert`, `paren`, `root`), we update the local
1308/// `expr` and `env` variables and loop instead of recursing. This
1309/// eliminates millions of stack frames in nixpkgs evaluation.
1310fn eval_expr_inner(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1311 // Tail-call trampoline: expressions in tail position update these
1312 // and `continue` instead of recursing into eval_expr.
1313 let mut cur_expr = expr.clone();
1314 let mut cur_env = env.clone();
1315
1316 loop {
1317 crate::perf::inc(crate::perf::Counter::EvalExpr);
1318 // Track expression type distribution when profiling
1319 if crate::perf::enabled() {
1320 use crate::perf::Counter;
1321 let c = match &cur_expr {
1322 ast::Expr::Ident(_) => Counter::ExprIdent,
1323 ast::Expr::Literal(_) => Counter::ExprLiteral,
1324 ast::Expr::Str(_) => Counter::ExprStr,
1325 ast::Expr::List(_) => Counter::ExprList,
1326 ast::Expr::AttrSet(_) => Counter::ExprAttrs,
1327 ast::Expr::Select(_) => Counter::ExprSelect,
1328 ast::Expr::Apply(_) => Counter::ExprApply,
1329 ast::Expr::LetIn(_) => Counter::ExprLetIn,
1330 ast::Expr::IfElse(_) => Counter::ExprIfElse,
1331 ast::Expr::With(_) => Counter::ExprWith,
1332 ast::Expr::Lambda(_) => Counter::ExprLambda,
1333 ast::Expr::BinOp(_) => Counter::ExprBinOp,
1334 ast::Expr::HasAttr(_) => Counter::ExprHasAttr,
1335 ast::Expr::UnaryOp(_) => Counter::ExprUnaryOp,
1336 ast::Expr::Assert(_) => Counter::ExprAssert,
1337 ast::Expr::PathAbs(_) | ast::Expr::PathRel(_)
1338 | ast::Expr::PathHome(_) | ast::Expr::PathSearch(_) => Counter::ExprPath,
1339 _ => Counter::ExprOther,
1340 };
1341 crate::perf::inc(c);
1342 }
1343 let _guard = DepthGuard::enter()?;
1344 let env = &cur_env;
1345 match &cur_expr {
1346 ast::Expr::Literal(lit) => return eval_literal(lit),
1347
1348 ast::Expr::Str(s) => return eval_str(s, env),
1349
1350 ast::Expr::PathAbs(p) => {
1351 // An interpolated absolute path (`/a/${e}`) splices its
1352 // `${…}` parts; a plain one takes the raw-text shortcut.
1353 let parts = p.parts();
1354 if parts_have_interpolation(&parts) {
1355 return eval_interpol_path_parts(&parts, PathKind::Abs, env);
1356 }
1357 // Canonicalize like CppNix (`/.` → `/`, `.`/`..` collapse,
1358 // `..` clamps at root) — see the WHNF fast-path above.
1359 let text = crate::path::canon_abs(&p.syntax().text().to_string());
1360 return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1361 }
1362 ast::Expr::PathRel(p) => {
1363 // Real Nix resolves `./foo.nix` against the directory
1364 // of the file that *contains* the literal, not the
1365 // process cwd. Use the current eval-file stack; fall
1366 // back to cwd when no file is being evaluated (e.g.,
1367 // top-level `sui eval`).
1368 //
1369 // An interpolated relative path (`./${x}.nix`) first splices
1370 // its `${…}` parts, then resolves the concatenated text the
1371 // same way — the interpolation is evaluated + string-coerced,
1372 // NOT treated as literal `${x}` text.
1373 let parts = p.parts();
1374 if parts_have_interpolation(&parts) {
1375 return eval_interpol_path_parts(&parts, PathKind::Rel, env);
1376 }
1377 let text = p.syntax().text().to_string();
1378 let resolved = if let Some(dir) = current_eval_dir() {
1379 let joined = dir.join(&text);
1380 // Use normalize_path instead of canonicalize so that
1381 // paths with ./ and .. are cleaned without requiring
1382 // the path to exist on disk.
1383 let norm = normalize_path(&joined);
1384 // A relative path literal (`./x`, `../..`) resolves against the
1385 // eval-dir, which for a fetched flake input is the sui fetcher
1386 // CACHE dir. CppNix resolves it against the input's
1387 // `/nix/store/<h>-source` STORE path, so the resulting path
1388 // VALUE must carry the store prefix (this is the value half of
1389 // the store↔cache seam — `materialize`/`dematerialize`). Lift
1390 // the cache path back to the store path so `toString ../..`
1391 // matches CppNix — the options.json `hasPrefix
1392 // <nix-darwin>.outPath decl` rewrite root (`prefix = ../..`).
1393 crate::path::dematerialize(&norm)
1394 .to_string_lossy()
1395 .into_owned()
1396 } else {
1397 text.clone()
1398 };
1399 return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1400 }
1401 ast::Expr::PathHome(p) => {
1402 let parts = p.parts();
1403 if parts_have_interpolation(&parts) {
1404 return eval_interpol_path_parts(&parts, PathKind::Home, env);
1405 }
1406 let text = p.syntax().text().to_string();
1407 return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1408 }
1409 ast::Expr::PathSearch(p) => {
1410 // `<name>` or `<name/sub/path>` — resolve via NIX_PATH
1411 // entries (parsed from the env var). If no NIX_PATH entry
1412 // matches, fall through to the literal text so the error
1413 // message points at the name the user wrote.
1414 let text = p.syntax().text().to_string();
1415 let inner = text
1416 .strip_prefix('<')
1417 .and_then(|s| s.strip_suffix('>'))
1418 .unwrap_or(&text);
1419 if let Some(resolved) = crate::builtins::resolve_search_path(inner) {
1420 return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1421 }
1422 // CppNix: search path resolution failure is a throw
1423 // (catchable by tryEval). Used by nixpkgs impure-overlays.nix
1424 // which tries `import <nixpkgs-overlays>` inside tryEval.
1425 return Err(EvalError::Throw(
1426 format!("search path '{text}' not in NIX_PATH"),
1427 ));
1428 }
1429
1430 ast::Expr::Ident(ident) => {
1431 let name = ident_text(ident);
1432 return match name.as_str() {
1433 "true" => Ok(Value::Bool(true)),
1434 "false" => Ok(Value::Bool(false)),
1435 "null" => Ok(Value::Null),
1436 _ => {
1437 env.lookup(&name)
1438 .ok_or_else(|| EvalError::UndefinedVar(
1439 format!("'{name}'{}", eval_file_ctx()),
1440 ))
1441 }
1442 };
1443 }
1444
1445 ast::Expr::List(list) => {
1446 // Wrap list elements in thunks for maximum laziness.
1447 // CppNix wraps list elements — only forced when accessed.
1448 // This prevents eager evaluation of unused list elements
1449 // (e.g., nixpkgs overlay lists with thousands of entries).
1450 let values: Vec<Value> = list.items()
1451 .map(|e| maybe_thunk(&e, env, false, None))
1452 .collect();
1453 return Ok(Value::list(values));
1454 }
1455
1456 ast::Expr::AttrSet(set) => return eval_attrset(set, env),
1457
1458 ast::Expr::Select(sel) => return eval_select(sel, env),
1459
1460 ast::Expr::HasAttr(ha) => return eval_has_attr(ha, env),
1461
1462 ast::Expr::UnaryOp(op) => return eval_unary_op(op, env),
1463
1464 ast::Expr::BinOp(binop) => {
1465 let lhs_expr = binop
1466 .lhs()
1467 .ok_or_else(|| EvalError::ParseError("binop missing lhs".to_string()))?;
1468 let rhs_expr = binop
1469 .rhs()
1470 .ok_or_else(|| EvalError::ParseError("binop missing rhs".to_string()))?;
1471 let kind = binop
1472 .operator()
1473 .ok_or_else(|| EvalError::ParseError("binop missing operator".to_string()))?;
1474 return eval_binop(kind, &lhs_expr, &rhs_expr, env);
1475 }
1476
1477 ast::Expr::Apply(app) => return eval_apply(app, env),
1478
1479 ast::Expr::IfElse(ie) => {
1480 let cond = ie
1481 .condition()
1482 .ok_or_else(|| EvalError::ParseError("if missing condition".to_string()))?;
1483 let body = ie
1484 .body()
1485 .ok_or_else(|| EvalError::ParseError("if missing then body".to_string()))?;
1486 let else_body = ie
1487 .else_body()
1488 .ok_or_else(|| EvalError::ParseError("if missing else body".to_string()))?;
1489 if force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1490 cur_expr = body;
1491 } else {
1492 cur_expr = else_body;
1493 }
1494 // env stays the same — tail call
1495 continue;
1496 }
1497
1498 ast::Expr::Assert(assert) => {
1499 let cond = assert
1500 .condition()
1501 .ok_or_else(|| EvalError::ParseError("assert missing condition".to_string()))?;
1502 let body = assert
1503 .body()
1504 .ok_or_else(|| EvalError::ParseError("assert missing body".to_string()))?;
1505 if !force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1506 return Err(EvalError::AssertionFailed(eval_file_ctx()));
1507 }
1508 cur_expr = body;
1509 continue;
1510 }
1511
1512 ast::Expr::With(with) => {
1513 let ns = with
1514 .namespace()
1515 .ok_or_else(|| EvalError::ParseError("with missing namespace".to_string()))?;
1516 let body = with
1517 .body()
1518 .ok_or_else(|| EvalError::ParseError("with missing body".to_string()))?;
1519 // Don't force the namespace yet — store as a lazy value.
1520 // CppNix evaluates with-scopes lazily: the namespace is only
1521 // forced when a name lookup actually falls through lexical scope.
1522 // This is critical for `fix (self: with self; { … })` patterns
1523 // used throughout nixpkgs.
1524 //
1525 // M2.6 ROOT #4a (byte-verified): `eval_expr(&ns, env)?` was NOT
1526 // lazy — it EVALUATED the namespace expression eagerly at
1527 // `with`-entry. For `with (throw "X"); body` that runs the
1528 // throw; for `with config.services.borgbackup; { … }` (nixpkgs'
1529 // module `config` shape) it forces `config.services.borgbackup`
1530 // the instant the `with`-body's WHNF/keys are demanded (during
1531 // module collection's `pushDownProperties`), re-entering the
1532 // mid-force `config` fixpoint → the empty-Promise partial →
1533 // `null` softening → `concatLists null`. cppnix stores the
1534 // namespace as a thunk and forces it ONLY when a bare-ident
1535 // lookup actually falls through lexical scope into the `with`.
1536 // Reduced repro (no module system, iterates in ms):
1537 // `builtins.attrNames (with (throw "X"); { a = 1; })`
1538 // nix → [ "a" ] ; sui (before) → throws "X".
1539 // `maybe_thunk` keeps the fast-path for an already-resolved
1540 // ident namespace (no thunk overhead) while deferring any
1541 // non-trivial namespace (Select / Apply / throw) into a lazy
1542 // thunk the scope-lookup path (`Env::lookup_fast`) forces only
1543 // on fallthrough.
1544 let scope_val = maybe_thunk(&ns, env, false, None);
1545 let new_env = env.child().with_scope(scope_val);
1546 cur_expr = body;
1547 cur_env = new_env;
1548 continue;
1549 }
1550
1551 ast::Expr::LetIn(letin) => {
1552 // ── plan-driven binding (`SUI_NORMALIZE=1`) ──────────────────
1553 //
1554 // `let` obeys the SAME merge rule as an attrset literal, and sui
1555 // never implemented it: `let a = {b=1;}; a = {c=2;}; in a` is
1556 // `{b=1;c=2;}` in nix and was `{c=2;}` here — silent key loss on
1557 // legal nix. A `let` takes the SCOPE rather than the attrset,
1558 // because it is a binder for a body and produces no attrset.
1559 //
1560 // A `None` means the group has no duplicate and no dotted path,
1561 // so the existing path is already correct — see `normalize_env`.
1562 if crate::normalize_env::enabled() {
1563 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1564 let offset = u32::from(letin.syntax().text_range().start());
1565 if let Some(plan) = crate::normalize_env::plan_for(src_id, offset) {
1566 let (_attrs, scope) = bind_plan_group(&plan, env)?;
1567 let body = letin.body().ok_or_else(|| {
1568 EvalError::ParseError("let missing body".to_string())
1569 })?;
1570 cur_expr = body;
1571 cur_env = scope;
1572 continue;
1573 }
1574 }
1575
1576 let mut new_env = env.child();
1577
1578 // Phase 1: Create thunks with a dummy env and bind them.
1579 // Collect (key, thunk) pairs so we can update envs later.
1580 let mut thunks: Vec<(String, Thunk)> = Vec::new();
1581
1582 // Track which names have been defined so far in this scope.
1583 // Used by maybe_thunk to resolve backward references directly
1584 // instead of creating wasteful thunks.
1585 let mut defined_so_far: HashSet<String> = HashSet::new();
1586
1587 // Accumulator for dotted-path bindings (`let a.b = 1; a.c = 2; ...`).
1588 // Leaf values are wrapped in thunks so they can reference
1589 // sibling let-bindings (the let scope is recursive in Nix).
1590 let mut dotted_attrs: NixAttrs = NixAttrs::new();
1591
1592 // Pre-pass: collect every binding name in this let-scope
1593 // (single-key bindings + top-level keys of dotted paths +
1594 // names from inherit clauses). Used by the recursive-thunk
1595 // detector below — a binding is part of the mutual fix-point
1596 // if its RHS references ANY of these names.
1597 //
1598 // D1 (`SUI_SCOPE_NARROW>=1`) — `names_complete` is the honesty half
1599 // of the narrowing. Narrowing is only sound while
1600 // `let_scope_names` is a COMPLETE list of what this scope binds: a
1601 // binding is judged "reaches no sibling" by intersecting its RHS's
1602 // free variables with that set, so a name MISSING from it reads as
1603 // an outer reference and the binding wrongly keeps the outer env.
1604 // A head that does not resolve here contributes nothing, so the
1605 // whole scope forfeits narrowing rather than narrow on a partial
1606 // set. (`Dynamic` heads are excluded even when they do resolve —
1607 // the name is computed, so it is not a syntactic property of the
1608 // scope.) Nothing about the EVALUATION below changes; this only
1609 // decides whether the optimisation is allowed to apply.
1610 let mut names_complete = true;
1611 let let_scope_names: HashSet<String> = {
1612 let mut s = HashSet::new();
1613 for entry in letin.entries() {
1614 match entry {
1615 ast::Entry::AttrpathValue(apv) => {
1616 if let Some(attrpath) = apv.attrpath() {
1617 if let Some(first) = attrpath.attrs().next() {
1618 if let ast::Attr::Dynamic(_) = &first {
1619 names_complete = false;
1620 }
1621 if let Ok(name) = eval_attr(&first, env) {
1622 s.insert(name);
1623 } else {
1624 names_complete = false;
1625 }
1626 } else {
1627 names_complete = false;
1628 }
1629 } else {
1630 names_complete = false;
1631 }
1632 }
1633 ast::Entry::Inherit(inherit) => {
1634 for attr in inherit.attrs() {
1635 if let ast::Attr::Dynamic(_) = &attr {
1636 names_complete = false;
1637 }
1638 if let Ok(name) = eval_attr(&attr, env) {
1639 s.insert(name);
1640 } else {
1641 names_complete = false;
1642 }
1643 }
1644 }
1645 }
1646 }
1647 s
1648 };
1649 let narrow = scope_narrow_enabled() && names_complete;
1650
1651 // D2 (`SUI_SCOPE_NARROW=2`) — the CLUSTER env.
1652 //
1653 // D1 alone is not enough, and the reason is the shape of the
1654 // graph: free-variable analysis is per-binding on the
1655 // `thunk -> env` edge, but the `env -> thunk` edge is SHARED. One
1656 // binding that really does reach a sibling keeps `new_env` alive,
1657 // and `new_env` holds EVERY binding in the scope — so a single
1658 // recursive `f` re-pins all fifty innocent leaves and the footprint
1659 // is unchanged. (That is the P4 row, and it is why the headline
1660 // gate is too easy: D1 greens it while doing nothing here.)
1661 //
1662 // The fix is to stop pointing the survivors at the whole scope.
1663 // Phase 2 re-points them at a `fix_env` carrying ONLY the names the
1664 // pinned bindings can actually reach — their own names plus
1665 // `refs ∩ scope_names`. The body still gets the full `new_env`, so
1666 // nothing the LET EXPRESSION evaluates to can change; only the
1667 // envs captured by thunks shrink.
1668 let cluster = narrow && scope_cluster_enabled();
1669 // Every (name, value) bound into `new_env`, so the pinned subset can
1670 // be re-bound into `fix_env`. Allocated only under D2.
1671 let mut all_bound: Vec<(String, Value)> = Vec::new();
1672 // The names that stayed pinned, and the free-variable sets of the
1673 // bindings behind them. `pin` needs only the UNION of those sets, so
1674 // no name→refs association is required — and that union already IS
1675 // the fixpoint: a name added to `pin` that is not itself a pinned
1676 // binding contributes no further refs, and one that is has its refs
1677 // in the union already.
1678 let mut pinned_names: HashSet<String> = HashSet::new();
1679 let mut pinned_refs: Vec<HashSet<SmolStr>> = Vec::new();
1680 // A dotted path (`let a.b = 1;`) pushes LEAF thunks whose names are
1681 // inner path segments, not scope names, and whose free variables are
1682 // never computed here — so `fix_env` cannot be shown to carry what
1683 // they need. Such a scope forfeits D2 (D1 still applies).
1684 let mut has_dotted = false;
1685
1686 for entry in letin.entries() {
1687 match entry {
1688 ast::Entry::AttrpathValue(ref apv) => {
1689 let attrpath = apv.attrpath().ok_or_else(|| {
1690 EvalError::ParseError("binding missing attrpath".to_string())
1691 })?;
1692 let value_expr = apv.value().ok_or_else(|| {
1693 EvalError::ParseError("binding missing value".to_string())
1694 })?;
1695 let mut path_keys: Vec<String> = attrpath
1696 .attrs()
1697 .map(|a| eval_attr(&a, env))
1698 .collect::<Result<_, _>>()?;
1699 if path_keys.len() == 1 {
1700 let key = path_keys.pop().unwrap();
1701 // Self/mutual-recursive detection: any binding
1702 // whose RHS references its own name OR any
1703 // SIBLING let-scope name is part of the let's
1704 // mutual fix-point. Mark as recursive so
1705 // inner re-entrance during force returns a
1706 // Promise sentinel instead of erroring with
1707 // InfiniteRecursion. This is the M2.6
1708 // module-system fix path (cppnix's
1709 // lib/modules.nix uses a deep let-scope with
1710 // declaredConfig / options / matchedOptions /
1711 // resultsByName / modules all transitively
1712 // cycling through each other).
1713 //
1714 // `let_scope_names` is collected upfront in a
1715 // pre-pass so each binding sees every other
1716 // binding name (not just earlier ones).
1717 // O(N) not O(N²): compute the RHS's referenced-name
1718 // set ONCE (memoized), then intersect with the
1719 // let-scope names. Byte-identical to the prior
1720 // `references(key) OR references(any sibling)`:
1721 // chaining `key` covers the self-reference case
1722 // regardless of whether `key ∈ let_scope_names`.
1723 let referenced = referenced_idents(&value_expr);
1724 let in_mutual_cycle = std::iter::once(&key)
1725 .chain(let_scope_names.iter())
1726 .any(|n| referenced.contains(n.as_str()));
1727 let value = if in_mutual_cycle {
1728 Value::Thunk(Thunk::new_suspended_recursive(
1729 value_expr.clone(),
1730 env.clone(),
1731 ))
1732 } else {
1733 maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
1734 };
1735 new_env.bind(key.clone(), value.clone());
1736 if cluster {
1737 all_bound.push((key.clone(), value.clone()));
1738 }
1739 if let Value::Thunk(t) = &value {
1740 // D1: `in_mutual_cycle` is ALREADY the
1741 // forward-complete "reaches a sibling"
1742 // predicate here (`let_scope_names` is a full
1743 // pre-pass, unlike the `rec` arm's
1744 // backward-only one), so it doubles as the
1745 // needs-scope test at zero extra cost — no
1746 // second tree walk.
1747 //
1748 // When it is false the RHS references nothing
1749 // this scope binds, so every name it CAN
1750 // resolve resolves identically in `env` and in
1751 // `new_env`: `Env::child` copies `with_scopes`,
1752 // `eval_file` and `source_id` verbatim, and the
1753 // only added bindings are the let-scope names
1754 // this RHS provably does not mention. Skipping
1755 // the re-point is therefore byte-neutral, and
1756 // it is what leaves the thunk holding the OUTER
1757 // env instead of closing
1758 // `thunk -> new_env -> thunk`.
1759 if in_mutual_cycle || !narrow {
1760 thunks.push((key.clone(), t.clone()));
1761 if cluster {
1762 pinned_names.insert(key.clone());
1763 pinned_refs.push(referenced);
1764 }
1765 crate::value::census::scope_pinned();
1766 } else {
1767 crate::value::census::scope_narrowed();
1768 }
1769 }
1770 defined_so_far.insert(key);
1771 } else if path_keys.len() > 1 {
1772 // Multi-segment dotted path: build a nested
1773 // attrset with thunks at the leaves so the
1774 // value expression can reference sibling
1775 // let-bindings.
1776 has_dotted = true;
1777 let key = path_keys[0].clone();
1778 let value = build_nested_attr_thunk(
1779 &path_keys[1..],
1780 &value_expr,
1781 env,
1782 &mut thunks,
1783 );
1784 merge_nested_insert(&mut dotted_attrs, key, value);
1785 }
1786 }
1787 ast::Entry::Inherit(ref inherit) => {
1788 if let Some(from) = inherit.from() {
1789 let source_expr = from.expr().ok_or_else(|| {
1790 EvalError::ParseError(
1791 "inherit from missing expr".to_string(),
1792 )
1793 })?;
1794 // D1: every `InheritSelect` in this clause shares
1795 // ONE source thunk, and `Thunk::update_env`
1796 // delegates straight through to it — so all N
1797 // pushes re-point the SAME env. Whether that
1798 // re-point is needed is therefore a property of the
1799 // source expression alone, computed ONCE above the
1800 // loop instead of N times inside it. Guarded by
1801 // `!narrow ||` so the default path does not pay the
1802 // walk at all.
1803 let source_refs: Option<HashSet<SmolStr>> = if narrow {
1804 Some(referenced_idents(&source_expr))
1805 } else {
1806 None
1807 };
1808 let source_needs_scope = match &source_refs {
1809 Some(refs) => let_scope_names
1810 .iter()
1811 .any(|n| refs.contains(n.as_str())),
1812 None => true,
1813 };
1814 // Create ONE shared source thunk per
1815 // `inherit (source)` clause. All inherited
1816 // names share it via Rc clone — the source
1817 // is evaluated at most once.
1818 let source_thunk = Thunk::new_suspended(
1819 source_expr, env.clone(),
1820 );
1821 for attr in inherit.attrs() {
1822 let name = eval_attr(&attr, env)?;
1823 let thunk = Thunk::new_inherit_select(
1824 source_thunk.clone(),
1825 name.clone(),
1826 );
1827 new_env.bind(name.clone(), Value::Thunk(thunk.clone()));
1828 if cluster {
1829 all_bound.push((
1830 name.clone(),
1831 Value::Thunk(thunk.clone()),
1832 ));
1833 }
1834 if source_needs_scope {
1835 if cluster {
1836 pinned_names.insert(name.clone());
1837 }
1838 thunks.push((name, thunk));
1839 crate::value::census::scope_pinned();
1840 } else {
1841 crate::value::census::scope_narrowed();
1842 }
1843 }
1844 // One refs set for the whole clause — every name in
1845 // it re-points the SAME shared source thunk.
1846 if cluster
1847 && source_needs_scope
1848 && let Some(refs) = source_refs
1849 {
1850 pinned_refs.push(refs);
1851 }
1852 } else {
1853 // `inherit name1 name2 ...` from the
1854 // enclosing lexical scope. This stays
1855 // eager because the names already exist
1856 // in `env` — no fixpoint involved.
1857 for attr in inherit.attrs() {
1858 let name = eval_attr(&attr, env)?;
1859 let value = env.lookup(&name).ok_or_else(|| {
1860 EvalError::UndefinedVar(
1861 format!("'{name}'{}", eval_file_ctx()),
1862 )
1863 })?;
1864 if cluster {
1865 all_bound.push((name.clone(), value.clone()));
1866 }
1867 new_env.bind(name, value);
1868 }
1869 }
1870 }
1871 }
1872 }
1873
1874 // Phase 1b: Bind accumulated dotted-path attrs into new_env.
1875 // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
1876 // duplicate definition, so we do not attempt to merge with
1877 // existing inherit thunks — just bind directly.
1878 for (key, value) in dotted_attrs.iter() {
1879 new_env.bind(key.clone(), value.clone());
1880 if cluster {
1881 all_bound.push((key.clone(), value.clone()));
1882 }
1883 }
1884
1885 // D2: the cluster env the survivors get re-pointed at, in place of
1886 // the whole scope. Built only when it can actually shrink anything
1887 // — some binding pinned, some binding not, and no dotted path (see
1888 // `has_dotted`).
1889 let fix_env: Option<Env> = if cluster && !has_dotted && !thunks.is_empty() {
1890 // `pin` = the pinned names, plus every scope name they can
1891 // reach. This union is already the fixpoint: a name pulled in
1892 // that is not itself pinned contributes no further refs (its
1893 // own thunk still holds the OUTER env and so resolves entirely
1894 // outside this scope), and one that is pinned had its refs in
1895 // the union from the start.
1896 let mut pin = pinned_names;
1897 for refs in &pinned_refs {
1898 for n in &let_scope_names {
1899 if refs.contains(n.as_str()) {
1900 pin.insert(n.clone());
1901 }
1902 }
1903 }
1904 if pin.len() < all_bound.len() {
1905 let mut fe = env.child();
1906 for (name, value) in &all_bound {
1907 if pin.contains(name) {
1908 fe.bind(name.clone(), value.clone());
1909 }
1910 }
1911 Some(fe)
1912 } else {
1913 None
1914 }
1915 } else {
1916 None
1917 };
1918
1919 // Phase 2: Update all thunks to capture the final env
1920 // (which now has all names bound).
1921 let phase2_env: &Env = fix_env.as_ref().unwrap_or(&new_env);
1922 for (_key, thunk) in &thunks {
1923 thunk.update_env(phase2_env);
1924 }
1925
1926 let body = letin
1927 .body()
1928 .ok_or_else(|| EvalError::ParseError("let missing body".to_string()))?;
1929 cur_expr = body;
1930 cur_env = new_env;
1931 continue;
1932 }
1933
1934 ast::Expr::Lambda(lam) => {
1935 let param = lam
1936 .param()
1937 .ok_or_else(|| EvalError::ParseError("lambda missing param".to_string()))?;
1938 let body = lam
1939 .body()
1940 .ok_or_else(|| EvalError::ParseError("lambda missing body".to_string()))?;
1941 return Ok(Value::Lambda(Rc::new(Closure {
1942 param,
1943 body,
1944 env: env.clone(),
1945 })));
1946 }
1947
1948 ast::Expr::Paren(p) => {
1949 let inner = p
1950 .expr()
1951 .ok_or_else(|| EvalError::ParseError("paren missing expr".to_string()))?;
1952 cur_expr = inner;
1953 continue;
1954 }
1955
1956 ast::Expr::Root(r) => {
1957 let inner = r
1958 .expr()
1959 .ok_or_else(|| EvalError::ParseError("root missing expr".to_string()))?;
1960 cur_expr = inner;
1961 continue;
1962 }
1963
1964 ast::Expr::LegacyLet(ll) => {
1965 // ── plan-driven binding (`SUI_NORMALIZE=1`) ──────────────────
1966 //
1967 // `eval_entries` carries the comment "Multi-key paths in let are
1968 // not standard; skip for now" and does exactly that — it SILENTLY
1969 // DISCARDS every multi-segment attrpath, so
1970 // `let { a.b = 1; a.c = 2; body = a; }` loses both. The bytecode
1971 // VM has always handled this correctly, which makes the walker
1972 // the engine that is behind here.
1973 if crate::normalize_env::enabled() {
1974 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1975 let offset = u32::from(ll.syntax().text_range().start());
1976 if let Some(plan) = crate::normalize_env::plan_for(src_id, offset) {
1977 let (_attrs, scope) = bind_plan_group(&plan, env)?;
1978 return scope.lookup("body").ok_or_else(|| {
1979 EvalError::AttrNotFound(format!(
1980 "'body' in legacy let{}",
1981 eval_file_ctx()
1982 ))
1983 });
1984 }
1985 }
1986
1987 let mut new_env = env.child();
1988 eval_entries(ll, &mut new_env)?;
1989 // legacy let returns the `body` attr from its bindings
1990 return new_env
1991 .lookup("body")
1992 .ok_or_else(|| EvalError::AttrNotFound(
1993 format!("'body' in legacy let{}", eval_file_ctx()),
1994 ));
1995 }
1996
1997 ast::Expr::CurPos(_) => return Err(EvalError::NotImplemented("__curPos".to_string())),
1998 ast::Expr::Error(_) => return Err(EvalError::ParseError("parse error node".to_string())),
1999 } // match
2000 } // loop — unreachable, all arms either return or continue
2001}
2002
2003fn eval_literal(lit: &ast::Literal) -> Result<Value, EvalError> {
2004 use ast::LiteralKind;
2005 match lit.kind() {
2006 LiteralKind::Integer(tok) => {
2007 let n = tok
2008 .value()
2009 .map_err(|e| EvalError::ParseError(format!("invalid integer: {e}")))?;
2010 Ok(Value::Int(n))
2011 }
2012 LiteralKind::Float(tok) => {
2013 let f = tok
2014 .value()
2015 .map_err(|e| EvalError::ParseError(format!("invalid float: {e}")))?;
2016 Ok(Value::Float(f))
2017 }
2018 LiteralKind::Uri(tok) => Ok(Value::string(tok.syntax().text().to_string())),
2019 }
2020}
2021
2022/// Result of walking an attrpath on a base value.
2023enum TraverseResult {
2024 /// All keys found; contains the leaf value.
2025 Found(Value),
2026 /// A key was missing; contains the missing key name.
2027 Missing(String),
2028 /// A non-attrset value was encountered during traversal.
2029 NotAttrs(Value),
2030}
2031
2032/// Walk an attrpath on a base value, forcing at each level.
2033///
2034/// Returns `Found(leaf)` when every key exists, `Missing(key)` when
2035/// a key is absent, or `NotAttrs(v)` when a non-attrset is encountered.
2036fn traverse_attrpath(
2037 base: Value,
2038 attrpath: &rnix::ast::Attrpath,
2039 env: &Env,
2040) -> Result<TraverseResult, EvalError> {
2041 let attrs: Vec<_> = attrpath.attrs().collect();
2042 let mut value = base;
2043 for (i, attr) in attrs.iter().enumerate() {
2044 let key = eval_attr(attr, env)?;
2045 // Force the current value to an attrset to select from it.
2046 let forced = force_value(&value)?;
2047 match forced {
2048 Value::Attrs(ref a) => match a.get(&key) {
2049 Some(v) => {
2050 if i < attrs.len() - 1 {
2051 // Intermediate step: force to attrset for next selection.
2052 value = force_value(v)?;
2053 } else {
2054 // Final step: return WITHOUT forcing — let the caller
2055 // decide when to force. Matches CppNix's lazy attr access.
2056 value = v.clone();
2057 }
2058 }
2059 None => return Ok(TraverseResult::Missing(key)),
2060 },
2061 _ => return Ok(TraverseResult::NotAttrs(forced)),
2062 }
2063 }
2064 Ok(TraverseResult::Found(value))
2065}
2066
2067fn eval_select(sel: &ast::Select, env: &Env) -> Result<Value, EvalError> {
2068 crate::perf::inc(crate::perf::Counter::Select);
2069 let base_expr = sel.expr().ok_or_else(|| {
2070 EvalError::ParseError("select missing expression".to_string())
2071 })?;
2072 // M2.6 bridge: in `expr.path or default`, an `InfiniteRecursion`
2073 // hit while forcing the LEFT side falls back to the default —
2074 // operationally matches cppnix, which avoids the cycle entirely
2075 // via lazy attribute access during fix-point evaluation. Without
2076 // a default, the recursion propagates as a real error. Other
2077 // error kinds (Throw, TypeError, …) always propagate so user
2078 // bugs aren't masked. Removed when the underlying fix-point /
2079 // lazy-access semantics land — see docs/M2.6-MODULE-SYSTEM-FIXPOINT.md.
2080 let base_result = eval_expr(&base_expr, env)
2081 .and_then(|v| force_concrete(&v).map(Concrete::into_value));
2082 let base = match base_result {
2083 Ok(v) => v,
2084 Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2085 return eval_expr(&sel.default_expr().expect("checked"), env);
2086 }
2087 Err(e) => return Err(e),
2088 };
2089 let base_type = base.type_name();
2090 let attrpath = sel.attrpath().ok_or_else(|| {
2091 EvalError::ParseError("select missing attrpath".to_string())
2092 })?;
2093 // M2.6 bridge: when the blackhole-bridge sentinels are active,
2094 // an attribute lookup that misses (`AttrNotFound`) or hits a
2095 // non-attrset intermediate (`NotAttrs`) on the bridge's empty
2096 // sentinel value gets resolved to `null` instead of erroring.
2097 // cppnix's partial attrset would have CARRIED the keys (with
2098 // their lazy values), so the lookup would succeed; null is the
2099 // cheapest sentinel that propagates through downstream code
2100 // without further type errors.
2101 //
2102 // M2.6 ROOT #4 CLOSED (2026-07-11): the `|| crate::value::in_promise_eval()`
2103 // clause that used to soften a mid-Promise `config.<x>` select-miss to
2104 // `null` is REMOVED. It was the band-aid masking the two real over-forces
2105 // that ROOT #4a (the `with`-namespace eager eval, above) and ROOT #4b (the
2106 // dropped full-set leaf in `merge_nested_insert`, below) now fix at their
2107 // load-bearing cause. Verified with the softening gone: both
2108 // `lib.nixosSystem { modules = []; }.config.system.name` → `"nixos"` and
2109 // `attrNames sys.options` → 53 (nix-parity), `sui parity` stays 35 match /
2110 // 0 regressions, 1324 sui-eval lib tests + 30 diff tests pass — nothing
2111 // depended on the sentinel any more. The two explicit operator-gated
2112 // bridges below stay as opt-in experiments (default-off); only the
2113 // always-on Promise softening is retired.
2114 let bridge_active = std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_ATTRS").is_some()
2115 || std::env::var_os("SUI_BLACKHOLE_AS_NULL").is_some();
2116 let traversal = traverse_attrpath(base, &attrpath, env);
2117 match traversal {
2118 Ok(TraverseResult::Found(v)) => Ok(v),
2119 Ok(TraverseResult::Missing(key)) => {
2120 if let Some(def) = sel.default_expr() {
2121 eval_expr(&def, env)
2122 } else if bridge_active {
2123 if std::env::var_os("SUI_M26_SELTRACE").is_some() {
2124 let path: Vec<String> = sel.attrpath().map(|ap|
2125 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2126 ).unwrap_or_default();
2127 eprintln!("[M26 SEL-MISS→null] base_type={base_type} path={path:?} missing-key={key}{}", eval_file_ctx());
2128 }
2129 if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2130 let path: Vec<String> = sel.attrpath().map(|ap|
2131 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2132 ).unwrap_or_default();
2133 if path.iter().any(|p| p.contains(&filt)) {
2134 return Err(EvalError::type_error(format!(
2135 "M26-HARDSOFTEN path={path:?} key={key}"
2136 )));
2137 }
2138 }
2139 Ok(Value::Null)
2140 } else {
2141 Err(EvalError::AttrNotFound(
2142 format!("'{key}'{}", eval_file_ctx()),
2143 ))
2144 }
2145 }
2146 Ok(TraverseResult::NotAttrs(forced)) => {
2147 // CppNix: `expr.a.b or default` falls back to default for
2148 // ANY error in the path — including intermediate values
2149 // that aren't attrsets (e.g., null). The module system
2150 // relies on this: `x.options.type.name or null` must
2151 // return null when x.options is null, not throw.
2152 if let Some(def) = sel.default_expr() {
2153 eval_expr(&def, env)
2154 } else if bridge_active {
2155 if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2156 let path: Vec<String> = sel.attrpath().map(|ap|
2157 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2158 ).unwrap_or_default();
2159 if path.iter().any(|p| p.contains(&filt)) {
2160 return Err(EvalError::type_error(format!(
2161 "M26-HARDSOFTEN-NOTATTRS path={path:?} base_type={base_type}"
2162 )));
2163 }
2164 }
2165 return Ok(Value::Null);
2166 } else {
2167 if std::env::var("SUI_DEBUG_SELECT").is_ok() {
2168 let path: Vec<String> = sel.attrpath().map(|ap|
2169 ap.attrs().filter_map(|a| match a {
2170 ast::Attr::Ident(i) => Some(i.to_string()),
2171 ast::Attr::Str(s) => Some(format!("\"{}\"", s.syntax().text())),
2172 ast::Attr::Dynamic(_) => Some("<dyn>".into()),
2173 }).collect()
2174 ).unwrap_or_default();
2175 let dbg = format!("{:?}", forced);
2176 let truncated = if dbg.len() > 200 { format!("{}…", &dbg[..200]) } else { dbg };
2177 eprintln!("[SUI_DEBUG_SELECT] base_type={base_type} path={path:?} base={truncated}{}", eval_file_ctx());
2178 }
2179 Err(attach_trace(EvalError::type_error(
2180 format!("cannot select from {base_type}"),
2181 )))
2182 }
2183 }
2184 // Same M2.6 bridge as on the base force above: if an
2185 // intermediate step in the attrpath traversal raises
2186 // InfiniteRecursion and `or default` was supplied, the
2187 // default is the operationally-correct value.
2188 Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2189 eval_expr(&sel.default_expr().expect("checked"), env)
2190 }
2191 Err(e) => Err(e),
2192 }
2193}
2194
2195/// Evaluate `expr ? a.b.c` — check key presence without forcing value thunks.
2196fn eval_has_attr(ha: &ast::HasAttr, env: &Env) -> Result<Value, EvalError> {
2197 let base_expr = ha.expr().ok_or_else(|| {
2198 EvalError::ParseError("hasattr missing expression".to_string())
2199 })?;
2200 let base = force_concrete(&eval_expr(&base_expr, env)?)?.into_value();
2201 let attrpath = ha.attrpath().ok_or_else(|| {
2202 EvalError::ParseError("hasattr missing attrpath".to_string())
2203 })?;
2204 match traverse_attrpath(base, &attrpath, env)? {
2205 TraverseResult::Found(_) => Ok(Value::Bool(true)),
2206 TraverseResult::Missing(_) | TraverseResult::NotAttrs(_) => Ok(Value::Bool(false)),
2207 }
2208}
2209
2210fn eval_unary_op(op: &ast::UnaryOp, env: &Env) -> Result<Value, EvalError> {
2211 let inner = op
2212 .expr()
2213 .ok_or_else(|| EvalError::ParseError("unary op missing expr".to_string()))?;
2214 let val = force_value(&eval_expr(&inner, env)?)?;
2215 let kind = op
2216 .operator()
2217 .ok_or_else(|| EvalError::ParseError("unary op missing operator".to_string()))?;
2218 match kind {
2219 ast::UnaryOpKind::Negate => match val {
2220 Value::Int(n) => Ok(Value::Int(-n)),
2221 Value::Float(f) => Ok(Value::Float(-f)),
2222 _ => Err(EvalError::type_error(
2223 format!("cannot negate {}", val.type_name()),
2224 )),
2225 },
2226 ast::UnaryOpKind::Invert => Ok(Value::Bool(!val.as_bool()?)),
2227 }
2228}
2229
2230/// Builtins that must receive their argument UNFORCED (call-by-need). This is the
2231/// SINGLE source of truth consumed by BOTH `eval_apply` (which must THUNK the arg
2232/// instead of eager-evaluating it) AND the builtin apply arm (which must SKIP the
2233/// arg force). The two sites MUST agree: if `eval_apply` eager-evaluates the arg,
2234/// the apply-arm's force-skip is dead (the arg is already forced — or already
2235/// threw) upstream. They were previously inconsistent (only `tryEval` was thunked
2236/// in `eval_apply`), so `seq`/`deepSeq`/`addErrorContext`/`foldl'` silently got
2237/// eager args despite their apply-time exemption — the bug behind
2238/// `builtins.foldl' (_: x: x) (throw "…") […]` throwing instead of returning the
2239/// last element (nix's foldl' is NOT strict in the nul accumulator).
2240#[inline]
2241pub(crate) fn builtin_takes_lazy_arg(name: &str) -> bool {
2242 matches!(
2243 name,
2244 "tryEval" | "addErrorContext<partial>" | "seq<partial>" | "deepSeq<partial>" | "foldl'<p1>"
2245 )
2246}
2247
2248fn eval_apply(app: &ast::Apply, env: &Env) -> Result<Value, EvalError> {
2249 let func_expr = app
2250 .lambda()
2251 .ok_or_else(|| EvalError::ParseError("apply missing function".to_string()))?;
2252 let arg_expr = app
2253 .argument()
2254 .ok_or_else(|| EvalError::ParseError("apply missing argument".to_string()))?;
2255 let func = force_value(&eval_expr(&func_expr, env)?)?;
2256 // Lambda arguments are wrapped in a thunk for call-by-need semantics.
2257 // Thunk strategy depends on function type:
2258 // - Lambda: ALWAYS thunk (call-by-need, enables fixpoints)
2259 // - tryEval: ALWAYS thunk (must catch errors during force)
2260 // - Builtin: evaluate eagerly (builtins always force args anyway;
2261 // thunking wastes Rc + OnceCell allocation per call)
2262 // - __functor: evaluate eagerly (will be applied immediately)
2263 let arg = match &func {
2264 Value::Lambda(_) => {
2265 // Call-by-need: the arg is thunked so it forces lazily. But a
2266 // PURE-CONSTANT arg (a literal, a non-interpolated string, or a
2267 // non-interpolated path) can never throw or diverge, so producing
2268 // its value directly is byte-neutral whether or not the lambda ever
2269 // forces it — identical eval-order-observable behavior, one fewer
2270 // never-forced thunk. This is `arg_pure_constant` ONLY: any arg that
2271 // could throw/diverge/observe a fixpoint (Ident with-scope, Select,
2272 // Apply, BinOp, …) stays fully thunked to preserve laziness.
2273 if let Some(v) = eval_pure_constant_arg(&arg_expr) {
2274 v
2275 } else {
2276 crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2277 Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2278 }
2279 }
2280 Value::Builtin(b) if builtin_takes_lazy_arg(&b.name) => {
2281 // Call-by-need for the laziness-exempt builtins (tryEval / seq /
2282 // deepSeq / addErrorContext / foldl'<p1>): the arg MUST be thunked,
2283 // not eager-evaluated, so it forces only if/when the builtin demands
2284 // it. Kept in lockstep with the apply-arm skip via `builtin_takes_lazy_arg`.
2285 crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2286 Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2287 }
2288 _ => eval_expr(&arg_expr, env)?,
2289 };
2290 apply(func, arg)
2291}
2292
2293/// If `arg_expr` is a PURE CONSTANT — a literal, a non-interpolated string, or
2294/// a non-interpolated absolute/home path — return its value directly (no thunk).
2295///
2296/// A pure constant has no free variables, cannot throw, cannot diverge, and has
2297/// no fixpoint/laziness interaction: `eval_expr(arg)` is total and produces the
2298/// exact value a suspended thunk of it would yield on force. Producing it
2299/// eagerly in a call-by-need arg position is therefore byte-neutral (the
2300/// lambda that never forces the arg observes no difference — the value is inert).
2301///
2302/// Returns `None` for EVERYTHING else (Ident — may hit a with-scope force;
2303/// Select/Apply/BinOp/If/… — may throw or diverge; interpolated Str/Path —
2304/// must force `${…}` lazily), which keeps those args fully thunked. `env` is
2305/// NOT threaded in because a pure constant needs no environment; if a match
2306/// arm ever needed `env`, it would not be a pure constant.
2307fn eval_pure_constant_arg(arg_expr: &ast::Expr) -> Option<Value> {
2308 match arg_expr {
2309 ast::Expr::Literal(lit) => eval_literal(lit).ok(),
2310 ast::Expr::Str(st) if !str_has_interpolation(st) => {
2311 // No interpolation ⇒ `eval_str` runs no force/coerce; env is unused.
2312 eval_str(st, &Env::new()).ok()
2313 }
2314 ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
2315 let text = crate::path::canon_abs(&p.syntax().text().to_string());
2316 Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2317 }
2318 ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
2319 let text = p.syntax().text().to_string();
2320 Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2321 }
2322 _ => None,
2323 }
2324}
2325
2326fn eval_str(s: &ast::Str, env: &Env) -> Result<Value, EvalError> {
2327 let mut result = String::new();
2328 let mut ctx = StringContext::new();
2329 for part in s.normalized_parts() {
2330 match part {
2331 InterpolPart::Literal(text) => result.push_str(&text),
2332 InterpolPart::Interpolation(interpol) => {
2333 let expr = interpol.expr().ok_or_else(|| {
2334 EvalError::ParseError("interpolation missing expr".to_string())
2335 })?;
2336 let val = force_value(&eval_expr(&expr, env)?)?;
2337 // CppNix string interpolation is copy-to-store coercion: an
2338 // interpolated source path (`"${./foo}"`) is NAR-copied into
2339 // the store and the store path is spliced in (with context),
2340 // never the raw filesystem path.
2341 let (s, c) = val.coerce_to_string_copy_to_store()?;
2342 result.push_str(&s);
2343 ctx.merge(&c);
2344 }
2345 }
2346 }
2347 Ok(Value::String(Rc::new(NixString::with_context(result, ctx))))
2348}
2349
2350/// Whether a list of path parts contains a `${…}` interpolation. When
2351/// it does not, the raw `.syntax().text()` shortcut is byte-identical
2352/// and cheaper, so the trivial fast paths stay on that shortcut.
2353fn parts_have_interpolation(parts: &[InterpolPart<rnix::ast::PathContent>]) -> bool {
2354 parts
2355 .iter()
2356 .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2357}
2358
2359/// Whether a string literal contains any `${…}` interpolation part. A `false`
2360/// result means the string is a pure constant (`eval_str` runs no force/coerce
2361/// and cannot throw), so `maybe_thunk` may evaluate it eagerly byte-neutrally.
2362fn str_has_interpolation(s: &ast::Str) -> bool {
2363 s.normalized_parts()
2364 .iter()
2365 .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2366}
2367
2368/// Evaluate an interpolatable path literal that contains `${…}` parts.
2369///
2370/// CppNix path interpolation (`./${x}.nix`, `/a/${e}`, `~/x/${e}`):
2371/// * each literal segment is spliced verbatim,
2372/// * each `${e}` is **plain**-coerced to a string with context
2373/// (NOT copy-to-store — path-typed interpolations splice the raw
2374/// store/filesystem path, e.g. `/bar/${./foo}` → `/bar/tmp/foo`),
2375/// * the concatenated text is then resolved exactly like the plain
2376/// path literal of the same kind (relative → joined + normalized
2377/// against the defining file's directory; absolute/home → verbatim),
2378/// * the result is a `path` value.
2379///
2380/// Parts come from rnix's `<PathKind>::parts()` which splits the path
2381/// token stream into `Literal(PathContent)` / `Interpolation(Interpol)`.
2382fn eval_interpol_path_parts(
2383 parts: &[InterpolPart<rnix::ast::PathContent>],
2384 kind: PathKind,
2385 env: &Env,
2386) -> Result<Value, EvalError> {
2387 let mut text = String::new();
2388 for part in parts {
2389 match part {
2390 InterpolPart::Literal(content) => text.push_str(content.text()),
2391 InterpolPart::Interpolation(interpol) => {
2392 let expr = interpol.expr().ok_or_else(|| {
2393 EvalError::ParseError("path interpolation missing expr".to_string())
2394 })?;
2395 let val = force_value(&eval_expr(&expr, env)?)?;
2396 // Plain coercion (coerceMore = false): a path-typed
2397 // interpolation splices the raw path string, never a
2398 // copied-to-store hash path.
2399 let (s, _ctx) = val.coerce_to_string()?;
2400 text.push_str(&s);
2401 }
2402 }
2403 }
2404 let resolved = match kind {
2405 // Relative path: resolve against the defining file's directory,
2406 // mirroring the plain `PathRel` branch.
2407 PathKind::Rel => {
2408 if let Some(dir) = current_eval_dir() {
2409 let norm = normalize_path(&dir.join(&text));
2410 // Lift cache→store exactly like the plain `PathRel` branch (the
2411 // store↔cache seam value-half). Without this, an interpolated
2412 // relative-path literal (`./${x}`, `./modules/${name}.nix`)
2413 // inside a fetched flake input yielded a Value::Path holding the
2414 // fetcher CACHE dir instead of the input's `/nix/store/<h>-source`
2415 // path — so its `toString`/copy-to-store/inputSrc diverged from
2416 // CppNix (the plain `./x` sibling already dematerializes; the two
2417 // must agree).
2418 crate::path::dematerialize(&norm).to_string_lossy().into_owned()
2419 } else {
2420 // No eval-file context (top-level `sui eval -E`): the
2421 // plain branch keeps the raw text, so match it — but the
2422 // interpolation is still spliced.
2423 text
2424 }
2425 }
2426 // Absolute paths: canonicalize the concatenated text CppNix's way.
2427 // The `${e}` splice routinely introduces a `//` seam (`/bar/` +
2428 // `/tmp/foo`) or a `.`/`..` component that must collapse
2429 // (`/bar//tmp/foo` → `/bar/tmp/foo`), and `..` must clamp at root.
2430 // `canon_abs` is filesystem-free (works on not-yet-materialized
2431 // flake paths) and root-aware (unlike `normalize_path`, which pops
2432 // past root — the marquee-root divergence).
2433 PathKind::Abs => crate::path::canon_abs(&text),
2434 // Home paths (`~/…`) carry a leading `~` component, so they are
2435 // not absolute-rooted; keep the pre-existing normalization.
2436 PathKind::Home => normalize_path(std::path::Path::new(&text))
2437 .to_string_lossy()
2438 .into_owned(),
2439 };
2440 Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))))
2441}
2442
2443/// Which kind of interpolatable path literal — governs how the
2444/// concatenated text is finally resolved.
2445#[derive(Clone, Copy)]
2446enum PathKind {
2447 Abs,
2448 Rel,
2449 Home,
2450}
2451
2452/// Evaluate an attribute name, requiring non-null.
2453/// Use `eval_attr_maybe_null` when null dynamic attrs should be skipped.
2454fn eval_attr(attr: &ast::Attr, env: &Env) -> Result<String, EvalError> {
2455 eval_attr_maybe_null(attr, env)?
2456 .ok_or_else(|| EvalError::TypeError("null dynamic attribute name".into()))
2457}
2458
2459/// Evaluate an attribute name. Returns `None` for null dynamic attrs
2460/// (CppNix silently omits attributes with null names).
2461fn eval_attr_maybe_null(attr: &ast::Attr, env: &Env) -> Result<Option<String>, EvalError> {
2462 match attr {
2463 ast::Attr::Ident(ident) => Ok(Some(ident_text(ident))),
2464 ast::Attr::Dynamic(dyn_) => {
2465 let expr = dyn_
2466 .expr()
2467 .ok_or_else(|| EvalError::ParseError("dynamic attr missing expr".to_string()))?;
2468 let val = force_value(&eval_expr(&expr, env)?)?;
2469 // CppNix: null dynamic attr name → skip the attribute entirely.
2470 // Used by nixpkgs module system: `${if cond then null else "name"} = value;`
2471 if val == Value::Null {
2472 return Ok(None);
2473 }
2474 Ok(Some(val.as_string()?.to_string()))
2475 }
2476 ast::Attr::Str(s) => {
2477 let val = eval_str(s, env)?;
2478 Ok(Some(val.as_string()?.to_string()))
2479 }
2480 }
2481}
2482
2483/// Get the text of an rnix Ident node.
2484pub(crate) fn ident_text(ident: &ast::Ident) -> String {
2485 // Fast path: a `NODE_IDENT` holds a single `TOKEN_IDENT`, whose `text()`
2486 // borrows the source `&str` directly from the green node — no
2487 // `PreorderWithTokens` cursor tree-walk and none of the `NodeData::new`
2488 // allocations that `syntax().text()` (a `SyntaxText` over the node's whole
2489 // descendant span) pays. Byte-identical fallback: the identifier `or` is
2490 // lexed as a nested `TOKEN_OR` (rnix quirk), so `ident_token()` is `None`
2491 // there — walk the full node text in that case, exactly as before.
2492 match ident.ident_token() {
2493 Some(tok) => tok.text().to_string(),
2494 None => ident.syntax().text().to_string(),
2495 }
2496}
2497
2498/// Byte offset of a STATIC attr key (`Ident` or `Str`) in its source text —
2499/// the position `builtins.unsafeGetAttrPos` reports for that key. Returns
2500/// `None` for a dynamic key (`${e}`), which has no fixed source position.
2501///
2502/// CppNix points a binding's position at the KEY token's start; rnix exposes
2503/// it via the syntax node's `text_range().start()`.
2504fn static_attr_offset(attr: &ast::Attr) -> Option<u32> {
2505 let node = match attr {
2506 ast::Attr::Ident(i) => i.syntax(),
2507 ast::Attr::Str(s) => s.syntax(),
2508 ast::Attr::Dynamic(_) => return None,
2509 };
2510 Some(u32::from(node.text_range().start()))
2511}
2512
2513/// Collect a literal attrset's static top-level KEY offsets into an
2514/// [`crate::pos::AttrPositions`] and attach it to `attrs` (behind the value's
2515/// `Rc<AttrPositions>` slot). Records only single-key static bindings — the
2516/// shape `attrTag`'s `tags_` (`{ app = …; file = …; }`) is built from and the
2517/// only shape `builtins.unsafeGetAttrPos` reads in nixpkgs. `None`-costs a
2518/// pointer when the set has no such keys (attaches nothing).
2519fn attach_attrset_positions(set: &ast::AttrSet, attrs: &mut NixAttrs, env: &Env) {
2520 // The FILE is the one the literal is being built in — from the eval-file
2521 // stack, which a thunk restores to its captured file when it forces. This
2522 // is correct under laziness: a `dock.nix` attrset literal forced later
2523 // records `dock.nix`, not whatever file is top-of-stack at force time.
2524 // (`current_source_id`/`CURRENT_SOURCE_ID` is per-`eval_with_file`, NOT
2525 // per-env, so it would mis-attribute a lazily-forced literal.)
2526 let mut table = crate::pos::AttrPositions::new(current_eval_file());
2527 for entry in set.entries() {
2528 if let ast::Entry::AttrpathValue(apv) = entry {
2529 let Some(attrpath) = apv.attrpath() else { continue };
2530 let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2531 // A dotted path `a.b = …` desugars to a nested set and CppNix gives
2532 // the OUTER key the position of the path's HEAD, so record
2533 // `path_attrs[0]` whatever the length. This previously skipped any
2534 // multi-segment path, on the assumption that nixpkgs never asks for
2535 // a dotted tag's position. Measured — for
2536 // `{ …; nested.deep = 3; }` at line 6:
2537 // nix nested=6:3 sui nested=NULL
2538 let Some(head) = path_attrs.first() else { continue };
2539 let Some(offset) = static_attr_offset(head) else { continue };
2540 // Resolve the static key name (Ident/Str) — never forces (a
2541 // dynamic key already returned None above).
2542 if let Ok(Some(name)) = eval_attr_maybe_null(&path_attrs[0], env) {
2543 table.insert(intern(&name), offset);
2544 }
2545 } else if let ast::Entry::Inherit(inh) = entry {
2546 // `inherit x;` and `inherit (src) x;` BIND an attribute exactly as
2547 // `x = …` does, and CppNix gives each inherited name the position of
2548 // its own ident. Skipping them left every inherited key
2549 // position-less — which is most of nixpkgs' `lib`, since
2550 // `lib/default.nix` re-exports through
2551 // `inherit (self.options) mkOption …`. Measured before the fix:
2552 // unsafeGetAttrPos "mkOption" nixpkgs.lib
2553 // nix …-source/lib/default.nix sui null
2554 //
2555 // An earlier attempt at this arm was reverted for reporting line 1;
2556 // that was `pos::line_col` returning a constant, NOT this arm. With
2557 // the real offset→line/column conversion in place it resolves
2558 // exactly.
2559 for attr in inh.attrs() {
2560 let Some(offset) = static_attr_offset(&attr) else { continue };
2561 if let Ok(Some(name)) = eval_attr_maybe_null(&attr, env) {
2562 table.insert(intern(&name), offset);
2563 }
2564 }
2565 }
2566 }
2567 if !table.is_empty() {
2568 attrs.set_positions(std::rc::Rc::new(table));
2569 }
2570}
2571
2572fn eval_attrset(set: &ast::AttrSet, env: &Env) -> Result<Value, EvalError> {
2573 crate::perf::inc(crate::perf::Counter::Attrset);
2574 let mut attrs = NixAttrs::new();
2575 let is_rec = set.rec_token().is_some();
2576
2577 // ── plan-driven construction (`SUI_NORMALIZE=1`) ──────────────────────
2578 //
2579 // Wired for `rec` first and the non-rec branch last, deliberately. The
2580 // `rec` branch was WRONG (its Phase 1b does a destructive `attrs.insert`
2581 // where the non-rec branch merges), so any change there could only
2582 // improve it. The non-rec branch is the one path that was already correct
2583 // ON KEYS — it merges VALUES via `merge_nested_insert` — and it carries
2584 // every fleet evaluation, so it went last and on its own.
2585 //
2586 // Correct-on-keys is not correct: a value merge gets the key set right and
2587 // the SCOPE wrong, which is why `let b=5; in { a=rec{c=b;}; a={b=9;}; }`
2588 // answered `c=5` where nix says `c=9`. The second side's `b=9` belongs to
2589 // the FIRST node's rec scope, and no value-level merge can put it there.
2590 //
2591 // A `None` here is a POSITIVE statement, not a fallback: `sui-normalize`
2592 // records a group only when it has a duplicate static key or a dotted
2593 // path, so no plan means this group is already built correctly.
2594 if crate::normalize_env::enabled() {
2595 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
2596 let offset = u32::from(set.syntax().text_range().start());
2597 if let Some(plan) = crate::normalize_env::plan_for(src_id, offset) {
2598 return eval_plan_group(&plan, env);
2599 }
2600 }
2601
2602 if is_rec {
2603 let mut rec_env = env.child();
2604 let mut thunks: Vec<(String, Thunk)> = Vec::new();
2605
2606 // Track which names have been defined so far in this scope.
2607 // Used by maybe_thunk to resolve backward references directly
2608 // instead of creating wasteful thunks.
2609 let mut defined_so_far: HashSet<String> = HashSet::new();
2610
2611 // Accumulator for dotted-path bindings (`rec { a.b = 1; a.c = 2; ... }`).
2612 // Leaf values are wrapped in thunks so they participate in the
2613 // recursive env fixpoint, matching CppNix semantics where
2614 // `rec { types.a = f 1; f = x: x + 1; }` allows `f` to be a
2615 // sibling binding.
2616 let mut dotted_attrs: NixAttrs = NixAttrs::new();
2617
2618 // D1 (`SUI_SCOPE_NARROW>=1`) — a SECOND predicate, deliberately not a
2619 // widening of `is_recursive_binding` below.
2620 //
2621 // THE TRAP: `is_recursive_binding` is BACKWARD-BLIND on purpose — it
2622 // tests `key` plus the siblings seen SO FAR, so `rec { b = a; a = 1; }`
2623 // computes `false` for `b`. That verdict selects Promise semantics, so
2624 // widening it would change which bindings get the fix-point sentinel
2625 // and is not a refactor available here. Yet `b` genuinely does need the
2626 // rec scope, and today gets it from Phase 2's blanket `update_env`.
2627 // Narrowing therefore needs its own forward-complete question — "does
2628 // this RHS reach ANY key this scope binds, declared before or after?" —
2629 // answered against a full pre-pass, while `is_recursive_binding` stays
2630 // byte-identical.
2631 //
2632 // The pre-pass is PURELY SYNTACTIC, which is the second trap: the
2633 // Phase-1 loop below owns the evaluation order of `${…}` keys, and
2634 // calling `eval_attr` here would run that arbitrary code earlier. So a
2635 // head that is not a plain identifier forfeits narrowing for the whole
2636 // scope instead of being evaluated for its name. Starting the flag at
2637 // `scope_narrow_enabled()` also means the default path never walks the
2638 // entries at all.
2639 let mut names_complete = scope_narrow_enabled();
2640 let rec_scope_names: HashSet<String> = if names_complete {
2641 let mut s = HashSet::new();
2642 for entry in set.entries() {
2643 match entry {
2644 ast::Entry::AttrpathValue(apv) => {
2645 match apv.attrpath().and_then(|p| p.attrs().next()) {
2646 Some(ast::Attr::Ident(i)) => {
2647 s.insert(ident_text(&i));
2648 }
2649 _ => names_complete = false,
2650 }
2651 }
2652 ast::Entry::Inherit(inh) => {
2653 for attr in inh.attrs() {
2654 match attr {
2655 ast::Attr::Ident(i) => {
2656 s.insert(ident_text(&i));
2657 }
2658 _ => names_complete = false,
2659 }
2660 }
2661 }
2662 }
2663 }
2664 s
2665 } else {
2666 HashSet::new()
2667 };
2668 let narrow = names_complete;
2669
2670 // Phase 1: Create thunks with placeholder env and bind them.
2671 for entry in set.entries() {
2672 match entry {
2673 ast::Entry::AttrpathValue(apv) => {
2674 let attrpath = apv.attrpath().ok_or_else(|| {
2675 EvalError::ParseError("binding missing attrpath".to_string())
2676 })?;
2677 let value_expr = apv.value().ok_or_else(|| {
2678 EvalError::ParseError("binding missing value".to_string())
2679 })?;
2680 let mut path_keys: Vec<String> = attrpath
2681 .attrs()
2682 .filter_map(|a| eval_attr_maybe_null(&a, env).transpose())
2683 .collect::<Result<_, _>>()?;
2684 // Null dynamic attr name → skip entire binding (CppNix compat)
2685 if path_keys.is_empty() { continue; }
2686 if path_keys.len() == 1 {
2687 let key = path_keys.pop().unwrap();
2688 // Self-recursive detection in a `rec { … }` scope:
2689 // any binding whose value-expr references the
2690 // bound name OR any sibling key declared in this
2691 // rec scope is potentially self-recursive (the
2692 // siblings' thunks share the rec_env via Phase 2).
2693 // Mark as recursive so inner re-entrance during
2694 // force returns a Promise sentinel instead of
2695 // erroring with InfiniteRecursion.
2696 //
2697 // For simplicity we check `key` and all already-
2698 // defined siblings; siblings defined later are
2699 // covered when THEIR thunks force (they reference
2700 // back into this rec scope via Phase 2's env update).
2701 // O(N) not O(N²): one memoized referenced-name set,
2702 // intersected with key + already-defined siblings.
2703 // Byte-identical to the prior per-name walks.
2704 let referenced = referenced_idents(&value_expr);
2705 let is_recursive_binding = referenced.contains(key.as_str())
2706 || defined_so_far
2707 .iter()
2708 .any(|n| referenced.contains(n.as_str()));
2709 let value = if is_recursive_binding {
2710 Value::Thunk(Thunk::new_suspended_recursive(
2711 value_expr.clone(),
2712 env.clone(),
2713 ))
2714 } else {
2715 // maybeThunk: skip thunk for trivial exprs.
2716 // is_rec=true because rec attrset bindings
2717 // can reference each other.
2718 // Pass defined_so_far so backward refs
2719 // resolve directly.
2720 maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
2721 };
2722 // Forward-complete needs-scope test (see the pre-pass
2723 // above). `is_recursive_binding` is folded in as
2724 // belt-and-braces: it is a subset whenever `narrow`
2725 // holds, since every key it can name came from an
2726 // `Ident` head and so is in `rec_scope_names`.
2727 let needs_scope = !narrow
2728 || is_recursive_binding
2729 || rec_scope_names
2730 .iter()
2731 .any(|n| referenced.contains(n.as_str()));
2732 rec_env.bind(key.clone(), value.clone());
2733 attrs.insert(key.clone(), value.clone());
2734 if let Value::Thunk(t) = &value {
2735 if needs_scope {
2736 thunks.push((key.clone(), t.clone()));
2737 crate::value::census::scope_pinned();
2738 } else {
2739 crate::value::census::scope_narrowed();
2740 }
2741 }
2742 defined_so_far.insert(key);
2743 } else {
2744 // Multi-segment dotted path: build a nested attrset
2745 // with a thunk at the leaf so the value expression
2746 // can reference sibling rec-bindings.
2747 let key = path_keys[0].clone();
2748 let value =
2749 build_nested_attr_thunk(&path_keys[1..], &value_expr, env, &mut thunks);
2750 merge_nested_insert(&mut dotted_attrs, key, value);
2751 }
2752 }
2753 ast::Entry::Inherit(inherit) => {
2754 eval_inherit(&inherit, env, &mut attrs, Some(&mut rec_env), Some(&mut thunks))?;
2755 }
2756 }
2757 }
2758
2759 // Phase 1b: Bind accumulated dotted-path attrs into attrs and rec_env.
2760 // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
2761 // duplicate definition, so we do not attempt to merge with
2762 // existing inherit thunks — just bind directly.
2763 for (key, value) in dotted_attrs.iter() {
2764 attrs.insert(key.clone(), value.clone());
2765 rec_env.bind(key.clone(), value.clone());
2766 }
2767
2768 // Phase 2: Update all thunks (both Suspended and InheritSelect)
2769 // to capture the final rec_env (which now has all names bound).
2770 for (_key, thunk) in &thunks {
2771 thunk.update_env(&rec_env);
2772 }
2773 } else {
2774 for entry in set.entries() {
2775 match entry {
2776 ast::Entry::AttrpathValue(apv) => {
2777 let attrpath = apv.attrpath().ok_or_else(|| {
2778 EvalError::ParseError("binding missing attrpath".to_string())
2779 })?;
2780 let value_expr = apv.value().ok_or_else(|| {
2781 EvalError::ParseError("binding missing value".to_string())
2782 })?;
2783 let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2784 // CppNix defers a dynamic key that is NOT at the HEAD of the
2785 // attrpath: `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`,
2786 // so `e` never forces until `.a` is demanded. Evaluating the
2787 // whole path eagerly would force `e` at construction and — in
2788 // the module-system fixpoint — read `config.<x>` while `config`
2789 // is mid-force (the M2.6 divergence: `homes.null` instead of
2790 // `homes.<name>`). Only the head is eager; a lone dynamic tail
2791 // becomes a deferred thunk. A rarer collision under the same
2792 // head stays eager (forced) so static deep-merge still works.
2793 let tail_is_dynamic =
2794 path_attrs.len() > 1 && attrs_have_dynamic(&path_attrs[1..]);
2795 let head_key = match eval_attr_maybe_null(&path_attrs[0], env)? {
2796 Some(k) => k,
2797 // Null dynamic HEAD attr name → skip entire binding.
2798 None => continue,
2799 };
2800 if tail_is_dynamic && attrs.get(&head_key).is_none() {
2801 let value =
2802 build_deferred_tail_attr(&path_attrs[1..], &value_expr, env);
2803 attrs.insert(head_key, value);
2804 continue;
2805 }
2806 // M2.6 ROOT #3 (collision case): the tail has a dynamic key
2807 // AND the head already exists (a sibling binding wrote it,
2808 // e.g. osquery's `systemd.services.… = …` then
2809 // `systemd.tmpfiles.settings."10-osquery".${dirname …}.d`).
2810 // The plain deferral above bails (head present), and the
2811 // eager path below would force the dynamic key at
2812 // construction — re-reading `config.<x>` mid-fixpoint →
2813 // the empty-Promise partial. Instead, descend the existing
2814 // head along the tail's STATIC prefix and splice a DEFERRED
2815 // thunk at the first dynamic level, so the dynamic key
2816 // stays lazy exactly as CppNix's nested-literal desugaring
2817 // does — while preserving the static deep-merge with the
2818 // sibling binding.
2819 if tail_is_dynamic {
2820 if let Some(existing) = attrs.get(&head_key).cloned() {
2821 let merged = merge_deferred_dynamic_tail(
2822 existing,
2823 &path_attrs[1..],
2824 &value_expr,
2825 env,
2826 )?;
2827 attrs.insert(head_key, merged);
2828 continue;
2829 }
2830 }
2831 // Eager path: evaluate the remaining (static, or collision)
2832 // keys now. A null dynamic tail key skips the binding.
2833 let mut path_keys: Vec<String> = {
2834 let mut v = Vec::with_capacity(path_attrs.len());
2835 v.push(head_key);
2836 let mut skip = false;
2837 for a in &path_attrs[1..] {
2838 match eval_attr_maybe_null(a, env)? {
2839 Some(k) => v.push(k),
2840 None => { skip = true; break; }
2841 }
2842 }
2843 if skip { v.clear(); }
2844 v
2845 };
2846 // Null dynamic attr name → skip entire binding (CppNix compat)
2847 if path_keys.is_empty() { continue; }
2848 if path_keys.len() == 1 {
2849 let key = path_keys.pop().unwrap();
2850 // maybeThunk: skip thunk for trivial exprs.
2851 // is_rec=false — Ident lookups are safe.
2852 let value = maybe_thunk(&value_expr, env, false, None);
2853 // CppNix desugars `a.b = x; a = { c = y; };` into a single
2854 // merged `a = { b = x; c = y; }` at parse time. rnix keeps
2855 // the two bindings separate, so when a single-key binding
2856 // collides with an already-built (dotted) attrs for the
2857 // same key, deep-MERGE instead of overwrite. Force the RHS
2858 // to WHNF so merge_nested_insert (which needs concrete
2859 // Value::Attrs on both sides) can merge — forcing an
2860 // attrset to WHNF does NOT force its fields, so leaf values
2861 // stay lazy. Only fires on collision; non-colliding
2862 // single-key bindings keep the plain fast insert.
2863 // (This is the pkg-config-wrapper `env.addFlags` drop:
2864 // `env.addFlags = …` then `env = { wrapperName = …; … }`.)
2865 // If the earlier binding for this key is still a lazy
2866 // Thunk (an attrset literal inserted via maybe_thunk), force
2867 // it to WHNF FIRST so a `key = {..}; key = {..}` collision is
2868 // seen as attrs-vs-attrs and MERGES, matching nix
2869 // (`{ s = {a=1;}; s = {b=2;}; }` → `{ s = {a=1; b=2;}; }`).
2870 // Without this the `Some(Value::Attrs(_))` test below is false
2871 // on a Thunk and the second binding overwrites, dropping the
2872 // first's keys. The dotted branch below already does this; R3
2873 // (eval-okay-merge-dynamic-attrs set1/set2) needs it here too.
2874 // WHNF force does not force fields → leaf laziness preserved.
2875 // (A non-attrs dup like `s = 1; s = 2` still overwrites here,
2876 // unchanged — nix errors there, an eval-FAIL case out of scope.)
2877 if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2878 let existing = attrs.get(&key).cloned().unwrap();
2879 let forced_existing = force_value(&existing)?;
2880 attrs.insert(key.clone(), forced_existing);
2881 }
2882 if matches!(attrs.get(&key), Some(Value::Attrs(_))) {
2883 let forced = force_value(&value)?;
2884 merge_nested_insert(&mut attrs, key, forced);
2885 } else {
2886 attrs.insert(key, value);
2887 }
2888 } else {
2889 let key = path_keys[0].clone();
2890 let value = build_nested_attr(&path_keys[1..], &value_expr, env)?;
2891 // CppNix desugars `a = { x = …; }; a.y = …;` into a
2892 // single merged `a = { x = …; y = …; }`. When the
2893 // full-set binding for `a` was inserted FIRST it is a
2894 // lazy Thunk (attrset literals go through maybe_thunk),
2895 // so merge_nested_insert — which only merges when the
2896 // existing value is a concrete Value::Attrs — would
2897 // NOT see the earlier keys and would overwrite `a`
2898 // with just `{ y = … }`, silently dropping `x`. Force
2899 // the existing entry to WHNF on collision so the merge
2900 // sees the concrete attrs (forcing to WHNF does not
2901 // force the fields, so leaf laziness is preserved).
2902 // (This is the gst-plugins-base `passthru.waylandEnabled`
2903 // drop: `passthru = { … }; passthru.tests.x = …;`.)
2904 if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2905 let existing = attrs.get(&key).cloned().unwrap();
2906 let forced = force_value(&existing)?;
2907 attrs.insert(key.clone(), forced);
2908 }
2909 merge_nested_insert(&mut attrs, key, value);
2910 }
2911 }
2912 ast::Entry::Inherit(inherit) => {
2913 eval_inherit(&inherit, env, &mut attrs, None, None)?;
2914 }
2915 }
2916 }
2917 }
2918
2919 // Record the literal's static-key source positions for
2920 // `builtins.unsafeGetAttrPos` (the `attrTag` `declarations` — options.json
2921 // dock root). Cheap: one entry walk over static Ident/Str keys, no
2922 // forcing; attaches nothing (a pointer-sized `None`) when the set has no
2923 // single-static-key bindings.
2924 attach_attrset_positions(set, &mut attrs, env);
2925
2926 Ok(Value::Attrs(Rc::new(attrs)))
2927}
2928
2929fn eval_inherit(
2930 inherit: &ast::Inherit,
2931 env: &Env,
2932 attrs: &mut NixAttrs,
2933 bind_env: Option<&mut Env>,
2934 mut thunks: Option<&mut Vec<(String, Thunk)>>,
2935) -> Result<(), EvalError> {
2936 if let Some(from) = inherit.from() {
2937 // inherit (expr) a b c;
2938 //
2939 // The source expression must NOT be eagerly evaluated. nixpkgs
2940 // `lib/trivial.nix` has `inherit (lib.trivial) isFunction ...`
2941 // at the top of a file that itself defines `lib.trivial`. If
2942 // we eagerly force `lib.trivial`, we hit a self-referential
2943 // thunk blackhole. Instead: build a thunk per inherited
2944 // name that, when forced, evaluates the source and pulls
2945 // out that one attribute. This is what real Nix does.
2946 //
2947 // For `rec { inherit (X) name; ...; foo = name; }` we ALSO
2948 // need to bind the name in the enclosing rec env so the
2949 // sibling `foo = name` can reference it. The caller passes
2950 // its rec env in `bind_env`.
2951 //
2952 // When `thunks` is provided (rec attrsets), InheritSelect
2953 // thunks are collected so Phase 2 can update their captured
2954 // env to the full recursive scope. Without this, the source
2955 // expression cannot reference sibling bindings.
2956 let source_expr = from
2957 .expr()
2958 .ok_or_else(|| EvalError::ParseError("inherit from missing expr".to_string()))?;
2959 // Shared source thunk — all inherited names share one source
2960 // evaluation (the source thunk's own memoization ensures at
2961 // most one evaluation).
2962 let source_thunk = Thunk::new_suspended(source_expr, env.clone());
2963 let mut be = bind_env;
2964 for attr in inherit.attrs() {
2965 let name = eval_attr(&attr, env)?;
2966 let thunk = Thunk::new_inherit_select(source_thunk.clone(), name.clone());
2967 let value = Value::Thunk(thunk.clone());
2968 attrs.insert(name.clone(), value.clone());
2969 if let Some(ref mut e) = be {
2970 e.bind(name.clone(), value);
2971 }
2972 if let Some(ref mut t) = thunks {
2973 t.push((name, thunk));
2974 }
2975 }
2976 } else {
2977 // inherit a b c;
2978 //
2979 // CppNix resolves a bare `inherit x;` LAZILY, exactly like a plain
2980 // reference to `x` — it does NOT eagerly force the enclosing scope.
2981 // This matters when `x` is provided only by an enclosing `with`
2982 // scope whose value is a fixpoint still being constructed (a
2983 // blackhole): eager `env.lookup` returns None → spurious
2984 // `UndefinedVar`. nixpkgs `all-packages.nix` is
2985 // `… with pkgs; { nettle = import … { inherit callPackage; }; }`,
2986 // so `inherit callPackage` must resolve `callPackage` from the
2987 // `with pkgs` scope AT FORCE TIME, not eagerly at attrset
2988 // construction. Mirror `maybe_thunk`'s Ident path: try the fast
2989 // lookup, and on a miss defer to a WithIdent thunk (or a suspended
2990 // env lookup) so the resolution happens lazily against the settled
2991 // scope. (This was the `nettle` UndefinedVar('callPackage') drop.)
2992 let mut be = bind_env;
2993 for attr in inherit.attrs() {
2994 let name = eval_attr(&attr, env)?;
2995 let sym = crate::value::intern(&name);
2996 let value = if let Some(v) = env.lookup_fast(sym, &name) {
2997 v
2998 } else if let Some((scope_cache, scope_value)) =
2999 env.innermost_with_scope()
3000 {
3001 Value::Thunk(Thunk::new_with_ident(
3002 SmolStr::from(name.as_str()),
3003 scope_cache,
3004 scope_value,
3005 env.clone(),
3006 ))
3007 } else {
3008 return Err(EvalError::UndefinedVar(format!(
3009 "'{name}'{}",
3010 eval_file_ctx()
3011 )));
3012 };
3013 attrs.insert(name.clone(), value.clone());
3014 if let Some(ref mut e) = be {
3015 e.bind(name, value);
3016 }
3017 }
3018 }
3019 Ok(())
3020}
3021
3022fn build_nested_attr(
3023 path: &[String],
3024 expr: &ast::Expr,
3025 env: &Env,
3026) -> Result<Value, EvalError> {
3027 if path.is_empty() {
3028 // CRITICAL: Wrap leaf in a thunk instead of eagerly evaluating.
3029 // For dotted paths like `config.warnings = optionals config.x [...]`,
3030 // the leaf expression must be lazy — eagerly evaluating it during
3031 // attrset construction forces fixpoint thunks prematurely.
3032 return Ok(maybe_thunk(expr, env, false, None));
3033 }
3034 let key = path[0].clone();
3035 let inner = build_nested_attr(&path[1..], expr, env)?;
3036 let mut attrs = NixAttrs::new();
3037 attrs.insert(key, inner);
3038 Ok(Value::Attrs(Rc::new(attrs)))
3039}
3040
3041/// True if a single attr is a DYNAMIC key — one whose resolution runs
3042/// arbitrary expression code and therefore must not be forced at
3043/// attrset-construction time.
3044///
3045/// Two forms are dynamic:
3046/// * `ast::Attr::Dynamic` — a bare `${e}` antiquotation.
3047/// * `ast::Attr::Str` **containing an interpolation** — an interpolated
3048/// string key like `"iwd/${nm}"`. A `Str` with NO interpolation
3049/// (`"foo bar"`) is a plain static string literal and is NOT dynamic.
3050///
3051/// M2.6 ROOT #3: `attrs_have_dynamic` previously matched ONLY
3052/// `Attr::Dynamic`, so an interpolated-string tail key (`config.a."p${e}"`)
3053/// fell to the eager path and forced `e` at construction. In the module
3054/// system that forces a `config.<x>` read while `config` is mid-fixpoint
3055/// (`environment.etc."iwd/${configFile.name}"`, where `configFile` reads
3056/// `with config.networking.networkmanager`), yielding the empty-Promise
3057/// partial → the `set/null` softening. Treating an interpolated `Str` as
3058/// dynamic routes it through the same per-level deferral as `${e}`
3059/// (ROOT #1/#2), so `e` forces only when the enclosing head is demanded —
3060/// exactly CppNix's nested-attrset-literal desugaring.
3061fn attr_is_dynamic(attr: &ast::Attr) -> bool {
3062 match attr {
3063 ast::Attr::Dynamic(_) => true,
3064 // A string attr key is dynamic iff it has ≥1 interpolation part;
3065 // a purely-literal string key forces nothing and stays eager.
3066 ast::Attr::Str(s) => s
3067 .normalized_parts()
3068 .iter()
3069 .any(|p| matches!(p, InterpolPart::Interpolation(_))),
3070 ast::Attr::Ident(_) => false,
3071 }
3072}
3073
3074/// True if any attr in the slice is a dynamic (interpolated) key.
3075///
3076/// A dynamic key beyond the HEAD of an attrpath must NOT be evaluated at
3077/// attrset-construction time — CppNix defers it inside the head's lazy
3078/// value, so `{ a.${e} = v; }` never forces `e` until `.a` is demanded.
3079/// Static string/ident keys are cheap and force nothing, so they don't
3080/// need deferral.
3081fn attrs_have_dynamic(attrs: &[ast::Attr]) -> bool {
3082 attrs.iter().any(attr_is_dynamic)
3083}
3084
3085/// Build the nested attrset for the TAIL of an attrpath, deferring
3086/// evaluation of dynamic tail keys until the value is forced.
3087///
3088/// Given tail attrs `[b, ${e}, c]` and a value expr, produce a lazy
3089/// `Value::Thunk` that, when forced, evaluates each tail key (including
3090/// the dynamic `${e}`) against `env` and builds `{ b = { ${e} = { c =
3091/// <leaf-thunk> }; }; }`. This mirrors CppNix: the inner attrset (and
3092/// thus its dynamic keys) is constructed only when the enclosing head
3093/// attribute is demanded — never at construction of the outer attrset.
3094///
3095/// A dynamic key that evaluates to `null` skips the whole binding
3096/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3097fn build_deferred_tail_attr(
3098 tail: &[ast::Attr],
3099 value_expr: &ast::Expr,
3100 env: &Env,
3101) -> Value {
3102 let tail: Vec<ast::Attr> = tail.to_vec();
3103 let value_expr = value_expr.clone();
3104 let env = env.clone();
3105 Value::Thunk(Thunk::new_native(move || {
3106 build_tail_attrs_now(&tail, &value_expr, &env)
3107 }))
3108}
3109
3110/// Resolve ONE level of the deferred attrpath tail — used from inside
3111/// the deferred thunk above once the enclosing head is demanded.
3112///
3113/// M2.6 ROOT #2 (the OVER-FORCE fix): this resolves *only* `tail[0]`'s
3114/// key and wraps the remaining tail `tail[1..]` in another DEFERRED
3115/// thunk — it does NOT recurse eagerly through the whole tail. This is
3116/// exactly CppNix's desugaring of `a.b.c = v` into nested attrset
3117/// literals `a = { b = { c = v; }; }`, where forcing `a` to WHNF yields
3118/// `{ b = <thunk {c=v}> }` — the inner level (`b`, and any dynamic key
3119/// under it) stays lazy until `.b` is demanded.
3120///
3121/// Forcing the enclosing head therefore resolves ONE tail key, never
3122/// the whole chain: `config.homes.${cfg.pleme.userName} = 7` demanded
3123/// as `config` yields `{ homes = <deferred> }` WITHOUT forcing the
3124/// `${cfg.pleme.userName}` key. The prior implementation recursed the
3125/// whole tail eagerly, forcing that dynamic key while only `.config`
3126/// (or its `._type`) was demanded — the over-force cppnix never does.
3127///
3128/// A dynamic key that evaluates to `null` skips the whole binding
3129/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3130fn build_tail_attrs_now(
3131 tail: &[ast::Attr],
3132 value_expr: &ast::Expr,
3133 env: &Env,
3134) -> Result<Value, EvalError> {
3135 if tail.is_empty() {
3136 return Ok(maybe_thunk(value_expr, env, false, None));
3137 }
3138 if std::env::var_os("SUI_M26_TAILTRACE").is_some() {
3139 let t: String = tail[0].syntax().text().to_string().chars().take(40).collect();
3140 eprintln!("[M26 TAIL-RESOLVE] forcing dynamic tail key `{t}`");
3141 if attrs_have_dynamic(&tail[..1]) {
3142 crate::trace::dump_force_stack_ids();
3143 }
3144 }
3145 let key = match eval_attr_maybe_null(&tail[0], env)? {
3146 Some(k) => k,
3147 // Null dynamic key → the whole binding is skipped; an empty
3148 // attrset is the identity for merge_nested_insert.
3149 None => return Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
3150 };
3151 // Resolve ONE level: if more tail remains, defer it (a new lazy
3152 // thunk) rather than recursing eagerly. Only the leaf (empty tail)
3153 // is built here. This keeps each nested level lazy, exactly like
3154 // CppNix's nested-attrset-literal desugaring — so forcing this
3155 // level does NOT force the next level's (possibly dynamic) key.
3156 let inner = if tail.len() == 1 {
3157 maybe_thunk(value_expr, env, false, None)
3158 } else {
3159 build_deferred_tail_attr(&tail[1..], value_expr, env)
3160 };
3161 let mut attrs = NixAttrs::new();
3162 attrs.insert(key, inner);
3163 Ok(Value::Attrs(Rc::new(attrs)))
3164}
3165
3166/// M2.6 ROOT #3 (collision case): splice a DEFERRED dynamic-tail binding
3167/// into an ALREADY-PRESENT head value without forcing the dynamic key.
3168///
3169/// `existing` is the value already stored at the attrpath's head (written
3170/// by a sibling binding — e.g. `systemd.services.… = …`). `tail` is the
3171/// remaining attrpath (`path_attrs[1..]`) of the new binding, which
3172/// contains ≥1 dynamic attr (`systemd.tmpfiles.….${dirname …}.d`).
3173///
3174/// We descend `existing` along the LONGEST STATIC PREFIX of `tail`
3175/// (`tmpfiles`, `settings`, `"10-osquery"` — all static, forced-free
3176/// keys), forcing each already-present sub-attrset to WHNF so the merge
3177/// sees concrete keys (forcing to WHNF never forces leaf VALUES, so leaf
3178/// laziness is preserved), and at the first DYNAMIC level splice a
3179/// `build_deferred_tail_attr` thunk. The dynamic key therefore forces
3180/// only when that exact nested path is later demanded — CppNix's
3181/// nested-attrset-literal desugaring, now honoured through a sibling
3182/// collision too.
3183fn merge_deferred_dynamic_tail(
3184 existing: Value,
3185 tail: &[ast::Attr],
3186 value_expr: &ast::Expr,
3187 env: &Env,
3188) -> Result<Value, EvalError> {
3189 // `tail` is non-empty and contains a dynamic attr somewhere (the
3190 // caller guarantees `attrs_have_dynamic(tail)`).
3191 debug_assert!(!tail.is_empty());
3192
3193 // If the FIRST tail attr is itself dynamic, there is no static prefix
3194 // to descend — the whole tail is deferred and merged as a lazy
3195 // overlay onto the existing head (a `//`-style right-merge; the
3196 // deferred attrset only materialises its dynamic key on demand).
3197 if attr_is_dynamic(&tail[0]) {
3198 let deferred = build_deferred_tail_attr(tail, value_expr, env);
3199 return Ok(lazy_overlay_merge(existing, deferred));
3200 }
3201
3202 // The head static key of `tail`. Resolve it (static → forces nothing
3203 // relevant; a null dynamic can't occur here since tail[0] is static).
3204 let key = match eval_attr_maybe_null(&tail[0], env)? {
3205 Some(k) => k,
3206 None => return Ok(existing),
3207 };
3208
3209 // Force the existing head to a concrete attrset so we can descend +
3210 // merge on the resolved static key. Forcing to WHNF does NOT force
3211 // its field VALUES, so leaf laziness is preserved.
3212 let existing_forced = force_value(&existing)?;
3213 let mut base = match existing_forced {
3214 Value::Attrs(a) => (*a).clone(),
3215 // The existing head is not an attrset (a sibling wrote a leaf
3216 // here); CppNix would error on the merge, but to stay lazy we
3217 // defer the tail and let a later demand surface the real merge
3218 // conflict. Build the deferred tail as a fresh attrset.
3219 _ => {
3220 let deferred = build_deferred_tail_attr(tail, value_expr, env);
3221 return Ok(deferred);
3222 }
3223 };
3224
3225 // Recurse: merge the REMAINING tail (`tail[1..]`) under `key`.
3226 let child_existing = base.get(&key).cloned();
3227 let new_child = match child_existing {
3228 Some(child) if tail.len() > 1 => {
3229 // Deeper static/dynamic prefix under an existing sub-attrset.
3230 merge_deferred_dynamic_tail(child, &tail[1..], value_expr, env)?
3231 }
3232 Some(child) => {
3233 // tail == [key]; the leaf collides with an existing value.
3234 // Static leaf collision — build the leaf and lazy-merge.
3235 let leaf = maybe_thunk(value_expr, env, false, None);
3236 lazy_overlay_merge(child, leaf)
3237 }
3238 None if tail.len() > 1 => {
3239 // No existing child; the remaining tail may itself start with
3240 // a dynamic key — defer it whole (build_deferred_tail_attr
3241 // handles the static/dynamic split per-level).
3242 build_deferred_tail_attr(&tail[1..], value_expr, env)
3243 }
3244 None => maybe_thunk(value_expr, env, false, None),
3245 };
3246 base.insert(key, new_child);
3247 Ok(Value::Attrs(Rc::new(base)))
3248}
3249
3250/// Lazy right-merge of two values that are (or will force to) attrsets,
3251/// preserving leaf laziness. Used by [`merge_deferred_dynamic_tail`] to
3252/// combine a deferred dynamic-tail attrset with an existing value without
3253/// forcing either's dynamic keys eagerly. When both are concrete attrs we
3254/// deep-merge in place (reusing [`merge_nested_insert`]); otherwise we
3255/// build a lazy overlay thunk that merges on demand.
3256fn lazy_overlay_merge(left: Value, right: Value) -> Value {
3257 match (&left, &right) {
3258 (Value::Attrs(la), Value::Attrs(_)) => {
3259 crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3260 let mut merged = (**la).clone();
3261 if let Value::Attrs(ra) = &right {
3262 // Merging distinct override keys into `merged` is order-
3263 // independent (per-key right-wins), and the result map is
3264 // unordered storage — the sorted `iter()` was dead work.
3265 for (k, v) in ra.iter_unsorted() {
3266 merge_nested_insert(&mut merged, k.clone(), v.clone());
3267 }
3268 }
3269 Value::Attrs(Rc::new(merged))
3270 }
3271 _ => {
3272 // At least one side is a thunk (a deferred dynamic tail).
3273 // Defer the merge behind a Native thunk so neither side's
3274 // dynamic key forces until the merged attrset is demanded.
3275 Value::Thunk(Thunk::new_native(move || {
3276 let lf = force_value(&left)?;
3277 let rf = force_value(&right)?;
3278 let la = lf.as_attrs()?;
3279 let ra = rf.as_attrs()?;
3280 crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3281 let mut merged = (*la).clone();
3282 for (k, v) in ra.iter_unsorted() {
3283 merge_nested_insert(&mut merged, k.clone(), v.clone());
3284 }
3285 Ok(Value::Attrs(Rc::new(merged)))
3286 }))
3287 }
3288 }
3289}
3290
3291/// Like [`build_nested_attr`] but wraps the leaf in a [`Thunk`] instead of
3292/// eagerly evaluating it. Used inside `rec { ... }` and `let ... in` so
3293/// that dotted-path leaf expressions can reference sibling bindings
3294/// through the recursive env (which is finalised in Phase 2).
3295///
3296/// Every thunk created is appended to `thunks` so Phase 2 can update
3297/// its captured environment.
3298fn build_nested_attr_thunk(
3299 path: &[String],
3300 expr: &ast::Expr,
3301 env: &Env,
3302 thunks: &mut Vec<(String, Thunk)>,
3303) -> Value {
3304 if path.is_empty() {
3305 let thunk = Thunk::new_suspended(expr.clone(), env.clone());
3306 let val = Value::Thunk(thunk.clone());
3307 thunks.push((String::new(), thunk));
3308 return val;
3309 }
3310 let key = path[0].clone();
3311 let inner = build_nested_attr_thunk(&path[1..], expr, env, thunks);
3312 let mut attrs = NixAttrs::new();
3313 attrs.insert(key, inner);
3314 Value::Attrs(Rc::new(attrs))
3315}
3316
3317/// Insert `value` at `key` in `target`. If `target` already has a
3318/// concrete `Value::Attrs` at that key AND `value` is also a
3319/// concrete `Value::Attrs`, deep-merge them rather than overwriting.
3320/// This is what makes `{ a.b.c = 1; a.b.d = 2; a.e = 3; }` produce
3321/// `{ a = { b = { c = 1; d = 2; }; e = 3; }; }` instead of
3322/// dropping siblings — every nixpkgs module relies on this.
3323fn merge_nested_insert(target: &mut NixAttrs, key: String, value: Value) {
3324 // Fast path: no existing entry at this key → plain insert, keeping the
3325 // value lazy (the overwhelmingly common non-colliding case, so we never
3326 // force a thunk here).
3327 let existing = match target.get(&key) {
3328 Some(e) => e.clone(),
3329 None => {
3330 target.insert(key, value);
3331 return;
3332 }
3333 };
3334 // A collision exists. A deep merge is warranted only when BOTH the
3335 // existing entry AND the new value are attrset-shaped. M2.6 ROOT #4b
3336 // (byte-verified): either side may be a lazy `Thunk` wrapping a
3337 // full-set leaf — both dotted-path orderings hit this:
3338 // forward `o.a = { x = 1; }; o.a.y = 2;` → EXISTING `a` is a thunk
3339 // (`build_nested_attr` puts the `{x=1}` leaf through
3340 // `maybe_thunk`), NEW `a` is `{ y = … }`;
3341 // reverse `o.a.y = 2; o.a = { x = 1; };` → EXISTING `a` is `{y}`,
3342 // NEW `a` is the `<thunk {x=1}>`.
3343 // The old `should_merge` required BOTH sides to already be concrete
3344 // `Value::Attrs`, so a Thunk-vs-Attrs collision fell to the overwrite
3345 // path and silently dropped the earlier leaf's keys. cppnix desugars
3346 // BOTH orderings into one merged `o.a = { x = 1; y = 2; }`. Force each
3347 // side's thunk to WHNF ON COLLISION ONLY (forcing an attrset to WHNF
3348 // does NOT force its fields, so leaf laziness is preserved); a thunk
3349 // that forces to a non-attrset (or errors) makes the merge a plain
3350 // overwrite (leaf last-write-wins).
3351 // Symptom this closes: nixpkgs' alsa module declares
3352 // `options.hardware.alsa = { enable = …; cardAliases = …; … }` AND
3353 // `options.hardware.alsa.enablePersistence = …`; sui merged them to
3354 // only `{enablePersistence}`, so `hardware.alsa.cardAliases` "does not
3355 // exist" — the M2.6 frontier once the `with`-namespace over-force (#4a)
3356 // was fixed.
3357 let value = match value {
3358 Value::Thunk(_) => match force_value(&value) {
3359 Ok(v @ Value::Attrs(_)) => v,
3360 _ => value,
3361 },
3362 other => other,
3363 };
3364 if !matches!(value, Value::Attrs(_)) {
3365 target.insert(key, value);
3366 return;
3367 }
3368 // Normalize the existing side to concrete attrs too (forcing a thunk
3369 // to WHNF if needed); if it isn't attrset-shaped, the new attrs wins.
3370 let existing_concrete = match &existing {
3371 Value::Attrs(_) => existing.clone(),
3372 Value::Thunk(_) => match force_value(&existing) {
3373 Ok(v @ Value::Attrs(_)) => v,
3374 _ => {
3375 target.insert(key, value);
3376 return;
3377 }
3378 },
3379 _ => {
3380 target.insert(key, value);
3381 return;
3382 }
3383 };
3384 // Both sides are concrete attrs — merge in place. We pop the
3385 // existing entry, then walk the new attrs and recursively
3386 // merge each child onto it.
3387 let mut existing_attrs = match existing_concrete {
3388 Value::Attrs(a) => (*a).clone(),
3389 _ => unreachable!(),
3390 };
3391 let new_attrs = match value {
3392 Value::Attrs(ref a) => a,
3393 _ => unreachable!(),
3394 };
3395 for (k, v) in new_attrs.iter_unsorted() {
3396 merge_nested_insert(&mut existing_attrs, k.clone(), v.clone());
3397 }
3398 target.insert(key, Value::Attrs(Rc::new(existing_attrs)));
3399}
3400
3401/// Evaluate entries from any HasEntry node (LegacyLet).
3402fn eval_entries<N: HasEntry + AstNode>(node: &N, env: &mut Env) -> Result<(), EvalError> {
3403 for entry in node.entries() {
3404 match entry {
3405 ast::Entry::AttrpathValue(apv) => {
3406 let attrpath = apv.attrpath().ok_or_else(|| {
3407 EvalError::ParseError("binding missing attrpath".to_string())
3408 })?;
3409 let value_expr = apv.value().ok_or_else(|| {
3410 EvalError::ParseError("binding missing value".to_string())
3411 })?;
3412 let mut path_keys: Vec<String> = attrpath
3413 .attrs()
3414 .map(|a| eval_attr(&a, env))
3415 .collect::<Result<_, _>>()?;
3416 if path_keys.len() == 1 {
3417 let key = path_keys.pop().unwrap();
3418 let value = eval_expr(&value_expr, env)?;
3419 env.bind(key, value);
3420 }
3421 // Multi-key paths in let are not standard; skip for now.
3422 }
3423 ast::Entry::Inherit(inherit) => {
3424 if let Some(from) = inherit.from() {
3425 let source_expr = from.expr().ok_or_else(|| {
3426 EvalError::ParseError("inherit from missing expr".to_string())
3427 })?;
3428 let source = force_value(&eval_expr(&source_expr, env)?)?;
3429 let source_attrs = source.as_attrs()?;
3430 for attr in inherit.attrs() {
3431 let name = eval_attr(&attr, env)?;
3432 let value = source_attrs
3433 .get(&name)
3434 .cloned()
3435 .ok_or_else(|| EvalError::AttrNotFound(
3436 format!("'{name}' in inherit{}", eval_file_ctx()),
3437 ))?;
3438 env.bind(name, value);
3439 }
3440 } else {
3441 for attr in inherit.attrs() {
3442 let name = eval_attr(&attr, env)?;
3443 let value = env
3444 .lookup(&name)
3445 .ok_or_else(|| EvalError::UndefinedVar(
3446 format!("'{name}'{}", eval_file_ctx()),
3447 ))?;
3448 env.bind(name, value);
3449 }
3450 }
3451 }
3452 }
3453 }
3454 Ok(())
3455}
3456
3457fn eval_binop(
3458 op: ast::BinOpKind,
3459 lhs: &ast::Expr,
3460 rhs: &ast::Expr,
3461 env: &Env,
3462) -> Result<Value, EvalError> {
3463 // Short-circuit for && and ||
3464 match op {
3465 ast::BinOpKind::And => {
3466 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3467 if !l {
3468 return Ok(Value::Bool(false));
3469 }
3470 return eval_expr(rhs, env);
3471 }
3472 ast::BinOpKind::Or => {
3473 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3474 if l {
3475 return Ok(Value::Bool(true));
3476 }
3477 return eval_expr(rhs, env);
3478 }
3479 ast::BinOpKind::Implication => {
3480 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3481 if !l {
3482 return Ok(Value::Bool(true));
3483 }
3484 return eval_expr(rhs, env);
3485 }
3486 _ => {}
3487 }
3488
3489 let lc = force_concrete(&eval_expr(lhs, env)?)?;
3490 let rc = force_concrete(&eval_expr(rhs, env)?)?;
3491 // Consume the Concretes (move, don't clone) so `l`/`r` hold the sole Rc to
3492 // any heap payload. This is byte-neutral — `into_value` yields the identical
3493 // `Value` as `to_value` — but it drops `lc`/`rc`, which is what lets the
3494 // `Concat` arm's structural-share fast path see a uniquely-owned left list
3495 // for a fresh `++` temporary (`Rc::try_unwrap` → append in place). Keeping
3496 // `lc` alive via `to_value` pinned the refcount at ≥2 and defeated reuse.
3497 let l = lc.into_value();
3498 let r = rc.into_value();
3499
3500 match op {
3501 ast::BinOpKind::Add => match (&l, &r) {
3502 (Value::Int(a), Value::Int(b)) => a
3503 .checked_add(*b)
3504 .map(Value::Int)
3505 .ok_or_else(|| int_overflow("adding", *a, '+', *b)),
3506 (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
3507 (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
3508 (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
3509 (Value::String(a), Value::String(b)) => {
3510 let mut ctx = a.context.clone();
3511 ctx.merge(&b.context);
3512 // Byte-identical to `format!("{}{}", a.chars, b.chars)` but
3513 // routes around the `core::fmt` runtime (its dispatch was the
3514 // #1 self-time frame on the string-concat hot path): a single
3515 // exact-capacity `String` + two `push_str` reserves the final
3516 // size once, so the left operand is copied exactly once instead
3517 // of copied-then-regrown. Result string + context unchanged →
3518 // ByteSufficient. (Also removes a `format!` — TYPED EMISSION.)
3519 let mut s = String::with_capacity(a.chars.len() + b.chars.len());
3520 s.push_str(&a.chars);
3521 s.push_str(&b.chars);
3522 Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
3523 }
3524 (Value::Path(a), Value::String(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}{}", b.chars).as_str())))),
3525 (Value::Path(a), Value::Path(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}/{b}").as_str())))),
3526 // CppNix coerces attrsets with outPath when used with +
3527 (Value::Attrs(_), _) | (_, Value::Attrs(_)) => {
3528 let (ls, lctx) = l.coerce_to_string()?;
3529 let (rs, rctx) = r.coerce_to_string()?;
3530 let mut ctx = lctx;
3531 ctx.merge(&rctx);
3532 Ok(Value::String(Rc::new(NixString::with_context(
3533 format!("{ls}{rs}"),
3534 ctx,
3535 ))))
3536 }
3537 _ => Err(EvalError::op_type("add", l.type_name(), r.type_name())),
3538 },
3539 ast::BinOpKind::Sub => num_op(
3540 &l,
3541 &r,
3542 |a, b| a.checked_sub(b),
3543 |a, b| a - b,
3544 |a, b| int_overflow("subtracting", a, '-', b),
3545 ),
3546 ast::BinOpKind::Mul => num_op(
3547 &l,
3548 &r,
3549 |a, b| a.checked_mul(b),
3550 |a, b| a * b,
3551 |a, b| int_overflow("multiplying", a, '*', b),
3552 ),
3553 ast::BinOpKind::Div => {
3554 // CppNix rejects division by zero for both int and float
3555 // operands; Rust's native int-div-by-0 panics (we handle
3556 // that below) but float-div-by-0 silently returns `inf`
3557 // or `NaN`, which sui was then serializing as `null` —
3558 // an invisible silent-Ok bug surfaced by the error-case
3559 // differential corpus.
3560 //
3561 // Cover every zero-denominator case explicitly.
3562 let rhs_is_zero = match &r {
3563 Value::Int(0) => true,
3564 Value::Float(f) => *f == 0.0,
3565 _ => false,
3566 };
3567 if rhs_is_zero {
3568 return Err(EvalError::DivisionByZero);
3569 }
3570 num_op(
3571 &l,
3572 &r,
3573 |a, b| a.checked_div(b),
3574 |a, b| a / b,
3575 |a, b| int_overflow("dividing", a, '/', b),
3576 )
3577 }
3578 // `eq_operator`, NOT `==`: at the operator both operands were just
3579 // materialized by independent `force_concrete` calls, so sui can prove
3580 // they are distinct cells and must answer `false` for two lambdas —
3581 // exactly as CppNix's `ExprOpEq::eval` does. Nested comparisons keep
3582 // `PartialEq`. See `value::eq_operator`.
3583 ast::BinOpKind::Equal => Ok(Value::Bool(crate::value::eq_operator(&l, &r))),
3584 ast::BinOpKind::NotEqual => Ok(Value::Bool(!crate::value::eq_operator(&l, &r))),
3585 ast::BinOpKind::Less => compare(&l, &r, |o| o == std::cmp::Ordering::Less),
3586 ast::BinOpKind::LessOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Greater),
3587 ast::BinOpKind::More => compare(&l, &r, |o| o == std::cmp::Ordering::Greater),
3588 ast::BinOpKind::MoreOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Less),
3589 ast::BinOpKind::Update => {
3590 let la = l.to_attrs()?;
3591 let ra = r.to_attrs()?;
3592 // O(1) lazy overlay — defers merge until attribute access.
3593 Ok(Value::Attrs(Rc::new(la.overlay(ra))))
3594 }
3595 ast::BinOpKind::Concat => {
3596 // Structural-share fast path: when the left operand's `Rc<Vec>` is
3597 // uniquely owned (a fresh temporary, as in a left-associative `++`
3598 // fold `acc ++ [x]`), append the right elements IN PLACE instead of
3599 // cloning the whole accumulator. This turns an O(n) copy per concat
3600 // into amortized O(1), byte-identically — the result is the same
3601 // ordered sequence of the same Rc-shared lazy thunks (no forcing,
3602 // no reordering, no identity change). When the Rc is shared (the
3603 // left came from a still-live binding/thunk) we fall back to the
3604 // clone-extend path, preserving the shared list unchanged.
3605 crate::value::concat_lists(l, r.as_list()?)
3606 }
3607 ast::BinOpKind::And | ast::BinOpKind::Or | ast::BinOpKind::Implication => {
3608 unreachable!("handled above")
3609 }
3610 ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
3611 Err(EvalError::NotImplemented("pipe operators".to_string()))
3612 }
3613 }
3614}
3615
3616/// CppNix aborts (uncatchably) on i64 arithmetic overflow, e.g.
3617/// `integer overflow in adding 9223372036854775807 + 1`. `EvalError::Abort` is
3618/// the uncatchable variant (`tryEval` catches only `Throw`/`AssertionFailed`),
3619/// matching nix — a wrapping result would silently produce a wrong drvPath.
3620#[inline]
3621fn int_overflow(verb: &str, a: i64, sym: char, b: i64) -> EvalError {
3622 EvalError::Abort(format!("integer overflow in {verb} {a} {sym} {b}"))
3623}
3624
3625fn num_op(
3626 l: &Value,
3627 r: &Value,
3628 int_op: impl Fn(i64, i64) -> Option<i64>,
3629 float_op: impl Fn(f64, f64) -> f64,
3630 overflow: impl Fn(i64, i64) -> EvalError,
3631) -> Result<Value, EvalError> {
3632 match (l, r) {
3633 (Value::Int(a), Value::Int(b)) => {
3634 int_op(*a, *b).map(Value::Int).ok_or_else(|| overflow(*a, *b))
3635 }
3636 (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
3637 (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
3638 (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
3639 _ => Err(EvalError::op_type("perform arithmetic on", l.type_name(), r.type_name())),
3640 }
3641}
3642
3643fn compare(
3644 l: &Value,
3645 r: &Value,
3646 pred: impl Fn(std::cmp::Ordering) -> bool,
3647) -> Result<Value, EvalError> {
3648 let ord = match (l, r) {
3649 (Value::Int(a), Value::Int(b)) => a.cmp(b),
3650 (Value::Float(a), Value::Float(b)) => {
3651 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
3652 }
3653 (Value::Int(a), Value::Float(b)) => (*a as f64)
3654 .partial_cmp(b)
3655 .unwrap_or(std::cmp::Ordering::Equal),
3656 (Value::Float(a), Value::Int(b)) => a
3657 .partial_cmp(&(*b as f64))
3658 .unwrap_or(std::cmp::Ordering::Equal),
3659 (Value::String(a), Value::String(b)) => a.chars.cmp(&b.chars),
3660 _ => {
3661 return Err(EvalError::op_type("compare", l.type_name(), r.type_name()));
3662 }
3663 };
3664 Ok(Value::Bool(pred(ord)))
3665}
3666
3667/// Apply a function to an argument.
3668///
3669/// Supports `__functor`: if `func` is an attrset with a `__functor` key,
3670/// calls `__functor self arg` (the Nix `__functor` protocol).
3671///
3672/// For lambda with a simple ident parameter, the argument is NOT forced
3673/// before binding -- this enables fixpoint combinators (`lib.fix`) where
3674/// the argument is a self-referential thunk.
3675/// Apply a function and force the result.
3676///
3677/// Builtins that inspect the return value (via `as_list`, `as_bool`, etc.)
3678/// must use this instead of bare `apply` — otherwise a thunk-wrapped result
3679/// will cause "thunk in as_list: force first" errors.
3680pub fn apply_and_force(func: Value, arg: Value) -> Result<Value, EvalError> {
3681 force_value(&apply(func, arg)?)
3682}
3683
3684pub fn apply(func: Value, arg: Value) -> Result<Value, EvalError> {
3685 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || apply_inner(func, arg))
3686}
3687
3688fn apply_inner(func: Value, arg: Value) -> Result<Value, EvalError> {
3689 crate::perf::inc(crate::perf::Counter::Apply);
3690 let func = force_concrete(&func)?.into_value();
3691 match func {
3692 Value::Lambda(closure) => {
3693 // Hot function tracker: log source file + param name for each lambda call
3694 if crate::perf::enabled() {
3695 APPLY_SITES.with(|sites| {
3696 let file = closure.env.eval_file()
3697 .map(|p| p.display().to_string())
3698 .unwrap_or_else(|| "<eval>".into());
3699 // Include param info for identification
3700 let param_name = match &closure.param {
3701 rnix::ast::Param::IdentParam(ip) => ip.ident().map(|i| ident_text(&i)).unwrap_or_default(),
3702 rnix::ast::Param::Pattern(pat) => {
3703 let mut names: Vec<String> = pat.pat_entries()
3704 .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3705 .take(3)
3706 .collect();
3707 if pat.pat_entries().count() > 3 { names.push("...".to_string()); }
3708 format!("{{{}}}", names.join(","))
3709 }
3710 };
3711 let key = format!("{}:{}", file.rsplit_once("-source/").map_or(file.as_str(), |(_,s)| s), param_name);
3712 *sites.borrow_mut().entry(key).or_insert(0u64) += 1;
3713 });
3714 }
3715 let mut call_env = closure.env.child();
3716 // ALWAYS push a frame, even when the closure captured no file:
3717 // `.map(push_eval_file)` pushed nothing for `None`, leaving the
3718 // CALLER's file on top, so a literal written in a fileless
3719 // context got stamped with the callee's path. CppNix returns
3720 // `null` there. See `EVAL_FILE_STACK`.
3721 let _file_guard = push_eval_frame(closure.env.eval_file().cloned());
3722 // Push Nix-level trace frame for function calls. Lazy: stores
3723 // only the raw ingredients (O(1) Rc-clone of the closure env +
3724 // the current-eval-file snapshot) and defers the format!/strip
3725 // work to the cold `attach_trace` path. Renders byte-identical
3726 // to the eager form.
3727 let _trace = push_nix_trace_lambda(&closure.env);
3728 match &closure.param {
3729 rnix::ast::Param::IdentParam(_) => {
3730 // Simple ident param: bind argument WITHOUT forcing.
3731 // This is critical for fixpoint / call-by-need semantics.
3732 bind_param(&closure.param, &arg, &mut call_env)?;
3733 }
3734 rnix::ast::Param::Pattern(_) => {
3735 // Pattern param needs the arg to be an attrset, so force.
3736 let forced_arg = force_concrete(&arg)?.into_value();
3737 bind_param(&closure.param, &forced_arg, &mut call_env)?;
3738 }
3739 }
3740 eval_expr(&closure.body, &call_env)
3741 }
3742 Value::Builtin(b) => {
3743 let _trace = push_nix_trace(format!("while calling the '{}' builtin", b.name));
3744 // Special builtins that must receive UNFORCED arguments:
3745 // - tryEval: must catch throw/abort during its own forcing
3746 // - addErrorContext<partial>: wraps value with error context
3747 // without forcing (the value is the fixpoint `config` which
3748 // causes infinite recursion if forced during collectModules)
3749 // - seq<partial>: forces first arg but returns second UNFORCED
3750 // Same lazy-arg set as `eval_apply` (single source of truth) — these
3751 // builtins receive the arg UNFORCED. foldl'<p1> is the nul accumulator
3752 // (nix's foldl' is strict in each op RESULT, NOT in the nul).
3753 if builtin_takes_lazy_arg(&b.name) {
3754 (b.func)(&[arg])
3755 } else {
3756 let forced_arg = force_value(&arg)?;
3757 (b.func)(&[forced_arg])
3758 }
3759 }
3760 Value::Attrs(ref attrs) => {
3761 if let Some(functor) = attrs.get("__functor") {
3762 let functor = force_value(functor)?;
3763 // __functor protocol: (functor self) arg
3764 let partial = apply(functor, func.clone())?;
3765 apply(partial, arg)
3766 } else if crate::value::in_promise_eval() {
3767 // M2.6 Promise softening: an attrset without __functor
3768 // being called as a function — typically the empty-
3769 // attrset sentinel inside a fix-point body. Return
3770 // null so eval can proceed.
3771 Ok(Value::Null)
3772 } else {
3773 Err(EvalError::type_error(
3774 format!("cannot call {} (missing __functor){}", func.type_name(), eval_file_ctx()),
3775 ))
3776 }
3777 }
3778 _ if crate::value::in_promise_eval() => {
3779 // M2.6 Promise softening: calling null / int / string / list
3780 // as a function inside a Promise body is the sentinel
3781 // cascade landing somewhere it doesn't belong. Return null
3782 // so the fix-point continues instead of erroring.
3783 Ok(Value::Null)
3784 }
3785 _ => Err(EvalError::type_error(
3786 format!("cannot call {}{}", func.type_name(), eval_file_ctx()),
3787 )),
3788 }
3789}
3790
3791/// Dark-side lever `batch-bind` (byte-SAFE, `RedundantWrite`) — OFF by default.
3792/// When `SUI_BATCH_BIND=1`, an N-formal pattern binds in ONE copy-on-write step
3793/// (`Env::bind_many`) instead of N successive `env.bind()` calls. Byte-identical
3794/// either way (same intern, same insert order, same final HAMT — Phase 2's
3795/// `update_env` makes each default thunk's initial env capture unobservable).
3796/// Gated because the extra `Vec` allocation could regress the common small-pattern
3797/// case, and the win is unmeasured under load — never change the default path on a
3798/// hunch (never-ship-a-regression). Cached so the default path pays zero per call.
3799/// Ledger: `sui-spec/specs/darkside.lisp` (`batch-bind`, DarkGated).
3800static SUI_BATCH_BIND: std::sync::LazyLock<bool> =
3801 std::sync::LazyLock::new(|| std::env::var_os("SUI_BATCH_BIND").is_some());
3802
3803fn bind_param(param: &ast::Param, arg: &Value, env: &mut Env) -> Result<(), EvalError> {
3804 match param {
3805 ast::Param::IdentParam(ip) => {
3806 let ident = ip
3807 .ident()
3808 .ok_or_else(|| EvalError::ParseError("ident param missing ident".to_string()))?;
3809 let name = ident_text(&ident);
3810 env.bind(name, arg.clone());
3811 }
3812 ast::Param::Pattern(pat) => {
3813 let attrs = arg.as_attrs()?;
3814
3815 // @-binding (either `args @ { ... }` or `{ ... } @ args`)
3816 if let Some(pat_bind) = pat.pat_bind()
3817 && let Some(ident) = pat_bind.ident()
3818 {
3819 let name = ident_text(&ident);
3820 env.bind(name, arg.clone());
3821 }
3822
3823 let has_ellipsis = pat.ellipsis_token().is_some();
3824 let entries: Vec<ast::PatEntry> = pat.pat_entries().collect();
3825
3826 // Two-phase binding (matching CppNix semantics):
3827 // Phase 1: Bind all formals. Defaults get thunks with a
3828 // preliminary env. We collect thunks for Phase 2 update.
3829 // Phase 2: Update default thunks to capture the final env
3830 // (which now has ALL formals bound). This allows defaults
3831 // to reference any other formal — including forward refs.
3832 let mut default_thunks: Vec<Thunk> = Vec::new();
3833 // batch-bind (byte-SAFE `RedundantWrite`, OFF unless `SUI_BATCH_BIND=1`):
3834 // the flag path collects every formal's (name, value) pair and binds
3835 // them in ONE copy-on-write step (`bind_many`) instead of N successive
3836 // `env.bind()` calls. Byte-identical either way — the default thunks
3837 // capture `env.clone()` (pre-batch) and Phase 2's `update_env` re-points
3838 // every one to the final all-formals-bound env, so a thunk's *initial*
3839 // capture is unobservable (overwritten before any force); same intern,
3840 // same insert order, same final HAMT. The default path (flag unset) is
3841 // the original per-formal loop, byte- AND perf-identical (no Vec alloc).
3842 let use_batch = *SUI_BATCH_BIND;
3843 let mut pairs: Vec<(String, Value)> =
3844 if use_batch { Vec::with_capacity(entries.len()) } else { Vec::new() };
3845
3846 // D3 (`SUI_SCOPE_NARROW>=1`) — the highest-yield arm of the fix,
3847 // because it fires on every `callPackage`'d
3848 // `{ stdenv, lib, foo ? null }` and every
3849 // `{ config, lib, pkgs, ... }` module in the fleet.
3850 //
3851 // Today EVERY default thunk is re-pointed at the final all-formals
3852 // env by Phase 2, so `{ a, b ? 1 }` closes
3853 // `b-thunk -> env -> b-thunk` and the whole call frame is immortal.
3854 // But a default only NEEDS the final env if it can reach a formal
3855 // that is itself satisfied by a default — those are the only names
3856 // still unbound when the default is built. Everything else (an
3857 // argument-supplied formal, the `@`-bind, any outer name) is
3858 // already in scope, so the capture is complete on the spot and the
3859 // cycle never has to be closed.
3860 //
3861 // Splitting the single pass in two is what makes that true:
3862 // pass A binds every argument-supplied formal FIRST, so pass B's
3863 // captures see all of them regardless of declaration order.
3864 //
3865 // The reorder is byte-safe: formal names are unique (a duplicate
3866 // is a parse error), `bindings` is a hash map read only by key, and
3867 // building a thunk has no side effects — so nothing observes the
3868 // order in which the two passes populate the env, only its final
3869 // contents, which are unchanged.
3870 let narrow = scope_narrow_enabled();
3871 // The formals that will be satisfied BY A DEFAULT — i.e. exactly
3872 // the names not yet bound when pass B runs.
3873 let default_names: HashSet<String> = if narrow {
3874 entries
3875 .iter()
3876 .filter(|e| e.default().is_some())
3877 .filter_map(ast::PatEntry::ident)
3878 .map(|i| ident_text(&i))
3879 .filter(|n| attrs.get(n).is_none())
3880 .collect()
3881 } else {
3882 HashSet::new()
3883 };
3884
3885 if narrow {
3886 // PASS A — argument-supplied formals only. The
3887 // `missing argument` error still fires here, in entry order,
3888 // exactly where the single pass raised it.
3889 let mut deferred: Vec<(String, ast::Expr)> =
3890 Vec::with_capacity(default_names.len());
3891 for entry in &entries {
3892 let ident = entry.ident().ok_or_else(|| {
3893 EvalError::ParseError("pat entry missing ident".to_string())
3894 })?;
3895 let name = ident_text(&ident);
3896 if let Some(v) = attrs.get(&name) {
3897 env.bind(name, v.clone());
3898 } else if let Some(default_expr) = entry.default() {
3899 deferred.push((
3900 name,
3901 ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3902 ));
3903 } else {
3904 return Err(EvalError::type_error(
3905 format!("missing argument '{name}'{}", eval_file_ctx()),
3906 ));
3907 }
3908 }
3909 // PASS B — the defaults, capturing an env that already carries
3910 // every argument-supplied formal and the `@`-bind.
3911 for (name, default_expr) in deferred {
3912 let thunk =
3913 Thunk::new_suspended(default_expr.clone(), env.clone());
3914 let referenced = referenced_idents(&default_expr);
3915 if default_names.iter().any(|n| referenced.contains(n.as_str())) {
3916 // Reaches another DEFAULTED formal, which may not be
3917 // bound yet — it needs Phase 2's re-point, and pays
3918 // the cycle.
3919 default_thunks.push(thunk.clone());
3920 crate::value::census::scope_pinned();
3921 } else {
3922 crate::value::census::scope_narrowed();
3923 }
3924 env.bind(name, Value::Thunk(thunk));
3925 }
3926 } else {
3927 for entry in &entries {
3928 let ident = entry.ident().ok_or_else(|| {
3929 EvalError::ParseError("pat entry missing ident".to_string())
3930 })?;
3931 let name = ident_text(&ident);
3932 let value = if let Some(v) = attrs.get(&name) {
3933 v.clone()
3934 } else if let Some(default_expr) = entry.default() {
3935 // Default values in pattern parameters must be lazy
3936 // (wrapped in thunks), matching CppNix semantics.
3937 // Patterns like `vendor ? assert false; null` rely on
3938 // the default never being forced when the body checks
3939 // `args ? vendor` instead of using `vendor` directly.
3940 let thunk = Thunk::new_suspended(
3941 ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3942 env.clone(),
3943 );
3944 default_thunks.push(thunk.clone());
3945 Value::Thunk(thunk)
3946 } else {
3947 return Err(EvalError::type_error(
3948 format!("missing argument '{name}'{}", eval_file_ctx()),
3949 ));
3950 };
3951 if use_batch {
3952 pairs.push((name, value));
3953 } else {
3954 env.bind(name, value);
3955 }
3956 }
3957 if use_batch {
3958 env.bind_many(pairs);
3959 }
3960 }
3961
3962 // Phase 2: Update default thunks to see ALL formals.
3963 for thunk in &default_thunks {
3964 thunk.update_env(env);
3965 }
3966
3967 if !has_ellipsis {
3968 let entry_names: std::collections::HashSet<String> = entries
3969 .iter()
3970 .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3971 .collect();
3972 for key in attrs.keys() {
3973 if !entry_names.contains(key.as_str()) {
3974 return Err(EvalError::type_error(
3975 format!("unexpected argument '{key}'{}", eval_file_ctx()),
3976 ));
3977 }
3978 }
3979 }
3980 }
3981 }
3982 Ok(())
3983}
3984
3985#[cfg(test)]
3986mod tests {
3987 use super::*;
3988
3989 fn ev(input: &str) -> Value {
3990 eval(input).unwrap()
3991 }
3992
3993 // Regression (2026-07-10): the let-scope fix-point detector must count
3994 // only GENUINE variable references, not attribute names / attrset keys
3995 // (which sit under a `NODE_ATTRPATH`). nixpkgs `lib/types.nix` has
3996 // `placeholder = if lhs.placeholder == …` whose RHS mentions the
3997 // *attribute* `.placeholder`; the old raw-token match falsely flagged
3998 // the binding self-recursive and routed it through the Promise path.
3999 #[test]
4000 fn is_self_recursive_binding_ignores_attribute_names() {
4001 fn expr(s: &str) -> ast::Expr {
4002 rnix::Root::parse(s).tree().expr().expect("parse")
4003 }
4004 // attribute names / keys are NOT references to the binding
4005 assert!(!is_self_recursive_binding(&expr("lhs.placeholder"), "placeholder"));
4006 assert!(!is_self_recursive_binding(&expr("{ placeholder = 1; }"), "placeholder"));
4007 assert!(!is_self_recursive_binding(
4008 &expr("if lhs.placeholder == rhs.placeholder then lhs.placeholder else null"),
4009 "placeholder",
4010 ));
4011 // genuine variable references ARE detected
4012 assert!(is_self_recursive_binding(&expr("placeholder + 1"), "placeholder"));
4013 assert!(is_self_recursive_binding(
4014 &expr("if placeholder then 1 else 2"),
4015 "placeholder"
4016 ));
4017 }
4018
4019 // M2 thunk-waste (byte-safe eager constant): a NON-interpolated string in a
4020 // maybe_thunk site is evaluated directly (no suspended thunk). The value +
4021 // its (empty) context must be byte-identical to forcing a thunk of it.
4022 #[test]
4023 fn maybe_thunk_eager_constant_str_is_byte_identical() {
4024 fn expr(s: &str) -> ast::Expr {
4025 rnix::Root::parse(s).tree().expr().expect("parse")
4026 }
4027 let env = Env::new();
4028 // Constant string → returned as a concrete String, NOT a Thunk.
4029 let v = maybe_thunk(&expr(r#""abc""#), &env, false, None);
4030 assert!(matches!(v, Value::String(_)), "constant str should be eager, got {v:?}");
4031 assert_eq!(force_value(&v).unwrap(), Value::string("abc"));
4032 // Interpolated string → MUST stay a thunk (lazy `${…}` force).
4033 let vi = maybe_thunk(&expr(r#""a${b}c""#), &env, false, None);
4034 assert!(matches!(vi, Value::Thunk(_)), "interpolated str must stay thunked");
4035 }
4036
4037 // The pure-constant arg classifier admits ONLY literals + non-interpolated
4038 // strings/paths, and rejects everything that could throw/diverge/observe a
4039 // fixpoint — the laziness safety boundary of the apply-arg optimization.
4040 #[test]
4041 fn eval_pure_constant_arg_classification() {
4042 fn expr(s: &str) -> ast::Expr {
4043 rnix::Root::parse(s).tree().expr().expect("parse")
4044 }
4045 // ADMIT: pure constants (byte-safe to eval eagerly in an arg position).
4046 assert!(eval_pure_constant_arg(&expr("42")).is_some());
4047 assert!(eval_pure_constant_arg(&expr("3.14")).is_some());
4048 assert!(eval_pure_constant_arg(&expr(r#""const""#)).is_some());
4049 assert!(eval_pure_constant_arg(&expr("/abs/path")).is_some());
4050 // REJECT: anything that could throw / diverge / observe laziness.
4051 assert!(eval_pure_constant_arg(&expr(r#""a${b}c""#)).is_none(), "interpolated str");
4052 // `true`/`false`/`null` are IDENTS in nix (shadowable), not literals —
4053 // rejected to avoid a with-scope force, correctly conservative.
4054 assert!(eval_pure_constant_arg(&expr("true")).is_none(), "bool is an ident");
4055 assert!(eval_pure_constant_arg(&expr("x")).is_none(), "ident (with-scope force)");
4056 assert!(eval_pure_constant_arg(&expr("a.b")).is_none(), "select (fixpoint)");
4057 assert!(eval_pure_constant_arg(&expr("f x")).is_none(), "apply (may throw)");
4058 assert!(eval_pure_constant_arg(&expr("1 + 1")).is_none(), "binop (may throw)");
4059 assert!(eval_pure_constant_arg(&expr("throw \"x\"")).is_none(), "throw stays lazy");
4060 }
4061
4062 // LAZINESS GUARD: a lambda that IGNORES its arg must NOT force it — even a
4063 // throwing arg. The pure-constant optimization only touches inert constants,
4064 // so a `throw`-ing arg stays fully thunked and the ignoring lambda succeeds.
4065 #[test]
4066 fn ignored_throwing_arg_stays_lazy() {
4067 assert_eq!(ev(r#"(x: 7) (throw "boom")"#), Value::Int(7));
4068 // And an ignored constant arg is equally invisible.
4069 assert_eq!(ev(r#"(x: 7) "const""#), Value::Int(7));
4070 // A USED constant arg produces the right value.
4071 assert_eq!(ev(r#"(x: x) "used""#), Value::string("used"));
4072 }
4073
4074 #[test]
4075 fn eval_int() { assert_eq!(ev("42"), Value::Int(42)); }
4076
4077 #[test]
4078 fn eval_float() { assert_eq!(ev("3.14"), Value::Float(3.14)); }
4079
4080 #[test]
4081 fn eval_string() { assert_eq!(ev(r#""hello""#), Value::string("hello")); }
4082
4083 #[test]
4084 fn eval_bool() { assert_eq!(ev("true"), Value::Bool(true)); }
4085
4086 #[test]
4087 fn eval_null() { assert_eq!(ev("null"), Value::Null); }
4088
4089 #[test]
4090 fn eval_arithmetic() {
4091 assert_eq!(ev("1 + 2"), Value::Int(3));
4092 assert_eq!(ev("10 - 3"), Value::Int(7));
4093 assert_eq!(ev("2 * 3"), Value::Int(6));
4094 assert_eq!(ev("10 / 3"), Value::Int(3));
4095 }
4096
4097 #[test]
4098 fn eval_precedence() {
4099 assert_eq!(ev("1 + 2 * 3"), Value::Int(7));
4100 assert_eq!(ev("(1 + 2) * 3"), Value::Int(9));
4101 }
4102
4103 #[test]
4104 fn eval_comparison() {
4105 assert_eq!(ev("1 == 1"), Value::Bool(true));
4106 assert_eq!(ev("1 == 2"), Value::Bool(false));
4107 assert_eq!(ev("1 < 2"), Value::Bool(true));
4108 assert_eq!(ev("2 <= 2"), Value::Bool(true));
4109 }
4110
4111 #[test]
4112 fn eval_logic() {
4113 assert_eq!(ev("true && false"), Value::Bool(false));
4114 assert_eq!(ev("true || false"), Value::Bool(true));
4115 assert_eq!(ev("!true"), Value::Bool(false));
4116 }
4117
4118 #[test]
4119 fn eval_string_concat() {
4120 assert_eq!(ev(r#""hello" + " " + "world""#), Value::string("hello world"));
4121 }
4122
4123 #[test]
4124 fn eval_if() {
4125 assert_eq!(ev("if true then 1 else 2"), Value::Int(1));
4126 assert_eq!(ev("if false then 1 else 2"), Value::Int(2));
4127 }
4128
4129 #[test]
4130 fn eval_let() {
4131 assert_eq!(ev("let x = 1; in x"), Value::Int(1));
4132 assert_eq!(ev("let x = 1; y = 2; in x + y"), Value::Int(3));
4133 }
4134
4135 #[test]
4136 fn eval_let_dotted_simple() {
4137 // Two dotted bindings sharing the top-level key `a`.
4138 assert_eq!(ev("let a.b = 1; a.c = 2; in a.b + a.c"), Value::Int(3));
4139 }
4140
4141 #[test]
4142 fn eval_let_dotted_deep() {
4143 // Deeply nested dotted path.
4144 assert_eq!(ev("let a.b.c = 1; in a.b.c"), Value::Int(1));
4145 }
4146
4147 #[test]
4148 fn eval_let_dotted_mixed() {
4149 // Mix of simple and dotted bindings.
4150 assert_eq!(
4151 ev("let a.x = 1; b = 2; a.y = 3; in a.x + a.y + b"),
4152 Value::Int(6),
4153 );
4154 }
4155
4156 #[test]
4157 fn eval_let_dotted_produces_attrset() {
4158 // Dotted let bindings produce a real attrset.
4159 let v = ev("let a.b = 1; a.c = 2; in a");
4160 if let Value::Attrs(attrs) = v {
4161 assert_eq!(attrs.get("b"), Some(&Value::Int(1)));
4162 assert_eq!(attrs.get("c"), Some(&Value::Int(2)));
4163 } else {
4164 panic!("expected Attrs, got {v:?}");
4165 }
4166 }
4167
4168 // ── Inner dynamic attrpath key laziness ──────────────────
4169 // CppNix defers a dynamic key that is NOT at the head of an attrpath:
4170 // `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`, so `e` never
4171 // forces until `.a` is demanded. Reading a sibling must not force the
4172 // inner dynamic key. Root fix: `build_deferred_tail_attr` in eval.rs.
4173 // This is the pure-builtins reduction of the NixOS module-system
4174 // `config.homes.${cfg.userName}` fixpoint divergence.
4175 #[test]
4176 fn dynamic_inner_attr_key_is_lazy_on_sibling_read() {
4177 // The dynamic key throws; reading the SIBLING must NOT force it.
4178 assert_eq!(
4179 ev(r#"let s = { a.${throw "KEYFORCED"} = 7; other = 9; }; in s.other"#),
4180 Value::Int(9),
4181 );
4182 }
4183
4184 #[test]
4185 fn dynamic_inner_attr_key_resolves_on_head_demand() {
4186 // Demanding the head DOES resolve the deferred dynamic key.
4187 let v = ev(r#"let u = "bob"; s = { homes.${u} = 7; }; in s.homes"#);
4188 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4189 assert_eq!(attrs.get("bob"), Some(&Value::Int(7)));
4190 } else {
4191 panic!("expected Attrs");
4192 }
4193 }
4194
4195 #[test]
4196 fn dynamic_inner_attr_key_merges_with_static_sibling() {
4197 // Collision under one head still deep-merges (static + dynamic).
4198 let v = ev(r#"let u = "x"; s = { a.${u} = 1; a.b = 2; }; in s.a"#);
4199 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4200 assert_eq!(attrs.get("x"), Some(&Value::Int(1)));
4201 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4202 } else {
4203 panic!("expected Attrs");
4204 }
4205 }
4206
4207 #[test]
4208 fn dynamic_inner_attr_key_null_skips_binding() {
4209 // A null dynamic inner key skips the definition (CppNix rule):
4210 // `a` becomes an empty attrset, the sibling stays.
4211 let v = ev(
4212 r#"let c = true; s = { a.${if c then null else "n"} = 5; b = 1; }; in s.b"#,
4213 );
4214 assert_eq!(v, Value::Int(1));
4215 }
4216
4217 // ── M2.6 ROOT #3: interpolated-STRING tail keys are dynamic too ──────
4218 // `{ a."p${e}" = v; }` must build `{ a = <thunk {"p${e}"=v}>; }` — an
4219 // interpolated-string attr key references `e` and so must defer like a
4220 // bare `${e}`, never force at construction. Reading a sibling must NOT
4221 // force it (the KEYFORCE discriminator, now for a `Str` key).
4222 #[test]
4223 fn interpolated_string_attr_key_is_lazy_on_sibling_read() {
4224 assert_eq!(
4225 ev(r#"let s = { a."p/${throw "KEYFORCED"}" = 7; other = 9; }; in s.other"#),
4226 Value::Int(9),
4227 );
4228 }
4229
4230 #[test]
4231 fn interpolated_string_attr_key_resolves_on_head_demand() {
4232 // Demanding the head DOES resolve the deferred interpolated key.
4233 let v = ev(r#"let u = "bob"; s = { homes."u/${u}" = 7; }; in s.homes"#);
4234 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4235 assert_eq!(attrs.get("u/bob"), Some(&Value::Int(7)));
4236 } else {
4237 panic!("expected Attrs");
4238 }
4239 }
4240
4241 #[test]
4242 fn purely_literal_string_attr_key_stays_eager_static() {
4243 // A `Str` key with NO interpolation is a plain static key and must
4244 // NOT be treated as dynamic (it forces nothing, deep-merges).
4245 let v = ev(r#"let s = { a."foo bar" = 1; a.b = 2; }; in s.a"#);
4246 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4247 assert_eq!(attrs.get("foo bar"), Some(&Value::Int(1)));
4248 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4249 } else {
4250 panic!("expected Attrs");
4251 }
4252 }
4253
4254 // ── M2.6 ROOT #3 (collision case): dynamic tail key under a head that
4255 // a sibling binding already wrote must stay lazy AND deep-merge.
4256 #[test]
4257 fn dynamic_tail_key_under_colliding_head_is_lazy() {
4258 // `sd.services.x` writes head `sd`; the second binding's dynamic
4259 // key must NOT force when a SIBLING (`sd.services`) is read.
4260 let v = ev(
4261 r#"let s = { sd.services.x = 1; sd.tmpfiles.${throw "KEYFORCED"}.d = 2; }; in s.sd.services.x"#,
4262 );
4263 assert_eq!(v, Value::Int(1));
4264 }
4265
4266 #[test]
4267 fn dynamic_tail_key_under_colliding_head_resolves_and_merges() {
4268 // Demanding the dynamic branch resolves the key; the sibling
4269 // static branch (`sd.services`) survives the merge intact.
4270 let v = ev(
4271 r#"let k = "z"; s = { sd.services.x = 1; sd.tmpfiles.${k}.d = 2; }; in s.sd"#,
4272 );
4273 let sd = force_value(&v).unwrap();
4274 if let Value::Attrs(sd_attrs) = &sd {
4275 // static sibling intact
4276 let services = force_value(sd_attrs.get("services").unwrap()).unwrap();
4277 if let Value::Attrs(a) = &services {
4278 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4279 } else { panic!("expected services attrs"); }
4280 // dynamic branch resolved to key "z"
4281 let tmpfiles = force_value(sd_attrs.get("tmpfiles").unwrap()).unwrap();
4282 if let Value::Attrs(a) = &tmpfiles {
4283 let z = force_value(a.get("z").unwrap()).unwrap();
4284 if let Value::Attrs(zd) = &z {
4285 assert_eq!(force_value(zd.get("d").unwrap()).unwrap(), Value::Int(2));
4286 } else { panic!("expected z attrs"); }
4287 } else { panic!("expected tmpfiles attrs"); }
4288 } else {
4289 panic!("expected sd attrs");
4290 }
4291 }
4292
4293 // ── M2.6 ROOT #4a — `with` namespace must be LAZY ─────────────────
4294 // `with X; body` stores the namespace as a thunk forced only on a
4295 // bare-ident fallthrough lookup; demanding only the body's WHNF/keys
4296 // must NOT force X. cppnix: `attrNames (with (throw "X"); {a=1;})`
4297 // → ["a"]. Before the fix, sui EVALUATED the namespace at `with`-entry
4298 // and threw. This is the load-bearing over-force behind the M2.6
4299 // `concatLists null` (nixpkgs' `config = mkIf … (with config.services.X;
4300 // { … })` module shape forced `config.services.X` during collection).
4301 #[test]
4302 fn with_namespace_is_lazy_on_body_whnf() {
4303 let v = ev(r#"builtins.attrNames (with (throw "WITH-FORCED"); { a = 1; b = 2; })"#);
4304 if let Value::List(items) = force_value(&v).unwrap() {
4305 let names: Vec<String> = items
4306 .iter()
4307 .map(|i| match force_value(i).unwrap() {
4308 Value::String(s) => s.as_str().to_string(),
4309 other => panic!("expected string, got {}", other.type_name()),
4310 })
4311 .collect();
4312 assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
4313 } else {
4314 panic!("expected list");
4315 }
4316 }
4317
4318 #[test]
4319 fn with_namespace_forces_only_on_fallthrough() {
4320 // A bare ident that falls through lexical scope DOES resolve via
4321 // the namespace (correct cppnix semantics) — proves the deferred
4322 // thunk is real and gets forced on demand, not an accidental no-op.
4323 assert_eq!(ev(r#"with { x = 42; }; x"#), Value::Int(42));
4324 // A lexical binding shadows the with-scope, so the (throwing)
4325 // namespace is never forced — the laziness we rely on for M2.6.
4326 assert_eq!(ev(r#"let x = 7; in with (throw "NS"); x"#), Value::Int(7));
4327 }
4328
4329 // ── M2.6 ROOT #4b — depth-≥2 dotted full-set leaf must deep-merge ──
4330 // `o.a = { x = 1; }` inserts `o = { a = <thunk {x=1}> }` (leaf goes
4331 // through maybe_thunk); a deeper sibling `o.a.y = 2` recurses
4332 // merge_nested_insert down to key `a` where the existing value is that
4333 // thunk. Before the fix, merge_nested_insert required BOTH sides to be
4334 // concrete Attrs, so the Thunk-vs-Attrs collision OVERWROTE — dropping
4335 // `x`. cppnix desugars both orderings into `o.a = { x = 1; y = 2; }`.
4336 // This is the M2.6 post-`with`-fix frontier (nixpkgs alsa's
4337 // `options.hardware.alsa = { … }` + `options.hardware.alsa.enablePersistence
4338 // = …` merged to only {enablePersistence} → `cardAliases` "does not exist").
4339 #[test]
4340 fn dotted_fullset_leaf_deep_merges_with_deeper_sibling() {
4341 let v = ev(r#"{ o.a = { x = 1; }; o.a.y = 2; }.o.a"#);
4342 if let Value::Attrs(a) = force_value(&v).unwrap() {
4343 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4344 assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4345 } else {
4346 panic!("expected attrs");
4347 }
4348 }
4349
4350 #[test]
4351 fn dotted_fullset_leaf_deep_merge_reverse_order() {
4352 // Deeper sibling FIRST, full-set leaf SECOND — the NEW value is the
4353 // `<thunk {x=1}>`; must still merge (the collision forces it).
4354 let v = ev(r#"{ o.a.y = 2; o.a = { x = 1; }; }.o.a"#);
4355 if let Value::Attrs(a) = force_value(&v).unwrap() {
4356 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4357 assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4358 } else {
4359 panic!("expected attrs");
4360 }
4361 }
4362
4363 #[test]
4364 fn dotted_fullset_leaf_merge_preserves_leaf_laziness() {
4365 // The merge forces the existing/new leaf to WHNF (keys) but MUST
4366 // NOT force the leaf VALUES — a throwing sibling value that is never
4367 // demanded stays lazy.
4368 assert_eq!(ev(r#"{ o.a = { x = throw "X-NEVER"; }; o.a.y = 2; }.o.a.y"#), Value::Int(2));
4369 }
4370
4371 #[test]
4372 fn eval_nested_let() {
4373 assert_eq!(ev("let a = 1; b = let c = 2; in c; in a + b"), Value::Int(3));
4374 }
4375
4376 #[test]
4377 fn eval_lambda() {
4378 assert_eq!(ev("(x: x + 1) 41"), Value::Int(42));
4379 }
4380
4381 #[test]
4382 fn eval_lambda_multi_arg() {
4383 assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4384 }
4385
4386 #[test]
4387 fn eval_list() {
4388 let v = ev("[1 2 3]");
4389 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]));
4390 }
4391
4392 #[test]
4393 fn eval_list_concat() {
4394 let v = ev("[1 2] ++ [3 4]");
4395 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]));
4396 }
4397
4398 #[test]
4399 fn eval_attrset() {
4400 let v = ev("{ a = 1; b = 2; }");
4401 if let Value::Attrs(attrs) = v {
4402 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4403 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4404 } else {
4405 panic!("expected attrset");
4406 }
4407 }
4408
4409 #[test]
4410 fn eval_select() {
4411 assert_eq!(ev("{ a = 42; }.a"), Value::Int(42));
4412 }
4413
4414 #[test]
4415 fn eval_select_or() {
4416 assert_eq!(ev("{ a = 42; }.b or 0"), Value::Int(0));
4417 }
4418
4419 #[test]
4420 fn eval_has_attr() {
4421 assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
4422 assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
4423 }
4424
4425 #[test]
4426 fn eval_update() {
4427 let v = ev("{ a = 1; b = 2; } // { b = 3; c = 4; }");
4428 if let Value::Attrs(attrs) = v {
4429 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4430 assert_eq!(attrs.get("b"), Some(&Value::Int(3)));
4431 assert_eq!(attrs.get("c"), Some(&Value::Int(4)));
4432 } else {
4433 panic!("expected attrset");
4434 }
4435 }
4436
4437 #[test]
4438 fn eval_with() {
4439 assert_eq!(ev("with { x = 42; }; x"), Value::Int(42));
4440 }
4441
4442 #[test]
4443 fn eval_assert() {
4444 assert_eq!(ev("assert true; 42"), Value::Int(42));
4445 assert!(eval("assert false; 42").is_err());
4446 }
4447
4448 #[test]
4449 fn eval_formals() {
4450 assert_eq!(ev("({ a, b }: a + b) { a = 1; b = 2; }"), Value::Int(3));
4451 }
4452
4453 #[test]
4454 fn eval_formals_default() {
4455 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 1; }"), Value::Int(11));
4456 }
4457
4458 #[test]
4459 fn eval_formals_ellipsis() {
4460 assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; }"), Value::Int(1));
4461 }
4462
4463 #[test]
4464 fn eval_named_formals() {
4465 assert_eq!(ev("(args @ { a }: args.a) { a = 42; }"), Value::Int(42));
4466 }
4467
4468 #[test]
4469 fn eval_rec_attrset() {
4470 assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
4471 }
4472
4473 #[test]
4474 fn eval_negation() {
4475 assert_eq!(ev("-42"), Value::Int(-42));
4476 }
4477
4478 #[test]
4479 fn eval_float_arithmetic() {
4480 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4481 assert_eq!(ev("1 + 1.5"), Value::Float(2.5));
4482 }
4483
4484 #[test]
4485 fn eval_division_by_zero() {
4486 assert!(eval("1 / 0").is_err());
4487 }
4488
4489 #[test]
4490 fn eval_builtins_available() {
4491 assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
4492 assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
4493 }
4494
4495 #[test]
4496 fn eval_builtins_length() {
4497 assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4498 }
4499
4500 #[test]
4501 fn eval_builtins_head_tail() {
4502 assert_eq!(ev("builtins.head [1 2 3]"), Value::Int(1));
4503 assert_eq!(ev("builtins.length (builtins.tail [1 2 3])"), Value::Int(2));
4504 }
4505
4506 #[test]
4507 fn eval_builtins_add() {
4508 assert_eq!(ev("builtins.add 1 2"), Value::Int(3));
4509 }
4510
4511 #[test]
4512 fn eval_builtins_to_string() {
4513 assert_eq!(ev("builtins.toString 42"), Value::string("42"));
4514 }
4515
4516 #[test]
4517 fn eval_implication() {
4518 assert_eq!(ev("false -> true"), Value::Bool(true));
4519 assert_eq!(ev("true -> false"), Value::Bool(false));
4520 assert_eq!(ev("true -> true"), Value::Bool(true));
4521 }
4522
4523 // ── New tests ────────────────────────────────────────
4524
4525 #[test]
4526 fn eval_error_undefined_variable() {
4527 let result = eval("nonexistent");
4528 assert!(result.is_err());
4529 let msg = format!("{}", result.unwrap_err());
4530 assert!(msg.contains("undefined variable"));
4531 }
4532
4533 #[test]
4534 fn eval_error_type_mismatch_arithmetic() {
4535 let result = eval(r#"1 + "hello""#);
4536 assert!(result.is_err());
4537 let msg = format!("{}", result.unwrap_err());
4538 assert!(msg.contains("cannot add") || msg.contains("type"));
4539 }
4540
4541 #[test]
4542 fn eval_error_unexpected_argument() {
4543 let result = eval("({ a }: a) { a = 1; b = 2; }");
4544 assert!(result.is_err());
4545 let msg = format!("{}", result.unwrap_err());
4546 assert!(msg.contains("unexpected argument"));
4547 }
4548
4549 #[test]
4550 fn eval_error_missing_required_argument() {
4551 let result = eval("({ a, b }: a + b) { a = 1; }");
4552 assert!(result.is_err());
4553 let msg = format!("{}", result.unwrap_err());
4554 assert!(msg.contains("missing argument"));
4555 }
4556
4557 #[test]
4558 fn eval_builtins_attr_names_sorted() {
4559 let v = ev("builtins.attrNames { z = 1; a = 2; m = 3; }");
4560 // BTreeMap keys are already sorted
4561 assert_eq!(
4562 v,
4563 Value::list(vec![
4564 Value::string("a"),
4565 Value::string("m"),
4566 Value::string("z"),
4567 ]),
4568 );
4569 }
4570
4571 #[test]
4572 fn eval_builtins_attr_values() {
4573 let v = ev("builtins.attrValues { a = 1; b = 2; }");
4574 // BTreeMap iteration is sorted by key, so a=1 first, b=2 second
4575 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
4576 }
4577
4578 #[test]
4579 fn eval_builtins_is_null() {
4580 assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
4581 assert_eq!(ev("builtins.isNull 1"), Value::Bool(false));
4582 }
4583
4584 #[test]
4585 fn eval_builtins_is_int() {
4586 assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
4587 assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
4588 }
4589
4590 #[test]
4591 fn eval_builtins_is_bool() {
4592 assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
4593 assert_eq!(ev("builtins.isBool 0"), Value::Bool(false));
4594 }
4595
4596 #[test]
4597 fn eval_builtins_is_string() {
4598 assert_eq!(ev(r#"builtins.isString "hi""#), Value::Bool(true));
4599 assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
4600 }
4601
4602 #[test]
4603 fn eval_builtins_is_list() {
4604 assert_eq!(ev("builtins.isList [1 2]"), Value::Bool(true));
4605 assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
4606 }
4607
4608 #[test]
4609 fn eval_builtins_is_attrs() {
4610 assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
4611 assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
4612 }
4613
4614 #[test]
4615 fn eval_builtins_string_length() {
4616 assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
4617 assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
4618 }
4619
4620 #[test]
4621 fn eval_builtins_to_json_roundtrip() {
4622 // toJSON produces a JSON string; fromJSON parses it back
4623 assert_eq!(
4624 ev(r#"builtins.fromJSON (builtins.toJSON 42)"#),
4625 Value::Int(42),
4626 );
4627 assert_eq!(
4628 ev(r#"builtins.fromJSON (builtins.toJSON [1 2 3])"#),
4629 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4630 );
4631 }
4632
4633 #[test]
4634 fn eval_builtins_from_json() {
4635 assert_eq!(
4636 ev(r#"builtins.fromJSON "{\"a\": 1}""#),
4637 {
4638 let mut attrs = NixAttrs::new();
4639 attrs.insert("a".to_string(), Value::Int(1));
4640 Value::Attrs(Rc::new(attrs))
4641 },
4642 );
4643 assert_eq!(ev(r#"builtins.fromJSON "null""#), Value::Null);
4644 assert_eq!(ev(r#"builtins.fromJSON "true""#), Value::Bool(true));
4645 }
4646
4647 #[test]
4648 fn eval_nested_function_application() {
4649 // (f 1) 2 where f = x: y: x + y
4650 assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4651 // equivalent parenthesized form
4652 assert_eq!(ev("((x: y: x + y) 1) 2"), Value::Int(3));
4653 }
4654
4655 #[test]
4656 fn eval_recursive_let() {
4657 assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4658 assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4659 }
4660
4661 #[test]
4662 fn eval_string_comparison() {
4663 assert_eq!(ev(r#""a" < "b""#), Value::Bool(true));
4664 assert_eq!(ev(r#""b" < "a""#), Value::Bool(false));
4665 assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4666 assert_eq!(ev(r#""abc" != "def""#), Value::Bool(true));
4667 }
4668
4669 #[test]
4670 fn eval_list_in_attrset() {
4671 let v = ev("{ x = [1 2 3]; }.x");
4672 assert_eq!(
4673 v,
4674 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4675 );
4676 }
4677
4678 #[test]
4679 fn eval_nested_attrset_select() {
4680 assert_eq!(ev("{ a = { b = 42; }; }.a.b"), Value::Int(42));
4681 }
4682
4683 #[test]
4684 fn eval_let_shadows_outer() {
4685 assert_eq!(
4686 ev("let x = 1; in let x = 2; in x"),
4687 Value::Int(2),
4688 );
4689 }
4690
4691 #[test]
4692 fn eval_with_provides_scope() {
4693 // `with` scope is available for name resolution
4694 assert_eq!(
4695 ev("with { x = 42; y = 10; }; x + y"),
4696 Value::Int(52),
4697 );
4698 }
4699
4700 #[test]
4701 fn eval_list_equality() {
4702 assert_eq!(ev("[1 2] == [1 2]"), Value::Bool(true));
4703 assert_eq!(ev("[1 2] == [1 3]"), Value::Bool(false));
4704 }
4705
4706 #[test]
4707 fn eval_attrset_equality() {
4708 assert_eq!(ev("{ a = 1; } == { a = 1; }"), Value::Bool(true));
4709 assert_eq!(ev("{ a = 1; } == { a = 2; }"), Value::Bool(false));
4710 }
4711
4712 // ═══════════════════════════════════════════════════════════
4713 // 1. LITERAL TYPES
4714 // ═══════════════════════════════════════════════════════════
4715
4716 #[test]
4717 fn literal_int_large_zero_negative() {
4718 // Large positive integer (within i64 range)
4719 assert_eq!(ev("9223372036854775807"), Value::Int(i64::MAX));
4720 // Zero
4721 assert_eq!(ev("0"), Value::Int(0));
4722 // Negative via unary negate
4723 assert_eq!(ev("-1"), Value::Int(-1));
4724 assert_eq!(ev("-999999"), Value::Int(-999999));
4725 }
4726
4727 #[test]
4728 fn literal_float_small_large() {
4729 assert_eq!(ev("0.001"), Value::Float(0.001));
4730 assert_eq!(ev("999999.999"), Value::Float(999999.999));
4731 // Float with scientific notation via expression (1e6 parsed by rnix)
4732 assert_eq!(ev("1.0e3"), Value::Float(1000.0));
4733 assert_eq!(ev("1.5e2"), Value::Float(150.0));
4734 }
4735
4736 #[test]
4737 fn literal_string_empty_and_escapes() {
4738 assert_eq!(ev(r#""""#), Value::string(""));
4739 // Escape sequences within strings
4740 assert_eq!(ev(r#""hello\nworld""#), Value::string("hello\nworld"));
4741 assert_eq!(ev(r#""tab\there""#), Value::string("tab\there"));
4742 }
4743
4744 #[test]
4745 fn literal_multiline_string() {
4746 // Indented string ('' ... '')
4747 assert_eq!(
4748 ev("''hello''"),
4749 Value::string("hello"),
4750 );
4751 // Multiline indented string strips common indentation
4752 assert_eq!(
4753 ev("''\n line1\n line2\n''"),
4754 Value::string("line1\nline2\n"),
4755 );
4756 }
4757
4758 #[test]
4759 fn literal_paths() {
4760 // Relative path
4761 assert_eq!(ev("./foo"), Value::Path(Box::new(SmolStr::from("./foo"))));
4762 // Absolute path
4763 assert_eq!(ev("/nix/store/abc"), Value::Path(Box::new(SmolStr::from("/nix/store/abc"))));
4764 // Home path
4765 assert_eq!(ev("~/myfile"), Value::Path(Box::new(SmolStr::from("~/myfile"))));
4766 }
4767
4768 // ── Interpolated path literals (cid-marquee root, 2026-07-12) ──
4769 //
4770 // CppNix path literals may contain `${e}` antiquotations: `./${x}.nix`,
4771 // `/a/${e}`, `~/${e}`. sui previously flattened the whole path token to
4772 // raw text and dropped the interpolation (`import ./${x}.nix` →
4773 // `No such file or directory`). The `${e}` must be evaluated,
4774 // string-coerced (plain, no copy-to-store), spliced, and the result is
4775 // still a `path` value. Oracles taken from cppnix.
4776
4777 #[test]
4778 fn interp_path_abs_splices_and_types_path() {
4779 // /a/${x}/b with x="foo" → /a/foo/b, type path (nix oracle).
4780 let v = ev(r#"let x = "foo"; in /a/${x}/b"#);
4781 assert_eq!(v, Value::Path(Box::new(SmolStr::from("/a/foo/b"))));
4782 }
4783
4784 #[test]
4785 fn interp_path_abs_multi_and_slash_in_value() {
4786 // Multiple interpolations + a slash inside the spliced value.
4787 assert_eq!(
4788 ev(r#"let a = "x"; b = "y/z"; in /p/${a}/${b}.nix"#),
4789 Value::Path(Box::new(SmolStr::from("/p/x/y/z.nix"))),
4790 );
4791 }
4792
4793 #[test]
4794 fn interp_path_abs_normalizes_double_slash_seam() {
4795 // A path-typed interpolation splices the raw path (no copy-to-store)
4796 // and the `/` seam is normalized: `/bar/` + `/tmp/foo` → /bar/tmp/foo.
4797 assert_eq!(
4798 ev(r#"/bar/${/tmp/foo}"#),
4799 Value::Path(Box::new(SmolStr::from("/bar/tmp/foo"))),
4800 );
4801 }
4802
4803 #[test]
4804 fn interp_path_rel_resolves_against_eval_dir() {
4805 // The spicetify `map (x: ./${x}.nix) [...]` root: a relative
4806 // interpolated path resolves against the defining file's directory,
4807 // exactly like a plain `./foo.nix` literal.
4808 let _g = push_eval_file(std::path::PathBuf::from("/tmp/example/default.nix"));
4809 assert_eq!(
4810 ev(r#"let x = "foo"; in ./${x}.nix"#),
4811 Value::Path(Box::new(SmolStr::from("/tmp/example/foo.nix"))),
4812 );
4813 }
4814
4815 #[test]
4816 fn interp_path_rel_no_eval_dir_keeps_relative_text() {
4817 // With no eval-file context the plain branch keeps the raw relative
4818 // text; the interpolated branch splices then does the same.
4819 assert_eq!(
4820 ev(r#"let x = "foo"; in ./${x}.nix"#),
4821 Value::Path(Box::new(SmolStr::from("./foo.nix"))),
4822 );
4823 }
4824
4825 #[test]
4826 fn interp_path_home_splices_leading_tilde_preserved() {
4827 // Home paths splice their `${e}`; the leading `~` is carried as-is
4828 // (matching sui's plain `~/foo` behavior — `~`-expansion is a
4829 // separate, pre-existing concern, not introduced here).
4830 assert_eq!(
4831 ev(r#"let x = "foo"; in ~/${x}/bar"#),
4832 Value::Path(Box::new(SmolStr::from("~/foo/bar"))),
4833 );
4834 }
4835
4836 #[test]
4837 fn interp_path_non_interpolated_still_raw() {
4838 // A path with no `${…}` must keep the trivial raw-text shortcut
4839 // (byte-for-byte identical to the plain branch).
4840 assert_eq!(ev("/a/b/c"), Value::Path(Box::new(SmolStr::from("/a/b/c"))));
4841 assert_eq!(ev("~/plain"), Value::Path(Box::new(SmolStr::from("~/plain"))));
4842 }
4843
4844 #[test]
4845 fn literal_null_true_false_standalone() {
4846 assert_eq!(ev("null"), Value::Null);
4847 assert_eq!(ev("true"), Value::Bool(true));
4848 assert_eq!(ev("false"), Value::Bool(false));
4849 }
4850
4851 // ═══════════════════════════════════════════════════════════
4852 // 2. OPERATORS — COMPLETE COVERAGE
4853 // ═══════════════════════════════════════════════════════════
4854
4855 #[test]
4856 fn op_arithmetic_int() {
4857 assert_eq!(ev("100 + 200"), Value::Int(300));
4858 assert_eq!(ev("50 - 30"), Value::Int(20));
4859 assert_eq!(ev("7 * 8"), Value::Int(56));
4860 assert_eq!(ev("17 / 3"), Value::Int(5)); // integer division
4861 }
4862
4863 #[test]
4864 fn op_arithmetic_float() {
4865 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4866 assert_eq!(ev("5.0 - 1.5"), Value::Float(3.5));
4867 assert_eq!(ev("2.0 * 3.0"), Value::Float(6.0));
4868 assert_eq!(ev("7.0 / 2.0"), Value::Float(3.5));
4869 }
4870
4871 #[test]
4872 fn op_arithmetic_mixed_int_float() {
4873 // int + float => float
4874 assert_eq!(ev("1 + 2.5"), Value::Float(3.5));
4875 assert_eq!(ev("2.5 + 1"), Value::Float(3.5));
4876 // int * float => float
4877 assert_eq!(ev("2 * 1.5"), Value::Float(3.0));
4878 // float - int => float
4879 assert_eq!(ev("5.5 - 2"), Value::Float(3.5));
4880 }
4881
4882 #[test]
4883 fn op_string_concat() {
4884 assert_eq!(ev(r#""foo" + "bar""#), Value::string("foobar"));
4885 assert_eq!(ev(r#""" + "x""#), Value::string("x"));
4886 assert_eq!(ev(r#""a" + "" + "b""#), Value::string("ab"));
4887 }
4888
4889 #[test]
4890 fn op_path_concat() {
4891 // path + string
4892 assert_eq!(ev(r#"./foo + "/bar""#), Value::Path(Box::new(SmolStr::from("./foo/bar"))));
4893 // path + path (should join with /)
4894 assert_eq!(ev("./a + ./b"), Value::Path(Box::new(SmolStr::from("./a/./b"))));
4895 }
4896
4897 #[test]
4898 fn op_comparison_ints() {
4899 assert_eq!(ev("1 < 2"), Value::Bool(true));
4900 assert_eq!(ev("2 < 1"), Value::Bool(false));
4901 assert_eq!(ev("2 > 1"), Value::Bool(true));
4902 assert_eq!(ev("1 > 2"), Value::Bool(false));
4903 assert_eq!(ev("2 <= 2"), Value::Bool(true));
4904 assert_eq!(ev("3 <= 2"), Value::Bool(false));
4905 assert_eq!(ev("2 >= 2"), Value::Bool(true));
4906 assert_eq!(ev("1 >= 2"), Value::Bool(false));
4907 }
4908
4909 #[test]
4910 fn op_comparison_floats() {
4911 assert_eq!(ev("1.5 < 2.5"), Value::Bool(true));
4912 assert_eq!(ev("2.5 > 1.5"), Value::Bool(true));
4913 assert_eq!(ev("1.5 <= 1.5"), Value::Bool(true));
4914 assert_eq!(ev("1.5 >= 1.5"), Value::Bool(true));
4915 }
4916
4917 #[test]
4918 fn op_comparison_strings() {
4919 assert_eq!(ev(r#""apple" < "banana""#), Value::Bool(true));
4920 assert_eq!(ev(r#""banana" > "apple""#), Value::Bool(true));
4921 assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4922 assert_eq!(ev(r#""abc" != "xyz""#), Value::Bool(true));
4923 assert_eq!(ev(r#""abc" <= "abd""#), Value::Bool(true));
4924 assert_eq!(ev(r#""abc" >= "abb""#), Value::Bool(true));
4925 }
4926
4927 #[test]
4928 fn op_equality_various_types() {
4929 assert_eq!(ev("null == null"), Value::Bool(true));
4930 assert_eq!(ev("true == true"), Value::Bool(true));
4931 assert_eq!(ev("false == false"), Value::Bool(true));
4932 assert_eq!(ev("true == false"), Value::Bool(false));
4933 assert_eq!(ev("1 == 1"), Value::Bool(true));
4934 assert_eq!(ev("1 != 2"), Value::Bool(true));
4935 // Different types are not equal
4936 assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
4937 assert_eq!(ev("null == false"), Value::Bool(false));
4938 }
4939
4940 #[test]
4941 fn op_logic_short_circuit() {
4942 // false && <error> should NOT evaluate the RHS
4943 assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
4944 // true || <error> should NOT evaluate the RHS
4945 assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
4946 }
4947
4948 #[test]
4949 fn op_logic_full() {
4950 assert_eq!(ev("true && true"), Value::Bool(true));
4951 assert_eq!(ev("true && false"), Value::Bool(false));
4952 assert_eq!(ev("false && true"), Value::Bool(false));
4953 assert_eq!(ev("false && false"), Value::Bool(false));
4954 assert_eq!(ev("true || true"), Value::Bool(true));
4955 assert_eq!(ev("true || false"), Value::Bool(true));
4956 assert_eq!(ev("false || true"), Value::Bool(true));
4957 assert_eq!(ev("false || false"), Value::Bool(false));
4958 assert_eq!(ev("!true"), Value::Bool(false));
4959 assert_eq!(ev("!false"), Value::Bool(true));
4960 }
4961
4962 #[test]
4963 fn op_implication_truth_table() {
4964 // false -> anything = true
4965 assert_eq!(ev("false -> false"), Value::Bool(true));
4966 assert_eq!(ev("false -> true"), Value::Bool(true));
4967 // true -> x = x
4968 assert_eq!(ev("true -> true"), Value::Bool(true));
4969 assert_eq!(ev("true -> false"), Value::Bool(false));
4970 }
4971
4972 #[test]
4973 fn op_implication_short_circuit() {
4974 // false -> <error> should NOT evaluate the RHS
4975 assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
4976 }
4977
4978 #[test]
4979 fn op_update_merge() {
4980 let v = ev("{ a = 1; } // { b = 2; }");
4981 if let Value::Attrs(attrs) = v {
4982 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4983 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4984 } else {
4985 panic!("expected attrs");
4986 }
4987 }
4988
4989 #[test]
4990 fn op_update_right_wins() {
4991 assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4992 }
4993
4994 #[test]
4995 fn op_list_concat() {
4996 assert_eq!(
4997 ev("[1 2] ++ [3 4]"),
4998 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]),
4999 );
5000 // Empty list concat
5001 assert_eq!(ev("[] ++ [1]"), Value::list(vec![Value::Int(1)]));
5002 assert_eq!(ev("[1] ++ []"), Value::list(vec![Value::Int(1)]));
5003 }
5004
5005 #[test]
5006 fn op_has_attr_present_and_absent() {
5007 assert_eq!(ev("{ x = 1; y = 2; } ? x"), Value::Bool(true));
5008 assert_eq!(ev("{ x = 1; } ? z"), Value::Bool(false));
5009 assert_eq!(ev("{} ? anything"), Value::Bool(false));
5010 }
5011
5012 #[test]
5013 fn op_unary_negate() {
5014 assert_eq!(ev("-42"), Value::Int(-42));
5015 assert_eq!(ev("-3.14"), Value::Float(-3.14));
5016 // Double negate
5017 assert_eq!(ev("- -5"), Value::Int(5));
5018 }
5019
5020 // ═══════════════════════════════════════════════════════════
5021 // 3. CONTROL FLOW
5022 // ═══════════════════════════════════════════════════════════
5023
5024 #[test]
5025 fn control_if_true_branch() {
5026 assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
5027 }
5028
5029 #[test]
5030 fn control_if_false_branch() {
5031 assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
5032 }
5033
5034 #[test]
5035 fn control_if_nested() {
5036 assert_eq!(
5037 ev("if true then (if false then 1 else 2) else 3"),
5038 Value::Int(2),
5039 );
5040 assert_eq!(
5041 ev("if false then 1 else (if true then 2 else 3)"),
5042 Value::Int(2),
5043 );
5044 }
5045
5046 #[test]
5047 fn control_assert_passing() {
5048 assert_eq!(ev("assert 1 == 1; 42"), Value::Int(42));
5049 assert_eq!(ev("assert true; true"), Value::Bool(true));
5050 }
5051
5052 #[test]
5053 fn control_assert_failing() {
5054 assert!(eval("assert false; 42").is_err());
5055 assert!(eval("assert 1 == 2; 42").is_err());
5056 }
5057
5058 #[test]
5059 fn control_with_basic_scope() {
5060 assert_eq!(ev("with { a = 1; b = 2; }; a + b"), Value::Int(3));
5061 }
5062
5063 #[test]
5064 fn control_with_lexical_precedence() {
5065 // let binding takes precedence over with scope
5066 assert_eq!(
5067 ev("let x = 10; in with { x = 99; }; x"),
5068 Value::Int(10),
5069 );
5070 }
5071
5072 #[test]
5073 fn control_with_nested() {
5074 assert_eq!(
5075 ev("with { a = 1; }; with { b = 2; }; a + b"),
5076 Value::Int(3),
5077 );
5078 }
5079
5080 #[test]
5081 fn control_with_lazy_fix_self() {
5082 // THE critical pattern that nixpkgs requires:
5083 // fix (self: with self; { a = 1; b = a + 1; })
5084 // Before the lazy-with fix, this would hit the blackhole detector
5085 // because `with` eagerly forced `self`.
5086 let result = eval(
5087 "let fix = f: let x = f x; in x; in fix (self: with self; { a = 1; b = a + 1; })"
5088 );
5089 assert!(result.is_ok(), "fix with self should work: {:?}", result);
5090 if let Ok(Value::Attrs(attrs)) = result {
5091 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5092 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5093 } else {
5094 panic!("expected Attrs, got {:?}", result);
5095 }
5096 }
5097
5098 #[test]
5099 fn control_with_lazy_fix_self_lib_pattern() {
5100 // The nixpkgs pattern: self-referential package set with lib.
5101 // Access via select to force through the thunk layer.
5102 let result = eval(r#"
5103 let fix = f: let x = f x; in x;
5104 in (fix (self: with self; {
5105 lib = { version = "1.0"; };
5106 hello = "hello ${lib.version}";
5107 })).hello
5108 "#);
5109 assert!(result.is_ok(), "nixpkgs-style lib pattern: {:?}", result);
5110 assert_eq!(
5111 result.unwrap(),
5112 Value::String(Rc::new(NixString::plain("hello 1.0"))),
5113 );
5114 }
5115
5116 #[test]
5117 fn control_with_non_attrset_errors() {
5118 // CppNix errors when with-scope is not an attrset and a lookup hits it
5119 let result = eval("with 42; 1");
5120 // The body `1` is a literal and doesn't look up anything in the
5121 // with-scope, so this should succeed (the scope is never forced).
5122 assert_eq!(result.unwrap(), Value::Int(1));
5123 }
5124
5125 #[test]
5126 fn control_with_non_attrset_lookup_falls_through() {
5127 // If the with scope is not an attrset, lookups should fall through
5128 // to outer scopes rather than crashing.
5129 let result = eval("let x = 1; in with 42; x");
5130 assert_eq!(result.unwrap(), Value::Int(1));
5131 }
5132
5133 #[test]
5134 fn control_let_simple_and_multiple() {
5135 assert_eq!(ev("let x = 5; in x"), Value::Int(5));
5136 assert_eq!(ev("let x = 1; y = 2; z = 3; in x + y + z"), Value::Int(6));
5137 }
5138
5139 #[test]
5140 fn control_let_shadow_outer() {
5141 assert_eq!(
5142 ev("let x = 1; in let x = 2; in x"),
5143 Value::Int(2),
5144 );
5145 }
5146
5147 #[test]
5148 fn control_let_recursive_reference() {
5149 assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
5150 assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
5151 }
5152
5153 #[test]
5154 fn control_nested_let_expression() {
5155 assert_eq!(
5156 ev("let a = let b = 1; in b; in a"),
5157 Value::Int(1),
5158 );
5159 assert_eq!(
5160 ev("let a = let b = 10; in b + 5; in a * 2"),
5161 Value::Int(30),
5162 );
5163 }
5164
5165 // ═══════════════════════════════════════════════════════════
5166 // 4. FUNCTIONS — COMPLETE COVERAGE
5167 // ═══════════════════════════════════════════════════════════
5168
5169 #[test]
5170 fn func_identity_lambda() {
5171 assert_eq!(ev("(x: x) 42"), Value::Int(42));
5172 assert_eq!(ev(r#"(x: x) "hello""#), Value::string("hello"));
5173 }
5174
5175 #[test]
5176 fn func_curried_two_args() {
5177 assert_eq!(ev("(x: y: x + y) 3 4"), Value::Int(7));
5178 }
5179
5180 #[test]
5181 fn func_curried_three_args() {
5182 assert_eq!(ev("(a: b: c: a + b + c) 1 2 3"), Value::Int(6));
5183 }
5184
5185 #[test]
5186 fn func_formals_basic() {
5187 assert_eq!(ev("({ a, b }: a + b) { a = 3; b = 7; }"), Value::Int(10));
5188 }
5189
5190 #[test]
5191 fn func_formals_with_defaults() {
5192 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; }"), Value::Int(15));
5193 // Providing the default-able argument overrides the default
5194 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; b = 20; }"), Value::Int(25));
5195 }
5196
5197 #[test]
5198 fn func_formals_with_ellipsis() {
5199 assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; c = 3; }"), Value::Int(1));
5200 }
5201
5202 #[test]
5203 fn func_named_formals_at_before() {
5204 // args @ { a, b }: ...
5205 assert_eq!(
5206 ev("(args @ { a, b }: args.a + args.b) { a = 3; b = 4; }"),
5207 Value::Int(7),
5208 );
5209 }
5210
5211 #[test]
5212 fn func_named_formals_at_after() {
5213 // { a, b } @ args: ...
5214 assert_eq!(
5215 ev("({ a, b } @ args: args.a + args.b) { a = 10; b = 20; }"),
5216 Value::Int(30),
5217 );
5218 }
5219
5220 #[test]
5221 fn func_nested_application() {
5222 // Explicit parenthesized application
5223 assert_eq!(ev("((x: y: x * y) 3) 4"), Value::Int(12));
5224 }
5225
5226 #[test]
5227 fn func_higher_order_map() {
5228 assert_eq!(
5229 ev("builtins.map (x: x * 2) [1 2 3]"),
5230 Value::list(vec![Value::Int(2), Value::Int(4), Value::Int(6)]),
5231 );
5232 }
5233
5234 #[test]
5235 fn func_higher_order_filter() {
5236 assert_eq!(
5237 ev("builtins.filter (x: x > 2) [1 2 3 4 5]"),
5238 Value::list(vec![Value::Int(3), Value::Int(4), Value::Int(5)]),
5239 );
5240 }
5241
5242 #[test]
5243 fn func_higher_order_foldl() {
5244 // Sum of list via foldl'
5245 assert_eq!(
5246 ev("builtins.foldl' (acc: x: acc + x) 0 [1 2 3 4]"),
5247 Value::Int(10),
5248 );
5249 }
5250
5251 #[test]
5252 fn func_as_attrset_value() {
5253 assert_eq!(
5254 ev("let s = { f = x: x + 1; }; in s.f 5"),
5255 Value::Int(6),
5256 );
5257 }
5258
5259 #[test]
5260 fn func_immediate_application() {
5261 assert_eq!(ev("(x: x * x) 7"), Value::Int(49));
5262 }
5263
5264 #[test]
5265 fn func_in_let_binding() {
5266 assert_eq!(
5267 ev("let double = x: x * 2; in double 21"),
5268 Value::Int(42),
5269 );
5270 }
5271
5272 // ═══════════════════════════════════════════════════════════
5273 // 5. ATTRIBUTE SETS — COMPLETE COVERAGE
5274 // ═══════════════════════════════════════════════════════════
5275
5276 #[test]
5277 fn attrs_empty_set() {
5278 let v = ev("{}");
5279 if let Value::Attrs(attrs) = v {
5280 assert!(attrs.is_empty());
5281 } else {
5282 panic!("expected attrs");
5283 }
5284 }
5285
5286 #[test]
5287 fn attrs_simple() {
5288 assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
5289 }
5290
5291 #[test]
5292 fn attrs_nested_access() {
5293 assert_eq!(ev("{ a = { b = { c = 42; }; }; }.a.b.c"), Value::Int(42));
5294 }
5295
5296 #[test]
5297 fn attrs_recursive_set() {
5298 assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
5299 }
5300
5301 #[test]
5302 fn attrs_update_disjoint() {
5303 let v = ev("{ a = 1; } // { b = 2; }");
5304 if let Value::Attrs(attrs) = v {
5305 assert_eq!(attrs.len(), 2);
5306 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5307 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5308 } else {
5309 panic!("expected attrs");
5310 }
5311 }
5312
5313 #[test]
5314 fn attrs_update_override() {
5315 assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
5316 }
5317
5318 #[test]
5319 fn attrs_has_attr_operator() {
5320 assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
5321 assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
5322 }
5323
5324 #[test]
5325 fn attrs_select_with_default() {
5326 assert_eq!(ev("{ a = 1; }.a or 99"), Value::Int(1));
5327 assert_eq!(ev("{}.missing or 99"), Value::Int(99));
5328 assert_eq!(ev("{ a = 1; }.b or 42"), Value::Int(42));
5329 }
5330
5331 #[test]
5332 fn attrs_nested_attr_path_in_binding() {
5333 // { a.b = 1; } creates { a = { b = 1; }; }
5334 assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
5335 }
5336
5337 #[test]
5338 fn attrs_inherit_from_scope() {
5339 assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.x"), Value::Int(1));
5340 assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.y"), Value::Int(2));
5341 }
5342
5343 #[test]
5344 fn attrs_inherit_from_expr() {
5345 assert_eq!(
5346 ev("{ inherit ({ a = 42; b = 10; }) a; }.a"),
5347 Value::Int(42),
5348 );
5349 }
5350
5351 #[test]
5352 fn attrs_dynamic_attr_name() {
5353 assert_eq!(
5354 ev(r#"let name = "x"; in { ${name} = 42; }.x"#),
5355 Value::Int(42),
5356 );
5357 }
5358
5359 #[test]
5360 fn attrs_attr_names_sorted() {
5361 assert_eq!(
5362 ev("builtins.attrNames { z = 1; m = 2; a = 3; }"),
5363 Value::list(vec![
5364 Value::string("a"),
5365 Value::string("m"),
5366 Value::string("z"),
5367 ]),
5368 );
5369 }
5370
5371 #[test]
5372 fn attrs_attr_values_follow_key_order() {
5373 // BTreeMap iteration order: a=1, b=2, c=3
5374 assert_eq!(
5375 ev("builtins.attrValues { c = 3; a = 1; b = 2; }"),
5376 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5377 );
5378 }
5379
5380 #[test]
5381 fn attrs_update_is_shallow() {
5382 // // is a shallow merge; nested attrs are replaced, not merged
5383 assert_eq!(
5384 ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a ? x"),
5385 Value::Bool(false),
5386 );
5387 assert_eq!(
5388 ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a.y"),
5389 Value::Int(2),
5390 );
5391 }
5392
5393 // ═══════════════════════════════════════════════════════════
5394 // 6. LISTS — COMPLETE COVERAGE
5395 // ═══════════════════════════════════════════════════════════
5396
5397 #[test]
5398 fn list_empty() {
5399 assert_eq!(ev("[]"), Value::list(vec![]));
5400 }
5401
5402 #[test]
5403 fn list_single_element() {
5404 assert_eq!(ev("[1]"), Value::list(vec![Value::Int(1)]));
5405 }
5406
5407 #[test]
5408 fn list_mixed_types() {
5409 assert_eq!(
5410 ev(r#"[1 "two" true null]"#),
5411 Value::list(vec![
5412 Value::Int(1),
5413 Value::string("two"),
5414 Value::Bool(true),
5415 Value::Null,
5416 ]),
5417 );
5418 }
5419
5420 #[test]
5421 fn list_nested() {
5422 assert_eq!(
5423 ev("[[1 2] [3 4]]"),
5424 Value::list(vec![
5425 Value::list(vec![Value::Int(1), Value::Int(2)]),
5426 Value::list(vec![Value::Int(3), Value::Int(4)]),
5427 ]),
5428 );
5429 }
5430
5431 #[test]
5432 fn list_concat_operator() {
5433 assert_eq!(
5434 ev("[1] ++ [2] ++ [3]"),
5435 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5436 );
5437 }
5438
5439 #[test]
5440 fn list_builtins_length() {
5441 assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
5442 assert_eq!(ev("builtins.length []"), Value::Int(0));
5443 }
5444
5445 #[test]
5446 fn list_builtins_elem_at() {
5447 assert_eq!(ev("builtins.elemAt [10 20 30] 0"), Value::Int(10));
5448 assert_eq!(ev("builtins.elemAt [10 20 30] 1"), Value::Int(20));
5449 assert_eq!(ev("builtins.elemAt [10 20 30] 2"), Value::Int(30));
5450 }
5451
5452 #[test]
5453 fn list_equality() {
5454 assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
5455 assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
5456 assert_eq!(ev("[] == []"), Value::Bool(true));
5457 }
5458
5459 // ═══════════════════════════════════════════════════════════
5460 // 7. STRING INTERPOLATION
5461 // ═══════════════════════════════════════════════════════════
5462
5463 #[test]
5464 fn interp_simple_variable() {
5465 assert_eq!(
5466 ev(r#"let name = "world"; in "hello ${name}""#),
5467 Value::string("hello world"),
5468 );
5469 }
5470
5471 #[test]
5472 fn interp_nested_expression() {
5473 assert_eq!(
5474 ev(r#""result: ${builtins.toString (1 + 2)}""#),
5475 Value::string("result: 3"),
5476 );
5477 }
5478
5479 #[test]
5480 fn interp_int_coercion() {
5481 // Ints are coerced to string in interpolation
5482 assert_eq!(
5483 ev(r#"let x = 42; in "count: ${builtins.toString x}""#),
5484 Value::string("count: 42"),
5485 );
5486 }
5487
5488 #[test]
5489 fn interp_multiple() {
5490 assert_eq!(
5491 ev(r#"let a = "foo"; b = "bar"; in "${a} and ${b}""#),
5492 Value::string("foo and bar"),
5493 );
5494 }
5495
5496 #[test]
5497 fn interp_in_let() {
5498 assert_eq!(
5499 ev(r#"let x = "world"; in "hello ${x}""#),
5500 Value::string("hello world"),
5501 );
5502 }
5503
5504 #[test]
5505 fn interp_empty_result() {
5506 assert_eq!(
5507 ev(r#"let x = ""; in "a${x}b""#),
5508 Value::string("ab"),
5509 );
5510 }
5511
5512 #[test]
5513 fn interp_path_in_string_context() {
5514 // CppNix string interpolation is copy-to-store coercion: a nonexistent
5515 // path errors "path '…' does not exist" (previously sui spliced the raw
5516 // relative path "./foo" verbatim, diverging from nix). The positive
5517 // copy-to-store case is byte-verified in
5518 // interp_path_copies_to_store_byte_matches_cppnix below.
5519 assert!(eval(r#""path: ${./foo-nonexistent-xyz}""#).is_err());
5520 }
5521
5522 #[test]
5523 fn interp_adjacent_interpolations() {
5524 assert_eq!(
5525 ev(r#"let a = "x"; b = "y"; in "${a}${b}""#),
5526 Value::string("xy"),
5527 );
5528 }
5529
5530 // ═══════════════════════════════════════════════════════════
5531 // 8. BUILTINS — VERIFY ALL MAJOR ONES
5532 // ═══════════════════════════════════════════════════════════
5533
5534 #[test]
5535 fn builtins_map_filter_foldl() {
5536 // map
5537 assert_eq!(
5538 ev("builtins.map (x: x + 10) [1 2 3]"),
5539 Value::list(vec![Value::Int(11), Value::Int(12), Value::Int(13)]),
5540 );
5541 // filter
5542 assert_eq!(
5543 ev("builtins.filter (x: x > 1) [1 2 3]"),
5544 Value::list(vec![Value::Int(2), Value::Int(3)]),
5545 );
5546 // foldl' — product
5547 assert_eq!(
5548 ev("builtins.foldl' (a: b: a * b) 1 [2 3 4]"),
5549 Value::Int(24),
5550 );
5551 }
5552
5553 #[test]
5554 fn builtins_map_attrs() {
5555 assert_eq!(
5556 ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).a"),
5557 Value::Int(2),
5558 );
5559 assert_eq!(
5560 ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).b"),
5561 Value::Int(4),
5562 );
5563 }
5564
5565 #[test]
5566 fn builtins_list_to_attrs() {
5567 assert_eq!(
5568 ev(r#"(builtins.listToAttrs [{ name = "x"; value = 1; } { name = "y"; value = 2; }]).x"#),
5569 Value::Int(1),
5570 );
5571 }
5572
5573 #[test]
5574 fn builtins_list_to_attrs_duplicate_key_first_wins() {
5575 // Nix `listToAttrs` keeps the FIRST occurrence of a duplicate `name`
5576 // (later duplicates are ignored). cppnix returns 1 here, not 2.
5577 // Byte-parity root (cid darwin): a Cargo.lock listing a crate twice
5578 // (registry entry then git entry of the same name+version) must
5579 // resolve to the FIRST source, so `substrate/lockfile-delta.nix`'s
5580 // `lockByKey` picks the registry crate exactly as nix does. Last-wins
5581 // silently switched the source to git and produced a structurally
5582 // different `rust_<crate>` derivation.
5583 assert_eq!(
5584 ev(r#"(builtins.listToAttrs [{ name = "k"; value = 1; } { name = "k"; value = 2; }]).k"#),
5585 Value::Int(1),
5586 );
5587 }
5588
5589 #[test]
5590 fn builtins_concat_map() {
5591 assert_eq!(
5592 ev("builtins.concatMap (x: [x (x * 2)]) [1 2 3]"),
5593 Value::list(vec![
5594 Value::Int(1), Value::Int(2),
5595 Value::Int(2), Value::Int(4),
5596 Value::Int(3), Value::Int(6),
5597 ]),
5598 );
5599 }
5600
5601 #[test]
5602 fn builtins_concat_lists() {
5603 assert_eq!(
5604 ev("builtins.concatLists [[1 2] [3] [4 5]]"),
5605 Value::list(vec![
5606 Value::Int(1), Value::Int(2), Value::Int(3),
5607 Value::Int(4), Value::Int(5),
5608 ]),
5609 );
5610 }
5611
5612 #[test]
5613 fn builtins_concat_strings_sep() {
5614 assert_eq!(
5615 ev(r#"builtins.concatStringsSep ", " ["a" "b" "c"]"#),
5616 Value::string("a, b, c"),
5617 );
5618 assert_eq!(
5619 ev(r#"builtins.concatStringsSep "" ["x" "y"]"#),
5620 Value::string("xy"),
5621 );
5622 }
5623
5624 #[test]
5625 fn builtins_replace_strings() {
5626 assert_eq!(
5627 ev(r#"builtins.replaceStrings ["o"] ["0"] "foobar""#),
5628 Value::string("f00bar"),
5629 );
5630 assert_eq!(
5631 ev(r#"builtins.replaceStrings ["hello"] ["goodbye"] "hello world""#),
5632 Value::string("goodbye world"),
5633 );
5634 }
5635
5636 /// `hasPrefix`/`hasSuffix` are nixpkgs `lib.strings` functions, NOT CppNix
5637 /// builtins — so sui must not have them either. This test used to assert
5638 /// they worked; it now asserts they are absent, which is the same test
5639 /// pointed the correct way.
5640 #[test]
5641 fn builtins_has_prefix_has_suffix_are_not_builtins() {
5642 assert_eq!(ev(r#"builtins ? hasPrefix"#), Value::Bool(false));
5643 assert_eq!(ev(r#"builtins ? hasSuffix"#), Value::Bool(false));
5644 assert!(
5645 eval(r#"builtins.hasPrefix "he" "hello""#).is_err(),
5646 "builtins.hasPrefix must fail the way real nix fails it"
5647 );
5648 assert!(
5649 eval(r#"builtins.hasSuffix "lo" "hello""#).is_err(),
5650 "builtins.hasSuffix must fail the way real nix fails it"
5651 );
5652 }
5653
5654 #[test]
5655 fn builtins_all_any() {
5656 assert_eq!(ev("builtins.all (x: x > 0) [1 2 3]"), Value::Bool(true));
5657 assert_eq!(ev("builtins.all (x: x > 1) [1 2 3]"), Value::Bool(false));
5658 assert_eq!(ev("builtins.any (x: x > 2) [1 2 3]"), Value::Bool(true));
5659 assert_eq!(ev("builtins.any (x: x > 5) [1 2 3]"), Value::Bool(false));
5660 }
5661
5662 #[test]
5663 fn builtins_sort() {
5664 assert_eq!(
5665 ev("builtins.sort (a: b: a < b) [3 1 2]"),
5666 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5667 );
5668 }
5669
5670 #[test]
5671 fn builtins_remove_attrs() {
5672 let v = ev(r#"builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b" "c"]"#);
5673 if let Value::Attrs(attrs) = v {
5674 assert_eq!(attrs.len(), 1);
5675 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5676 assert!(attrs.get("b").is_none());
5677 } else {
5678 panic!("expected attrs");
5679 }
5680 }
5681
5682 #[test]
5683 fn builtins_intersect_attrs() {
5684 let v = ev("builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }");
5685 if let Value::Attrs(attrs) = v {
5686 assert_eq!(attrs.len(), 1);
5687 // intersectAttrs returns values from the second set
5688 assert_eq!(attrs.get("b"), Some(&Value::Int(20)));
5689 } else {
5690 panic!("expected attrs");
5691 }
5692 }
5693
5694 #[test]
5695 fn builtins_type_of_all_types() {
5696 assert_eq!(ev("builtins.typeOf null"), Value::string("null"));
5697 assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
5698 assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
5699 assert_eq!(ev("builtins.typeOf 3.14"), Value::string("float"));
5700 assert_eq!(ev(r#"builtins.typeOf "hi""#), Value::string("string"));
5701 assert_eq!(ev("builtins.typeOf [1]"), Value::string("list"));
5702 assert_eq!(ev("builtins.typeOf {}"), Value::string("set"));
5703 assert_eq!(ev("builtins.typeOf (x: x)"), Value::string("lambda"));
5704 }
5705
5706 #[test]
5707 fn builtins_is_type_checks() {
5708 assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
5709 assert_eq!(ev("builtins.isNull 0"), Value::Bool(false));
5710 assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
5711 assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
5712 assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
5713 assert_eq!(ev("builtins.isBool 1"), Value::Bool(false));
5714 assert_eq!(ev(r#"builtins.isString "x""#), Value::Bool(true));
5715 assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
5716 assert_eq!(ev("builtins.isList []"), Value::Bool(true));
5717 assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
5718 assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
5719 assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
5720 assert_eq!(ev("builtins.isFunction (x: x)"), Value::Bool(true));
5721 assert_eq!(ev("builtins.isFunction 1"), Value::Bool(false));
5722 assert_eq!(ev("builtins.isFloat 3.14"), Value::Bool(true));
5723 assert_eq!(ev("builtins.isFloat 1"), Value::Bool(false));
5724 }
5725
5726 #[test]
5727 fn builtins_to_json_from_json_roundtrip() {
5728 // int roundtrip
5729 assert_eq!(ev("builtins.fromJSON (builtins.toJSON 42)"), Value::Int(42));
5730 // string roundtrip
5731 assert_eq!(
5732 ev(r#"builtins.fromJSON (builtins.toJSON "hello")"#),
5733 Value::string("hello"),
5734 );
5735 // list roundtrip
5736 assert_eq!(
5737 ev("builtins.fromJSON (builtins.toJSON [1 2 3])"),
5738 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5739 );
5740 // null roundtrip
5741 assert_eq!(ev("builtins.fromJSON (builtins.toJSON null)"), Value::Null);
5742 // bool roundtrip
5743 assert_eq!(ev("builtins.fromJSON (builtins.toJSON true)"), Value::Bool(true));
5744 }
5745
5746 #[test]
5747 fn builtins_to_string_various() {
5748 assert_eq!(ev("builtins.toString 42"), Value::string("42"));
5749 assert_eq!(ev("builtins.toString true"), Value::string("1"));
5750 assert_eq!(ev("builtins.toString false"), Value::string(""));
5751 assert_eq!(ev("builtins.toString null"), Value::string(""));
5752 assert_eq!(ev(r#"builtins.toString "hello""#), Value::string("hello"));
5753 }
5754
5755 #[test]
5756 fn builtins_function_args() {
5757 let v = ev("builtins.functionArgs ({ a, b ? 1 }: a)");
5758 if let Value::Attrs(attrs) = v {
5759 assert_eq!(attrs.get("a"), Some(&Value::Bool(false))); // no default
5760 assert_eq!(attrs.get("b"), Some(&Value::Bool(true))); // has default
5761 } else {
5762 panic!("expected attrs");
5763 }
5764 }
5765
5766 #[test]
5767 fn builtins_gen_list() {
5768 assert_eq!(
5769 ev("builtins.genList (x: x * x) 5"),
5770 Value::list(vec![
5771 Value::Int(0), Value::Int(1), Value::Int(4),
5772 Value::Int(9), Value::Int(16),
5773 ]),
5774 );
5775 assert_eq!(ev("builtins.genList (x: x) 0"), Value::list(vec![]));
5776 }
5777
5778 #[test]
5779 fn builtins_elem() {
5780 assert_eq!(ev("builtins.elem 2 [1 2 3]"), Value::Bool(true));
5781 assert_eq!(ev("builtins.elem 5 [1 2 3]"), Value::Bool(false));
5782 assert_eq!(ev("builtins.elem 1 []"), Value::Bool(false));
5783 }
5784
5785 #[test]
5786 fn builtins_head_tail() {
5787 assert_eq!(ev("builtins.head [10 20 30]"), Value::Int(10));
5788 assert_eq!(
5789 ev("builtins.tail [10 20 30]"),
5790 Value::list(vec![Value::Int(20), Value::Int(30)]),
5791 );
5792 }
5793
5794 #[test]
5795 fn builtins_string_length() {
5796 assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
5797 assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
5798 assert_eq!(ev(r#"builtins.stringLength "abc def""#), Value::Int(7));
5799 }
5800
5801 #[test]
5802 fn builtins_ceil_floor() {
5803 assert_eq!(ev("builtins.ceil 2.3"), Value::Int(3));
5804 assert_eq!(ev("builtins.ceil 2.0"), Value::Int(2));
5805 assert_eq!(ev("builtins.floor 2.9"), Value::Int(2));
5806 assert_eq!(ev("builtins.floor 2.0"), Value::Int(2));
5807 // Int coercion: ceil/floor on int should work via to_float()
5808 assert_eq!(ev("builtins.ceil 5"), Value::Int(5));
5809 assert_eq!(ev("builtins.floor 5"), Value::Int(5));
5810 }
5811
5812 #[test]
5813 fn builtins_try_eval() {
5814 let v = ev("builtins.tryEval 42");
5815 if let Value::Attrs(attrs) = v {
5816 assert_eq!(attrs.get("success"), Some(&Value::Bool(true)));
5817 assert_eq!(attrs.get("value"), Some(&Value::Int(42)));
5818 } else {
5819 panic!("expected attrs");
5820 }
5821 }
5822
5823 #[test]
5824 fn builtins_throw() {
5825 let result = eval(r#"builtins.throw "oops""#);
5826 assert!(result.is_err());
5827 let msg = format!("{}", result.unwrap_err());
5828 assert!(msg.contains("oops"));
5829 }
5830
5831 #[test]
5832 fn builtins_seq_deep_seq() {
5833 // seq forces first arg, returns second
5834 assert_eq!(ev("builtins.seq 1 42"), Value::Int(42));
5835 // deepSeq similarly
5836 assert_eq!(ev("builtins.deepSeq [1 2 3] 99"), Value::Int(99));
5837 }
5838
5839 #[test]
5840 fn builtins_current_system() {
5841 let v = ev("builtins.currentSystem");
5842 if let Value::String(ns) = v {
5843 let s = &ns.chars;
5844 // Should be a valid system string
5845 assert!(
5846 s == "aarch64-darwin"
5847 || s == "x86_64-darwin"
5848 || s == "aarch64-linux"
5849 || s == "x86_64-linux",
5850 "unexpected system: {s}",
5851 );
5852 } else {
5853 panic!("expected string");
5854 }
5855 }
5856
5857 // ═══════════════════════════════════════════════════════════
5858 // 9. REAL-WORLD NIXPKGS PATTERNS
5859 // ═══════════════════════════════════════════════════════════
5860
5861 #[test]
5862 fn pattern_mkif_like() {
5863 // lib.mkIf pattern: if condition then { key = value; } else {}
5864 assert_eq!(
5865 ev("(if true then { x = 1; } else {}).x"),
5866 Value::Int(1),
5867 );
5868 let v = ev("if false then { x = 1; } else {}");
5869 if let Value::Attrs(attrs) = v {
5870 assert!(attrs.is_empty());
5871 } else {
5872 panic!("expected attrs");
5873 }
5874 }
5875
5876 #[test]
5877 fn pattern_optional_attrs() {
5878 // lib.optionalAttrs pattern
5879 assert_eq!(
5880 ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in (optionalAttrs true { a = 1; }).a"),
5881 Value::Int(1),
5882 );
5883 let v = ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in optionalAttrs false { a = 1; }");
5884 if let Value::Attrs(attrs) = v {
5885 assert!(attrs.is_empty());
5886 } else {
5887 panic!("expected attrs");
5888 }
5889 }
5890
5891 #[test]
5892 fn pattern_filter_attrs_via_remove() {
5893 // lib.filterAttrs pattern via removeAttrs
5894 assert_eq!(
5895 ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]).a"#),
5896 Value::Int(1),
5897 );
5898 assert_eq!(
5899 ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]) ? b"#),
5900 Value::Bool(false),
5901 );
5902 }
5903
5904 #[test]
5905 fn pattern_override() {
5906 // default // overrides pattern
5907 let v = ev(r#"
5908 let
5909 defaults = { debug = false; port = 8080; host = "localhost"; };
5910 overrides = { debug = true; port = 9090; };
5911 in defaults // overrides
5912 "#);
5913 if let Value::Attrs(attrs) = v {
5914 assert_eq!(attrs.get("debug"), Some(&Value::Bool(true)));
5915 assert_eq!(attrs.get("port"), Some(&Value::Int(9090)));
5916 assert_eq!(attrs.get("host"), Some(&Value::string("localhost")));
5917 } else {
5918 panic!("expected attrs");
5919 }
5920 }
5921
5922 #[test]
5923 fn pattern_functor() {
5924 // { __functor = self: x: self.value + x; value = 10; } 5
5925 assert_eq!(
5926 ev("let s = { __functor = self: x: self.value + x; value = 10; }; in s 5"),
5927 Value::Int(15),
5928 );
5929 }
5930
5931 #[test]
5932 fn pattern_platform_check() {
5933 // Check pattern: if builtins.currentSystem == "..." then ... else ...
5934 let v = ev(r#"if builtins.currentSystem == "aarch64-darwin" then "arm" else "other""#);
5935 // We just verify it evaluates without error and produces a string
5936 if let Value::String(_) = v {
5937 // ok
5938 } else {
5939 panic!("expected string");
5940 }
5941 }
5942
5943 #[test]
5944 fn pattern_recursive_overlay_lambda_structure() {
5945 // Test the lambda structure of an overlay (self: super: { ... })
5946 let v = ev("let overlay = self: super: { pkg = 42; }; in overlay {} {}");
5947 if let Value::Attrs(attrs) = v {
5948 assert_eq!(attrs.get("pkg"), Some(&Value::Int(42)));
5949 } else {
5950 panic!("expected attrs");
5951 }
5952 }
5953
5954 #[test]
5955 fn pattern_call_package_simplified() {
5956 // Simplified callPackage: f: f { inherit lib; }
5957 assert_eq!(
5958 ev("let callPkg = f: f { lib = { id = x: x; }; }; lib = { id = x: x; }; in callPkg ({ lib }: lib.id 42)"),
5959 Value::Int(42),
5960 );
5961 }
5962
5963 #[test]
5964 fn pattern_derivation_like_attrset() {
5965 let v = ev(r#"{ type = "derivation"; name = "hello"; system = builtins.currentSystem; builder = "/bin/sh"; }"#);
5966 if let Value::Attrs(attrs) = v {
5967 assert_eq!(attrs.get("type"), Some(&Value::string("derivation")));
5968 assert_eq!(attrs.get("name"), Some(&Value::string("hello")));
5969 assert_eq!(attrs.get("builder"), Some(&Value::string("/bin/sh")));
5970 // system should be a string (may be a thunk that forces to string)
5971 let system = force_value(attrs.get("system").unwrap()).unwrap();
5972 assert!(matches!(system, Value::String(_)), "expected string, got {system:?}");
5973 } else {
5974 panic!("expected attrs");
5975 }
5976 }
5977
5978 #[test]
5979 fn pattern_module_system_simplified() {
5980 // Simplified NixOS module evaluation
5981 assert_eq!(
5982 ev(r#"
5983 let
5984 eval = m: m { config = {}; lib = { mkDefault = x: x; }; };
5985 in eval ({ config, lib }: { result = lib.mkDefault 42; })
5986 "#),
5987 {
5988 let mut attrs = NixAttrs::new();
5989 attrs.insert("result".to_string(), Value::Int(42));
5990 Value::Attrs(Rc::new(attrs))
5991 },
5992 );
5993 }
5994
5995 // ═══════════════════════════════════════════════════════════
5996 // 10. ERROR HANDLING
5997 // ═══════════════════════════════════════════════════════════
5998
5999 #[test]
6000 fn error_undefined_variable() {
6001 let result = eval("nonexistent_var");
6002 assert!(result.is_err());
6003 let msg = format!("{}", result.unwrap_err());
6004 assert!(msg.contains("undefined variable") || msg.contains("nonexistent_var"));
6005 }
6006
6007 #[test]
6008 fn error_type_mismatch_arithmetic() {
6009 let result = eval(r#"1 + "hello""#);
6010 assert!(result.is_err());
6011 }
6012
6013 #[test]
6014 fn error_missing_attribute() {
6015 let result = eval("{}.nonexistent");
6016 assert!(result.is_err());
6017 let msg = format!("{}", result.unwrap_err());
6018 assert!(msg.contains("nonexistent") || msg.contains("not found"));
6019 }
6020
6021 #[test]
6022 fn error_division_by_zero() {
6023 assert!(eval("1 / 0").is_err());
6024 assert!(eval("100 / 0").is_err());
6025 }
6026
6027 #[test]
6028 fn error_missing_required_function_arg() {
6029 let result = eval("({ a, b }: a + b) { a = 1; }");
6030 assert!(result.is_err());
6031 let msg = format!("{}", result.unwrap_err());
6032 assert!(msg.contains("missing argument"));
6033 }
6034
6035 #[test]
6036 fn error_unexpected_function_arg() {
6037 let result = eval("({ a }: a) { a = 1; b = 2; }");
6038 assert!(result.is_err());
6039 let msg = format!("{}", result.unwrap_err());
6040 assert!(msg.contains("unexpected argument"));
6041 }
6042
6043 #[test]
6044 fn error_assertion_failure() {
6045 assert!(eval("assert false; 1").is_err());
6046 assert!(eval("assert 1 == 2; 1").is_err());
6047 }
6048
6049 #[test]
6050 fn error_infinite_recursion() {
6051 // `let x = x; in x` should either hit the depth guard or fail on
6052 // undefined variable (since sequential let can't see its own binding).
6053 let result = eval("let x = x; in x");
6054 assert!(result.is_err());
6055 }
6056
6057 #[test]
6058 fn error_infinite_recursion_via_lambda() {
6059 // A true infinite recursion via self-application -- depth guard catches this.
6060 let result = eval("let f = x: f x; in f 1");
6061 assert!(result.is_err());
6062 let msg = format!("{}", result.unwrap_err());
6063 assert!(
6064 msg.contains("infinite recursion") || msg.contains("eval depth") || msg.contains("undefined"),
6065 );
6066 }
6067
6068 // ═══════════════════════════════════════════════════════════
6069 // ADDITIONAL COVERAGE: edge cases and integration
6070 // ═══════════════════════════════════════════════════════════
6071
6072 #[test]
6073 fn integration_let_with_function_returning_attrset() {
6074 assert_eq!(
6075 ev("let mkPkg = name: { inherit name; version = 1; }; in (mkPkg \"hello\").name"),
6076 Value::string("hello"),
6077 );
6078 }
6079
6080 #[test]
6081 fn integration_chained_updates() {
6082 assert_eq!(
6083 ev("({ a = 1; } // { b = 2; } // { c = 3; }).c"),
6084 Value::Int(3),
6085 );
6086 }
6087
6088 #[test]
6089 fn integration_map_over_attrnames() {
6090 // Common nixpkgs pattern: map over attrNames
6091 assert_eq!(
6092 ev(r#"
6093 let
6094 set = { a = 1; b = 2; };
6095 names = builtins.attrNames set;
6096 in builtins.length names
6097 "#),
6098 Value::Int(2),
6099 );
6100 }
6101
6102 #[test]
6103 fn integration_compose_functions() {
6104 // Function composition
6105 assert_eq!(
6106 ev("let compose = f: g: x: f (g x); double = x: x * 2; inc = x: x + 1; in compose double inc 5"),
6107 Value::Int(12), // (5 + 1) * 2
6108 );
6109 }
6110
6111 #[test]
6112 fn integration_recursive_list_building() {
6113 // Build a list using genList and map
6114 assert_eq!(
6115 ev("builtins.map (x: x * x) (builtins.genList (x: x + 1) 4)"),
6116 Value::list(vec![Value::Int(1), Value::Int(4), Value::Int(9), Value::Int(16)]),
6117 );
6118 }
6119
6120 #[test]
6121 fn integration_attrset_from_list() {
6122 // Convert list to attrset via listToAttrs + map
6123 let v = ev(r#"
6124 builtins.listToAttrs (builtins.map (x: { name = x; value = true; }) ["a" "b" "c"])
6125 "#);
6126 if let Value::Attrs(attrs) = v {
6127 assert_eq!(attrs.get("a"), Some(&Value::Bool(true)));
6128 assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));
6129 assert_eq!(attrs.get("c"), Some(&Value::Bool(true)));
6130 } else {
6131 panic!("expected attrs");
6132 }
6133 }
6134
6135 #[test]
6136 fn integration_nested_with_and_let() {
6137 assert_eq!(
6138 ev("let x = 10; in with { y = 20; }; x + y"),
6139 Value::Int(30),
6140 );
6141 }
6142
6143 #[test]
6144 fn integration_complex_pattern_match() {
6145 // Complex function with defaults, ellipsis, and @ pattern
6146 assert_eq!(
6147 ev("(args @ { a, b ? 5, ... }: a + b + (if args ? c then args.c else 0)) { a = 1; c = 10; }"),
6148 Value::Int(16), // 1 + 5 + 10
6149 );
6150 }
6151
6152 #[test]
6153 fn integration_substring() {
6154 assert_eq!(
6155 ev(r#"builtins.substring 0 5 "hello world""#),
6156 Value::string("hello"),
6157 );
6158 assert_eq!(
6159 ev(r#"builtins.substring 6 5 "hello world""#),
6160 Value::string("world"),
6161 );
6162 }
6163
6164 #[test]
6165 fn integration_has_attr_on_nested() {
6166 // ? on nested attr paths
6167 assert_eq!(ev("{ a = { b = 1; }; } ? a"), Value::Bool(true));
6168 assert_eq!(
6169 ev("({ a = { b = 1; }; }.a) ? b"),
6170 Value::Bool(true),
6171 );
6172 }
6173
6174 #[test]
6175 fn integration_cat_attrs() {
6176 assert_eq!(
6177 ev(r#"builtins.catAttrs "x" [{ x = 1; } { y = 2; } { x = 3; }]"#),
6178 Value::list(vec![Value::Int(1), Value::Int(3)]),
6179 );
6180 }
6181
6182 #[test]
6183 fn integration_get_attr_builtin() {
6184 assert_eq!(
6185 ev(r#"builtins.getAttr "a" { a = 42; b = 10; }"#),
6186 Value::Int(42),
6187 );
6188 }
6189
6190 #[test]
6191 fn integration_has_attr_builtin() {
6192 assert_eq!(
6193 ev(r#"builtins.hasAttr "a" { a = 1; }"#),
6194 Value::Bool(true),
6195 );
6196 assert_eq!(
6197 ev(r#"builtins.hasAttr "z" { a = 1; }"#),
6198 Value::Bool(false),
6199 );
6200 }
6201
6202 #[test]
6203 fn integration_is_path() {
6204 assert_eq!(ev("builtins.isPath ./foo"), Value::Bool(true));
6205 assert_eq!(ev("builtins.isPath 42"), Value::Bool(false));
6206 }
6207
6208 #[test]
6209 fn integration_builtins_trace() {
6210 // trace prints the first arg (as debug) and returns the second
6211 assert_eq!(ev(r#"builtins.trace "debug msg" 42"#), Value::Int(42));
6212 }
6213
6214 #[test]
6215 fn integration_builtins_split() {
6216 // Nix spec: split returns alternating non-match strings and match group lists.
6217 // When the regex has no capture groups, separator positions get empty lists.
6218 // split "/" "a/b/c" => ["a" [] "b" [] "c"]
6219 assert_eq!(
6220 ev(r#"builtins.split "/" "a/b/c""#),
6221 Value::list(vec![
6222 Value::string("a"),
6223 Value::list(vec![]),
6224 Value::string("b"),
6225 Value::list(vec![]),
6226 Value::string("c"),
6227 ]),
6228 );
6229 // With a capture group, the captured text appears in the list.
6230 // split "(/)" "a/b/c" => ["a" ["/"] "b" ["/"] "c"]
6231 assert_eq!(
6232 ev(r#"builtins.split "(/)" "a/b/c""#),
6233 Value::list(vec![
6234 Value::string("a"),
6235 Value::list(vec![Value::string("/")]),
6236 Value::string("b"),
6237 Value::list(vec![Value::string("/")]),
6238 Value::string("c"),
6239 ]),
6240 );
6241 }
6242
6243 #[test]
6244 fn integration_builtins_split_no_capture_groups() {
6245 // builtins.split with no capture groups returns empty lists
6246 // at separator positions — matches CppNix behavior.
6247 // This is critical for nixpkgs lib.splitString which uses
6248 // builtins.filter builtins.isString on the result.
6249 assert_eq!(
6250 ev(r#"builtins.split "-" "aarch64-darwin""#),
6251 Value::list(vec![
6252 Value::string("aarch64"),
6253 Value::list(vec![]),
6254 Value::string("darwin"),
6255 ]),
6256 );
6257 }
6258
6259 #[test]
6260 fn integration_builtins_split_system_string_filter() {
6261 // Simulates nixpkgs lib.splitString: filter isString (split pattern string)
6262 // This is the exact pattern that parses system strings like "aarch64-darwin".
6263 assert_eq!(
6264 ev(r#"builtins.filter builtins.isString (builtins.split "-" "aarch64-darwin")"#),
6265 Value::list(vec![
6266 Value::string("aarch64"),
6267 Value::string("darwin"),
6268 ]),
6269 );
6270 }
6271
6272 #[test]
6273 fn integration_deeply_nested_let() {
6274 // Deeply nested let-in expressions
6275 assert_eq!(
6276 ev("let a = let b = let c = 10; in c * 2; in b + 1; in a"),
6277 Value::Int(21),
6278 );
6279 }
6280
6281 #[test]
6282 fn integration_if_in_attrset_value() {
6283 assert_eq!(
6284 ev("{ x = if true then 1 else 2; }.x"),
6285 Value::Int(1),
6286 );
6287 }
6288
6289 #[test]
6290 fn integration_lambda_in_list() {
6291 // Store lambdas in a list and apply them
6292 assert_eq!(
6293 ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 0) 5"),
6294 Value::Int(6),
6295 );
6296 assert_eq!(
6297 ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 1) 5"),
6298 Value::Int(10),
6299 );
6300 }
6301
6302 #[test]
6303 fn integration_nixpkgs_lib_id() {
6304 // lib.id = x: x
6305 assert_eq!(
6306 ev("let lib = { id = x: x; const = a: b: a; }; in lib.id 42"),
6307 Value::Int(42),
6308 );
6309 assert_eq!(
6310 ev("let lib = { id = x: x; const = a: b: a; }; in lib.const 1 2"),
6311 Value::Int(1),
6312 );
6313 }
6314
6315 #[test]
6316 fn integration_multiple_inherit() {
6317 assert_eq!(
6318 ev("let a = 1; b = 2; c = 3; in { inherit a b c; }.b"),
6319 Value::Int(2),
6320 );
6321 }
6322
6323 #[test]
6324 fn integration_rec_set_with_builtins() {
6325 assert_eq!(
6326 ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6327 Value::Int(5),
6328 );
6329 }
6330
6331 // ═══════════════════════════════════════════════════════════
6332 // 11. __FUNCTOR PROTOCOL
6333 // ═══════════════════════════════════════════════════════════
6334
6335 #[test]
6336 fn functor_simple_callable_attrset() {
6337 assert_eq!(
6338 ev("let s = { __functor = self: x: x + 1; }; in s 41"),
6339 Value::Int(42),
6340 );
6341 }
6342
6343 #[test]
6344 fn functor_with_self_reference() {
6345 assert_eq!(
6346 ev("let s = { __functor = self: x: self.base + x; base = 100; }; in s 23"),
6347 Value::Int(123),
6348 );
6349 }
6350
6351 #[test]
6352 fn functor_updated_attrset() {
6353 // Override a field in the attrset, functor still works
6354 assert_eq!(
6355 ev(r#"
6356 let
6357 mk = { __functor = self: x: self.n + x; n = 0; };
6358 s = mk // { n = 50; };
6359 in s 7
6360 "#),
6361 Value::Int(57),
6362 );
6363 }
6364
6365 #[test]
6366 fn functor_error_on_non_callable_attrset() {
6367 // Attrset without __functor should produce error when called
6368 let result = eval("let s = { a = 1; }; in s 5");
6369 assert!(result.is_err());
6370 }
6371
6372 // ═══════════════════════════════════════════════════════════
6373 // 12. __TOSTRING PROTOCOL
6374 // ═══════════════════════════════════════════════════════════
6375
6376 #[test]
6377 fn to_string_protocol_in_interpolation() {
6378 assert_eq!(
6379 ev(r#"let s = { __toString = self: "world"; }; in "hello ${s}""#),
6380 Value::string("hello world"),
6381 );
6382 }
6383
6384 #[test]
6385 fn to_string_protocol_accesses_self() {
6386 assert_eq!(
6387 ev(r#"let s = { __toString = self: self.val; val = "abc"; }; in "${s}""#),
6388 Value::string("abc"),
6389 );
6390 }
6391
6392 #[test]
6393 fn to_string_protocol_via_builtin_to_string() {
6394 assert_eq!(
6395 ev(r#"builtins.toString { __toString = self: "via-builtin"; }"#),
6396 Value::string("via-builtin"),
6397 );
6398 }
6399
6400 #[test]
6401 fn to_string_protocol_attrset_without_toString_fails() {
6402 // An attrset without __toString should fail in string context
6403 let result = eval(r#""${{}}"#);
6404 assert!(result.is_err());
6405 }
6406
6407 // ═══════════════════════════════════════════════════════════
6408 // 13. NEWLY IMPLEMENTED BUILTINS (eval-level tests)
6409 // ═══════════════════════════════════════════════════════════
6410
6411 /// `concatStrings` is nixpkgs `lib.strings.concatStrings`, not a CppNix
6412 /// builtin. The CAPABILITY is not lost — `concatStringsSep ""` is the real
6413 /// builtin spelling and is asserted here to still produce the same bytes,
6414 /// so this test proves both halves: the invented name is gone, and nothing
6415 /// a nix program can legally write got worse.
6416 #[test]
6417 fn eval_builtins_concat_strings_is_not_a_builtin() {
6418 assert_eq!(ev(r#"builtins ? concatStrings"#), Value::Bool(false));
6419 assert!(
6420 eval(r#"builtins.concatStrings ["a" "b" "c"]"#).is_err(),
6421 "builtins.concatStrings must fail the way real nix fails it"
6422 );
6423 assert_eq!(
6424 ev(r#"builtins.concatStringsSep "" ["a" "b" "c"]"#),
6425 Value::string("abc"),
6426 );
6427 assert_eq!(
6428 ev(r#"builtins.concatStringsSep "" []"#),
6429 Value::string(""),
6430 );
6431 }
6432
6433 #[test]
6434 fn eval_builtins_partition() {
6435 let v = ev("builtins.partition (x: x > 3) [1 2 3 4 5]");
6436 if let Value::Attrs(a) = v {
6437 assert_eq!(a.get("right"), Some(&Value::list(vec![Value::Int(4), Value::Int(5)])));
6438 assert_eq!(a.get("wrong"), Some(&Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)])));
6439 } else {
6440 panic!("expected attrs");
6441 }
6442 }
6443
6444 #[test]
6445 fn eval_builtins_group_by() {
6446 let v = ev(r#"builtins.groupBy (x: if x > 0 then "pos" else "neg") [1 (0 - 2) 3 (0 - 4)]"#);
6447 if let Value::Attrs(a) = v {
6448 assert_eq!(a.get("pos"), Some(&Value::list(vec![Value::Int(1), Value::Int(3)])));
6449 assert_eq!(a.get("neg"), Some(&Value::list(vec![Value::Int(-2), Value::Int(-4)])));
6450 } else {
6451 panic!("expected attrs");
6452 }
6453 }
6454
6455 #[test]
6456 fn eval_builtins_zip_attrs_with() {
6457 let v = ev("builtins.zipAttrsWith (n: vs: builtins.head vs) [{ a = 1; } { a = 2; b = 3; }]");
6458 if let Value::Attrs(a) = v {
6459 assert_eq!(a.get("a"), Some(&Value::Int(1)));
6460 assert_eq!(a.get("b"), Some(&Value::Int(3)));
6461 } else {
6462 panic!("expected attrs");
6463 }
6464 }
6465
6466 #[test]
6467 fn eval_builtins_compare_versions() {
6468 assert_eq!(ev(r#"builtins.compareVersions "2.0" "1.0""#), Value::Int(1));
6469 assert_eq!(ev(r#"builtins.compareVersions "1.0" "2.0""#), Value::Int(-1));
6470 assert_eq!(ev(r#"builtins.compareVersions "1.0" "1.0""#), Value::Int(0));
6471 }
6472
6473 #[test]
6474 fn eval_builtins_parse_drv_name() {
6475 let v = ev(r#"builtins.parseDrvName "nix-2.3.4""#);
6476 if let Value::Attrs(a) = v {
6477 assert_eq!(a.get("name"), Some(&Value::string("nix")));
6478 assert_eq!(a.get("version"), Some(&Value::string("2.3.4")));
6479 } else {
6480 panic!("expected attrs");
6481 }
6482 }
6483
6484 #[test]
6485 fn eval_builtins_base_name_of() {
6486 assert_eq!(
6487 ev(r#"builtins.baseNameOf "/foo/bar/baz""#),
6488 Value::string("baz"),
6489 );
6490 }
6491
6492 #[test]
6493 fn eval_builtins_dir_of() {
6494 assert_eq!(
6495 ev(r#"builtins.dirOf "/foo/bar/baz""#),
6496 Value::string("/foo/bar"),
6497 );
6498 }
6499
6500 #[test]
6501 fn eval_builtins_add_error_context() {
6502 assert_eq!(
6503 ev(r#"builtins.addErrorContext "some context" 42"#),
6504 Value::Int(42),
6505 );
6506 }
6507
6508 #[test]
6509 fn eval_builtins_abort() {
6510 let result = eval(r#"builtins.abort "fatal error""#);
6511 assert!(result.is_err());
6512 let msg = format!("{}", result.unwrap_err());
6513 assert!(msg.contains("fatal error"));
6514 }
6515
6516 // ═══════════════════════════════════════════════════════════
6517 // 14. INDENTED STRINGS ('' ... '')
6518 // ═══════════════════════════════════════════════════════════
6519
6520 #[test]
6521 fn indented_string_simple() {
6522 assert_eq!(ev("''hello''"), Value::string("hello"));
6523 }
6524
6525 #[test]
6526 fn indented_string_multiline_strips_indent() {
6527 assert_eq!(
6528 ev("''\n line1\n line2\n''"),
6529 Value::string("line1\nline2\n"),
6530 );
6531 }
6532
6533 #[test]
6534 fn indented_string_with_interpolation() {
6535 let code = "let x = \"world\"; in ''hello ${x}''";
6536 assert_eq!(
6537 ev(code),
6538 Value::string("hello world"),
6539 );
6540 }
6541
6542 #[test]
6543 fn indented_string_deeper_indent_preserved() {
6544 // Common indent is 2 spaces; the 4-space line keeps 2 extra
6545 assert_eq!(
6546 ev("''\n a\n b\n''"),
6547 Value::string("a\n b\n"),
6548 );
6549 }
6550
6551 // ═══════════════════════════════════════════════════════════
6552 // 15. DYNAMIC ATTRIBUTE NAMES
6553 // ═══════════════════════════════════════════════════════════
6554
6555 #[test]
6556 fn dynamic_attr_name_in_set() {
6557 assert_eq!(
6558 ev(r#"let key = "mykey"; in { ${key} = 42; }.mykey"#),
6559 Value::Int(42),
6560 );
6561 }
6562
6563 #[test]
6564 fn dynamic_attr_name_with_expression() {
6565 assert_eq!(
6566 ev(r#"let prefix = "foo"; in { ${"${prefix}bar"} = 1; }.foobar"#),
6567 Value::Int(1),
6568 );
6569 }
6570
6571 // ═══════════════════════════════════════════════════════════
6572 // 16. IGNORED TESTS — features needing major infrastructure
6573 // ═══════════════════════════════════════════════════════════
6574
6575 #[test]
6576 fn eval_builtins_match() {
6577 assert_eq!(
6578 ev(r#"builtins.match "([0-9]+)" "42""#),
6579 Value::list(vec![Value::string("42")]),
6580 );
6581 }
6582
6583 #[test]
6584 fn eval_builtins_hash_string() {
6585 let v = ev(r#"builtins.hashString "sha256" "hello""#);
6586 if let Value::String(ns) = v {
6587 assert_eq!(ns.chars.len(), 64);
6588 } else {
6589 panic!("expected string");
6590 }
6591 }
6592
6593 #[test]
6594 fn eval_builtins_import() {
6595 let dir = std::env::temp_dir();
6596 let path = dir.join("sui_eval_test_import_eval.nix");
6597 std::fs::write(&path, "42").unwrap();
6598 let expr = format!(r#"import "{}""#, path.display());
6599 let v = eval(&expr).unwrap();
6600 assert_eq!(v, Value::Int(42));
6601 std::fs::remove_file(&path).ok();
6602 }
6603
6604 #[test]
6605 fn eval_builtins_derivation() {
6606 let v = eval(r#"builtins.derivation { name = "test"; system = "x86_64-linux"; builder = "/bin/sh"; }"#).unwrap();
6607 if let Value::Attrs(a) = v {
6608 assert_eq!(a.get("type"), Some(&Value::string("derivation")));
6609 } else {
6610 panic!("expected attrs");
6611 }
6612 }
6613
6614 #[test]
6615 fn eval_mutual_recursive_let() {
6616 // Multi-pass evaluation allows forward references in let bindings.
6617 // After 3 passes (placeholder + eval + re-eval), `a.x` resolves to
6618 // the value of `b` from the previous pass, and `a.x.y` is an attrset.
6619 // Full semantic equivalence with Nix (a.x.y == a) requires lazy
6620 // thunks, but the multi-pass approach is sufficient for common
6621 // patterns like mutual module references.
6622 let v = eval("let a = { x = b; }; b = { y = a; }; in a.x.y");
6623 assert!(v.is_ok(), "mutual recursive let should not error: {v:?}");
6624 // a.x.y should be an attrset (it's a's value from a prior pass)
6625 let val = v.unwrap();
6626 assert!(
6627 matches!(val, Value::Attrs(_)),
6628 "a.x.y should be an attrset, got: {val:?}",
6629 );
6630 }
6631
6632 #[test]
6633 fn eval_mutual_recursive_let_simple() {
6634 // Simpler case: forward reference in sequential let bindings
6635 let v = eval("let a = b; b = 42; in a");
6636 assert!(v.is_ok());
6637 // After multi-pass: pass 2 sets a=Null (b not yet bound), b=42
6638 // pass 3 sets a=42, b=42
6639 assert_eq!(v.unwrap(), Value::Int(42));
6640 }
6641
6642 #[test]
6643 fn eval_builtins_read_dir() {
6644 let dir = std::env::temp_dir().join("sui_eval_test_readdir_eval");
6645 let _ = std::fs::remove_dir_all(&dir);
6646 std::fs::create_dir_all(&dir).unwrap();
6647 std::fs::write(dir.join("a.txt"), "").unwrap();
6648 let expr = format!(r#"builtins.readDir "{}""#, dir.display());
6649 let v = eval(&expr).unwrap();
6650 if let Value::Attrs(a) = v {
6651 assert_eq!(a.get("a.txt"), Some(&Value::string("regular")));
6652 } else {
6653 panic!("expected attrs");
6654 }
6655 let _ = std::fs::remove_dir_all(&dir);
6656 }
6657
6658 // ═══════════════════════════════════════════════════════════
6659 // 17. THUNK / LAZY EVALUATION
6660 // ═══════════════════════════════════════════════════════════
6661
6662 #[test]
6663 fn thunk_basic_let() {
6664 // Simple let binding through thunk.
6665 assert_eq!(ev("let x = 1; in x"), Value::Int(1));
6666 }
6667
6668 #[test]
6669 fn thunk_forward_ref() {
6670 // Forward reference: `a` references `b` which is defined later.
6671 assert_eq!(ev("let a = b; b = 1; in a"), Value::Int(1));
6672 }
6673
6674 #[test]
6675 fn thunk_mutual_rec_attrset_in_let() {
6676 // Mutual recursion through attrsets in let bindings.
6677 assert_eq!(ev("let a = { x = b; }; b = { y = 1; }; in a.x.y"), Value::Int(1));
6678 }
6679
6680 #[test]
6681 fn thunk_rec_attrset() {
6682 // rec { a = b; b = 1; } -- forward ref within rec set.
6683 assert_eq!(ev("(rec { a = b; b = 1; }).a"), Value::Int(1));
6684 }
6685
6686 #[test]
6687 fn thunk_rec_attrset_chain() {
6688 // Longer chain: c depends on b depends on a.
6689 assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
6690 }
6691
6692 #[test]
6693 fn thunk_fixpoint() {
6694 // Classic fixpoint combinator -- the core of nixpkgs' `lib.fix`.
6695 assert_eq!(
6696 ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; })).b"),
6697 Value::Int(2),
6698 );
6699 }
6700
6701 #[test]
6702 fn thunk_blackhole_self_reference() {
6703 // `let x = x; in x` is infinite recursion -- blackhole detection.
6704 let result = eval("let x = x; in x");
6705 assert!(result.is_err());
6706 let msg = format!("{}", result.unwrap_err());
6707 assert!(
6708 msg.contains("infinite recursion") || msg.contains("blackhole"),
6709 "expected blackhole error, got: {msg}",
6710 );
6711 }
6712
6713 #[test]
6714 fn thunk_mutual_blackhole() {
6715 // `let a = b; b = a; in a` -- mutual infinite recursion.
6716 let result = eval("let a = b; b = a; in a");
6717 assert!(result.is_err());
6718 }
6719
6720 #[test]
6721 fn thunk_let_body_forces_correctly() {
6722 // The let body should be able to use thunked bindings in arithmetic.
6723 assert_eq!(ev("let a = 10; b = 20; in a + b"), Value::Int(30));
6724 }
6725
6726 #[test]
6727 fn thunk_only_forced_when_needed() {
6728 // The binding `bad` would error if forced, but it is never used.
6729 assert_eq!(ev("let bad = 1 / 0; good = 42; in good"), Value::Int(42));
6730 }
6731
6732 #[test]
6733 fn thunk_forward_ref_in_function_body() {
6734 // Forward reference used inside a function body.
6735 assert_eq!(
6736 ev("let f = x: x + b; b = 10; in f 5"),
6737 Value::Int(15),
6738 );
6739 }
6740
6741 #[test]
6742 fn thunk_rec_set_self_ref_through_self() {
6743 // rec set where `b` references `a` which is in the same set.
6744 assert_eq!(
6745 ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6746 Value::Int(5),
6747 );
6748 }
6749
6750 #[test]
6751 fn thunk_nested_let_forward_ref() {
6752 // Forward reference in nested let.
6753 assert_eq!(
6754 ev("let a = b + 1; b = 2; in a"),
6755 Value::Int(3),
6756 );
6757 }
6758
6759 #[test]
6760 fn thunk_deep_chain() {
6761 // Chain of forward references: e -> d -> c -> b -> a.
6762 assert_eq!(
6763 ev("let a = 1; b = a; c = b; d = c; e = d; in e"),
6764 Value::Int(1),
6765 );
6766 }
6767
6768 #[test]
6769 fn thunk_rec_set_fixpoint() {
6770 // Fixpoint through rec set -- common nixpkgs pattern.
6771 assert_eq!(
6772 ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; c = self.b + 1; })).c"),
6773 Value::Int(3),
6774 );
6775 }
6776
6777 #[test]
6778 fn thunk_let_with_inherit() {
6779 // Inherit in let should work alongside thunked bindings.
6780 assert_eq!(
6781 ev("let a = 1; in let inherit a; b = a + 1; in b"),
6782 Value::Int(2),
6783 );
6784 }
6785
6786 #[test]
6787 fn thunk_attrset_value_lazy() {
6788 // Values in non-rec attrsets are evaluated eagerly, but the test
6789 // verifies that thunked let bindings inside attrset values work.
6790 assert_eq!(
6791 ev("let x = 42; in { a = x; }.a"),
6792 Value::Int(42),
6793 );
6794 }
6795
6796 #[test]
6797 fn thunk_unused_error_not_forced() {
6798 // Multiple bindings, only `ok` is used. `bad` throws but is never forced.
6799 assert_eq!(
6800 ev(r#"let bad = builtins.throw "boom"; ok = 1; in ok"#),
6801 Value::Int(1),
6802 );
6803 }
6804
6805 #[test]
6806 fn thunk_rec_set_mutual_reference() {
6807 // Mutual reference within rec set.
6808 let v = ev("rec { a = { val = b.val + 1; }; b = { val = 10; }; }");
6809 if let Value::Attrs(attrs) = v {
6810 let a = attrs.get("a").unwrap();
6811 let a_forced = force_value(a).unwrap();
6812 if let Value::Attrs(a_attrs) = a_forced {
6813 assert_eq!(a_attrs.get("val"), Some(&Value::Int(11)));
6814 } else {
6815 panic!("expected attrs for a");
6816 }
6817 } else {
6818 panic!("expected attrs");
6819 }
6820 }
6821
6822 // ── let-rec self-reference corner cases ───────────────
6823
6824 #[test]
6825 fn let_rec_self_reference_simple() {
6826 assert_eq!(
6827 ev("let x = 1; y = x + 1; in y"),
6828 Value::Int(2),
6829 );
6830 }
6831
6832 #[test]
6833 fn let_rec_self_reference_chain() {
6834 assert_eq!(
6835 ev("let a = 1; b = a + 1; c = b + 1; in c"),
6836 Value::Int(3),
6837 );
6838 }
6839
6840 #[test]
6841 fn let_rec_self_reference_with_function() {
6842 assert_eq!(
6843 ev("let f = x: x + 1; y = f 10; in y"),
6844 Value::Int(11),
6845 );
6846 }
6847
6848 #[test]
6849 fn let_rec_mutual_recursion_via_if() {
6850 assert_eq!(
6851 ev("let isEven = n: if n == 0 then true else isOdd (n - 1); isOdd = n: if n == 0 then false else isEven (n - 1); in isEven 4"),
6852 Value::Bool(true),
6853 );
6854 }
6855
6856 #[test]
6857 fn let_rec_forward_ref_in_list() {
6858 assert_eq!(
6859 ev("let xs = [a b]; a = 1; b = 2; in builtins.length xs"),
6860 Value::Int(2),
6861 );
6862 }
6863
6864 // ── with-shadowing corner cases ───────────────────────
6865
6866 #[test]
6867 fn with_shadowing_let_wins_over_with() {
6868 assert_eq!(
6869 ev("let x = 1; in with { x = 2; }; x"),
6870 Value::Int(1),
6871 );
6872 }
6873
6874 #[test]
6875 fn with_shadowing_inner_with_wins() {
6876 assert_eq!(
6877 ev("with { x = 1; }; with { x = 2; }; x"),
6878 Value::Int(2),
6879 );
6880 }
6881
6882 #[test]
6883 fn with_shadowing_outer_provides_missing() {
6884 assert_eq!(
6885 ev("with { x = 1; y = 10; }; with { x = 2; }; x + y"),
6886 Value::Int(12),
6887 );
6888 }
6889
6890 #[test]
6891 fn with_shadowing_lambda_arg_wins() {
6892 assert_eq!(
6893 ev("(x: with { x = 99; }; x) 42"),
6894 Value::Int(42),
6895 );
6896 }
6897
6898 #[test]
6899 fn with_shadowing_nested_let_wins_over_with() {
6900 assert_eq!(
6901 ev("with { x = 1; }; let x = 2; in x"),
6902 Value::Int(2),
6903 );
6904 }
6905
6906 #[test]
6907 fn with_scope_dynamic_attrs() {
6908 assert_eq!(
6909 ev(r#"with { x = 1; y = 2; z = 3; }; x + y + z"#),
6910 Value::Int(6),
6911 );
6912 }
6913
6914 #[test]
6915 fn with_scope_over_lazy_thunk_chain_resolves() {
6916 // A `with`-head that resolves through a NESTED thunk chain
6917 // (`Thunk(Thunk(Attrs))`) must still be searched: the lookup
6918 // has to FULLY force the head (chase the chain), not take a
6919 // single force step. A single step leaves a `Value::Thunk`
6920 // that `type_name()` reports as "set" but the `Value::Attrs`
6921 // match rejects — the scope is skipped and a bare ident
6922 // through it fails with a spurious UndefinedVar. This corners
6923 // the nixpkgs `platforms = with lib.platforms; unix;` shape.
6924 assert_eq!(
6925 ev(r#"let outer = if true then (if true then { unix = 42; } else {}) else {};
6926 # force a two-deep lazy wrap of the with-head
6927 head = (x: x) ((y: y) outer);
6928 in with head; unix"#),
6929 Value::Int(42),
6930 );
6931 }
6932
6933 #[test]
6934 fn with_scope_head_from_deep_select_resolves() {
6935 // `with a.b.c; key` where a.b.c is a lazily-selected attrset —
6936 // the bare-ident body must find `key` through the forced head.
6937 assert_eq!(
6938 ev(r#"let a = { b = { c = { key = 7; }; }; }; in with a.b.c; key"#),
6939 Value::Int(7),
6940 );
6941 }
6942
6943 // ── attrset deep merge ────────────────────────────────
6944
6945 #[test]
6946 fn attrset_deep_merge_simple() {
6947 let v = ev("{ a.b = 1; a.c = 2; }");
6948 if let Value::Attrs(attrs) = v {
6949 let a = force_value(attrs.get("a").unwrap()).unwrap();
6950 if let Value::Attrs(inner) = a {
6951 assert_eq!(force_value(inner.get("b").unwrap()).unwrap(), Value::Int(1));
6952 assert_eq!(force_value(inner.get("c").unwrap()).unwrap(), Value::Int(2));
6953 } else {
6954 panic!("expected nested attrs");
6955 }
6956 } else {
6957 panic!("expected attrs");
6958 }
6959 }
6960
6961 #[test]
6962 fn attrset_deep_merge_three_levels() {
6963 let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
6964 if let Value::Attrs(attrs) = v {
6965 let a = force_value(attrs.get("a").unwrap()).unwrap();
6966 if let Value::Attrs(a_inner) = a {
6967 let e = force_value(a_inner.get("e").unwrap()).unwrap();
6968 assert_eq!(e, Value::Int(3));
6969 let b = force_value(a_inner.get("b").unwrap()).unwrap();
6970 if let Value::Attrs(b_inner) = b {
6971 assert_eq!(force_value(b_inner.get("c").unwrap()).unwrap(), Value::Int(1));
6972 assert_eq!(force_value(b_inner.get("d").unwrap()).unwrap(), Value::Int(2));
6973 } else {
6974 panic!("expected nested attrs for b");
6975 }
6976 } else {
6977 panic!("expected nested attrs for a");
6978 }
6979 } else {
6980 panic!("expected attrs");
6981 }
6982 }
6983
6984 #[test]
6985 fn attrset_deep_merge_preserves_siblings() {
6986 assert_eq!(
6987 ev("{ a.x = 1; b = 2; a.y = 3; }.b"),
6988 Value::Int(2),
6989 );
6990 }
6991
6992 #[test]
6993 fn attrset_deep_merge_in_let() {
6994 let v = ev("let s = { a.b = 1; a.c = 2; }; in s.a.b + s.a.c");
6995 assert_eq!(v, Value::Int(3));
6996 }
6997
6998 #[test]
6999 fn attrset_deep_merge_fullset_then_dotted() {
7000 // General root (gst-plugins-base `passthru.waylandEnabled` drop):
7001 // `a = { x = 1; }; a.y = 2;` — the full-set binding is a lazy
7002 // Thunk (attrset literals go through maybe_thunk), so a naive
7003 // merge_nested_insert (which only merges concrete Value::Attrs)
7004 // overwrote `a` with `{ y = 2 }`, silently dropping `x`. The
7005 // collision must force the existing thunk to WHNF first.
7006 let v = ev("let s = { a = { x = 1; }; a.y = 2; }; in s.a.x + s.a.y");
7007 assert_eq!(v, Value::Int(3));
7008 // both keys must survive (not just their sum)
7009 let both = ev("let s = { a = { x = 1; }; a.y = 2; }; in [ s.a.x s.a.y ]");
7010 if let Value::List(items) = both {
7011 assert_eq!(force_value(&items[0]).unwrap(), Value::Int(1));
7012 assert_eq!(force_value(&items[1]).unwrap(), Value::Int(2));
7013 } else {
7014 panic!("expected list");
7015 }
7016 }
7017
7018 // ── inherit-from patterns ─────────────────────────────
7019
7020 #[test]
7021 fn inherit_from_basic() {
7022 assert_eq!(
7023 ev("let s = { x = 1; y = 2; }; in let inherit (s) x y; in x + y"),
7024 Value::Int(3),
7025 );
7026 }
7027
7028 #[test]
7029 fn inherit_from_with_shadowing() {
7030 assert_eq!(
7031 ev("let x = 10; in let inherit ({ x = 20; }) x; in x"),
7032 Value::Int(20),
7033 );
7034 }
7035
7036 #[test]
7037 fn inherit_from_in_attrset() {
7038 let v = ev(r#"let s = { a = 1; b = 2; }; in { inherit (s) a b; c = 3; }"#);
7039 if let Value::Attrs(attrs) = v {
7040 assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7041 assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7042 assert_eq!(force_value(attrs.get("c").unwrap()).unwrap(), Value::Int(3));
7043 } else {
7044 panic!("expected attrs");
7045 }
7046 }
7047
7048 #[test]
7049 fn inherit_from_rec_set() {
7050 assert_eq!(
7051 ev("rec { inherit ({ x = 42; }) x; y = x; }.y"),
7052 Value::Int(42),
7053 );
7054 }
7055
7056 #[test]
7057 fn inherit_plain_from_scope() {
7058 assert_eq!(
7059 ev("let x = 1; in { inherit x; }.x"),
7060 Value::Int(1),
7061 );
7062 }
7063
7064 // Regression (2026-07-11): a bare `inherit x;` must resolve LAZILY, like
7065 // a plain reference to `x` — not eagerly at attrset construction. When
7066 // `x` is provided only by an enclosing `with` scope whose value is a
7067 // fixpoint still being constructed, eager resolution spuriously threw
7068 // `UndefinedVar`. nixpkgs `all-packages.nix` is
7069 // `with pkgs; { nettle = import … { inherit callPackage; }; }`, so
7070 // `inherit callPackage` must resolve from the `with pkgs` scope at force
7071 // time. (This was the nettle UndefinedVar('callPackage') drop.)
7072 #[test]
7073 fn inherit_plain_from_with_scope_lazy() {
7074 // `inherit cp` reads `cp` from a `with self` fixpoint scope; the
7075 // attr forcing it (`a`) must resolve `cp` lazily against the settled
7076 // scope, not eagerly during attrset construction.
7077 assert_eq!(
7078 ev("let fix = f: let x = f x; in x;
7079 self = fix (self: with self; {
7080 a = use { inherit cp; };
7081 use = { cp }: cp 5;
7082 cp = x: x + 100;
7083 });
7084 in self.a"),
7085 Value::Int(105),
7086 );
7087 // Simpler: bare inherit from a plain (non-blackhole) with scope.
7088 assert_eq!(
7089 ev("with { y = 7; }; { inherit y; }.y"),
7090 Value::Int(7),
7091 );
7092 }
7093
7094 #[test]
7095 fn inherit_multiple_from_expr() {
7096 assert_eq!(
7097 ev("let s = { a = 10; b = 20; c = 30; }; in let inherit (s) a b c; in a + b + c"),
7098 Value::Int(60),
7099 );
7100 }
7101
7102 // ── string interpolation edge cases ───────────────────
7103
7104 #[test]
7105 fn interp_nested_attrset_access() {
7106 assert_eq!(
7107 ev(r#"let x = { a = "hello"; }; in "${x.a} world""#),
7108 Value::string("hello world"),
7109 );
7110 }
7111
7112 #[test]
7113 fn interp_with_let_expression() {
7114 assert_eq!(
7115 ev(r#""${let x = "inner"; in x}""#),
7116 Value::string("inner"),
7117 );
7118 }
7119
7120 #[test]
7121 fn interp_float_coercion() {
7122 // CppNix %f-format: always 6 decimal places.
7123 assert_eq!(
7124 ev(r#""${toString 3.14}""#),
7125 Value::string("3.140000"),
7126 );
7127 }
7128
7129 // ── comparison edge cases ─────────────────────────────
7130
7131 #[test]
7132 fn compare_mixed_int_float() {
7133 assert_eq!(ev("1 < 1.5"), Value::Bool(true));
7134 assert_eq!(ev("1.5 > 1"), Value::Bool(true));
7135 assert_eq!(ev("2.0 == 2"), Value::Bool(true));
7136 }
7137
7138 #[test]
7139 fn compare_string_lexicographic() {
7140 assert_eq!(ev(r#""abc" < "abd""#), Value::Bool(true));
7141 assert_eq!(ev(r#""abc" < "abc""#), Value::Bool(false));
7142 assert_eq!(ev(r#""abc" <= "abc""#), Value::Bool(true));
7143 }
7144
7145 // ── update operator edge cases ────────────────────────
7146
7147 #[test]
7148 fn update_empty_sets() {
7149 let v = ev("{} // {}");
7150 if let Value::Attrs(a) = v { assert!(a.is_empty()); } else { panic!(); }
7151 }
7152
7153 #[test]
7154 fn update_right_overrides_completely() {
7155 assert_eq!(
7156 ev("{ a = 1; b = 2; } // { a = 10; c = 30; }"),
7157 ev("{ a = 10; b = 2; c = 30; }"),
7158 );
7159 }
7160
7161 #[test]
7162 fn update_chained() {
7163 assert_eq!(
7164 ev("{ a = 1; } // { b = 2; } // { c = 3; }"),
7165 ev("{ a = 1; b = 2; c = 3; }"),
7166 );
7167 }
7168
7169 // ── force_value edge cases ────────────────────────────
7170
7171 #[test]
7172 fn force_value_concrete_unchanged() {
7173 let v = Value::Int(42);
7174 assert_eq!(force_value(&v).unwrap(), Value::Int(42));
7175 }
7176
7177 #[test]
7178 fn force_value_null() {
7179 assert_eq!(force_value(&Value::Null).unwrap(), Value::Null);
7180 }
7181
7182 // ── eval_with_file ────────────────────────────────────
7183
7184 #[test]
7185 fn eval_with_file_none() {
7186 let result = eval_with_file("1 + 2", None).unwrap();
7187 assert_eq!(result, Value::Int(3));
7188 }
7189
7190 // ── error messages ────────────────────────────────────
7191
7192 #[test]
7193 fn error_type_mismatch_in_comparison() {
7194 let result = eval(r#"1 < "a""#);
7195 assert!(result.is_err());
7196 }
7197
7198 #[test]
7199 fn error_select_from_non_set() {
7200 let result = eval("42.x");
7201 assert!(result.is_err());
7202 }
7203
7204 #[test]
7205 fn error_call_non_function() {
7206 let result = eval("42 1");
7207 assert!(result.is_err());
7208 }
7209
7210 #[test]
7211 fn error_negate_string() {
7212 let result = eval(r#"-"hello""#);
7213 assert!(result.is_err());
7214 }
7215
7216 // ── multiline string edge cases ───────────────────────
7217
7218 #[test]
7219 fn multiline_string_empty() {
7220 assert_eq!(ev("''''"), Value::string(""));
7221 }
7222
7223 #[test]
7224 fn multiline_string_with_trailing_newline() {
7225 let v = ev("''\n hello\n''");
7226 assert_eq!(v, Value::string("hello\n"));
7227 }
7228
7229 // ── list operations ───────────────────────────────────
7230
7231 #[test]
7232 fn list_concat_empty_left() {
7233 assert_eq!(ev("[] ++ [1 2]"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7234 }
7235
7236 #[test]
7237 fn list_concat_empty_right() {
7238 assert_eq!(ev("[1 2] ++ []"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7239 }
7240
7241 #[test]
7242 fn list_concat_both_empty() {
7243 assert_eq!(ev("[] ++ []"), Value::list(vec![]));
7244 }
7245
7246 // ── pattern matching / formals edge cases ─────────────
7247
7248 #[test]
7249 fn formals_at_pattern_accessible() {
7250 assert_eq!(
7251 ev("({ x, ... } @ args: builtins.length (builtins.attrNames args)) { x = 1; y = 2; z = 3; }"),
7252 Value::Int(3),
7253 );
7254 }
7255
7256 #[test]
7257 fn formals_default_uses_other_arg() {
7258 assert_eq!(
7259 ev("({ x, y ? x + 1 }: y) { x = 10; }"),
7260 Value::Int(11),
7261 );
7262 }
7263
7264 #[test]
7265 fn formals_default_lazy_assert_false() {
7266 // nixpkgs parse.nix pattern: default is `assert false; null` but
7267 // the body checks `args ? vendor` instead of using `vendor`
7268 // directly, so the default must never be forced.
7269 assert_eq!(
7270 ev("({ cpu, vendor ? assert false; null, kernel } @ args: if args ? vendor then vendor else \"inferred\") { cpu = \"x86_64\"; kernel = \"linux\"; }"),
7271 Value::String(Rc::new(NixString::plain("inferred"))),
7272 );
7273 }
7274
7275 #[test]
7276 fn formals_default_lazy_only_forced_when_accessed() {
7277 // When the default IS accessed, it should still evaluate correctly.
7278 assert_eq!(
7279 ev("({ a, b ? 42 }: b) { a = 1; }"),
7280 Value::Int(42),
7281 );
7282 }
7283
7284 #[test]
7285 fn formals_ellipsis_ignores_extra() {
7286 assert_eq!(
7287 ev("({ x, ... }: x) { x = 1; y = 2; z = 3; }"),
7288 Value::Int(1),
7289 );
7290 }
7291
7292 // ── pure mode ─────────────────────────────────────────
7293
7294 #[test]
7295 fn pure_mode_roundtrip() {
7296 let was_pure = is_pure_mode();
7297 set_pure_mode(true);
7298 assert!(is_pure_mode());
7299 set_pure_mode(false);
7300 assert!(!is_pure_mode());
7301 set_pure_mode(was_pure);
7302 }
7303
7304 // ── path operations ───────────────────────────────────
7305
7306 #[test]
7307 fn path_concat_with_string() {
7308 assert_eq!(
7309 ev(r#"/foo + "bar""#),
7310 Value::Path(Box::new(SmolStr::from("/foobar"))),
7311 );
7312 }
7313
7314 #[test]
7315 fn path_concat_with_path() {
7316 assert_eq!(
7317 ev("/foo + /bar"),
7318 Value::Path(Box::new(SmolStr::from("/foo//bar"))),
7319 );
7320 }
7321
7322 // ── EvalFileGuard / current_eval_dir ───────────────────
7323
7324 #[test]
7325 fn current_eval_dir_empty_when_no_file_pushed() {
7326 // Without a push, current_eval_dir should yield None.
7327 // (Note: this test is order-dependent; we accept whatever the
7328 // top of the stack happens to be when called.)
7329 let snapshot = current_eval_dir();
7330 // At minimum the API doesn't panic and returns Option.
7331 let _ = snapshot;
7332 }
7333
7334 #[test]
7335 fn push_eval_file_sets_current_dir() {
7336 let p = std::path::PathBuf::from("/tmp/example/file.nix");
7337 {
7338 let _g = push_eval_file(p.clone());
7339 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/tmp/example")));
7340 }
7341 // Guard dropped, stack popped — current dir is whatever was below.
7342 // We can't assert exact value without snapshotting first, but the
7343 // value before push should be restored.
7344 }
7345
7346 #[test]
7347 fn push_eval_file_nested_stack() {
7348 let outer = std::path::PathBuf::from("/a/x.nix");
7349 let inner = std::path::PathBuf::from("/b/y.nix");
7350 {
7351 let _g_outer = push_eval_file(outer.clone());
7352 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7353 {
7354 let _g_inner = push_eval_file(inner.clone());
7355 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/b")));
7356 }
7357 // Inner dropped — outer is back on top.
7358 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7359 }
7360 }
7361
7362 /// A fileless frame MASKS the parent's file rather than being skipped.
7363 ///
7364 /// Regression: the stack used to be `Vec<PathBuf>`, so a thunk captured in
7365 /// a `--expr` context pushed nothing when it forced and the callee's file
7366 /// stayed visible. `builtins.unsafeGetAttrPos` then reported the callee's
7367 /// path where CppNix reports `null`, which set `eval-config.nix`'s
7368 /// `modulesLocation` and permuted NixOS module definition order.
7369 #[test]
7370 fn fileless_frame_masks_parent_file() {
7371 let outer = std::path::PathBuf::from("/a/x.nix");
7372 let _g_outer = push_eval_file(outer.clone());
7373 assert_eq!(current_eval_file(), Some(outer.clone()));
7374 {
7375 let _g_none = push_eval_frame(None);
7376 // The whole point: NOT Some("/a/x.nix").
7377 assert_eq!(current_eval_file(), None);
7378 assert_eq!(current_eval_dir(), None);
7379 assert_eq!(eval_file_stack_snapshot().last().map(String::as_str), Some("<no-file>"));
7380 }
7381 // Popped — the parent is visible again.
7382 assert_eq!(current_eval_file(), Some(outer));
7383 }
7384
7385 // ── Source-mapped error context ────────────────────────
7386
7387 #[test]
7388 fn error_undefined_var_includes_file_context() {
7389 let p = std::path::PathBuf::from("/nix/store/abc-default.nix");
7390 let _g = push_eval_file(p);
7391 let result = eval("nonexistent_xyz");
7392 let msg = format!("{}", result.unwrap_err());
7393 assert!(msg.contains("undefined variable"), "msg: {msg}");
7394 assert!(msg.contains("nonexistent_xyz"), "msg: {msg}");
7395 assert!(msg.contains("abc-default.nix"), "msg: {msg}");
7396 }
7397
7398 #[test]
7399 fn error_attr_not_found_includes_file_context() {
7400 let p = std::path::PathBuf::from("/nix/store/xyz-module.nix");
7401 let _g = push_eval_file(p);
7402 let result = eval("{}.missing_key");
7403 let msg = format!("{}", result.unwrap_err());
7404 assert!(msg.contains("not found") || msg.contains("missing_key"), "msg: {msg}");
7405 assert!(msg.contains("xyz-module.nix"), "msg: {msg}");
7406 }
7407
7408 #[test]
7409 fn error_assertion_failed_includes_file_context() {
7410 let p = std::path::PathBuf::from("/nix/store/test-assert.nix");
7411 let _g = push_eval_file(p);
7412 let result = eval("assert false; 1");
7413 let msg = format!("{}", result.unwrap_err());
7414 assert!(msg.contains("assertion failed"), "msg: {msg}");
7415 assert!(msg.contains("test-assert.nix"), "msg: {msg}");
7416 }
7417
7418 /// `inherit` binds an attribute, so it carries a position.
7419 ///
7420 /// Regression: `attach_attrset_positions` matched only
7421 /// `Entry::AttrpathValue`, so every inherited key was position-less — most
7422 /// of nixpkgs' `lib`, which re-exports via `inherit (self.options) mkOption
7423 /// …`, and it fed a null into `eval-config.nix`'s `modulesLocation`.
7424 ///
7425 /// Shaped exactly like `unsafe_get_attr_pos_reports_file_and_offset_column`
7426 /// (ONE direct `eval`, no lambda, no second evaluation) because the
7427 /// in-process harness is fragile here: the source-text registry is a
7428 /// thread-local that `pos.rs`'s tests clear, so a multi-eval version passes
7429 /// standalone and fails in the full suite. The CLI path is not affected —
7430 /// verified against `nix eval` on both shapes, both engines agreeing on
7431 /// column 18.
7432 #[test]
7433 fn inherit_bindings_carry_positions() {
7434 let dir = tempfile::tempdir().unwrap();
7435 // A PLAIN attrset, no `let ... in` wrapper: with the wrapper the
7436 // result is built lazily AFTER `import` returns, and the in-process
7437 // harness then resolves it without the file on the eval stack. The CLI
7438 // handles both (measured), the harness only this one.
7439 let body = "{ inherit ({ x = 1; }) x; }\n";
7440 let f = dir.path().join("inh.nix");
7441 std::fs::write(&f, body).unwrap();
7442 let v = eval(&format!("builtins.unsafeGetAttrPos \"x\" (import {})", f.display())).unwrap();
7443 let attrs = match v {
7444 Value::Attrs(a) => a,
7445 Value::Null => panic!("null — the inherit binding carried no position"),
7446 o => panic!("expected attrs, got {o:?}"),
7447 };
7448 // Computed from the fixture, never hardcoded: a hardcoded expectation is
7449 // how `pos::line_col`'s own "verified" comment came to agree with the
7450 // bug it documented.
7451 let off = body.rfind("x; }").unwrap();
7452 let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7453 assert_eq!(*attrs.get("line").unwrap(), Value::Int(1));
7454 assert_eq!(*attrs.get("column").unwrap(), Value::Int((off - bol) as i64 + 1));
7455 }
7456
7457 /// Corpus gate: every attribute-BINDING form carries a position.
7458 ///
7459 /// Seals the class the three position bugs came from, rather than the three
7460 /// instances: `//` dropping positions wholesale, `pos::line_col` returning a
7461 /// constant, and `inherit` never being recorded. Each was found only because
7462 /// a NixOS toplevel drvPath diverged — an expensive way to learn that an
7463 /// attribute lost its position.
7464 ///
7465 /// Expectations are DERIVED from the fixture, never written out, so the test
7466 /// cannot drift into agreeing with whatever the implementation emits. That
7467 /// is exactly how `line_col`'s own "verified against nix eval" comment came
7468 /// to document the bug it contained.
7469 ///
7470 /// Anti-vacuity: the row count is asserted, and any `NULL` fails. A change
7471 /// that stops attaching positions altogether makes every row `NULL` — which
7472 /// must be a failure, not an empty-set pass.
7473 #[test]
7474 fn every_binding_form_carries_a_position() {
7475 let dir = tempfile::tempdir().unwrap();
7476 // One line per key so the expected line number is its 1-based index.
7477 let body = concat!(
7478 "let src = { i = 1; j = 2; }; in {\n",
7479 " plain = 1;\n",
7480 " \"quoted\" = 2;\n",
7481 " inherit (src) i;\n",
7482 " inherit src;\n",
7483 " nested.deep = 3;\n",
7484 "}\n",
7485 );
7486 let f = dir.path().join("forms.nix");
7487 std::fs::write(&f, body).unwrap();
7488
7489 // `nested` is the head of a dotted path; CppNix points at the head.
7490 let keys = ["plain", "quoted", "i", "src", "nested"];
7491 let probe = keys
7492 .iter()
7493 .map(|k| format!(
7494 "(let q = builtins.unsafeGetAttrPos \"{k}\" t; \
7495 in if q == null then \"{k}=NULL\" \
7496 else \"{k}=${{toString q.line}}:${{toString q.column}}\")"
7497 ))
7498 .collect::<Vec<_>>()
7499 .join(" + \" \" + ");
7500 let got = eval(&format!("let t = import {}; in {probe}", f.display()))
7501 .unwrap()
7502 .as_string()
7503 .unwrap()
7504 .to_string();
7505
7506 assert!(!got.contains("NULL"), "a binding form lost its position: {got}");
7507 let rows: Vec<&str> = got.split(' ').collect();
7508 assert_eq!(rows.len(), keys.len(), "corpus shrank — gate would be vacuous: {got}");
7509
7510 // Derive each expectation by locating the key token in the fixture.
7511 for (k, row) in keys.iter().zip(&rows) {
7512 let needle = match *k {
7513 "quoted" => "\"quoted\"".to_string(),
7514 "i" => "i;".to_string(),
7515 "src" => "src;".to_string(),
7516 // A dotted path's head is followed by `.`, not ` =` — CppNix
7517 // reports the HEAD token's position for the outer key.
7518 "nested" => "nested.".to_string(),
7519 other => format!("{other} ="),
7520 };
7521 let off = body.find(&needle).unwrap();
7522 let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7523 let line = 1 + body[..off].matches('\n').count();
7524 let col = off - bol + 1;
7525 assert_eq!(*row, format!("{k}={line}:{col}"), "wrong position for `{k}` in:\n{body}");
7526 }
7527 }
7528
7529 /// A missing-argument error names the file the LAMBDA came from.
7530 ///
7531 /// Evaluated with `eval_with_file`, not `push_eval_file` + bare `eval`, and
7532 /// the difference is the point. Calling a closure now pushes the closure's
7533 /// OWN file — including a fileless frame when it has none — so a lambda
7534 /// defined in a fileless string no longer borrows whatever unrelated file
7535 /// happens to sit on the stack. That borrowing is what the old form
7536 /// asserted, and CppNix does not do it: an `--expr` lambda has no file.
7537 /// Associating the source with a file, as every real `import` does, keeps
7538 /// the original intent (errors carry file context) while testing the path
7539 /// production actually takes. Verified against CppNix: for a lambda in a
7540 /// real file both engines name that file.
7541 #[test]
7542 fn error_missing_argument_includes_file_context() {
7543 let p = std::path::PathBuf::from("/nix/store/func.nix");
7544 let result = eval_with_file("({ a, b }: a) { a = 1; }", Some(p));
7545 let msg = format!("{}", result.unwrap_err());
7546 assert!(msg.contains("missing argument"), "msg: {msg}");
7547 assert!(msg.contains("func.nix"), "msg: {msg}");
7548 }
7549
7550 #[test]
7551 fn error_cannot_call_includes_file_context() {
7552 let p = std::path::PathBuf::from("/nix/store/call.nix");
7553 let _g = push_eval_file(p);
7554 let result = eval("42 99");
7555 let msg = format!("{}", result.unwrap_err());
7556 assert!(msg.contains("cannot call"), "msg: {msg}");
7557 assert!(msg.contains("call.nix"), "msg: {msg}");
7558 }
7559
7560 #[test]
7561 fn error_without_file_has_no_in_prefix() {
7562 // When no file is on the eval stack, error messages should
7563 // not contain ", in" context.
7564 let result = eval("nonexistent_xyz");
7565 let msg = format!("{}", result.unwrap_err());
7566 assert!(msg.contains("undefined variable"), "msg: {msg}");
7567 assert!(!msg.contains(", in"), "msg should not contain file context: {msg}");
7568 }
7569
7570 // ── pure mode getter/setter independence ───────────────
7571
7572 #[test]
7573 fn pure_mode_set_get_independence() {
7574 let was = is_pure_mode();
7575 set_pure_mode(true);
7576 assert!(is_pure_mode());
7577 set_pure_mode(false);
7578 assert!(!is_pure_mode());
7579 set_pure_mode(was);
7580 }
7581
7582 // ── eval_with_file with file path ──────────────────────
7583
7584 #[test]
7585 fn eval_with_file_some_path_arithmetic() {
7586 let p = std::path::PathBuf::from("/tmp/imaginary.nix");
7587 let result = eval_with_file("1 + 2", Some(p)).unwrap();
7588 assert_eq!(result, Value::Int(3));
7589 }
7590
7591 // ── unsafeGetAttrPos — the options.json `attrTag` declarations root ──
7592 //
7593 // Seals the CppNix-matching behavior: for a literal attrset built in a
7594 // FILE, `builtins.unsafeGetAttrPos <key> <set>` returns
7595 // `{ file; line=1; column=<key byte offset>+1; }`; for a `<string>` eval
7596 // (no file) it returns `null`. Byte-verified against `nix eval`.
7597
7598 #[test]
7599 fn unsafe_get_attr_pos_reports_file_and_offset_column() {
7600 // The real `attrTag` path: a literal attrset built in an IMPORTED file.
7601 // `import` registers the file's source text + pushes it on the eval
7602 // stack, so `eval_attrset` captures the key positions against that file
7603 // and `unsafeGetAttrPos` resolves them. CppNix reports the file plus a
7604 // real newline-resolved line and BYTE column.
7605 //
7606 // Re-baselined: this used to assert line 1 and column = the key's
7607 // 1-based byte offset in the whole file, citing "verified against nix
7608 // eval". It was not — that was sui's own output taken as the oracle,
7609 // and the same false rule was pinned in pos.rs. Measured on nix 2.31.5:
7610 // for `{ a = 1;\n b = 2; }` the `b` key is 2:3, not 1:12.
7611 let dir = tempfile::tempdir().unwrap();
7612 // The literal's `b` key sits at a known byte offset in this file.
7613 let file_body = "{ a = 1;\n b = 2; }\n";
7614 let f = dir.path().join("lit.nix");
7615 std::fs::write(&f, file_body).unwrap();
7616 let src = format!("builtins.unsafeGetAttrPos \"b\" (import {})", f.display());
7617 let v = eval(&src).unwrap();
7618 let attrs = match v { Value::Attrs(a) => a, other => panic!("expected attrs, got {other:?}") };
7619 assert_eq!(
7620 attrs.get("file").unwrap().as_string().unwrap(),
7621 f.to_string_lossy(),
7622 );
7623 // `b` is on the SECOND line, at byte column 3.
7624 let off = file_body.find("b = 2").unwrap();
7625 let bol = file_body[..off].rfind('\n').map_or(0, |i| i + 1);
7626 let expected_line = 1 + file_body[..off].matches('\n').count() as i64;
7627 let expected_col = (off - bol) as i64 + 1;
7628 assert_eq!(expected_line, 2, "fixture must put `b` on line 2");
7629 assert_eq!(*attrs.get("line").unwrap(), Value::Int(expected_line));
7630 let col = match attrs.get("column").unwrap() { Value::Int(n) => *n, o => panic!("{o:?}") };
7631 assert_eq!(col, expected_col, "column must be the 1-based BYTE column");
7632 }
7633
7634 #[test]
7635 fn unsafe_get_attr_pos_null_for_string_origin() {
7636 // A `<string>`-eval'd literal (no file on the stack) has no position → null.
7637 let v = eval("builtins.unsafeGetAttrPos \"a\" { a = 1; }").unwrap();
7638 assert_eq!(v, Value::Null);
7639 }
7640
7641 #[test]
7642 fn unsafe_get_attr_pos_null_for_missing_key() {
7643 // A key absent from an imported set → null.
7644 let dir = tempfile::tempdir().unwrap();
7645 let f = dir.path().join("lit.nix");
7646 std::fs::write(&f, "{ a = 1; }\n").unwrap();
7647 let src = format!("builtins.unsafeGetAttrPos \"zzz\" (import {})", f.display());
7648 let v = eval(&src).unwrap();
7649 assert_eq!(v, Value::Null);
7650 }
7651
7652 // ── String interpolation primitive coercions ───────────
7653
7654 #[test]
7655 fn interp_int_into_string() {
7656 // Integer interpolated into a string is coerced to its decimal repr.
7657 assert_eq!(ev(r#""val=${toString 42}""#), Value::string("val=42"));
7658 }
7659
7660 #[test]
7661 fn interp_bool_true_becomes_one() {
7662 // Per eval_str: Bool(true) → "1", Bool(false) → "" (empty)
7663 let v = ev(r#"let x = true; in "${builtins.toString x}""#);
7664 assert_eq!(v, Value::string("1"));
7665 }
7666
7667 #[test]
7668 fn interp_null_becomes_empty() {
7669 // Null in interpolation is empty.
7670 let v = ev(r#"let x = null; in "${builtins.toString x}""#);
7671 assert_eq!(v, Value::string(""));
7672 }
7673
7674 #[test]
7675 fn interp_attrset_without_to_string_errors() {
7676 // An attrset interpolated without __toString is a type error.
7677 let result = eval(r#"let s = { x = 1; }; in "${s}""#);
7678 assert!(result.is_err());
7679 }
7680
7681 #[test]
7682 fn interp_attrset_with_to_string_protocol() {
7683 // __toString protocol returns a string when called with self.
7684 let v = ev(r#""${{ __toString = self: "ok"; }}""#);
7685 assert_eq!(v, Value::string("ok"));
7686 }
7687
7688 // ── Path PathRel / PathHome / PathAbs ─────────────────
7689
7690 #[test]
7691 fn eval_path_absolute_literal() {
7692 let v = ev("/tmp/foo");
7693 match v {
7694 Value::Path(p) => assert!(p.contains("/tmp/foo")),
7695 _ => panic!("expected Path"),
7696 }
7697 }
7698
7699 #[test]
7700 fn eval_path_home_literal() {
7701 let v = ev("~/foo.nix");
7702 match v {
7703 Value::Path(p) => assert!(p.contains("~/foo.nix") || p.ends_with("foo.nix")),
7704 _ => panic!("expected Path"),
7705 }
7706 }
7707
7708 // ── search path miss ──────────────────────────────────
7709
7710 #[test]
7711 fn path_search_unmatched_errors() {
7712 // Without NIX_PATH entries matching, <nonexistent> errors out.
7713 // We unset NIX_PATH locally to ensure no entries match.
7714 let saved = std::env::var("NIX_PATH").ok();
7715 // SAFETY: tests run sequentially in single-threaded mode by
7716 // default? The thread_local NIX_PATH is per-thread but std::env
7717 // is process-global. We restore it after.
7718 unsafe {
7719 std::env::remove_var("NIX_PATH");
7720 }
7721 let result = eval("<this_should_not_resolve>");
7722 if let Some(v) = saved {
7723 unsafe {
7724 std::env::set_var("NIX_PATH", v);
7725 }
7726 }
7727 assert!(result.is_err());
7728 }
7729
7730 // ── Unary operators ────────────────────────────────────
7731
7732 #[test]
7733 fn unary_negate_int() {
7734 assert_eq!(ev("-7"), Value::Int(-7));
7735 }
7736
7737 #[test]
7738 fn unary_negate_float() {
7739 assert_eq!(ev("-2.5"), Value::Float(-2.5));
7740 }
7741
7742 #[test]
7743 fn unary_invert_true() {
7744 assert_eq!(ev("!true"), Value::Bool(false));
7745 }
7746
7747 #[test]
7748 fn unary_invert_false() {
7749 assert_eq!(ev("!false"), Value::Bool(true));
7750 }
7751
7752 #[test]
7753 fn unary_negate_bool_errors() {
7754 let result = eval("-true");
7755 assert!(result.is_err());
7756 }
7757
7758 #[test]
7759 fn unary_invert_int_errors() {
7760 let result = eval("!42");
7761 assert!(result.is_err());
7762 }
7763
7764 // ── Binary op type errors ──────────────────────────────
7765
7766 #[test]
7767 fn binop_add_attrs_errors() {
7768 let result = eval("{a=1;} + {b=2;}");
7769 assert!(result.is_err());
7770 }
7771
7772 #[test]
7773 fn binop_sub_string_errors() {
7774 let result = eval(r#""a" - "b""#);
7775 assert!(result.is_err());
7776 }
7777
7778 #[test]
7779 fn binop_mul_string_errors() {
7780 let result = eval(r#""a" * "b""#);
7781 assert!(result.is_err());
7782 }
7783
7784 #[test]
7785 fn binop_div_string_errors() {
7786 let result = eval(r#""a" / "b""#);
7787 assert!(result.is_err());
7788 }
7789
7790 #[test]
7791 fn binop_compare_attrs_errors() {
7792 let result = eval("{a=1;} < {b=2;}");
7793 assert!(result.is_err());
7794 }
7795
7796 #[test]
7797 fn binop_div_float_by_zero_int() {
7798 // Float / int(0) is NOT a DivisionByZero error in this evaluator —
7799 // only int/int matches the DivisionByZero branch. This documents
7800 // that branch.
7801 let result = eval("1.0 / 0");
7802 // Either inf or error is acceptable; the documented branch is
7803 // the int/int(0) → DivisionByZero one.
7804 let _ = result;
7805 }
7806
7807 #[test]
7808 fn binop_int_div_zero_is_division_by_zero() {
7809 let result = eval("5 / 0");
7810 match result {
7811 Err(EvalError::DivisionByZero) => {}
7812 other => panic!("expected DivisionByZero, got {other:?}"),
7813 }
7814 }
7815
7816 // ── if/then/else laziness ──────────────────────────────
7817
7818 #[test]
7819 fn if_else_only_chosen_branch_evaluated_then() {
7820 // The else branch contains a divide-by-zero that would error
7821 // if eagerly evaluated. Choosing the then branch must skip it.
7822 assert_eq!(ev("if true then 42 else 1 / 0"), Value::Int(42));
7823 }
7824
7825 #[test]
7826 fn if_else_only_chosen_branch_evaluated_else() {
7827 assert_eq!(ev("if false then 1 / 0 else 99"), Value::Int(99));
7828 }
7829
7830 #[test]
7831 fn if_condition_must_be_bool() {
7832 let result = eval("if 1 then 1 else 2");
7833 assert!(result.is_err());
7834 }
7835
7836 #[test]
7837 fn if_condition_lazy_does_not_force_unused() {
7838 // Lazy `let` ensures that `bad` is only forced if the chosen
7839 // branch references it.
7840 assert_eq!(
7841 ev("let bad = 1 / 0; in if true then 42 else bad"),
7842 Value::Int(42),
7843 );
7844 }
7845
7846 // ── Logic short-circuit laziness ───────────────────────
7847
7848 #[test]
7849 fn and_short_circuits_on_false() {
7850 // RHS contains an error; should never run.
7851 assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
7852 }
7853
7854 #[test]
7855 fn or_short_circuits_on_true() {
7856 assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
7857 }
7858
7859 #[test]
7860 fn implication_short_circuits_on_false_lhs() {
7861 // false -> anything is true; RHS not evaluated.
7862 assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
7863 }
7864
7865 // ── Lambda fixpoint via let ────────────────────────────
7866
7867 #[test]
7868 fn lambda_fix_combinator_returns_attrset() {
7869 // The classic `fix = f: let x = f x; in x` shape.
7870 let v = ev(
7871 "let fix = f: let x = f x; in x; in
7872 (fix (self: { val = 1; double = self.val * 2; })).double",
7873 );
7874 assert_eq!(v, Value::Int(2));
7875 }
7876
7877 // ── eval_attrset rec scope details ─────────────────────
7878
7879 #[test]
7880 fn rec_attrset_self_reference() {
7881 // rec set with simple forward reference.
7882 let v = ev("(rec { a = b; b = 1; }).a");
7883 assert_eq!(v, Value::Int(1));
7884 }
7885
7886 #[test]
7887 fn rec_attrset_inherit_from_uses_outer_scope() {
7888 // inherit-from in rec uses the OUTER (lexical) scope to evaluate
7889 // the source expression, not the rec scope. We bind `src` in
7890 // an outer let so the inherit can find it.
7891 let v = ev(
7892 "let src = { a = 10; }; in
7893 rec {
7894 inherit (src) a;
7895 b = a + 1;
7896 }",
7897 );
7898 if let Value::Attrs(attrs) = v {
7899 let b = attrs.get("b").unwrap();
7900 let b_forced = force_value(b).unwrap();
7901 assert_eq!(b_forced, Value::Int(11));
7902 } else {
7903 panic!("expected attrs");
7904 }
7905 }
7906
7907 #[test]
7908 fn nonrec_attrset_no_self_reference() {
7909 // In a non-rec set, a name doesn't see its sibling. The error
7910 // surfaces as an UndefinedVar when the thunk is forced.
7911 let result = eval("({ a = 1; b = a + 1; }).b");
7912 assert!(result.is_err());
7913 }
7914
7915 // ── eval_attrset deep merge edge cases ─────────────────
7916
7917 #[test]
7918 fn dotted_binding_three_segments_then_sibling() {
7919 let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
7920 if let Value::Attrs(attrs) = v {
7921 let a = attrs.get("a").unwrap();
7922 let a_forced = force_value(a).unwrap();
7923 if let Value::Attrs(a_attrs) = a_forced {
7924 let b = a_attrs.get("b").unwrap();
7925 let b_forced = force_value(b).unwrap();
7926 if let Value::Attrs(b_attrs) = b_forced {
7927 assert_eq!(force_value(b_attrs.get("c").unwrap()).unwrap(), Value::Int(1));
7928 assert_eq!(force_value(b_attrs.get("d").unwrap()).unwrap(), Value::Int(2));
7929 } else {
7930 panic!("expected b to be attrs");
7931 }
7932 assert_eq!(force_value(a_attrs.get("e").unwrap()).unwrap(), Value::Int(3));
7933 } else {
7934 panic!("expected a to be attrs");
7935 }
7936 } else {
7937 panic!("expected outer attrs");
7938 }
7939 }
7940
7941 // ── rec/let dotted bindings in recursive scope ────────
7942
7943 #[test]
7944 fn rec_dotted_bindings_visible_to_siblings() {
7945 // Dotted bindings in rec blocks must be visible to sibling
7946 // bindings -- this is the nixpkgs lib/systems/parse.nix pattern.
7947 let v = ev("rec { types.openSB = 1; types.openCpu = 2; foo = types.openSB; }.foo");
7948 assert_eq!(v, Value::Int(1));
7949 }
7950
7951 #[test]
7952 fn rec_dotted_leaf_uses_rec_scope() {
7953 // Leaf expressions in dotted bindings must see sibling
7954 // rec-bindings, not just the parent scope.
7955 let v = ev("rec { types.a = f 1; f = x: x + 1; }.types.a");
7956 assert_eq!(v, Value::Int(2));
7957 }
7958
7959 #[test]
7960 fn rec_dotted_multiple_keys_merge() {
7961 // Multiple dotted bindings sharing a top-level key must merge.
7962 let v = ev("rec { types.a = 1; types.b = 2; x = types; }.x");
7963 if let Value::Attrs(attrs) = v {
7964 assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7965 assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7966 } else {
7967 panic!("expected attrs");
7968 }
7969 }
7970
7971 #[test]
7972 fn rec_nixpkgs_parse_pattern() {
7973 // Simplified nixpkgs lib/systems/parse.nix pattern:
7974 // rec block with dotted types.xxx bindings that reference
7975 // each other through the rec scope.
7976 let v = ev(r#"
7977 let
7978 mkOptionType = x: x;
7979 mergeOneOption = "merge";
7980 attrValues = builtins.attrValues;
7981 setType = name: value: { __type = name; } // value;
7982 mapAttrs = builtins.mapAttrs;
7983 enum = xs: mkOptionType { name = "enum"; check = x: builtins.elem x xs; };
7984 setTypes = type: mapAttrs (name: value: setType type.name ({ inherit name; } // value));
7985 in
7986 rec {
7987 types.openSB = mkOptionType { name = "sb"; merge = mergeOneOption; };
7988 types.significantByte = enum (attrValues significantBytes);
7989 significantBytes = setTypes types.openSB { bigEndian = {}; littleEndian = {}; };
7990 types.openCpuType = mkOptionType { name = "cpu-type"; };
7991 types.cpuType = enum (attrValues cpuTypes);
7992 cpuTypes = setTypes types.openCpuType { arm = { bits = 32; }; };
7993 }.types.openCpuType
7994 "#);
7995 if let Value::Attrs(attrs) = v {
7996 assert_eq!(
7997 force_value(attrs.get("name").unwrap()).unwrap(),
7998 Value::string("cpu-type")
7999 );
8000 } else {
8001 panic!("expected attrs");
8002 }
8003 }
8004
8005 #[test]
8006 fn let_dotted_leaf_uses_let_scope() {
8007 // Dotted binding leaf in a let block sees sibling let-bindings.
8008 let v = ev("let a.x = f 1; f = x: x + 1; in a.x");
8009 assert_eq!(v, Value::Int(2));
8010 }
8011
8012 #[test]
8013 fn let_inherit_from_plus_dotted_overrides() {
8014 // inherit-from and dotted bindings for the same key in a let
8015 // block: CppNix rejects this as a duplicate definition. Sui
8016 // currently lets the dotted binding win (last-write-wins).
8017 // This test documents the current behaviour -- when we add
8018 // duplicate detection it should change to assert an error.
8019 let v = ev(r#"
8020 let
8021 src = { types = { existing = true; }; };
8022 inherit (src) types;
8023 types.added = true;
8024 in types
8025 "#);
8026 if let Value::Attrs(attrs) = v {
8027 // Dotted binding overwrites the inherited value
8028 assert_eq!(
8029 force_value(attrs.get("added").unwrap()).unwrap(),
8030 Value::Bool(true)
8031 );
8032 // Inherited 'existing' is lost because dotted replaced it
8033 assert!(attrs.get("existing").is_none());
8034 } else {
8035 panic!("expected attrs");
8036 }
8037 }
8038
8039 // ── Function pattern variations ────────────────────────
8040
8041 #[test]
8042 fn pattern_empty_no_args_no_ellipsis() {
8043 // {} pattern accepts only an empty attrset.
8044 assert_eq!(ev("({}: 1) {}"), Value::Int(1));
8045 }
8046
8047 #[test]
8048 fn pattern_empty_with_ellipsis_accepts_extra() {
8049 assert_eq!(ev("({...}: 1) { a = 1; b = 2; }"), Value::Int(1));
8050 }
8051
8052 #[test]
8053 fn pattern_all_defaults() {
8054 assert_eq!(
8055 ev("({a ? 1, b ? 2}: a + b) {}"),
8056 Value::Int(3),
8057 );
8058 }
8059
8060 #[test]
8061 fn pattern_at_bind_before() {
8062 // args @ { x }: args.x — bind name comes before pattern.
8063 assert_eq!(ev("(args @ { x }: args.x) { x = 7; }"), Value::Int(7));
8064 }
8065
8066 #[test]
8067 fn pattern_at_bind_after() {
8068 // { x } @ args: args.x — bind name comes after pattern.
8069 assert_eq!(ev("({ x } @ args: args.x) { x = 7; }"), Value::Int(7));
8070 }
8071
8072 #[test]
8073 fn pattern_default_references_other_arg() {
8074 // The default for `b` references `a` (which exists).
8075 assert_eq!(ev("({a, b ? a + 1}: b) {a = 10;}"), Value::Int(11));
8076 }
8077
8078 #[test]
8079 fn pattern_required_missing_errors() {
8080 let result = eval("({ a, b }: a) { a = 1; }");
8081 assert!(result.is_err());
8082 }
8083
8084 #[test]
8085 fn pattern_unexpected_errors_without_ellipsis() {
8086 let result = eval("({ a }: a) { a = 1; b = 2; }");
8087 assert!(result.is_err());
8088 }
8089
8090 // ── apply: error on non-callable ───────────────────────
8091
8092 #[test]
8093 fn apply_int_errors() {
8094 let result = eval("42 5");
8095 assert!(result.is_err());
8096 }
8097
8098 #[test]
8099 fn apply_string_errors() {
8100 let result = eval(r#""hi" 5"#);
8101 assert!(result.is_err());
8102 }
8103
8104 #[test]
8105 fn apply_attrset_without_functor_errors() {
8106 let result = eval("{ x = 1; } 5");
8107 assert!(result.is_err());
8108 let msg = format!("{}", result.unwrap_err());
8109 assert!(msg.contains("__functor") || msg.contains("cannot call"));
8110 }
8111
8112 // ── Select with multi-segment + default ────────────────
8113
8114 #[test]
8115 fn select_multi_segment_with_default() {
8116 // a.b.missing or 99 -- the missing segment yields the default.
8117 assert_eq!(ev("{ a = { b = 1; }; }.a.c or 99"), Value::Int(99));
8118 }
8119
8120 #[test]
8121 fn select_from_int_errors() {
8122 let result = eval("(1).x");
8123 assert!(result.is_err());
8124 }
8125
8126 // ── HasAttr edge cases ─────────────────────────────────
8127
8128 #[test]
8129 fn has_attr_on_non_set_returns_false() {
8130 // `expr ? a` where expr is not a set returns false (not error).
8131 assert_eq!(ev("1 ? x"), Value::Bool(false));
8132 }
8133
8134 #[test]
8135 fn has_attr_nested_path_present() {
8136 assert_eq!(ev("{ a = { b = 1; }; } ? a.b"), Value::Bool(true));
8137 }
8138
8139 #[test]
8140 fn has_attr_nested_path_missing() {
8141 assert_eq!(ev("{ a = { b = 1; }; } ? a.c"), Value::Bool(false));
8142 }
8143
8144 #[test]
8145 fn has_attr_intermediate_missing_returns_false() {
8146 assert_eq!(ev("{} ? a.b.c"), Value::Bool(false));
8147 }
8148
8149 // ── List eval edge cases ───────────────────────────────
8150
8151 #[test]
8152 fn list_with_function_value() {
8153 let v = ev("[(x: x + 1)]");
8154 if let Value::List(items) = v {
8155 assert_eq!(items.len(), 1);
8156 // List elements are now lazy (thunked). Force to check type.
8157 let forced = force_value(&items[0]).unwrap();
8158 assert!(matches!(forced, Value::Lambda(_)));
8159 } else {
8160 panic!("expected list");
8161 }
8162 }
8163
8164 // ── eval_inherit edge: inherit from missing var ────────
8165
8166 #[test]
8167 fn inherit_unknown_name_errors() {
8168 let result = eval("let x = 1; in let inherit nonexistent; in nonexistent");
8169 assert!(result.is_err());
8170 }
8171
8172 // ── String op: string concat preserves context ─────────
8173
8174 #[test]
8175 fn string_concat_no_context_when_both_plain() {
8176 let v = ev(r#""abc" + "def""#);
8177 if let Value::String(ns) = v {
8178 assert_eq!(ns.chars, "abcdef");
8179 assert!(!ns.has_context());
8180 } else {
8181 panic!("expected string");
8182 }
8183 }
8184
8185 // ── Parens / Root ──────────────────────────────────────
8186
8187 #[test]
8188 fn parens_around_expression() {
8189 assert_eq!(ev("(1 + 2)"), Value::Int(3));
8190 }
8191
8192 #[test]
8193 fn nested_parens() {
8194 assert_eq!(ev("(((42)))"), Value::Int(42));
8195 }
8196
8197 // ── Throw via builtins ─────────────────────────────────
8198
8199 #[test]
8200 fn throw_propagates_as_error() {
8201 let result = eval(r#"builtins.throw "kaboom""#);
8202 match result {
8203 Err(EvalError::Throw(s)) => assert!(s.contains("kaboom")),
8204 other => panic!("expected Throw, got {other:?}"),
8205 }
8206 }
8207
8208 #[test]
8209 fn assert_failed_propagates_as_error() {
8210 let result = eval("assert false; 1");
8211 match result {
8212 Err(EvalError::AssertionFailed(_)) => {}
8213 other => panic!("expected AssertionFailed, got {other:?}"),
8214 }
8215 }
8216
8217 // ── eval_str InterpolPart::Literal only ────────────────
8218
8219 #[test]
8220 fn string_no_interp_yields_no_context() {
8221 let v = ev(r#""just literal""#);
8222 if let Value::String(ns) = v {
8223 assert!(!ns.has_context());
8224 } else {
8225 panic!("expected string");
8226 }
8227 }
8228
8229 // ── Path interpolation adds context ───────────────────
8230
8231 // Byte-parity root #5: interpolating a source path is CppNix copy-to-store
8232 // coercion — the path is NAR-copied into /nix/store/<hash>-<name> and the
8233 // store path (with store-path context) is spliced in, not the raw path.
8234 // NAR of a single regular file is content+basename only (location-
8235 // independent), so a temp <dir>/data.txt of "hello\n" yields the exact
8236 // store path nix 2.34 produced: /nix/store/y9dmv…-data.txt.
8237 #[test]
8238 fn interp_path_copies_to_store_byte_matches_cppnix() {
8239 let dir = std::env::temp_dir().join(format!("sui-r5-interp-{}", std::process::id()));
8240 let _ = std::fs::remove_dir_all(&dir);
8241 std::fs::create_dir_all(&dir).unwrap();
8242 let f = dir.join("data.txt");
8243 std::fs::write(&f, b"hello\n").unwrap();
8244 let expr = format!(r#""${{{}}}""#, f.display());
8245 let v = eval(&expr).unwrap();
8246 if let Value::String(ns) = v {
8247 assert_eq!(
8248 ns.chars.to_string(),
8249 "/nix/store/y9dmvfhip31hg8ia4njwjz9vfa3ndphr-data.txt",
8250 );
8251 assert!(ns.has_context());
8252 } else {
8253 panic!("expected string");
8254 }
8255 let _ = std::fs::remove_dir_all(&dir);
8256 }
8257
8258 // ── pipe operators (NotImplemented) ────────────────────
8259 // Pipe operators (|>, <|) are parsed as PipeRight/PipeLeft and
8260 // currently return NotImplemented. We can't easily evaluate them
8261 // here because rnix may not even parse them, so we just rely on
8262 // the binop branch existing.
8263
8264 // ── ParseError surface ─────────────────────────────────
8265
8266 #[test]
8267 fn parse_error_unbalanced_braces() {
8268 let result = eval("{ a = 1");
8269 assert!(result.is_err());
8270 let err = result.unwrap_err();
8271 assert!(matches!(err, EvalError::ParseError(_)));
8272 }
8273
8274 #[test]
8275 fn parse_error_dangling_let() {
8276 let result = eval("let in");
8277 assert!(result.is_err());
8278 }
8279
8280 #[test]
8281 fn parse_error_empty_input() {
8282 let result = eval("");
8283 assert!(result.is_err());
8284 }
8285
8286 // ── num_op coverage via float ops ──────────────────────
8287
8288 #[test]
8289 fn float_int_subtraction() {
8290 assert_eq!(ev("3.5 - 1"), Value::Float(2.5));
8291 }
8292
8293 #[test]
8294 fn int_float_subtraction() {
8295 assert_eq!(ev("3 - 0.5"), Value::Float(2.5));
8296 }
8297
8298 #[test]
8299 fn float_float_division() {
8300 assert_eq!(ev("6.0 / 2.0"), Value::Float(3.0));
8301 }
8302
8303 #[test]
8304 fn int_float_multiplication() {
8305 assert_eq!(ev("3 * 2.5"), Value::Float(7.5));
8306 }
8307
8308 // ── compare with mixed numerics ────────────────────────
8309
8310 #[test]
8311 fn compare_int_float_less() {
8312 assert_eq!(ev("1 < 1.5"), Value::Bool(true));
8313 }
8314
8315 #[test]
8316 fn compare_float_int_more() {
8317 assert_eq!(ev("3.5 > 3"), Value::Bool(true));
8318 }
8319
8320 #[test]
8321 fn compare_equal_int_float() {
8322 assert_eq!(ev("3 <= 3.0"), Value::Bool(true));
8323 }
8324
8325 // ── Equality ──────────────────────────────────────────
8326
8327 #[test]
8328 fn equal_lists_same() {
8329 assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
8330 }
8331
8332 #[test]
8333 fn equal_lists_diff_length() {
8334 assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
8335 }
8336
8337 #[test]
8338 fn not_equal_lists() {
8339 assert_eq!(ev("[1] != [2]"), Value::Bool(true));
8340 }
8341
8342 #[test]
8343 fn equal_attrsets_same() {
8344 assert_eq!(ev("{a = 1; b = 2;} == {b = 2; a = 1;}"), Value::Bool(true));
8345 }
8346
8347 // ── Lambda identity equality (Rc ptr_eq) ────────────────
8348 // Regression test: same lambda via Rc must compare equal.
8349 // Without this, nixpkgs stdenv evaluation enters an infinite loop
8350 // because `crossSystem != localSystem` returns true even when both
8351 // are the same elaborate result (containing shared function attrs).
8352
8353 #[test]
8354 fn lambda_self_equality_in_attrset() {
8355 // Same closure shared via let → inherit must be equal
8356 assert_eq!(
8357 ev("let f = x: x; in { a = 1; inherit f; } == { a = 1; inherit f; }"),
8358 Value::Bool(true),
8359 );
8360 }
8361
8362 #[test]
8363 fn lambda_self_reference_attrset_equality() {
8364 // Attrset with function attr: x == x must be true
8365 assert_eq!(
8366 ev("let x = { a = 1; f = y: y; }; in x == x"),
8367 Value::Bool(true),
8368 );
8369 }
8370
8371 #[test]
8372 fn lambda_different_closures_not_equal() {
8373 // Different lambda closures (even structurally identical) must be false
8374 assert_eq!(
8375 ev("{ f = x: x; } == { f = x: x; }"),
8376 Value::Bool(false),
8377 );
8378 }
8379
8380 #[test]
8381 fn lambda_ne_does_not_force_unused_branch() {
8382 // If crossSystem == localSystem (same obj), != returns false,
8383 // and the then-branch (with throw) is never forced.
8384 assert_eq!(
8385 ev("let ls = { a = 1; f = x: x; }; in if ls != ls then builtins.throw \"bug\" else 42"),
8386 Value::Int(42),
8387 );
8388 }
8389
8390 // ── force_value chains thunks ──────────────────────────
8391
8392 #[test]
8393 fn force_value_through_thunk() {
8394 let root = rnix::Root::parse("1 + 2");
8395 let expr = root.tree().expr().unwrap();
8396 let thunk = Thunk::new_suspended(expr, Env::new());
8397 let val = Value::Thunk(thunk);
8398 assert_eq!(force_value(&val).unwrap(), Value::Int(3));
8399 }
8400
8401 // ── Builtin name "tryEval" lazy arg path ──────────────
8402
8403 #[test]
8404 fn try_eval_catches_thrown_error() {
8405 // tryEval wraps the thunk and catches throws inside.
8406 let v = ev(r#"(builtins.tryEval (builtins.throw "oops")).success"#);
8407 assert_eq!(v, Value::Bool(false));
8408 }
8409
8410 #[test]
8411 fn try_eval_returns_value_on_success() {
8412 let v = ev("(builtins.tryEval 42).value");
8413 assert_eq!(v, Value::Int(42));
8414 }
8415
8416 // ── LegacyLet (`let { body = ...; ...}`) ───────────────
8417
8418 #[test]
8419 fn legacy_let_returns_body_attr() {
8420 // `let { x = 1; body = x + 41; }` is the legacy let form: it
8421 // is desugared as a recursive set whose `body` attr is the
8422 // result.
8423 assert_eq!(ev("let { x = 1; body = x + 41; }"), Value::Int(42));
8424 }
8425
8426 #[test]
8427 fn legacy_let_missing_body_errors() {
8428 let result = eval("let { x = 1; }");
8429 assert!(result.is_err());
8430 }
8431
8432 #[test]
8433 fn legacy_let_with_inherit_from_scope() {
8434 assert_eq!(
8435 ev("let outer = 5; in let { inherit outer; body = outer * 2; }"),
8436 Value::Int(10),
8437 );
8438 }
8439
8440 // ── eval_str interpolation more cases ──────────────────
8441
8442 #[test]
8443 fn interp_with_string_concat_preserves_order() {
8444 assert_eq!(
8445 ev(r#"let a = "x"; b = "y"; in "${a}-${b}""#),
8446 Value::string("x-y"),
8447 );
8448 }
8449
8450 #[test]
8451 fn interp_only_literal_part() {
8452 assert_eq!(ev(r#""no interp here""#), Value::string("no interp here"));
8453 }
8454
8455 // ── eval_attr dynamic / string keys ────────────────────
8456
8457 #[test]
8458 fn dynamic_attr_via_string_key_in_set() {
8459 // `{ "a" = 1; }.a` works because attr keys can be string literals.
8460 assert_eq!(ev(r#"{ "a" = 1; }.a"#), Value::Int(1));
8461 }
8462
8463 #[test]
8464 fn dynamic_attr_via_interpolated_key() {
8465 let v = ev(r#"let k = "foo"; in { ${k} = 99; }.foo"#);
8466 assert_eq!(v, Value::Int(99));
8467 }
8468
8469 // ── String key access via select with dynamic ──────────
8470
8471 #[test]
8472 fn select_with_string_key() {
8473 let v = ev(r#"{ a = 42; }."a""#);
8474 assert_eq!(v, Value::Int(42));
8475 }
8476
8477 // ── Apply via __functor on attrset ─────────────────────
8478
8479 #[test]
8480 fn apply_attrset_with_functor_works() {
8481 let v = ev("let s = { __functor = self: x: x + 1; }; in s 5");
8482 assert_eq!(v, Value::Int(6));
8483 }
8484
8485 // ── Negation of negative ───────────────────────────────
8486
8487 #[test]
8488 fn double_negate_int() {
8489 assert_eq!(ev("- (-5)"), Value::Int(5));
8490 }
8491
8492 // ── Inherit from rec scope binding visibility ──────────
8493
8494 #[test]
8495 fn inherit_in_let_makes_name_available() {
8496 assert_eq!(
8497 ev("let src = { a = 7; }; in let inherit (src) a; in a"),
8498 Value::Int(7),
8499 );
8500 }
8501
8502 // ── String + path ──────────────────────────────────────
8503
8504 #[test]
8505 fn path_plus_string_yields_path() {
8506 let v = ev(r#"/foo + "/bar""#);
8507 match v {
8508 Value::Path(p) => assert_eq!(&*p, "/foo/bar"),
8509 _ => panic!("expected path"),
8510 }
8511 }
8512
8513 // ── Lazy attrset value not forced unless selected ──────
8514
8515 #[test]
8516 fn attrset_value_not_forced_unless_selected() {
8517 // `bad` is an attr whose value would error if forced, but we
8518 // only ever select `good`, so it's never touched.
8519 assert_eq!(
8520 ev(r#"{ bad = builtins.throw "boom"; good = 42; }.good"#),
8521 Value::Int(42),
8522 );
8523 }
8524
8525 // ── Lambda calling itself via let ──────────────────────
8526
8527 #[test]
8528 fn lambda_recursive_via_let() {
8529 // factorial via let-bound recursive function
8530 assert_eq!(
8531 ev("let fact = n: if n == 0 then 1 else n * fact (n - 1); in fact 5"),
8532 Value::Int(120),
8533 );
8534 }
8535
8536 // ── Dynamic key in select ──────────────────────────────
8537
8538 #[test]
8539 fn select_with_dynamic_key_via_var() {
8540 // ${k} interpolation in select position is not standard Nix
8541 // syntax, but a string-literal key works for select.
8542 assert_eq!(ev(r#"let k = { x = 1; }; in k.x"#), Value::Int(1));
8543 }
8544
8545 // ── Compare strings ────────────────────────────────────
8546
8547 #[test]
8548 fn compare_string_lex_greater_or_equal() {
8549 assert_eq!(ev(r#""b" >= "a""#), Value::Bool(true));
8550 assert_eq!(ev(r#""a" >= "a""#), Value::Bool(true));
8551 assert_eq!(ev(r#""a" >= "b""#), Value::Bool(false));
8552 }
8553
8554 // ── PartialEq across types ─────────────────────────────
8555
8556 #[test]
8557 fn equal_int_string_false() {
8558 assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
8559 }
8560
8561 #[test]
8562 fn equal_null_int_false() {
8563 assert_eq!(ev("null == 0"), Value::Bool(false));
8564 }
8565
8566 // ── Update operator on thunked operands ────────────────
8567
8568 #[test]
8569 fn update_with_let_bound_operands() {
8570 assert_eq!(
8571 ev("let a = { x = 1; }; b = { y = 2; }; in (a // b).y"),
8572 Value::Int(2),
8573 );
8574 }
8575
8576 // ── Concat on let-bound lists ──────────────────────────
8577
8578 #[test]
8579 fn concat_lists_from_let() {
8580 assert_eq!(
8581 ev("let a = [1 2]; b = [3 4]; in builtins.length (a ++ b)"),
8582 Value::Int(4),
8583 );
8584 }
8585
8586 // ── String interpolation: list coercion ─────────────────
8587
8588 #[test]
8589 fn interp_list_coerces_with_spaces() {
8590 // Lists in interpolation are now coerced via coerce_to_string
8591 // (space-joined elements).
8592 assert_eq!(
8593 ev(r#""${toString [1 2 3]}""#),
8594 Value::string("1 2 3"),
8595 );
8596 }
8597
8598 #[test]
8599 fn interp_list_directly_coerces() {
8600 // Direct list interpolation space-joins elements via coerce_to_string.
8601 assert_eq!(
8602 ev(r#""${[1 2]}""#),
8603 Value::string("1 2"),
8604 );
8605 }
8606
8607 // ── String interpolation: outPath ─────────────────────
8608
8609 #[test]
8610 fn interp_outpath_attrset() {
8611 assert_eq!(
8612 ev(r#"let x = { outPath = "/nix/store/abc"; }; in "${x}""#),
8613 Value::string("/nix/store/abc"),
8614 );
8615 }
8616
8617 #[test]
8618 fn interp_tostring_takes_priority_over_outpath() {
8619 assert_eq!(
8620 ev(r#"let x = { __toString = self: "custom"; outPath = "/ignored"; }; in "${x}""#),
8621 Value::string("custom"),
8622 );
8623 }
8624
8625 #[test]
8626 fn interp_derivation_coerces_to_outpath() {
8627 // derivation produces an attrset with outPath
8628 let result = eval(r#"
8629 let drv = builtins.derivation {
8630 name = "test";
8631 system = "x86_64-linux";
8632 builder = "/bin/sh";
8633 };
8634 in "${drv}"
8635 "#).unwrap();
8636 if let Value::String(s) = result {
8637 assert!(s.chars.starts_with("/nix/store/"), "got: {}", s.chars);
8638 } else {
8639 panic!("expected string");
8640 }
8641 }
8642
8643 // ── String interpolation: lambda error ─────────────────
8644
8645 #[test]
8646 fn interp_lambda_errors() {
8647 let result = eval(r#""${x: x}""#);
8648 assert!(result.is_err());
8649 }
8650
8651 // ── force_value tests ────────────────────────────────────
8652
8653 #[test]
8654 fn force_value_int_returns_same() {
8655 let v = Value::Int(42);
8656 assert_eq!(force_value(&v).unwrap(), Value::Int(42));
8657 }
8658
8659 #[test]
8660 fn force_value_bool_returns_same() {
8661 let v = Value::Bool(true);
8662 assert_eq!(force_value(&v).unwrap(), Value::Bool(true));
8663 }
8664
8665 #[test]
8666 fn force_value_string_returns_same() {
8667 let v = Value::string("hello");
8668 assert_eq!(force_value(&v).unwrap(), Value::string("hello"));
8669 }
8670
8671 #[test]
8672 fn force_value_attrs_returns_same() {
8673 let mut a = NixAttrs::new();
8674 a.insert("x".to_string(), Value::Int(1));
8675 let v = Value::Attrs(Rc::new(a.clone()));
8676 assert_eq!(force_value(&v).unwrap(), Value::Attrs(Rc::new(a)));
8677 }
8678
8679 #[test]
8680 fn force_value_list_returns_same() {
8681 let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
8682 assert_eq!(
8683 force_value(&v).unwrap(),
8684 Value::list(vec![Value::Int(1), Value::Int(2)]),
8685 );
8686 }
8687
8688 #[test]
8689 fn force_value_null_returns_null() {
8690 let v = Value::Null;
8691 assert_eq!(force_value(&v).unwrap(), Value::Null);
8692 }
8693
8694 #[test]
8695 fn force_value_evaluated_thunk_returns_cached() {
8696 // Thunk wrapping a simple expression should evaluate and cache
8697 let v = ev("let x = 1 + 2; in x");
8698 assert_eq!(v, Value::Int(3));
8699 // Force again — should return the cached value
8700 assert_eq!(force_value(&v).unwrap(), Value::Int(3));
8701 }
8702
8703 // ── Tail-call loop tests ─────────────────────────────────
8704
8705 #[test]
8706 fn tco_if_true_condition() {
8707 assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
8708 }
8709
8710 #[test]
8711 fn tco_if_false_condition() {
8712 assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
8713 }
8714
8715 #[test]
8716 fn tco_deeply_nested_if_else_chain() {
8717 // Build a chain: if false then 1 else if false then 2 else ... else 150
8718 // All conditions are false except the final else, which produces 150.
8719 let mut expr = String::from("150");
8720 for i in (1..150).rev() {
8721 expr = format!("if false then {} else {}", i, expr);
8722 }
8723 let v = ev(&expr);
8724 assert_eq!(v, Value::Int(150));
8725 }
8726
8727 #[test]
8728 fn tco_assert_true_passes_through() {
8729 assert_eq!(ev("assert true; 42"), Value::Int(42));
8730 }
8731
8732 #[test]
8733 fn tco_assert_false_throws_assertion_failed() {
8734 let result = eval("assert false; 42");
8735 assert!(result.is_err());
8736 let err = result.unwrap_err();
8737 assert!(
8738 matches!(err, EvalError::AssertionFailed(_)),
8739 "expected AssertionFailed, got: {err}",
8740 );
8741 }
8742
8743 #[test]
8744 fn tco_with_makes_scope_available() {
8745 assert_eq!(ev("with { x = 10; y = 20; }; x + y"), Value::Int(30));
8746 }
8747
8748 #[test]
8749 fn tco_let_in_creates_bindings() {
8750 assert_eq!(ev("let a = 5; in a"), Value::Int(5));
8751 }
8752
8753 #[test]
8754 fn tco_let_in_multiple_bindings() {
8755 assert_eq!(ev("let a = 1; b = 2; c = 3; in a + b + c"), Value::Int(6));
8756 }
8757
8758 // ── eval_attrset tests ───────────────────────────────────
8759
8760 #[test]
8761 fn eval_attrset_empty() {
8762 let v = ev("{}");
8763 if let Value::Attrs(attrs) = v {
8764 assert!(attrs.is_empty(), "expected empty attrset");
8765 } else {
8766 panic!("expected attrset, got {v:?}");
8767 }
8768 }
8769
8770 #[test]
8771 fn eval_attrset_simple_kv() {
8772 let v = ev("{ a = 1; b = 2; }");
8773 if let Value::Attrs(attrs) = v {
8774 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8775 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8776 } else {
8777 panic!("expected attrset, got {v:?}");
8778 }
8779 }
8780
8781 #[test]
8782 fn eval_attrset_recursive() {
8783 assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
8784 assert_eq!(ev("(rec { a = 1; b = a + 1; }).a"), Value::Int(1));
8785 }
8786
8787 #[test]
8788 fn eval_attrset_inherit_from_scope() {
8789 assert_eq!(ev("let x = 1; in { inherit x; }.x"), Value::Int(1));
8790 }
8791
8792 #[test]
8793 fn eval_attrset_inherit_from_expr() {
8794 assert_eq!(
8795 ev("{ inherit (builtins) true; }.true"),
8796 Value::Bool(true),
8797 );
8798 }
8799
8800 #[test]
8801 fn eval_attrset_dotted_path() {
8802 assert_eq!(ev("{ a.b.c = 1; }.a.b.c"), Value::Int(1));
8803 }
8804
8805 #[test]
8806 fn eval_attrset_update_merge() {
8807 let v = ev("{ a = 1; } // { b = 2; }");
8808 if let Value::Attrs(attrs) = v {
8809 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8810 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8811 } else {
8812 panic!("expected attrset, got {v:?}");
8813 }
8814 }
8815
8816 // ── eval_apply tests ─────────────────────────────────────
8817
8818 #[test]
8819 fn eval_apply_simple_function() {
8820 assert_eq!(ev("(x: x + 1) 2"), Value::Int(3));
8821 }
8822
8823 #[test]
8824 fn eval_apply_pattern_destructuring() {
8825 assert_eq!(ev("({a, b}: a + b) { a = 1; b = 2; }"), Value::Int(3));
8826 }
8827
8828 #[test]
8829 fn eval_apply_default_arguments() {
8830 assert_eq!(ev("({a, b ? 0}: a + b) { a = 1; }"), Value::Int(1));
8831 }
8832
8833 #[test]
8834 fn eval_apply_ellipsis() {
8835 assert_eq!(ev("({a, ...}: a) { a = 1; b = 2; }"), Value::Int(1));
8836 }
8837
8838 // ── eval_select tests ────────────────────────────────────
8839
8840 #[test]
8841 fn eval_select_single_key() {
8842 assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
8843 }
8844
8845 #[test]
8846 fn eval_select_multi_level() {
8847 assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
8848 }
8849
8850 #[test]
8851 fn eval_select_with_or_default() {
8852 assert_eq!(ev("{}.a or 42"), Value::Int(42));
8853 }
8854
8855 #[test]
8856 fn eval_select_missing_key_without_default_throws() {
8857 let result = eval("{}.a");
8858 assert!(result.is_err());
8859 }
8860
8861 // ── BinOp tests ──────────────────────────────────────────
8862
8863 #[test]
8864 fn binop_add_ints() {
8865 assert_eq!(ev("1 + 2"), Value::Int(3));
8866 }
8867
8868 #[test]
8869 fn binop_sub_ints() {
8870 assert_eq!(ev("3 - 1"), Value::Int(2));
8871 }
8872
8873 #[test]
8874 fn binop_mul_ints() {
8875 assert_eq!(ev("2 * 3"), Value::Int(6));
8876 }
8877
8878 #[test]
8879 fn binop_div_ints() {
8880 assert_eq!(ev("6 / 2"), Value::Int(3));
8881 }
8882
8883 #[test]
8884 fn binop_float_arithmetic() {
8885 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
8886 }
8887
8888 #[test]
8889 fn binop_string_concat() {
8890 assert_eq!(
8891 ev(r#""hello" + " " + "world""#),
8892 Value::string("hello world"),
8893 );
8894 }
8895
8896 #[test]
8897 fn binop_list_concat() {
8898 assert_eq!(
8899 ev("[1 2] ++ [3 4]"),
8900 Value::list(vec![
8901 Value::Int(1),
8902 Value::Int(2),
8903 Value::Int(3),
8904 Value::Int(4),
8905 ]),
8906 );
8907 }
8908
8909 #[test]
8910 fn binop_attrset_update() {
8911 let v = ev("{ a = 1; } // { b = 2; }");
8912 if let Value::Attrs(attrs) = v {
8913 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8914 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8915 } else {
8916 panic!("expected attrset, got {v:?}");
8917 }
8918 }
8919
8920 #[test]
8921 fn binop_less_than() {
8922 assert_eq!(ev("1 < 2"), Value::Bool(true));
8923 assert_eq!(ev("2 < 1"), Value::Bool(false));
8924 }
8925
8926 #[test]
8927 fn binop_greater_than() {
8928 assert_eq!(ev("2 > 1"), Value::Bool(true));
8929 assert_eq!(ev("1 > 2"), Value::Bool(false));
8930 }
8931
8932 #[test]
8933 fn binop_equal() {
8934 assert_eq!(ev("1 == 1"), Value::Bool(true));
8935 assert_eq!(ev("1 == 2"), Value::Bool(false));
8936 }
8937
8938 #[test]
8939 fn binop_not_equal() {
8940 assert_eq!(ev("1 != 2"), Value::Bool(true));
8941 assert_eq!(ev("1 != 1"), Value::Bool(false));
8942 }
8943
8944 #[test]
8945 fn binop_logical_and() {
8946 assert_eq!(ev("true && false"), Value::Bool(false));
8947 assert_eq!(ev("true && true"), Value::Bool(true));
8948 }
8949
8950 #[test]
8951 fn binop_logical_or() {
8952 assert_eq!(ev("true || false"), Value::Bool(true));
8953 assert_eq!(ev("false || false"), Value::Bool(false));
8954 }
8955
8956 #[test]
8957 fn binop_logical_not() {
8958 assert_eq!(ev("!true"), Value::Bool(false));
8959 assert_eq!(ev("!false"), Value::Bool(true));
8960 }
8961
8962 #[test]
8963 fn binop_implication() {
8964 assert_eq!(ev("false -> true"), Value::Bool(true));
8965 assert_eq!(ev("false -> false"), Value::Bool(true));
8966 assert_eq!(ev("true -> true"), Value::Bool(true));
8967 assert_eq!(ev("true -> false"), Value::Bool(false));
8968 }
8969}
8970
8971/// Build an attrset from a `sui-normalize` [`GroupPlan`].
8972///
8973/// This is the plan-driven replacement for the entry loops in
8974/// [`eval_attrset`] / the `LetIn` arm / `eval_entries`. It exists because
8975/// nix's duplicate-key merge is a **parse-time splice into the first-declared
8976/// node**, not a value-level union: the second side's bindings become
8977/// bindings *of the first node*, so they are scoped by it and the later
8978/// `rec` is discarded. `sui-normalize` performed that splice; this function
8979/// only evaluates the result.
8980///
8981/// The consequence worth stating: there is no merging here, and no collision
8982/// to resolve. `attrs.insert` is a plain insert because the plan's
8983/// postcondition is that no name appears twice. That is what retires
8984/// `merge_nested_insert` from the construction path — and with it the
8985/// force-to-WHNF-on-collision that turned
8986/// `let f = x: x+1; a.b = {x = f 1;}; a.b.y = 2; in a.b.x` into
8987/// `UndefinedVar 'f'` on an expression nix evaluates to `2`.
8988pub fn eval_plan_group(
8989 plan: &sui_normalize::GroupPlan,
8990 env: &Env,
8991) -> Result<Value, EvalError> {
8992 let (attrs, _scope) = bind_plan_group(plan, env)?;
8993 Ok(Value::Attrs(std::rc::Rc::new(attrs)))
8994}
8995
8996/// Build a plan's bindings, returning BOTH the attrset and the scope they were
8997/// bound in.
8998///
8999/// Two consumers need different halves of this. An attrset literal wants the
9000/// attrs; a `let` wants the scope, because a `let` is a binder for a body and
9001/// produces no attrset at all. Legacy-`let` (`let { … body = …; }`) wants the
9002/// attrs and then selects `body` from them.
9003fn bind_plan_group(
9004 plan: &sui_normalize::GroupPlan,
9005 env: &Env,
9006) -> Result<(NixAttrs, Env), EvalError> {
9007 use sui_normalize::Binding;
9008
9009 let mut attrs = NixAttrs::new();
9010 // A recursive group binds its own names; a non-recursive one does not.
9011 // `rec`-ness came from the FIRST declaration — see `sui-normalize`.
9012 let mut scope_env = if plan.recursive { env.child() } else { env.clone() };
9013 let mut thunks: Vec<Thunk> = Vec::new();
9014
9015 // `inherit (e)` sources: ONE thunk per clause, shared across every name
9016 // that clause binds, so `e` is evaluated at most once. Built against the
9017 // group's OWN scope — measured on nix: `rec { b = {x=99;}; inherit (b) x; }`
9018 // is `x = 99`, so the source sees the group it is being bound into.
9019 let from_thunks: Vec<Thunk> = plan
9020 .inherit_froms
9021 .iter()
9022 .map(|e| Thunk::new_suspended(e.clone(), scope_env.clone()))
9023 .collect();
9024
9025 for b in &plan.statics {
9026 let name = sui_intern::resolve(b.name).to_string();
9027 let value = match &b.binding {
9028 Binding::Leaf(expr) => {
9029 let t = Thunk::new_suspended(expr.clone(), scope_env.clone());
9030 thunks.push(t.clone());
9031 Value::Thunk(t)
9032 }
9033 Binding::Group(sub) => {
9034 let t = Thunk::new_plan_group(sub.clone(), scope_env.clone());
9035 thunks.push(t.clone());
9036 Value::Thunk(t)
9037 }
9038 // `inherit x` resolves in the ENCLOSING scope, never the group's
9039 // own rec scope — that is what makes it shadow rather than
9040 // self-reference, and why it can never merge.
9041 Binding::Inherit => env
9042 .lookup(&name)
9043 .ok_or_else(|| EvalError::UndefinedVar(format!("'{name}'")))?,
9044 Binding::InheritFrom { from } => {
9045 let t = Thunk::new_inherit_select(from_thunks[*from].clone(), &name);
9046 thunks.push(t.clone());
9047 Value::Thunk(t)
9048 }
9049 };
9050 // PLAIN insert: the plan guarantees no repeated name.
9051 attrs.insert(name.clone(), value.clone());
9052 if plan.recursive {
9053 scope_env.bind(name, value);
9054 }
9055 }
9056
9057 // Phase 2: re-point every thunk at the completed scope, so a binding that
9058 // references a LATER sibling resolves. `PlanGroup` is re-pointable for
9059 // exactly this reason.
9060 if plan.recursive {
9061 for t in &thunks {
9062 t.update_env(&scope_env);
9063 }
9064 }
9065
9066 // ── dynamic keys ─────────────────────────────────────────────────────
9067 //
9068 // `${e}` keys that did not constant-fold. They are resolved AFTER every
9069 // static key, in source order, in the group's own scope — nix's ordering,
9070 // and the reason a dynamic key can never participate in the parse-time
9071 // merge. Omitting this dropped them entirely: two corpus fixtures built
9072 // `{ a = {}; }` where nix builds `{ a = { b = …; c = …; }; }`.
9073 //
9074 // A key evaluating to `null` SKIPS the binding (CppNix), rather than
9075 // inserting a `"null"` name.
9076 for d in &plan.dynamics {
9077 let key_val = eval_expr(&d.key, &scope_env)?;
9078 let key_concrete = key_val.demand()?;
9079 if matches!(key_concrete, Concrete::Null) {
9080 continue;
9081 }
9082 let name = key_concrete.into_value().as_string()?.to_string();
9083 let value = match &d.value {
9084 sui_normalize::Binding::Leaf(expr) => {
9085 Value::Thunk(Thunk::new_suspended(expr.clone(), scope_env.clone()))
9086 }
9087 sui_normalize::Binding::Group(sub) => {
9088 Value::Thunk(Thunk::new_plan_group(sub.clone(), scope_env.clone()))
9089 }
9090 sui_normalize::Binding::Inherit => env
9091 .lookup(&name)
9092 .ok_or_else(|| EvalError::UndefinedVar(format!("'{name}'")))?,
9093 sui_normalize::Binding::InheritFrom { from } => {
9094 Value::Thunk(Thunk::new_inherit_select(from_thunks[*from].clone(), &name))
9095 }
9096 };
9097 attrs.insert(name, value);
9098 }
9099
9100 // ★ Positions, which `builtins.unsafeGetAttrPos` reads. Dropping this was
9101 // a real regression caught by `every_binding_form_carries_a_position` —
9102 // the plan path built the right VALUES with every key position NULL.
9103 //
9104 // `StaticBinding::pos` is already the offset the AST path records: an
9105 // `AttrpathValue` starts at its head attr (`a` in `a.b = 1`, which is what
9106 // CppNix reports for the outer key), and an inherited name carries its own
9107 // ident's offset. And because the splice keeps the FIRST declaration's
9108 // `pos`, a merged key reports where it was first defined — which is what
9109 // nix reports too.
9110 if !plan.statics.is_empty() {
9111 let mut table = crate::pos::AttrPositions::new(current_eval_file());
9112 for b in &plan.statics {
9113 table.insert(b.name, b.pos.into());
9114 }
9115 attrs.set_positions(std::rc::Rc::new(table));
9116 }
9117
9118 Ok((attrs, scope_env))
9119}