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 // Register this parse tree's file + text so a static key's byte offset
557 // (recorded by `eval_attrset`) resolves to a file/line/column for
558 // `builtins.unsafeGetAttrPos`. The file flows through the eval-file
559 // stack (store-path prefixed for imported inputs); the position resolver
560 // lifts a cache-dir path to its `/nix/store/<h>-source` store path.
561 crate::pos::register_source(file.as_deref(), input);
562 let prev_src_id = CURRENT_SOURCE_ID.with(|s| {
563 let old = s.get();
564 s.set(src_id);
565 old
566 });
567
568 let root = parse.tree();
569 let expr = match root.expr() {
570 Some(e) => e,
571 None => {
572 CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
573 EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
574 return Err(EvalError::ParseError("empty expression".to_string()));
575 }
576 };
577 let mut env = Env::new();
578 env.set_eval_file(file);
579 // Tag the env with THIS parse tree's source_id so a thunk created here
580 // and forced later (cross-file) restores this id on force (see the
581 // source-id guard in `Thunk::force`), keying `IDENT_CACHE` against the
582 // file where the thunk was defined.
583 env.set_source_id(src_id);
584 builtins::register(&mut env);
585 let result = eval_expr(&expr, &env).map_err(|e| attach_trace(e))?;
586 // Force the top-level result so callers always see a concrete value.
587 let final_result = force_value(&result).map_err(|e| attach_trace(e));
588 // Restore the previous source ID (matters for nested imports).
589 CURRENT_SOURCE_ID.with(|s| s.set(prev_src_id));
590 EVAL_NESTING.with(|n| n.set(n.get().saturating_sub(1)));
591 if nesting == 0 {
592 crate::perf::report();
593 }
594 final_result
595}
596
597/// Force a value: if it is a thunk, evaluate and memoize the result.
598/// Concrete values are returned unchanged.
599/// Force a value: if it is a thunk, evaluate and memoize the result.
600/// Concrete values are returned unchanged.
601///
602/// Inlined aggressively so the non-thunk fast path compiles to a
603/// simple clone without a function-call boundary.
604#[inline(always)]
605/// Force a value and return a type-safe `Concrete` (guaranteed non-Thunk).
606///
607/// This is the preferred forcing API. The `Concrete` return type makes it
608/// impossible to accidentally use an unforced thunk — the compiler rejects it.
609pub fn force_concrete(value: &Value) -> Result<Concrete, EvalError> {
610 value.demand()
611}
612
613/// Force a value (legacy API — returns `Value` for backward compatibility).
614///
615/// Prefer `force_concrete()` or `Value::demand()` for new code.
616pub fn force_value(value: &Value) -> Result<Value, EvalError> {
617 crate::perf::inc(crate::perf::Counter::ForceValue);
618 // Fast path: non-thunk values are returned immediately (no clone needed
619 // until we actually have work to do).
620 if !matches!(value, Value::Thunk(_)) {
621 return Ok(value.clone());
622 }
623 // Slow path: chase thunk chains.
624 //
625 // A legitimate chain is typically 1–3 links deep (result of lazy
626 // evaluation wrapping an intermediate value in another thunk).
627 // Reaching 100 means either (a) a self-referential cycle like
628 // `let x = x; in x` that bypassed per-thunk Blackhole detection,
629 // or (b) pathological Thunk(Thunk(...)) nesting. Both are errors.
630 //
631 // Previous behavior silently returned `Ok(last_thunk)` at depth
632 // 100, which hid infinite-recursion bugs — the blackhole tests
633 // in the lib suite failed because `result.is_ok()` instead of
634 // `is_err()`. Returning `Err` here makes the silent-bail visible
635 // at the CppNix-compatible call site (real Nix raises "infinite
636 // recursion encountered").
637 let mut v = value.clone();
638 let mut depth = 0u32;
639 loop {
640 match v {
641 Value::Thunk(ref thunk) => {
642 v = force_thunk(thunk)?;
643 depth += 1;
644 if depth > 100 {
645 return Err(EvalError::InfiniteRecursion(
646 "force_value: thunk chain exceeded depth 100 (cycle or runaway lazy wrap)".into(),
647 ));
648 }
649 }
650 _ => return Ok(v),
651 }
652 }
653}
654
655/// Force with call-site tracking (legacy API).
656pub fn force_value_tracked(value: &Value, site: &str) -> Result<Value, EvalError> {
657 crate::perf::inc(crate::perf::Counter::ForceValue);
658 if let Value::Thunk(thunk) = value {
659 FORCE_SITES.with(|sites| {
660 *sites.borrow_mut().entry(site.to_string()).or_insert(0) += 1;
661 });
662 force_thunk(thunk)
663 } else {
664 Ok(value.clone())
665 }
666}
667
668thread_local! {
669 static FORCE_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
670 std::cell::RefCell::new(std::collections::HashMap::new());
671 static APPLY_SITES: std::cell::RefCell<std::collections::HashMap<String, u64>> =
672 std::cell::RefCell::new(std::collections::HashMap::new());
673}
674
675/// Dump force-site counters (call from perf reporting).
676pub fn dump_force_sites() {
677 FORCE_SITES.with(|sites| {
678 let sites = sites.borrow();
679 let mut sorted: Vec<_> = sites.iter().collect();
680 sorted.sort_by(|a, b| b.1.cmp(a.1));
681 eprintln!("[force-sites] top thunk force call sites:");
682 for (site, count) in sorted.iter().take(10) {
683 eprintln!(" {count:>8} {site}");
684 }
685 });
686 APPLY_SITES.with(|sites| {
687 let sites = sites.borrow();
688 let mut sorted: Vec<_> = sites.iter().collect();
689 sorted.sort_by(|a, b| b.1.cmp(a.1));
690 eprintln!("[apply-sites] top lambda call sites by source file:");
691 for (site, count) in sorted.iter().take(15) {
692 // Strip nix store prefix for readability
693 let short = site.rsplit_once("-source/").map_or(site.as_str(), |(_,s)| s);
694 eprintln!(" {count:>8} {short}");
695 }
696 });
697}
698
699/// Force a thunk — split out from [`force_value`] so the fast path
700/// (non-thunk clone) stays fully inlined while this cold path can
701/// be a regular function call with stacker protection.
702fn force_thunk(thunk: &Thunk) -> Result<Value, EvalError> {
703 // Ultra-fast path: if the thunk is already cached, skip stacker overhead.
704 if let Some(cached) = thunk.peek() {
705 crate::perf::inc(crate::perf::Counter::ThunkHit);
706 return Ok(cached.clone().into_value());
707 }
708 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
709 // Force ONE level only — matches CppNix's forceValue which does
710 // not transitively chase thunk-in-thunk chains. The caller will
711 // force again when the value is actually needed. This is the key
712 // optimization: CppNix forces 71 thunks for lib.version while
713 // sui was forcing 180K due to transitive forcing.
714 thunk.force(&|expr, env| eval_expr(expr, env))
715 })
716}
717
718/// Decide whether to thunk an expression or evaluate it directly.
719///
720/// Trivial expressions (literals, paths) are evaluated immediately --
721/// no thunk allocation. For non-recursive scopes, variable lookups
722/// (Ident) and lambdas are also evaluated eagerly. This matches
723/// CppNix's `maybeThunk` optimization which avoids a large fraction
724/// of thunk creations on nixpkgs.
725///
726/// For recursive scopes (let-in, rec attrsets), set `is_rec = true` to
727/// prevent eager evaluation of `Ident` and `Lambda` expressions:
728/// - Ident: sibling bindings may not be defined yet (forward refs).
729/// - Lambda: the closure must capture the *final* env (set in Phase 2)
730/// so that the lambda body can reference sibling bindings.
731///
732/// `defined_so_far`: In recursive scopes, names that have already been
733/// bound in this scope (i.e. earlier bindings). Idents referencing these
734/// are backward references and can be resolved directly without thunking.
735/// Forward references (names not yet defined) must still be thunked.
736/// Detect whether `value_expr`'s source structurally references
737/// the identifier `name` — the signal that this let-binding is a
738/// self-recursive fix-point (`let x = f x; in x` or
739/// `let x = { a = 1; b = x.a; }; in x`). Used at let-binding
740/// thunking time to pick `Thunk::new_suspended_recursive` over the
741/// classic `Thunk::new_suspended`, so inner re-entrance during
742/// force returns the partial value via `ThunkRepr::Promise`
743/// instead of erroring with `InfiniteRecursion`.
744///
745/// Implementation walks the value-expr's rnix syntax tree looking
746/// for `TOKEN_IDENT` whose text equals `name`. This is a
747/// conservative over-approximation:
748/// - shadowing (e.g. `let x = let x = 1; in x; in x`) marks the
749/// outer thunk recursive even though no real cycle exists;
750/// - the resulting Promise behaviour is a strict superset of
751/// Blackhole for non-cyclic forces (the body runs to completion
752/// and the cell gets the final value), so false positives are
753/// semantically safe — they cost only the extra `Rc<RefCell>`
754/// allocation per recursive let-binding.
755///
756/// False negatives (e.g. the bound name appears only inside an
757/// inherit-from-source clause) leave the existing
758/// `InfiniteRecursion` behaviour intact, which is the conservative
759/// fallback.
760/// `SUI_SCOPE_NARROW` — the scope-narrowing latch.
761///
762/// Every `let` / `rec` / pattern-default binding closes an `Rc` cycle today:
763/// the thunk is bound INTO the scope env, then Phase 2's `update_env` puts
764/// that same env back INTO the thunk. `Rc` has no cycle collector and no
765/// `Weak` sits on that edge, so the whole scope — every innocent leaf in it —
766/// is immortal for the life of the process. Narrowing removes the second half
767/// of the cycle for the bindings that provably do not need it.
768///
769/// * unset / `0` — today's behaviour, byte- AND allocation-identical. Not one
770/// extra tree walk runs on this path.
771/// * `1` — D3 (pattern-lambda formal defaults) + D1 (`let` / `rec` bindings
772/// whose RHS reaches no sibling keep their outer-env capture).
773/// * `2` — additionally D2 (bindings that DO need the scope get a *cluster*
774/// env holding only the names they can reach, so one recursive binding
775/// stops pinning its innocent siblings).
776///
777/// Read once through a `OnceLock` one-way latch — the `resolve_env::enabled()`
778/// idiom — so the value cannot change mid-eval and the default path pays a
779/// single relaxed load.
780/// ★ THE DEFAULT IS 2 (flipped 2026-08-17). `0` and `1` remain selectable for
781/// bisecting a suspected narrowing bug — that is the whole reason the latch
782/// survives rather than the code being inlined.
783///
784/// It shipped as `0`, and NOTHING in the tree set it. So the measured result —
785/// 700.0 MB / 1,020,001 live nodes → 22.2 MB / 0 on the gate probe, with the
786/// process RSS floor at 20.5 MB, i.e. *at the floor* — reached nobody. A fix
787/// present but unreached is the same shape as the VM bridges that were
788/// installed two-of-three, and as `vm_fallback_count()` sitting unread since
789/// the day it was written.
790///
791/// Flipped only after byte-parity was proven at every level, because a wrong
792/// drvPath is far worse than a leak:
793/// - the 117-fixture lang corpus: identical at 0, 1 and 2
794/// - the full `sui-eval` suite at level 2: 1685 pass
795/// - `sui eval --raw <expr>.drvPath` byte-identical across 0/1/2 AND equal to
796/// real nix
797///
798/// The narrowing removes the second half of an `Rc` cycle for bindings that
799/// provably do not need the scope env. It is NOT free of judgement: `P2`, a
800/// genuinely-recursive scope, must still pin, and it does — a narrowing that
801/// improved every probe would mean it was discarding something it should keep.
802fn scope_narrow_level() -> u8 {
803 static LEVEL: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
804 *LEVEL.get_or_init(
805 || match std::env::var("SUI_SCOPE_NARROW").ok().as_deref() {
806 Some("0") => 0,
807 Some("1") => 1,
808 _ => 2,
809 },
810 )
811}
812
813/// True at `SUI_SCOPE_NARROW >= 1` — D1 + D3 are on.
814#[inline]
815fn scope_narrow_enabled() -> bool {
816 scope_narrow_level() >= 1
817}
818
819/// True at `SUI_SCOPE_NARROW = 2` — D2 (the cluster env) is on.
820#[inline]
821fn scope_cluster_enabled() -> bool {
822 scope_narrow_level() >= 2
823}
824
825/// The set of variable-reference ident names in `value_expr`'s subtree
826/// (`NODE_IDENT` whose parent is NOT a `NODE_ATTRPATH` — i.e. genuine
827/// variable references, not attribute names/keys). ONE subtree walk.
828///
829/// Kills the O(N²) re-walk storm (Storm A) at the call sites: previously
830/// `is_self_recursive_binding` did a full subtree walk once per
831/// `(binding × sibling-name)` in every `let`/`rec` scope; now each RHS is
832/// walked ONCE to build this set, then every name is an O(1) set lookup.
833/// Byte-neutral: the recursion verdict is unchanged (a name is self/mutually
834/// recursive iff it is in the set).
835///
836/// NOT cross-call memoized: a process-lifetime memo keyed on ephemeral AST
837/// node identity `(source-id, range)` collides when nodes are parsed/dropped
838/// without a per-eval clear (the standalone-predicate case). The call-site
839/// single-walk is the byte-safe win; `ContentMemo` (sui-intern) is reserved
840/// for sites with a STABLE content key (the NAR-hash memo's `(dir,name)`, the
841/// overlay-flatten per-node cache).
842///
843/// The attrpath exclusion matters: without it, `placeholder = if
844/// lhs.placeholder == …` in nixpkgs `lib/types.nix` would be falsely flagged
845/// self-recursive (its RHS mentions the *attribute* `.placeholder`), routing
846/// the binding through the `Promise` fix-point path whose env handling drops
847/// the let-scope — surfacing as a force-order-dependent `null` in the module
848/// system (`concatLists: expected list, got null`).
849fn referenced_idents(value_expr: &ast::Expr) -> HashSet<SmolStr> {
850 use rnix::SyntaxKind;
851 // Storm A instrumentation (byte-neutral, gated on perf::enabled()): count
852 // this walk + the rnix descendants it visits + its walltime, so the
853 // residual per-fixpoint-iteration self/mutual-recursion detection cost is
854 // VISIBLE in the SUI_EVAL_PERF report — symmetric with sorted_entries /
855 // overlay-flatten. The counter reads add zero output-relevant work.
856 let perf_on = crate::perf::enabled();
857 let t0 = if perf_on {
858 Some(std::time::Instant::now())
859 } else {
860 None
861 };
862 crate::perf::inc(crate::perf::Counter::SelfRecWalkCalls);
863 let mut nodes_walked: u64 = 0;
864 let mut set: HashSet<SmolStr> = HashSet::new();
865 for node in value_expr.syntax().descendants() {
866 nodes_walked += 1;
867 if node.kind() == SyntaxKind::NODE_IDENT
868 && node
869 .parent()
870 .is_none_or(|p| p.kind() != SyntaxKind::NODE_ATTRPATH)
871 && let Some(i) = ast::Ident::cast(node)
872 {
873 set.insert(SmolStr::from(ident_text(&i).as_str()));
874 }
875 }
876 crate::perf::add(crate::perf::Counter::SelfRecWalkNodes, nodes_walked);
877 if let Some(t0) = t0 {
878 crate::trace::add_self_rec_walk_nanos(t0.elapsed().as_nanos());
879 }
880 set
881}
882
883/// True iff `value_expr` references `name` as a variable. Now a set lookup
884/// over one subtree walk (see `referenced_idents`). Byte-neutral vs the prior
885/// per-name-walk implementation.
886fn is_self_recursive_binding(value_expr: &ast::Expr, name: &str) -> bool {
887 referenced_idents(value_expr).contains(name)
888}
889
890fn maybe_thunk(
891 expr: &ast::Expr,
892 env: &Env,
893 is_rec: bool,
894 defined_so_far: Option<&HashSet<String>>,
895) -> Value {
896 match expr {
897 // Literals: evaluate directly (no allocation needed).
898 ast::Expr::Literal(lit) => eval_literal(lit).unwrap_or_else(|_| {
899 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
900 }),
901 // Ident resolution: try full lookup (lexical + with-scope cache + force).
902 // On successful lookup → return value directly (most common case).
903 // On blackhole (fixpoint being constructed) → env.lookup returns None
904 // → create WithIdent thunk for deferred O(1) cache-based resolution.
905 // This approach: (1) is fast for resolved with-scopes (no thunk overhead),
906 // (2) handles blackhole fixpoints correctly via WithIdent deferral.
907 ast::Expr::Ident(ident) if !is_rec => {
908 // Cache the interned Symbol by (source_id, text_offset) — same
909 // zero-alloc steady-state path as the strict Ident arm in
910 // `eval_expr`. The ident text is materialized only on the
911 // once-per-offset cold miss and on the (rare) blackhole deferral.
912 // Same cross-file aliasing fix as the strict `eval_expr` Ident arm —
913 // key on the env's source id, not the unmaintained thread-local.
914 // This twin had NO stale-symbol guard at all (the one commit
915 // 2d93e77 added sits only on the strict arm's lookup-MISS path,
916 // after the keyword check), so it was the more exposed of the two.
917 let sym = {
918 let src_id = env.source_id();
919 let offset = u32::from(ident.syntax().text_range().start());
920 crate::value::intern_cached_with(src_id, offset, || {
921 crate::value::intern(&ident_text(ident))
922 })
923 };
924 // Zero-copy keyword check on the resolved Symbol.
925 if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
926 "true" => Some(Value::Bool(true)),
927 "false" => Some(Value::Bool(false)),
928 "null" => Some(Value::Null),
929 _ => None,
930 }) {
931 return kw;
932 }
933 {
934 {
935 // `name` arg to `lookup_fast` is unused (lookup is by
936 // Symbol) — pass "" to skip materializing the ident text on
937 // the hot HIT path.
938 if let Some(v) = env.lookup_fast(sym, "") {
939 return v;
940 }
941 // Failed — either blackhole or missing. Create WithIdent
942 // thunk for deferred resolution (only for the blackhole case).
943 if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
944 return Value::Thunk(Thunk::new_with_ident(
945 SmolStr::from(ident_text(ident).as_str()),
946 scope_cache,
947 scope_value,
948 env.clone(),
949 ));
950 }
951 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
952 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
953 }
954 }
955 }
956 // Identifiers in rec scope: check if it's a backward reference
957 // (name already defined earlier in the same scope). If so, we
958 // can resolve it directly instead of creating a wasteful thunk.
959 ast::Expr::Ident(ident) if is_rec => {
960 let name = ident_text(ident);
961 match name.as_str() {
962 "true" => Value::Bool(true),
963 "false" => Value::Bool(false),
964 "null" => Value::Null,
965 _ => {
966 // If this name was already defined earlier in the
967 // scope, it's a backward reference — resolve directly.
968 if defined_so_far.map_or(false, |d| d.contains(&name)) {
969 env.lookup(&name).unwrap_or_else(|| {
970 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
971 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
972 })
973 } else {
974 // Forward reference — must thunk
975 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeIdent);
976 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
977 }
978 }
979 }
980 }
981 // Absolute and home paths: trivial text extraction — but ONLY
982 // for the non-interpolated case. An interpolated path (`/a/${e}`,
983 // `~/${e}`) must be thunked so its `${…}` parts are evaluated in
984 // `eval_expr_inner`, never spliced as literal text.
985 ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
986 // CppNix canonicalizes every absolute path literal on eval
987 // (`/.` → `/`, `/a/./b` → `/a/b`, `/a/../b` → `/b`, `..`
988 // clamped at root). A path VALUE carries the canonical form —
989 // the marquee cid root threw in `lib.path.hasStorePathPrefix`
990 // precisely because sui kept the raw `/.` text.
991 let text = crate::path::canon_abs(&p.syntax().text().to_string());
992 Value::Path(Box::new(SmolStr::from(text.as_str())))
993 }
994 ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
995 let text = p.syntax().text().to_string();
996 Value::Path(Box::new(SmolStr::from(text.as_str())))
997 }
998 // Non-interpolated string literal: a constant value with no
999 // interpolation, so `eval_str` runs no `${…}` force/coerce — it is
1000 // pure, non-throwing, side-effect-free, and produces a
1001 // `String(NixString::with_context(text, EMPTY))`. Evaluating it here is
1002 // therefore byte-identical to forcing a suspended thunk of it (M2
1003 // thunk-waste: a constant Str thunk is always pure overhead — it can
1004 // never observably change eval order because it cannot throw or
1005 // diverge). Only the NON-interpolated case is direct; an interpolated
1006 // `"${e}"` must stay thunked so its parts force lazily in the right
1007 // env/order. `eval_str` on the empty-interpolation input cannot fail,
1008 // but fall back to a thunk on the (unreachable) error to preserve
1009 // exact prior behavior.
1010 ast::Expr::Str(st) if !str_has_interpolation(st) => {
1011 eval_str(st, env).unwrap_or_else(|_| {
1012 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1013 })
1014 }
1015 // Lambda: capture env directly (no computation needed).
1016 // But NOT in recursive scopes -- the closure must capture the
1017 // final env with all sibling bindings (set in Phase 2).
1018 ast::Expr::Lambda(lam) if !is_rec => {
1019 if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1020 Value::Lambda(Rc::new(Closure {
1021 param,
1022 body,
1023 env: env.clone(),
1024 }))
1025 } else {
1026 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1027 }
1028 }
1029 // Select on a variable: CppNix's maybeThunk evaluates these eagerly
1030 // when the base is a simple ident. However, this breaks fixpoints
1031 // where the base (e.g., `config`) is a thunk being computed — eagerly
1032 // evaluating `config.x` during attrset construction triggers blackhole.
1033 //
1034 // The nixpkgs module system relies on `{ ...; default = config.x; }`
1035 // being lazy. Wrap selects in thunks unconditionally.
1036 // The performance cost is minimal (thunk allocation + deferred eval)
1037 // and correctness is critical for fixpoint patterns.
1038 // Everything else: wrap in a thunk for lazy evaluation.
1039 _ => {
1040 crate::perf::inc(crate::perf::Counter::ThunkSiteMaybeOther);
1041 if crate::perf::enabled() {
1042 let kind = match expr {
1043 ast::Expr::Select(_) => "Select",
1044 ast::Expr::Apply(_) => "Apply",
1045 ast::Expr::BinOp(_) => "BinOp",
1046 ast::Expr::IfElse(_) => "IfElse",
1047 ast::Expr::Str(_) => "Str",
1048 ast::Expr::List(_) => "List",
1049 ast::Expr::With(_) => "With",
1050 ast::Expr::Assert(_) => "Assert",
1051 ast::Expr::HasAttr(_) => "HasAttr",
1052 ast::Expr::UnaryOp(_) => "UnaryOp",
1053 ast::Expr::Paren(_) => "Paren",
1054 ast::Expr::LetIn(_) => "LetIn",
1055 ast::Expr::AttrSet(_) => "AttrSet",
1056 ast::Expr::Ident(_) => "Ident(rec)",
1057 ast::Expr::Lambda(_) => "Lambda(rec)",
1058 ast::Expr::LegacyLet(_) => "LegacyLet",
1059 ast::Expr::PathAbs(_)
1060 | ast::Expr::PathHome(_)
1061 | ast::Expr::PathRel(_)
1062 | ast::Expr::PathSearch(_) => "Path(interp)",
1063 _ => "Other",
1064 };
1065 crate::trace::inc_maybe_other_kind(kind);
1066 }
1067 Value::Thunk(Thunk::new_suspended(expr.clone(), env.clone()))
1068 }
1069 }
1070}
1071
1072/// Evaluate an rnix expression in an environment.
1073///
1074/// Uses `stacker::maybe_grow` to dynamically extend the call stack when
1075/// it is close to exhaustion. This prevents stack overflow on deeply
1076/// nested nixpkgs fixpoints (50+ overlay applications each creating
1077/// multiple recursive `eval_expr` / `force_value` frames).
1078///
1079/// **Fast path:** Ident (~32% of all evals), Literal, Paren, and Root
1080/// expressions don't recurse and are handled directly, skipping the
1081/// `stacker::maybe_grow` overhead for ~40% of all `eval_expr` calls.
1082#[inline(always)]
1083pub fn eval_expr(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1084 // Fast path: trivial expressions that don't recurse.
1085 // Skip stacker overhead for ~40% of all eval_expr calls.
1086 match expr {
1087 ast::Expr::Ident(ident) => {
1088 crate::perf::inc(crate::perf::Counter::EvalExpr);
1089 if crate::perf::enabled() {
1090 crate::perf::inc(crate::perf::Counter::ExprIdent);
1091 }
1092 // ── ENV-RESOLVE M0 fast path (no-op unless `SUI_RESOLVE=1`) ──
1093 // A parse-time-`Lexical` reference carries its precomputed
1094 // Symbol; probe the lexical bindings map DIRECTLY, skipping the
1095 // per-lookup `ident_text().to_string()` + `intern()`. This is
1096 // parity-by-construction: `lookup_fast` probes the SAME lexical
1097 // map by the SAME Symbol FIRST, so a hit here is byte-identical
1098 // to what the unchanged path below returns. Any miss (a
1099 // mid-fixpoint blackhole where the binding isn't in scope yet, an
1100 // unrecorded ident, or `Dynamic`) falls through to the EXACT
1101 // unchanged path — including the whole with-chain + WithIdent
1102 // deferral. The resolver never records keywords, so the
1103 // true/false/null handling below is untouched on this path.
1104 if crate::resolve_env::enabled() {
1105 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1106 let offset = u32::from(ident.syntax().text_range().start());
1107 if let sui_resolve::Resolution::Lexical { sym } =
1108 crate::resolve_env::resolution_for(src_id, offset)
1109 {
1110 if let Some(v) = env.lookup_lexical_sym(sym) {
1111 return Ok(v);
1112 }
1113 }
1114 // Miss / Dynamic → fall through to the unchanged path.
1115 }
1116 // Cache the interned Symbol by (source_id, text_offset) so the
1117 // steady-state identifier lookup pays neither a per-lookup
1118 // `ident_text().to_string()` heap alloc nor a string re-hash — the
1119 // ident's text is materialized only on the once-per-offset cold
1120 // miss. The keyword check + the common `lookup_fast` HIT then run
1121 // fully allocation-free; `name` is materialized lazily only on the
1122 // miss/error branches, which need the string anyway.
1123 // KEY ON `env.source_id()`, NOT the thread-local (fixed 2026-07-20).
1124 //
1125 // `CURRENT_SOURCE_ID` is pushed at exactly ONE site —
1126 // `value.rs`'s `ThunkRepr::Suspended` force branch. Lambda
1127 // application and the Native/WithIdent/InheritSelect/Promise force
1128 // branches never push it, so while a callee's body was being
1129 // evaluated the thread-local still named the CALLER's file. The
1130 // `(source_id, offset)` cache key then aliased across files: an
1131 // identifier at byte N in file A could resolve to the Symbol
1132 // interned for a `null`/`true`/`false` token at byte N in file B —
1133 // and the zero-copy keyword check below turned that into a literal
1134 // `Value::Null` for a perfectly well-defined identifier, before any
1135 // environment lookup.
1136 //
1137 // That is what stopped sui evaluating nixpkgs: `hostSuffix` in
1138 // `make-derivation.nix` resolved to `null`, so `attrs.name +
1139 // hostSuffix` raised "cannot add string and null" — observed
1140 // directly as `STALE-KEYWORD ident="hostSuffix" resolvedAs="null"`.
1141 // It is not darwin-specific and has nothing to do with the module
1142 // system; `import <nixpkgs> {}` fails identically on x86_64-linux.
1143 //
1144 // `Env` already carries the correct value: `eval_with_file` sets it
1145 // and `child()` inherits it, and a lambda's `call_env` is
1146 // `closure.env.child()` — so a body's env names its DEFINING file.
1147 // Keying on it fixes every cross-file path at the cause, rather than
1148 // adding a fifth push/pop guard that a sixth path can forget.
1149 let sym = {
1150 let src_id = env.source_id();
1151 let offset = u32::from(ident.syntax().text_range().start());
1152 crate::value::intern_cached_with(src_id, offset, || {
1153 crate::value::intern(&ident_text(ident))
1154 })
1155 };
1156 // Zero-copy keyword check on the resolved Symbol — the resolver
1157 // never records keywords, so this matches the prior `name.as_str()`
1158 // arm exactly.
1159 if let Some(kw) = crate::value::with_resolved(sym, |s| match s {
1160 "true" => Some(Value::Bool(true)),
1161 "false" => Some(Value::Bool(false)),
1162 "null" => Some(Value::Null),
1163 _ => None,
1164 }) {
1165 return Ok(kw);
1166 }
1167 return {
1168 {
1169 // `lookup_fast`'s `name` argument is unused (lookup is by
1170 // Symbol); pass "" to avoid materializing the ident text on
1171 // the hot HIT path.
1172 if let Some(v) = env.lookup_fast(sym, "") {
1173 Ok(v)
1174 } else {
1175 let name = ident_text(ident);
1176 // The `(src_id, text_offset)` identifier-symbol cache
1177 // (`intern_cached_with`) can hand back a STALE Symbol when
1178 // a lazily-forced thunk's identifier is resolved under a
1179 // force-time `CURRENT_SOURCE_ID` that differs from the
1180 // identifier's PARSE-time src_id — a thunk from file A can
1181 // be forced while B is the current source, so
1182 // `(B_src_id, offset)` aliases B's parse tree's identifier
1183 // at that same byte offset and returns ITS Symbol. (Proven
1184 // root: nixpkgs `lib/systems/parse.nix` `mkOptionType` — the
1185 // binding IS present in the env, but the cache returned
1186 // `Symbol(566)` while the binding was interned under
1187 // `Symbol(506)`, so `lookup_fast(566)` missed a defined
1188 // var.) `intern` is deterministic + append-only, so on a
1189 // miss re-intern the name from its text (the authoritative
1190 // Symbol) and retry the lexical lookup BEFORE considering
1191 // with-scopes or undefined. A genuinely undefined variable
1192 // is unaffected — its fresh lookup also misses and falls
1193 // through unchanged.
1194 let fresh = crate::value::intern(name.as_str());
1195 if fresh != sym {
1196 if let Some(v) = env.lookup_fast(fresh, name.as_str()) {
1197 return Ok(v);
1198 }
1199 }
1200 if env.with_scope_count() > 0 {
1201 // With-scope lookup failed (likely blackhole from fixpoint).
1202 // Return a WithIdent thunk for deferred resolution.
1203 // This is the eval_expr equivalent of maybe_thunk's deferral.
1204 if let Some((scope_cache, scope_value)) = env.innermost_with_scope() {
1205 Ok(Value::Thunk(Thunk::new_with_ident(
1206 SmolStr::from(name.as_str()),
1207 scope_cache,
1208 scope_value,
1209 env.clone(),
1210 )))
1211 } else if crate::value::in_promise_eval() {
1212 // M2.6 Promise softening: an undefined
1213 // identifier inside Promise body evaluation
1214 // typically means a `with` block sourced
1215 // from the empty-attrset sentinel didn't
1216 // populate the with-scope. Returning null
1217 // lets the eval proceed; the result is
1218 // wrong-but-bounded (no further forces
1219 // happen on null until something downstream
1220 // demands a real value).
1221 Ok(Value::Null)
1222 } else {
1223 Err(EvalError::UndefinedVar(
1224 format!("'{name}'{}", eval_file_ctx()),
1225 ))
1226 }
1227 } else {
1228 if let Ok(dbg_var) = std::env::var("SUI_DEBUG_VAR") {
1229 if dbg_var == name || dbg_var == "*" {
1230 eprintln!(
1231 "[sui-debug] UndefinedVar '{name}' in {}\n\
1232 [sui-debug] env bindings ({} total): {:?}\n\
1233 [sui-debug] with_scopes: {}",
1234 eval_file_ctx(),
1235 env.binding_count(),
1236 env.binding_names_preview(20),
1237 env.with_scope_count(),
1238 );
1239 }
1240 }
1241 if crate::value::in_promise_eval() {
1242 // Same Promise softening as the with-scope
1243 // branch above.
1244 return Ok(Value::Null);
1245 }
1246 Err(EvalError::UndefinedVar(
1247 format!("'{name}'{}", eval_file_ctx()),
1248 ))
1249 }
1250 }
1251 }
1252 };
1253 }
1254 ast::Expr::Literal(lit) => {
1255 crate::perf::inc(crate::perf::Counter::EvalExpr);
1256 if crate::perf::enabled() {
1257 crate::perf::inc(crate::perf::Counter::ExprLiteral);
1258 }
1259 return eval_literal(lit);
1260 }
1261 ast::Expr::Paren(p) => {
1262 if let Some(inner) = p.expr() {
1263 return eval_expr(&inner, env);
1264 }
1265 }
1266 ast::Expr::Root(r) => {
1267 if let Some(inner) = r.expr() {
1268 return eval_expr(&inner, env);
1269 }
1270 }
1271 // Lambda: no recursion — just captures env into a closure.
1272 ast::Expr::Lambda(lam) => {
1273 crate::perf::inc(crate::perf::Counter::EvalExpr);
1274 if crate::perf::enabled() {
1275 crate::perf::inc(crate::perf::Counter::ExprLambda);
1276 }
1277 if let (Some(param), Some(body)) = (lam.param(), lam.body()) {
1278 return Ok(Value::Lambda(Rc::new(Closure {
1279 param,
1280 body,
1281 env: env.clone(),
1282 })));
1283 }
1284 }
1285 _ => {}
1286 }
1287 // Complex expressions: need stacker for recursion safety
1288 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || {
1289 eval_expr_inner(expr, env)
1290 })
1291}
1292
1293/// Inner implementation of [`eval_expr`] — called from the `stacker`
1294/// trampoline so that the stack is guaranteed to have headroom.
1295///
1296/// Uses a tail-call loop: for expressions in tail position (`if/else`,
1297/// `let..in`, `with`, `assert`, `paren`, `root`), we update the local
1298/// `expr` and `env` variables and loop instead of recursing. This
1299/// eliminates millions of stack frames in nixpkgs evaluation.
1300fn eval_expr_inner(expr: &ast::Expr, env: &Env) -> Result<Value, EvalError> {
1301 // Tail-call trampoline: expressions in tail position update these
1302 // and `continue` instead of recursing into eval_expr.
1303 let mut cur_expr = expr.clone();
1304 let mut cur_env = env.clone();
1305
1306 loop {
1307 crate::perf::inc(crate::perf::Counter::EvalExpr);
1308 // Track expression type distribution when profiling
1309 if crate::perf::enabled() {
1310 use crate::perf::Counter;
1311 let c = match &cur_expr {
1312 ast::Expr::Ident(_) => Counter::ExprIdent,
1313 ast::Expr::Literal(_) => Counter::ExprLiteral,
1314 ast::Expr::Str(_) => Counter::ExprStr,
1315 ast::Expr::List(_) => Counter::ExprList,
1316 ast::Expr::AttrSet(_) => Counter::ExprAttrs,
1317 ast::Expr::Select(_) => Counter::ExprSelect,
1318 ast::Expr::Apply(_) => Counter::ExprApply,
1319 ast::Expr::LetIn(_) => Counter::ExprLetIn,
1320 ast::Expr::IfElse(_) => Counter::ExprIfElse,
1321 ast::Expr::With(_) => Counter::ExprWith,
1322 ast::Expr::Lambda(_) => Counter::ExprLambda,
1323 ast::Expr::BinOp(_) => Counter::ExprBinOp,
1324 ast::Expr::HasAttr(_) => Counter::ExprHasAttr,
1325 ast::Expr::UnaryOp(_) => Counter::ExprUnaryOp,
1326 ast::Expr::Assert(_) => Counter::ExprAssert,
1327 ast::Expr::PathAbs(_) | ast::Expr::PathRel(_)
1328 | ast::Expr::PathHome(_) | ast::Expr::PathSearch(_) => Counter::ExprPath,
1329 _ => Counter::ExprOther,
1330 };
1331 crate::perf::inc(c);
1332 }
1333 let _guard = DepthGuard::enter()?;
1334 let env = &cur_env;
1335 match &cur_expr {
1336 ast::Expr::Literal(lit) => return eval_literal(lit),
1337
1338 ast::Expr::Str(s) => return eval_str(s, env),
1339
1340 ast::Expr::PathAbs(p) => {
1341 // An interpolated absolute path (`/a/${e}`) splices its
1342 // `${…}` parts; a plain one takes the raw-text shortcut.
1343 let parts = p.parts();
1344 if parts_have_interpolation(&parts) {
1345 return eval_interpol_path_parts(&parts, PathKind::Abs, env);
1346 }
1347 // Canonicalize like CppNix (`/.` → `/`, `.`/`..` collapse,
1348 // `..` clamps at root) — see the WHNF fast-path above.
1349 let text = crate::path::canon_abs(&p.syntax().text().to_string());
1350 return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1351 }
1352 ast::Expr::PathRel(p) => {
1353 // Real Nix resolves `./foo.nix` against the directory
1354 // of the file that *contains* the literal, not the
1355 // process cwd. Use the current eval-file stack; fall
1356 // back to cwd when no file is being evaluated (e.g.,
1357 // top-level `sui eval`).
1358 //
1359 // An interpolated relative path (`./${x}.nix`) first splices
1360 // its `${…}` parts, then resolves the concatenated text the
1361 // same way — the interpolation is evaluated + string-coerced,
1362 // NOT treated as literal `${x}` text.
1363 let parts = p.parts();
1364 if parts_have_interpolation(&parts) {
1365 return eval_interpol_path_parts(&parts, PathKind::Rel, env);
1366 }
1367 let text = p.syntax().text().to_string();
1368 let resolved = if let Some(dir) = current_eval_dir() {
1369 let joined = dir.join(&text);
1370 // Use normalize_path instead of canonicalize so that
1371 // paths with ./ and .. are cleaned without requiring
1372 // the path to exist on disk.
1373 let norm = normalize_path(&joined);
1374 // A relative path literal (`./x`, `../..`) resolves against the
1375 // eval-dir, which for a fetched flake input is the sui fetcher
1376 // CACHE dir. CppNix resolves it against the input's
1377 // `/nix/store/<h>-source` STORE path, so the resulting path
1378 // VALUE must carry the store prefix (this is the value half of
1379 // the store↔cache seam — `materialize`/`dematerialize`). Lift
1380 // the cache path back to the store path so `toString ../..`
1381 // matches CppNix — the options.json `hasPrefix
1382 // <nix-darwin>.outPath decl` rewrite root (`prefix = ../..`).
1383 crate::path::dematerialize(&norm)
1384 .to_string_lossy()
1385 .into_owned()
1386 } else {
1387 text.clone()
1388 };
1389 return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1390 }
1391 ast::Expr::PathHome(p) => {
1392 let parts = p.parts();
1393 if parts_have_interpolation(&parts) {
1394 return eval_interpol_path_parts(&parts, PathKind::Home, env);
1395 }
1396 let text = p.syntax().text().to_string();
1397 return Ok(Value::Path(Box::new(SmolStr::from(text.as_str()))));
1398 }
1399 ast::Expr::PathSearch(p) => {
1400 // `<name>` or `<name/sub/path>` — resolve via NIX_PATH
1401 // entries (parsed from the env var). If no NIX_PATH entry
1402 // matches, fall through to the literal text so the error
1403 // message points at the name the user wrote.
1404 let text = p.syntax().text().to_string();
1405 let inner = text
1406 .strip_prefix('<')
1407 .and_then(|s| s.strip_suffix('>'))
1408 .unwrap_or(&text);
1409 if let Some(resolved) = crate::builtins::resolve_search_path(inner) {
1410 return Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))));
1411 }
1412 // CppNix: search path resolution failure is a throw
1413 // (catchable by tryEval). Used by nixpkgs impure-overlays.nix
1414 // which tries `import <nixpkgs-overlays>` inside tryEval.
1415 return Err(EvalError::Throw(
1416 format!("search path '{text}' not in NIX_PATH"),
1417 ));
1418 }
1419
1420 ast::Expr::Ident(ident) => {
1421 let name = ident_text(ident);
1422 return match name.as_str() {
1423 "true" => Ok(Value::Bool(true)),
1424 "false" => Ok(Value::Bool(false)),
1425 "null" => Ok(Value::Null),
1426 _ => {
1427 env.lookup(&name)
1428 .ok_or_else(|| EvalError::UndefinedVar(
1429 format!("'{name}'{}", eval_file_ctx()),
1430 ))
1431 }
1432 };
1433 }
1434
1435 ast::Expr::List(list) => {
1436 // Wrap list elements in thunks for maximum laziness.
1437 // CppNix wraps list elements — only forced when accessed.
1438 // This prevents eager evaluation of unused list elements
1439 // (e.g., nixpkgs overlay lists with thousands of entries).
1440 let values: Vec<Value> = list.items()
1441 .map(|e| maybe_thunk(&e, env, false, None))
1442 .collect();
1443 return Ok(Value::list(values));
1444 }
1445
1446 ast::Expr::AttrSet(set) => return eval_attrset(set, env),
1447
1448 ast::Expr::Select(sel) => return eval_select(sel, env),
1449
1450 ast::Expr::HasAttr(ha) => return eval_has_attr(ha, env),
1451
1452 ast::Expr::UnaryOp(op) => return eval_unary_op(op, env),
1453
1454 ast::Expr::BinOp(binop) => {
1455 let lhs_expr = binop
1456 .lhs()
1457 .ok_or_else(|| EvalError::ParseError("binop missing lhs".to_string()))?;
1458 let rhs_expr = binop
1459 .rhs()
1460 .ok_or_else(|| EvalError::ParseError("binop missing rhs".to_string()))?;
1461 let kind = binop
1462 .operator()
1463 .ok_or_else(|| EvalError::ParseError("binop missing operator".to_string()))?;
1464 return eval_binop(kind, &lhs_expr, &rhs_expr, env);
1465 }
1466
1467 ast::Expr::Apply(app) => return eval_apply(app, env),
1468
1469 ast::Expr::IfElse(ie) => {
1470 let cond = ie
1471 .condition()
1472 .ok_or_else(|| EvalError::ParseError("if missing condition".to_string()))?;
1473 let body = ie
1474 .body()
1475 .ok_or_else(|| EvalError::ParseError("if missing then body".to_string()))?;
1476 let else_body = ie
1477 .else_body()
1478 .ok_or_else(|| EvalError::ParseError("if missing else body".to_string()))?;
1479 if force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1480 cur_expr = body;
1481 } else {
1482 cur_expr = else_body;
1483 }
1484 // env stays the same — tail call
1485 continue;
1486 }
1487
1488 ast::Expr::Assert(assert) => {
1489 let cond = assert
1490 .condition()
1491 .ok_or_else(|| EvalError::ParseError("assert missing condition".to_string()))?;
1492 let body = assert
1493 .body()
1494 .ok_or_else(|| EvalError::ParseError("assert missing body".to_string()))?;
1495 if !force_concrete(&eval_expr(&cond, env)?)?.as_bool()? {
1496 return Err(EvalError::AssertionFailed(eval_file_ctx()));
1497 }
1498 cur_expr = body;
1499 continue;
1500 }
1501
1502 ast::Expr::With(with) => {
1503 let ns = with
1504 .namespace()
1505 .ok_or_else(|| EvalError::ParseError("with missing namespace".to_string()))?;
1506 let body = with
1507 .body()
1508 .ok_or_else(|| EvalError::ParseError("with missing body".to_string()))?;
1509 // Don't force the namespace yet — store as a lazy value.
1510 // CppNix evaluates with-scopes lazily: the namespace is only
1511 // forced when a name lookup actually falls through lexical scope.
1512 // This is critical for `fix (self: with self; { … })` patterns
1513 // used throughout nixpkgs.
1514 //
1515 // M2.6 ROOT #4a (byte-verified): `eval_expr(&ns, env)?` was NOT
1516 // lazy — it EVALUATED the namespace expression eagerly at
1517 // `with`-entry. For `with (throw "X"); body` that runs the
1518 // throw; for `with config.services.borgbackup; { … }` (nixpkgs'
1519 // module `config` shape) it forces `config.services.borgbackup`
1520 // the instant the `with`-body's WHNF/keys are demanded (during
1521 // module collection's `pushDownProperties`), re-entering the
1522 // mid-force `config` fixpoint → the empty-Promise partial →
1523 // `null` softening → `concatLists null`. cppnix stores the
1524 // namespace as a thunk and forces it ONLY when a bare-ident
1525 // lookup actually falls through lexical scope into the `with`.
1526 // Reduced repro (no module system, iterates in ms):
1527 // `builtins.attrNames (with (throw "X"); { a = 1; })`
1528 // nix → [ "a" ] ; sui (before) → throws "X".
1529 // `maybe_thunk` keeps the fast-path for an already-resolved
1530 // ident namespace (no thunk overhead) while deferring any
1531 // non-trivial namespace (Select / Apply / throw) into a lazy
1532 // thunk the scope-lookup path (`Env::lookup_fast`) forces only
1533 // on fallthrough.
1534 let scope_val = maybe_thunk(&ns, env, false, None);
1535 let new_env = env.child().with_scope(scope_val);
1536 cur_expr = body;
1537 cur_env = new_env;
1538 continue;
1539 }
1540
1541 ast::Expr::LetIn(letin) => {
1542 // ── plan-driven binding (`SUI_NORMALIZE=1`) ──────────────────
1543 //
1544 // `let` obeys the SAME merge rule as an attrset literal, and sui
1545 // never implemented it: `let a = {b=1;}; a = {c=2;}; in a` is
1546 // `{b=1;c=2;}` in nix and was `{c=2;}` here — silent key loss on
1547 // legal nix. A `let` takes the SCOPE rather than the attrset,
1548 // because it is a binder for a body and produces no attrset.
1549 //
1550 // A `None` means the group has no duplicate and no dotted path,
1551 // so the existing path is already correct — see `normalize_env`.
1552 if crate::normalize_env::enabled() {
1553 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1554 let offset = u32::from(letin.syntax().text_range().start());
1555 if let Some(plan) =
1556 crate::normalize_env::plan_for_node(letin, true, src_id, offset)
1557 .map_err(reject)?
1558 {
1559 let (_attrs, scope) = bind_plan_group(&plan, env)?;
1560 let body = letin.body().ok_or_else(|| {
1561 EvalError::ParseError("let missing body".to_string())
1562 })?;
1563 cur_expr = body;
1564 cur_env = scope;
1565 continue;
1566 }
1567 }
1568
1569 let mut new_env = env.child();
1570
1571 // Phase 1: Create thunks with a dummy env and bind them.
1572 // Collect (key, thunk) pairs so we can update envs later.
1573 let mut thunks: Vec<(String, Thunk)> = Vec::new();
1574
1575 // Track which names have been defined so far in this scope.
1576 // Used by maybe_thunk to resolve backward references directly
1577 // instead of creating wasteful thunks.
1578 let mut defined_so_far: HashSet<String> = HashSet::new();
1579
1580 // Accumulator for dotted-path bindings (`let a.b = 1; a.c = 2; ...`).
1581 // Leaf values are wrapped in thunks so they can reference
1582 // sibling let-bindings (the let scope is recursive in Nix).
1583 let mut dotted_attrs: NixAttrs = NixAttrs::new();
1584
1585 // Pre-pass: collect every binding name in this let-scope
1586 // (single-key bindings + top-level keys of dotted paths +
1587 // names from inherit clauses). Used by the recursive-thunk
1588 // detector below — a binding is part of the mutual fix-point
1589 // if its RHS references ANY of these names.
1590 //
1591 // D1 (`SUI_SCOPE_NARROW>=1`) — `names_complete` is the honesty half
1592 // of the narrowing. Narrowing is only sound while
1593 // `let_scope_names` is a COMPLETE list of what this scope binds: a
1594 // binding is judged "reaches no sibling" by intersecting its RHS's
1595 // free variables with that set, so a name MISSING from it reads as
1596 // an outer reference and the binding wrongly keeps the outer env.
1597 // A head that does not resolve here contributes nothing, so the
1598 // whole scope forfeits narrowing rather than narrow on a partial
1599 // set. (`Dynamic` heads are excluded even when they do resolve —
1600 // the name is computed, so it is not a syntactic property of the
1601 // scope.) Nothing about the EVALUATION below changes; this only
1602 // decides whether the optimisation is allowed to apply.
1603 let mut names_complete = true;
1604 let let_scope_names: HashSet<String> = {
1605 let mut s = HashSet::new();
1606 for entry in letin.entries() {
1607 match entry {
1608 ast::Entry::AttrpathValue(apv) => {
1609 if let Some(attrpath) = apv.attrpath() {
1610 if let Some(first) = attrpath.attrs().next() {
1611 if let ast::Attr::Dynamic(_) = &first {
1612 names_complete = false;
1613 }
1614 if let Ok(name) = eval_attr(&first, env) {
1615 s.insert(name);
1616 } else {
1617 names_complete = false;
1618 }
1619 } else {
1620 names_complete = false;
1621 }
1622 } else {
1623 names_complete = false;
1624 }
1625 }
1626 ast::Entry::Inherit(inherit) => {
1627 for attr in inherit.attrs() {
1628 if let ast::Attr::Dynamic(_) = &attr {
1629 names_complete = false;
1630 }
1631 if let Ok(name) = eval_attr(&attr, env) {
1632 s.insert(name);
1633 } else {
1634 names_complete = false;
1635 }
1636 }
1637 }
1638 }
1639 }
1640 s
1641 };
1642 let narrow = scope_narrow_enabled() && names_complete;
1643
1644 // D2 (`SUI_SCOPE_NARROW=2`) — the CLUSTER env.
1645 //
1646 // D1 alone is not enough, and the reason is the shape of the
1647 // graph: free-variable analysis is per-binding on the
1648 // `thunk -> env` edge, but the `env -> thunk` edge is SHARED. One
1649 // binding that really does reach a sibling keeps `new_env` alive,
1650 // and `new_env` holds EVERY binding in the scope — so a single
1651 // recursive `f` re-pins all fifty innocent leaves and the footprint
1652 // is unchanged. (That is the P4 row, and it is why the headline
1653 // gate is too easy: D1 greens it while doing nothing here.)
1654 //
1655 // The fix is to stop pointing the survivors at the whole scope.
1656 // Phase 2 re-points them at a `fix_env` carrying ONLY the names the
1657 // pinned bindings can actually reach — their own names plus
1658 // `refs ∩ scope_names`. The body still gets the full `new_env`, so
1659 // nothing the LET EXPRESSION evaluates to can change; only the
1660 // envs captured by thunks shrink.
1661 let cluster = narrow && scope_cluster_enabled();
1662 // Every (name, value) bound into `new_env`, so the pinned subset can
1663 // be re-bound into `fix_env`. Allocated only under D2.
1664 let mut all_bound: Vec<(String, Value)> = Vec::new();
1665 // The names that stayed pinned, and the free-variable sets of the
1666 // bindings behind them. `pin` needs only the UNION of those sets, so
1667 // no name→refs association is required — and that union already IS
1668 // the fixpoint: a name added to `pin` that is not itself a pinned
1669 // binding contributes no further refs, and one that is has its refs
1670 // in the union already.
1671 let mut pinned_names: HashSet<String> = HashSet::new();
1672 let mut pinned_refs: Vec<HashSet<SmolStr>> = Vec::new();
1673 // A dotted path (`let a.b = 1;`) pushes LEAF thunks whose names are
1674 // inner path segments, not scope names, and whose free variables are
1675 // never computed here — so `fix_env` cannot be shown to carry what
1676 // they need. Such a scope forfeits D2 (D1 still applies).
1677 let mut has_dotted = false;
1678
1679 for entry in letin.entries() {
1680 match entry {
1681 ast::Entry::AttrpathValue(ref apv) => {
1682 let attrpath = apv.attrpath().ok_or_else(|| {
1683 EvalError::ParseError("binding missing attrpath".to_string())
1684 })?;
1685 let value_expr = apv.value().ok_or_else(|| {
1686 EvalError::ParseError("binding missing value".to_string())
1687 })?;
1688 let mut path_keys: Vec<String> = attrpath
1689 .attrs()
1690 .map(|a| eval_attr(&a, env))
1691 .collect::<Result<_, _>>()?;
1692 if path_keys.len() == 1 {
1693 let key = path_keys.pop().unwrap();
1694 // Self/mutual-recursive detection: any binding
1695 // whose RHS references its own name OR any
1696 // SIBLING let-scope name is part of the let's
1697 // mutual fix-point. Mark as recursive so
1698 // inner re-entrance during force returns a
1699 // Promise sentinel instead of erroring with
1700 // InfiniteRecursion. This is the M2.6
1701 // module-system fix path (cppnix's
1702 // lib/modules.nix uses a deep let-scope with
1703 // declaredConfig / options / matchedOptions /
1704 // resultsByName / modules all transitively
1705 // cycling through each other).
1706 //
1707 // `let_scope_names` is collected upfront in a
1708 // pre-pass so each binding sees every other
1709 // binding name (not just earlier ones).
1710 // O(N) not O(N²): compute the RHS's referenced-name
1711 // set ONCE (memoized), then intersect with the
1712 // let-scope names. Byte-identical to the prior
1713 // `references(key) OR references(any sibling)`:
1714 // chaining `key` covers the self-reference case
1715 // regardless of whether `key ∈ let_scope_names`.
1716 let referenced = referenced_idents(&value_expr);
1717 let in_mutual_cycle = std::iter::once(&key)
1718 .chain(let_scope_names.iter())
1719 .any(|n| referenced.contains(n.as_str()));
1720 let value = if in_mutual_cycle {
1721 Value::Thunk(Thunk::new_suspended_recursive(
1722 value_expr.clone(),
1723 env.clone(),
1724 ))
1725 } else {
1726 maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
1727 };
1728 new_env.bind(key.clone(), value.clone());
1729 if cluster {
1730 all_bound.push((key.clone(), value.clone()));
1731 }
1732 if let Value::Thunk(t) = &value {
1733 // D1: `in_mutual_cycle` is ALREADY the
1734 // forward-complete "reaches a sibling"
1735 // predicate here (`let_scope_names` is a full
1736 // pre-pass, unlike the `rec` arm's
1737 // backward-only one), so it doubles as the
1738 // needs-scope test at zero extra cost — no
1739 // second tree walk.
1740 //
1741 // When it is false the RHS references nothing
1742 // this scope binds, so every name it CAN
1743 // resolve resolves identically in `env` and in
1744 // `new_env`: `Env::child` copies `with_scopes`,
1745 // `eval_file` and `source_id` verbatim, and the
1746 // only added bindings are the let-scope names
1747 // this RHS provably does not mention. Skipping
1748 // the re-point is therefore byte-neutral, and
1749 // it is what leaves the thunk holding the OUTER
1750 // env instead of closing
1751 // `thunk -> new_env -> thunk`.
1752 if in_mutual_cycle || !narrow {
1753 thunks.push((key.clone(), t.clone()));
1754 if cluster {
1755 pinned_names.insert(key.clone());
1756 pinned_refs.push(referenced);
1757 }
1758 crate::value::census::scope_pinned();
1759 } else {
1760 crate::value::census::scope_narrowed();
1761 }
1762 }
1763 defined_so_far.insert(key);
1764 } else if path_keys.len() > 1 {
1765 // Multi-segment dotted path: build a nested
1766 // attrset with thunks at the leaves so the
1767 // value expression can reference sibling
1768 // let-bindings.
1769 has_dotted = true;
1770 let key = path_keys[0].clone();
1771 let value = build_nested_attr_thunk(
1772 &path_keys[1..],
1773 &value_expr,
1774 env,
1775 &mut thunks,
1776 );
1777 merge_nested_insert(&mut dotted_attrs, key, value);
1778 }
1779 }
1780 ast::Entry::Inherit(ref inherit) => {
1781 if let Some(from) = inherit.from() {
1782 let source_expr = from.expr().ok_or_else(|| {
1783 EvalError::ParseError(
1784 "inherit from missing expr".to_string(),
1785 )
1786 })?;
1787 // D1: every `InheritSelect` in this clause shares
1788 // ONE source thunk, and `Thunk::update_env`
1789 // delegates straight through to it — so all N
1790 // pushes re-point the SAME env. Whether that
1791 // re-point is needed is therefore a property of the
1792 // source expression alone, computed ONCE above the
1793 // loop instead of N times inside it. Guarded by
1794 // `!narrow ||` so the default path does not pay the
1795 // walk at all.
1796 let source_refs: Option<HashSet<SmolStr>> = if narrow {
1797 Some(referenced_idents(&source_expr))
1798 } else {
1799 None
1800 };
1801 let source_needs_scope = match &source_refs {
1802 Some(refs) => let_scope_names
1803 .iter()
1804 .any(|n| refs.contains(n.as_str())),
1805 None => true,
1806 };
1807 // Create ONE shared source thunk per
1808 // `inherit (source)` clause. All inherited
1809 // names share it via Rc clone — the source
1810 // is evaluated at most once.
1811 let source_thunk = Thunk::new_suspended(
1812 source_expr, env.clone(),
1813 );
1814 for attr in inherit.attrs() {
1815 let name = eval_attr(&attr, env)?;
1816 let thunk = Thunk::new_inherit_select(
1817 source_thunk.clone(),
1818 name.clone(),
1819 );
1820 new_env.bind(name.clone(), Value::Thunk(thunk.clone()));
1821 if cluster {
1822 all_bound.push((
1823 name.clone(),
1824 Value::Thunk(thunk.clone()),
1825 ));
1826 }
1827 if source_needs_scope {
1828 if cluster {
1829 pinned_names.insert(name.clone());
1830 }
1831 thunks.push((name, thunk));
1832 crate::value::census::scope_pinned();
1833 } else {
1834 crate::value::census::scope_narrowed();
1835 }
1836 }
1837 // One refs set for the whole clause — every name in
1838 // it re-points the SAME shared source thunk.
1839 if cluster
1840 && source_needs_scope
1841 && let Some(refs) = source_refs
1842 {
1843 pinned_refs.push(refs);
1844 }
1845 } else {
1846 // `inherit name1 name2 ...` from the
1847 // enclosing lexical scope. This stays
1848 // eager because the names already exist
1849 // in `env` — no fixpoint involved.
1850 for attr in inherit.attrs() {
1851 let name = eval_attr(&attr, env)?;
1852 let value = env.lookup(&name).ok_or_else(|| {
1853 EvalError::UndefinedVar(
1854 format!("'{name}'{}", eval_file_ctx()),
1855 )
1856 })?;
1857 if cluster {
1858 all_bound.push((name.clone(), value.clone()));
1859 }
1860 new_env.bind(name, value);
1861 }
1862 }
1863 }
1864 }
1865 }
1866
1867 // Phase 1b: Bind accumulated dotted-path attrs into new_env.
1868 // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
1869 // duplicate definition, so we do not attempt to merge with
1870 // existing inherit thunks — just bind directly.
1871 for (key, value) in dotted_attrs.iter() {
1872 new_env.bind(key.clone(), value.clone());
1873 if cluster {
1874 all_bound.push((key.clone(), value.clone()));
1875 }
1876 }
1877
1878 // D2: the cluster env the survivors get re-pointed at, in place of
1879 // the whole scope. Built only when it can actually shrink anything
1880 // — some binding pinned, some binding not, and no dotted path (see
1881 // `has_dotted`).
1882 let fix_env: Option<Env> = if cluster && !has_dotted && !thunks.is_empty() {
1883 // `pin` = the pinned names, plus every scope name they can
1884 // reach. This union is already the fixpoint: a name pulled in
1885 // that is not itself pinned contributes no further refs (its
1886 // own thunk still holds the OUTER env and so resolves entirely
1887 // outside this scope), and one that is pinned had its refs in
1888 // the union from the start.
1889 let mut pin = pinned_names;
1890 for refs in &pinned_refs {
1891 for n in &let_scope_names {
1892 if refs.contains(n.as_str()) {
1893 pin.insert(n.clone());
1894 }
1895 }
1896 }
1897 if pin.len() < all_bound.len() {
1898 let mut fe = env.child();
1899 for (name, value) in &all_bound {
1900 if pin.contains(name) {
1901 fe.bind(name.clone(), value.clone());
1902 }
1903 }
1904 Some(fe)
1905 } else {
1906 None
1907 }
1908 } else {
1909 None
1910 };
1911
1912 // Phase 2: Update all thunks to capture the final env
1913 // (which now has all names bound).
1914 let phase2_env: &Env = fix_env.as_ref().unwrap_or(&new_env);
1915 for (_key, thunk) in &thunks {
1916 thunk.update_env(phase2_env);
1917 }
1918
1919 let body = letin
1920 .body()
1921 .ok_or_else(|| EvalError::ParseError("let missing body".to_string()))?;
1922 cur_expr = body;
1923 cur_env = new_env;
1924 continue;
1925 }
1926
1927 ast::Expr::Lambda(lam) => {
1928 let param = lam
1929 .param()
1930 .ok_or_else(|| EvalError::ParseError("lambda missing param".to_string()))?;
1931 let body = lam
1932 .body()
1933 .ok_or_else(|| EvalError::ParseError("lambda missing body".to_string()))?;
1934 return Ok(Value::Lambda(Rc::new(Closure {
1935 param,
1936 body,
1937 env: env.clone(),
1938 })));
1939 }
1940
1941 ast::Expr::Paren(p) => {
1942 let inner = p
1943 .expr()
1944 .ok_or_else(|| EvalError::ParseError("paren missing expr".to_string()))?;
1945 cur_expr = inner;
1946 continue;
1947 }
1948
1949 ast::Expr::Root(r) => {
1950 let inner = r
1951 .expr()
1952 .ok_or_else(|| EvalError::ParseError("root missing expr".to_string()))?;
1953 cur_expr = inner;
1954 continue;
1955 }
1956
1957 ast::Expr::LegacyLet(ll) => {
1958 // ── plan-driven binding (`SUI_NORMALIZE=1`) ──────────────────
1959 //
1960 // `eval_entries` carries the comment "Multi-key paths in let are
1961 // not standard; skip for now" and does exactly that — it SILENTLY
1962 // DISCARDS every multi-segment attrpath, so
1963 // `let { a.b = 1; a.c = 2; body = a; }` loses both. The bytecode
1964 // VM has always handled this correctly, which makes the walker
1965 // the engine that is behind here.
1966 if crate::normalize_env::enabled() {
1967 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
1968 let offset = u32::from(ll.syntax().text_range().start());
1969 if let Some(plan) =
1970 crate::normalize_env::plan_for_node(ll, true, src_id, offset)
1971 .map_err(reject)?
1972 {
1973 let (_attrs, scope) = bind_plan_group(&plan, env)?;
1974 return scope.lookup("body").ok_or_else(|| {
1975 EvalError::AttrNotFound(format!(
1976 "'body' in legacy let{}",
1977 eval_file_ctx()
1978 ))
1979 });
1980 }
1981 }
1982
1983 let mut new_env = env.child();
1984 eval_entries(ll, &mut new_env)?;
1985 // legacy let returns the `body` attr from its bindings
1986 return new_env
1987 .lookup("body")
1988 .ok_or_else(|| EvalError::AttrNotFound(
1989 format!("'body' in legacy let{}", eval_file_ctx()),
1990 ));
1991 }
1992
1993 ast::Expr::CurPos(_) => return Err(EvalError::NotImplemented("__curPos".to_string())),
1994 ast::Expr::Error(_) => return Err(EvalError::ParseError("parse error node".to_string())),
1995 } // match
1996 } // loop — unreachable, all arms either return or continue
1997}
1998
1999fn eval_literal(lit: &ast::Literal) -> Result<Value, EvalError> {
2000 use ast::LiteralKind;
2001 match lit.kind() {
2002 LiteralKind::Integer(tok) => {
2003 let n = tok
2004 .value()
2005 .map_err(|e| EvalError::ParseError(format!("invalid integer: {e}")))?;
2006 Ok(Value::Int(n))
2007 }
2008 LiteralKind::Float(tok) => {
2009 let f = tok
2010 .value()
2011 .map_err(|e| EvalError::ParseError(format!("invalid float: {e}")))?;
2012 Ok(Value::Float(f))
2013 }
2014 LiteralKind::Uri(tok) => Ok(Value::string(tok.syntax().text().to_string())),
2015 }
2016}
2017
2018/// Result of walking an attrpath on a base value.
2019enum TraverseResult {
2020 /// All keys found; contains the leaf value.
2021 Found(Value),
2022 /// A key was missing; contains the missing key name.
2023 Missing(String),
2024 /// A non-attrset value was encountered during traversal.
2025 NotAttrs(Value),
2026}
2027
2028/// Walk an attrpath on a base value, forcing at each level.
2029///
2030/// Returns `Found(leaf)` when every key exists, `Missing(key)` when
2031/// a key is absent, or `NotAttrs(v)` when a non-attrset is encountered.
2032fn traverse_attrpath(
2033 base: Value,
2034 attrpath: &rnix::ast::Attrpath,
2035 env: &Env,
2036) -> Result<TraverseResult, EvalError> {
2037 let attrs: Vec<_> = attrpath.attrs().collect();
2038 let mut value = base;
2039 for (i, attr) in attrs.iter().enumerate() {
2040 let key = eval_attr(attr, env)?;
2041 // Force the current value to an attrset to select from it.
2042 let forced = force_value(&value)?;
2043 match forced {
2044 Value::Attrs(ref a) => match a.get(&key) {
2045 Some(v) => {
2046 if i < attrs.len() - 1 {
2047 // Intermediate step: force to attrset for next selection.
2048 value = force_value(v)?;
2049 } else {
2050 // Final step: return WITHOUT forcing — let the caller
2051 // decide when to force. Matches CppNix's lazy attr access.
2052 value = v.clone();
2053 }
2054 }
2055 None => return Ok(TraverseResult::Missing(key)),
2056 },
2057 _ => return Ok(TraverseResult::NotAttrs(forced)),
2058 }
2059 }
2060 Ok(TraverseResult::Found(value))
2061}
2062
2063fn eval_select(sel: &ast::Select, env: &Env) -> Result<Value, EvalError> {
2064 crate::perf::inc(crate::perf::Counter::Select);
2065 let base_expr = sel.expr().ok_or_else(|| {
2066 EvalError::ParseError("select missing expression".to_string())
2067 })?;
2068 // M2.6 bridge: in `expr.path or default`, an `InfiniteRecursion`
2069 // hit while forcing the LEFT side falls back to the default —
2070 // operationally matches cppnix, which avoids the cycle entirely
2071 // via lazy attribute access during fix-point evaluation. Without
2072 // a default, the recursion propagates as a real error. Other
2073 // error kinds (Throw, TypeError, …) always propagate so user
2074 // bugs aren't masked. Removed when the underlying fix-point /
2075 // lazy-access semantics land — see docs/M2.6-MODULE-SYSTEM-FIXPOINT.md.
2076 let base_result = eval_expr(&base_expr, env)
2077 .and_then(|v| force_concrete(&v).map(Concrete::into_value));
2078 let base = match base_result {
2079 Ok(v) => v,
2080 Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2081 return eval_expr(&sel.default_expr().expect("checked"), env);
2082 }
2083 Err(e) => return Err(e),
2084 };
2085 let base_type = base.type_name();
2086 let attrpath = sel.attrpath().ok_or_else(|| {
2087 EvalError::ParseError("select missing attrpath".to_string())
2088 })?;
2089 // M2.6 bridge: when the blackhole-bridge sentinels are active,
2090 // an attribute lookup that misses (`AttrNotFound`) or hits a
2091 // non-attrset intermediate (`NotAttrs`) on the bridge's empty
2092 // sentinel value gets resolved to `null` instead of erroring.
2093 // cppnix's partial attrset would have CARRIED the keys (with
2094 // their lazy values), so the lookup would succeed; null is the
2095 // cheapest sentinel that propagates through downstream code
2096 // without further type errors.
2097 //
2098 // M2.6 ROOT #4 CLOSED (2026-07-11): the `|| crate::value::in_promise_eval()`
2099 // clause that used to soften a mid-Promise `config.<x>` select-miss to
2100 // `null` is REMOVED. It was the band-aid masking the two real over-forces
2101 // that ROOT #4a (the `with`-namespace eager eval, above) and ROOT #4b (the
2102 // dropped full-set leaf in `merge_nested_insert`, below) now fix at their
2103 // load-bearing cause. Verified with the softening gone: both
2104 // `lib.nixosSystem { modules = []; }.config.system.name` → `"nixos"` and
2105 // `attrNames sys.options` → 53 (nix-parity), `sui parity` stays 35 match /
2106 // 0 regressions, 1324 sui-eval lib tests + 30 diff tests pass — nothing
2107 // depended on the sentinel any more. The two explicit operator-gated
2108 // bridges below stay as opt-in experiments (default-off); only the
2109 // always-on Promise softening is retired.
2110 let bridge_active = std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_ATTRS").is_some()
2111 || std::env::var_os("SUI_BLACKHOLE_AS_NULL").is_some();
2112 let traversal = traverse_attrpath(base, &attrpath, env);
2113 match traversal {
2114 Ok(TraverseResult::Found(v)) => Ok(v),
2115 Ok(TraverseResult::Missing(key)) => {
2116 if let Some(def) = sel.default_expr() {
2117 eval_expr(&def, env)
2118 } else if bridge_active {
2119 if std::env::var_os("SUI_M26_SELTRACE").is_some() {
2120 let path: Vec<String> = sel.attrpath().map(|ap|
2121 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2122 ).unwrap_or_default();
2123 eprintln!("[M26 SEL-MISS→null] base_type={base_type} path={path:?} missing-key={key}{}", eval_file_ctx());
2124 }
2125 if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2126 let path: Vec<String> = sel.attrpath().map(|ap|
2127 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2128 ).unwrap_or_default();
2129 if path.iter().any(|p| p.contains(&filt)) {
2130 return Err(EvalError::type_error(format!(
2131 "M26-HARDSOFTEN path={path:?} key={key}"
2132 )));
2133 }
2134 }
2135 Ok(Value::Null)
2136 } else {
2137 Err(EvalError::AttrNotFound(
2138 format!("'{key}'{}", eval_file_ctx()),
2139 ))
2140 }
2141 }
2142 Ok(TraverseResult::NotAttrs(forced)) => {
2143 // CppNix: `expr.a.b or default` falls back to default for
2144 // ANY error in the path — including intermediate values
2145 // that aren't attrsets (e.g., null). The module system
2146 // relies on this: `x.options.type.name or null` must
2147 // return null when x.options is null, not throw.
2148 if let Some(def) = sel.default_expr() {
2149 eval_expr(&def, env)
2150 } else if bridge_active {
2151 if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2152 let path: Vec<String> = sel.attrpath().map(|ap|
2153 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2154 ).unwrap_or_default();
2155 if path.iter().any(|p| p.contains(&filt)) {
2156 return Err(EvalError::type_error(format!(
2157 "M26-HARDSOFTEN-NOTATTRS path={path:?} base_type={base_type}"
2158 )));
2159 }
2160 }
2161 return Ok(Value::Null);
2162 } else {
2163 if std::env::var("SUI_DEBUG_SELECT").is_ok() {
2164 let path: Vec<String> = sel.attrpath().map(|ap|
2165 ap.attrs().filter_map(|a| match a {
2166 ast::Attr::Ident(i) => Some(i.to_string()),
2167 ast::Attr::Str(s) => Some(format!("\"{}\"", s.syntax().text())),
2168 ast::Attr::Dynamic(_) => Some("<dyn>".into()),
2169 }).collect()
2170 ).unwrap_or_default();
2171 let dbg = format!("{:?}", forced);
2172 let truncated = if dbg.len() > 200 { format!("{}…", &dbg[..200]) } else { dbg };
2173 eprintln!("[SUI_DEBUG_SELECT] base_type={base_type} path={path:?} base={truncated}{}", eval_file_ctx());
2174 }
2175 Err(attach_trace(EvalError::type_error(
2176 format!("cannot select from {base_type}"),
2177 )))
2178 }
2179 }
2180 // Same M2.6 bridge as on the base force above: if an
2181 // intermediate step in the attrpath traversal raises
2182 // InfiniteRecursion and `or default` was supplied, the
2183 // default is the operationally-correct value.
2184 Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2185 eval_expr(&sel.default_expr().expect("checked"), env)
2186 }
2187 Err(e) => Err(e),
2188 }
2189}
2190
2191/// Evaluate `expr ? a.b.c` — check key presence without forcing value thunks.
2192fn eval_has_attr(ha: &ast::HasAttr, env: &Env) -> Result<Value, EvalError> {
2193 let base_expr = ha.expr().ok_or_else(|| {
2194 EvalError::ParseError("hasattr missing expression".to_string())
2195 })?;
2196 let base = force_concrete(&eval_expr(&base_expr, env)?)?.into_value();
2197 let attrpath = ha.attrpath().ok_or_else(|| {
2198 EvalError::ParseError("hasattr missing attrpath".to_string())
2199 })?;
2200 match traverse_attrpath(base, &attrpath, env)? {
2201 TraverseResult::Found(_) => Ok(Value::Bool(true)),
2202 TraverseResult::Missing(_) | TraverseResult::NotAttrs(_) => Ok(Value::Bool(false)),
2203 }
2204}
2205
2206fn eval_unary_op(op: &ast::UnaryOp, env: &Env) -> Result<Value, EvalError> {
2207 let inner = op
2208 .expr()
2209 .ok_or_else(|| EvalError::ParseError("unary op missing expr".to_string()))?;
2210 let val = force_value(&eval_expr(&inner, env)?)?;
2211 let kind = op
2212 .operator()
2213 .ok_or_else(|| EvalError::ParseError("unary op missing operator".to_string()))?;
2214 match kind {
2215 ast::UnaryOpKind::Negate => match val {
2216 Value::Int(n) => Ok(Value::Int(-n)),
2217 Value::Float(f) => Ok(Value::Float(-f)),
2218 _ => Err(EvalError::type_error(
2219 format!("cannot negate {}", val.type_name()),
2220 )),
2221 },
2222 ast::UnaryOpKind::Invert => Ok(Value::Bool(!val.as_bool()?)),
2223 }
2224}
2225
2226/// Builtins that must receive their argument UNFORCED (call-by-need). This is the
2227/// SINGLE source of truth consumed by BOTH `eval_apply` (which must THUNK the arg
2228/// instead of eager-evaluating it) AND the builtin apply arm (which must SKIP the
2229/// arg force). The two sites MUST agree: if `eval_apply` eager-evaluates the arg,
2230/// the apply-arm's force-skip is dead (the arg is already forced — or already
2231/// threw) upstream. They were previously inconsistent (only `tryEval` was thunked
2232/// in `eval_apply`), so `seq`/`deepSeq`/`addErrorContext`/`foldl'` silently got
2233/// eager args despite their apply-time exemption — the bug behind
2234/// `builtins.foldl' (_: x: x) (throw "…") […]` throwing instead of returning the
2235/// last element (nix's foldl' is NOT strict in the nul accumulator).
2236#[inline]
2237pub(crate) fn builtin_takes_lazy_arg(name: &str) -> bool {
2238 matches!(
2239 name,
2240 "tryEval" | "addErrorContext<partial>" | "seq<partial>" | "deepSeq<partial>" | "foldl'<p1>"
2241 )
2242}
2243
2244fn eval_apply(app: &ast::Apply, env: &Env) -> Result<Value, EvalError> {
2245 let func_expr = app
2246 .lambda()
2247 .ok_or_else(|| EvalError::ParseError("apply missing function".to_string()))?;
2248 let arg_expr = app
2249 .argument()
2250 .ok_or_else(|| EvalError::ParseError("apply missing argument".to_string()))?;
2251 let func = force_value(&eval_expr(&func_expr, env)?)?;
2252 // Lambda arguments are wrapped in a thunk for call-by-need semantics.
2253 // Thunk strategy depends on function type:
2254 // - Lambda: ALWAYS thunk (call-by-need, enables fixpoints)
2255 // - tryEval: ALWAYS thunk (must catch errors during force)
2256 // - Builtin: evaluate eagerly (builtins always force args anyway;
2257 // thunking wastes Rc + OnceCell allocation per call)
2258 // - __functor: evaluate eagerly (will be applied immediately)
2259 let arg = match &func {
2260 Value::Lambda(_) => {
2261 // Call-by-need: the arg is thunked so it forces lazily. But a
2262 // PURE-CONSTANT arg (a literal, a non-interpolated string, or a
2263 // non-interpolated path) can never throw or diverge, so producing
2264 // its value directly is byte-neutral whether or not the lambda ever
2265 // forces it — identical eval-order-observable behavior, one fewer
2266 // never-forced thunk. This is `arg_pure_constant` ONLY: any arg that
2267 // could throw/diverge/observe a fixpoint (Ident with-scope, Select,
2268 // Apply, BinOp, …) stays fully thunked to preserve laziness.
2269 if let Some(v) = eval_pure_constant_arg(&arg_expr) {
2270 v
2271 } else {
2272 crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2273 Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2274 }
2275 }
2276 Value::Builtin(b) if builtin_takes_lazy_arg(&b.name) => {
2277 // Call-by-need for the laziness-exempt builtins (tryEval / seq /
2278 // deepSeq / addErrorContext / foldl'<p1>): the arg MUST be thunked,
2279 // not eager-evaluated, so it forces only if/when the builtin demands
2280 // it. Kept in lockstep with the apply-arm skip via `builtin_takes_lazy_arg`.
2281 crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2282 Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2283 }
2284 _ => eval_expr(&arg_expr, env)?,
2285 };
2286 apply(func, arg)
2287}
2288
2289/// If `arg_expr` is a PURE CONSTANT — a literal, a non-interpolated string, or
2290/// a non-interpolated absolute/home path — return its value directly (no thunk).
2291///
2292/// A pure constant has no free variables, cannot throw, cannot diverge, and has
2293/// no fixpoint/laziness interaction: `eval_expr(arg)` is total and produces the
2294/// exact value a suspended thunk of it would yield on force. Producing it
2295/// eagerly in a call-by-need arg position is therefore byte-neutral (the
2296/// lambda that never forces the arg observes no difference — the value is inert).
2297///
2298/// Returns `None` for EVERYTHING else (Ident — may hit a with-scope force;
2299/// Select/Apply/BinOp/If/… — may throw or diverge; interpolated Str/Path —
2300/// must force `${…}` lazily), which keeps those args fully thunked. `env` is
2301/// NOT threaded in because a pure constant needs no environment; if a match
2302/// arm ever needed `env`, it would not be a pure constant.
2303fn eval_pure_constant_arg(arg_expr: &ast::Expr) -> Option<Value> {
2304 match arg_expr {
2305 ast::Expr::Literal(lit) => eval_literal(lit).ok(),
2306 ast::Expr::Str(st) if !str_has_interpolation(st) => {
2307 // No interpolation ⇒ `eval_str` runs no force/coerce; env is unused.
2308 eval_str(st, &Env::new()).ok()
2309 }
2310 ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
2311 let text = crate::path::canon_abs(&p.syntax().text().to_string());
2312 Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2313 }
2314 ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
2315 let text = p.syntax().text().to_string();
2316 Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2317 }
2318 _ => None,
2319 }
2320}
2321
2322fn eval_str(s: &ast::Str, env: &Env) -> Result<Value, EvalError> {
2323 let mut result = String::new();
2324 let mut ctx = StringContext::new();
2325 for part in s.normalized_parts() {
2326 match part {
2327 InterpolPart::Literal(text) => result.push_str(&text),
2328 InterpolPart::Interpolation(interpol) => {
2329 let expr = interpol.expr().ok_or_else(|| {
2330 EvalError::ParseError("interpolation missing expr".to_string())
2331 })?;
2332 let val = force_value(&eval_expr(&expr, env)?)?;
2333 // CppNix string interpolation is copy-to-store coercion: an
2334 // interpolated source path (`"${./foo}"`) is NAR-copied into
2335 // the store and the store path is spliced in (with context),
2336 // never the raw filesystem path.
2337 let (s, c) = val.coerce_to_string_copy_to_store()?;
2338 result.push_str(&s);
2339 ctx.merge(&c);
2340 }
2341 }
2342 }
2343 Ok(Value::String(Rc::new(NixString::with_context(result, ctx))))
2344}
2345
2346/// Whether a list of path parts contains a `${…}` interpolation. When
2347/// it does not, the raw `.syntax().text()` shortcut is byte-identical
2348/// and cheaper, so the trivial fast paths stay on that shortcut.
2349fn parts_have_interpolation(parts: &[InterpolPart<rnix::ast::PathContent>]) -> bool {
2350 parts
2351 .iter()
2352 .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2353}
2354
2355/// Whether a string literal contains any `${…}` interpolation part. A `false`
2356/// result means the string is a pure constant (`eval_str` runs no force/coerce
2357/// and cannot throw), so `maybe_thunk` may evaluate it eagerly byte-neutrally.
2358fn str_has_interpolation(s: &ast::Str) -> bool {
2359 s.normalized_parts()
2360 .iter()
2361 .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2362}
2363
2364/// Evaluate an interpolatable path literal that contains `${…}` parts.
2365///
2366/// CppNix path interpolation (`./${x}.nix`, `/a/${e}`, `~/x/${e}`):
2367/// * each literal segment is spliced verbatim,
2368/// * each `${e}` is **plain**-coerced to a string with context
2369/// (NOT copy-to-store — path-typed interpolations splice the raw
2370/// store/filesystem path, e.g. `/bar/${./foo}` → `/bar/tmp/foo`),
2371/// * the concatenated text is then resolved exactly like the plain
2372/// path literal of the same kind (relative → joined + normalized
2373/// against the defining file's directory; absolute/home → verbatim),
2374/// * the result is a `path` value.
2375///
2376/// Parts come from rnix's `<PathKind>::parts()` which splits the path
2377/// token stream into `Literal(PathContent)` / `Interpolation(Interpol)`.
2378fn eval_interpol_path_parts(
2379 parts: &[InterpolPart<rnix::ast::PathContent>],
2380 kind: PathKind,
2381 env: &Env,
2382) -> Result<Value, EvalError> {
2383 let mut text = String::new();
2384 for part in parts {
2385 match part {
2386 InterpolPart::Literal(content) => text.push_str(content.text()),
2387 InterpolPart::Interpolation(interpol) => {
2388 let expr = interpol.expr().ok_or_else(|| {
2389 EvalError::ParseError("path interpolation missing expr".to_string())
2390 })?;
2391 let val = force_value(&eval_expr(&expr, env)?)?;
2392 // Plain coercion (coerceMore = false): a path-typed
2393 // interpolation splices the raw path string, never a
2394 // copied-to-store hash path.
2395 let (s, _ctx) = val.coerce_to_string()?;
2396 text.push_str(&s);
2397 }
2398 }
2399 }
2400 let resolved = match kind {
2401 // Relative path: resolve against the defining file's directory,
2402 // mirroring the plain `PathRel` branch.
2403 PathKind::Rel => {
2404 if let Some(dir) = current_eval_dir() {
2405 let norm = normalize_path(&dir.join(&text));
2406 // Lift cache→store exactly like the plain `PathRel` branch (the
2407 // store↔cache seam value-half). Without this, an interpolated
2408 // relative-path literal (`./${x}`, `./modules/${name}.nix`)
2409 // inside a fetched flake input yielded a Value::Path holding the
2410 // fetcher CACHE dir instead of the input's `/nix/store/<h>-source`
2411 // path — so its `toString`/copy-to-store/inputSrc diverged from
2412 // CppNix (the plain `./x` sibling already dematerializes; the two
2413 // must agree).
2414 crate::path::dematerialize(&norm).to_string_lossy().into_owned()
2415 } else {
2416 // No eval-file context (top-level `sui eval -E`): the
2417 // plain branch keeps the raw text, so match it — but the
2418 // interpolation is still spliced.
2419 text
2420 }
2421 }
2422 // Absolute paths: canonicalize the concatenated text CppNix's way.
2423 // The `${e}` splice routinely introduces a `//` seam (`/bar/` +
2424 // `/tmp/foo`) or a `.`/`..` component that must collapse
2425 // (`/bar//tmp/foo` → `/bar/tmp/foo`), and `..` must clamp at root.
2426 // `canon_abs` is filesystem-free (works on not-yet-materialized
2427 // flake paths) and root-aware (unlike `normalize_path`, which pops
2428 // past root — the marquee-root divergence).
2429 PathKind::Abs => crate::path::canon_abs(&text),
2430 // Home paths (`~/…`) carry a leading `~` component, so they are
2431 // not absolute-rooted; keep the pre-existing normalization.
2432 PathKind::Home => normalize_path(std::path::Path::new(&text))
2433 .to_string_lossy()
2434 .into_owned(),
2435 };
2436 Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))))
2437}
2438
2439/// Which kind of interpolatable path literal — governs how the
2440/// concatenated text is finally resolved.
2441#[derive(Clone, Copy)]
2442enum PathKind {
2443 Abs,
2444 Rel,
2445 Home,
2446}
2447
2448/// Evaluate an attribute name, requiring non-null.
2449/// Use `eval_attr_maybe_null` when null dynamic attrs should be skipped.
2450fn eval_attr(attr: &ast::Attr, env: &Env) -> Result<String, EvalError> {
2451 eval_attr_maybe_null(attr, env)?
2452 .ok_or_else(|| EvalError::TypeError("null dynamic attribute name".into()))
2453}
2454
2455/// Evaluate an attribute name. Returns `None` for null dynamic attrs
2456/// (CppNix silently omits attributes with null names).
2457fn eval_attr_maybe_null(attr: &ast::Attr, env: &Env) -> Result<Option<String>, EvalError> {
2458 match attr {
2459 ast::Attr::Ident(ident) => Ok(Some(ident_text(ident))),
2460 ast::Attr::Dynamic(dyn_) => {
2461 let expr = dyn_
2462 .expr()
2463 .ok_or_else(|| EvalError::ParseError("dynamic attr missing expr".to_string()))?;
2464 let val = force_value(&eval_expr(&expr, env)?)?;
2465 // CppNix: null dynamic attr name → skip the attribute entirely.
2466 // Used by nixpkgs module system: `${if cond then null else "name"} = value;`
2467 if val == Value::Null {
2468 return Ok(None);
2469 }
2470 Ok(Some(val.as_string()?.to_string()))
2471 }
2472 ast::Attr::Str(s) => {
2473 let val = eval_str(s, env)?;
2474 Ok(Some(val.as_string()?.to_string()))
2475 }
2476 }
2477}
2478
2479/// Get the text of an rnix Ident node.
2480pub(crate) fn ident_text(ident: &ast::Ident) -> String {
2481 // Fast path: a `NODE_IDENT` holds a single `TOKEN_IDENT`, whose `text()`
2482 // borrows the source `&str` directly from the green node — no
2483 // `PreorderWithTokens` cursor tree-walk and none of the `NodeData::new`
2484 // allocations that `syntax().text()` (a `SyntaxText` over the node's whole
2485 // descendant span) pays. Byte-identical fallback: the identifier `or` is
2486 // lexed as a nested `TOKEN_OR` (rnix quirk), so `ident_token()` is `None`
2487 // there — walk the full node text in that case, exactly as before.
2488 match ident.ident_token() {
2489 Some(tok) => tok.text().to_string(),
2490 None => ident.syntax().text().to_string(),
2491 }
2492}
2493
2494/// Byte offset of a STATIC attr key (`Ident` or `Str`) in its source text —
2495/// the position `builtins.unsafeGetAttrPos` reports for that key. Returns
2496/// `None` for a dynamic key (`${e}`), which has no fixed source position.
2497///
2498/// CppNix points a binding's position at the KEY token's start; rnix exposes
2499/// it via the syntax node's `text_range().start()`.
2500fn static_attr_offset(attr: &ast::Attr) -> Option<u32> {
2501 let node = match attr {
2502 ast::Attr::Ident(i) => i.syntax(),
2503 ast::Attr::Str(s) => s.syntax(),
2504 ast::Attr::Dynamic(_) => return None,
2505 };
2506 Some(u32::from(node.text_range().start()))
2507}
2508
2509/// Collect a literal attrset's static top-level KEY offsets into an
2510/// [`crate::pos::AttrPositions`] and attach it to `attrs` (behind the value's
2511/// `Rc<AttrPositions>` slot). Records only single-key static bindings — the
2512/// shape `attrTag`'s `tags_` (`{ app = …; file = …; }`) is built from and the
2513/// only shape `builtins.unsafeGetAttrPos` reads in nixpkgs. `None`-costs a
2514/// pointer when the set has no such keys (attaches nothing).
2515fn attach_attrset_positions(set: &ast::AttrSet, attrs: &mut NixAttrs, env: &Env) {
2516 // The FILE is the one the literal is being built in — from the eval-file
2517 // stack, which a thunk restores to its captured file when it forces. This
2518 // is correct under laziness: a `dock.nix` attrset literal forced later
2519 // records `dock.nix`, not whatever file is top-of-stack at force time.
2520 // (`current_source_id`/`CURRENT_SOURCE_ID` is per-`eval_with_file`, NOT
2521 // per-env, so it would mis-attribute a lazily-forced literal.)
2522 let mut table = crate::pos::AttrPositions::new(current_eval_file());
2523 for entry in set.entries() {
2524 if let ast::Entry::AttrpathValue(apv) = entry {
2525 let Some(attrpath) = apv.attrpath() else { continue };
2526 let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2527 // A dotted path `a.b = …` desugars to a nested set and CppNix gives
2528 // the OUTER key the position of the path's HEAD, so record
2529 // `path_attrs[0]` whatever the length. This previously skipped any
2530 // multi-segment path, on the assumption that nixpkgs never asks for
2531 // a dotted tag's position. Measured — for
2532 // `{ …; nested.deep = 3; }` at line 6:
2533 // nix nested=6:3 sui nested=NULL
2534 let Some(head) = path_attrs.first() else { continue };
2535 let Some(offset) = static_attr_offset(head) else { continue };
2536 // Resolve the static key name (Ident/Str) — never forces (a
2537 // dynamic key already returned None above).
2538 if let Ok(Some(name)) = eval_attr_maybe_null(&path_attrs[0], env) {
2539 table.insert(intern(&name), offset);
2540 }
2541 } else if let ast::Entry::Inherit(inh) = entry {
2542 // `inherit x;` and `inherit (src) x;` BIND an attribute exactly as
2543 // `x = …` does, and CppNix gives each inherited name the position of
2544 // its own ident. Skipping them left every inherited key
2545 // position-less — which is most of nixpkgs' `lib`, since
2546 // `lib/default.nix` re-exports through
2547 // `inherit (self.options) mkOption …`. Measured before the fix:
2548 // unsafeGetAttrPos "mkOption" nixpkgs.lib
2549 // nix …-source/lib/default.nix sui null
2550 //
2551 // An earlier attempt at this arm was reverted for reporting line 1;
2552 // that was `pos::line_col` returning a constant, NOT this arm. With
2553 // the real offset→line/column conversion in place it resolves
2554 // exactly.
2555 for attr in inh.attrs() {
2556 let Some(offset) = static_attr_offset(&attr) else { continue };
2557 if let Ok(Some(name)) = eval_attr_maybe_null(&attr, env) {
2558 table.insert(intern(&name), offset);
2559 }
2560 }
2561 }
2562 }
2563 if !table.is_empty() {
2564 attrs.set_positions(std::rc::Rc::new(table));
2565 }
2566}
2567
2568fn eval_attrset(set: &ast::AttrSet, env: &Env) -> Result<Value, EvalError> {
2569 crate::perf::inc(crate::perf::Counter::Attrset);
2570 let mut attrs = NixAttrs::new();
2571 let is_rec = set.rec_token().is_some();
2572
2573 // ── plan-driven construction (`SUI_NORMALIZE=1`) ──────────────────────
2574 //
2575 // Wired for `rec` first and the non-rec branch last, deliberately. The
2576 // `rec` branch was WRONG (its Phase 1b does a destructive `attrs.insert`
2577 // where the non-rec branch merges), so any change there could only
2578 // improve it. The non-rec branch is the one path that was already correct
2579 // ON KEYS — it merges VALUES via `merge_nested_insert` — and it carries
2580 // every fleet evaluation, so it went last and on its own.
2581 //
2582 // Correct-on-keys is not correct: a value merge gets the key set right and
2583 // the SCOPE wrong, which is why `let b=5; in { a=rec{c=b;}; a={b=9;}; }`
2584 // answered `c=5` where nix says `c=9`. The second side's `b=9` belongs to
2585 // the FIRST node's rec scope, and no value-level merge can put it there.
2586 //
2587 // A `None` here is a POSITIVE statement, not a fallback: `sui-normalize`
2588 // records a group only when it has a duplicate static key or a dotted
2589 // path, so no plan means this group is already built correctly.
2590 if crate::normalize_env::enabled() {
2591 let src_id = CURRENT_SOURCE_ID.with(std::cell::Cell::get);
2592 let offset = u32::from(set.syntax().text_range().start());
2593 if let Some(plan) =
2594 crate::normalize_env::plan_for_node(set, is_rec, src_id, offset).map_err(reject)?
2595 {
2596 return eval_plan_group(&plan, env);
2597 }
2598 }
2599
2600 if is_rec {
2601 let mut rec_env = env.child();
2602 let mut thunks: Vec<(String, Thunk)> = Vec::new();
2603
2604 // Track which names have been defined so far in this scope.
2605 // Used by maybe_thunk to resolve backward references directly
2606 // instead of creating wasteful thunks.
2607 let mut defined_so_far: HashSet<String> = HashSet::new();
2608
2609 // Accumulator for dotted-path bindings (`rec { a.b = 1; a.c = 2; ... }`).
2610 // Leaf values are wrapped in thunks so they participate in the
2611 // recursive env fixpoint, matching CppNix semantics where
2612 // `rec { types.a = f 1; f = x: x + 1; }` allows `f` to be a
2613 // sibling binding.
2614 let mut dotted_attrs: NixAttrs = NixAttrs::new();
2615
2616 // D1 (`SUI_SCOPE_NARROW>=1`) — a SECOND predicate, deliberately not a
2617 // widening of `is_recursive_binding` below.
2618 //
2619 // THE TRAP: `is_recursive_binding` is BACKWARD-BLIND on purpose — it
2620 // tests `key` plus the siblings seen SO FAR, so `rec { b = a; a = 1; }`
2621 // computes `false` for `b`. That verdict selects Promise semantics, so
2622 // widening it would change which bindings get the fix-point sentinel
2623 // and is not a refactor available here. Yet `b` genuinely does need the
2624 // rec scope, and today gets it from Phase 2's blanket `update_env`.
2625 // Narrowing therefore needs its own forward-complete question — "does
2626 // this RHS reach ANY key this scope binds, declared before or after?" —
2627 // answered against a full pre-pass, while `is_recursive_binding` stays
2628 // byte-identical.
2629 //
2630 // The pre-pass is PURELY SYNTACTIC, which is the second trap: the
2631 // Phase-1 loop below owns the evaluation order of `${…}` keys, and
2632 // calling `eval_attr` here would run that arbitrary code earlier. So a
2633 // head that is not a plain identifier forfeits narrowing for the whole
2634 // scope instead of being evaluated for its name. Starting the flag at
2635 // `scope_narrow_enabled()` also means the default path never walks the
2636 // entries at all.
2637 let mut names_complete = scope_narrow_enabled();
2638 let rec_scope_names: HashSet<String> = if names_complete {
2639 let mut s = HashSet::new();
2640 for entry in set.entries() {
2641 match entry {
2642 ast::Entry::AttrpathValue(apv) => {
2643 match apv.attrpath().and_then(|p| p.attrs().next()) {
2644 Some(ast::Attr::Ident(i)) => {
2645 s.insert(ident_text(&i));
2646 }
2647 _ => names_complete = false,
2648 }
2649 }
2650 ast::Entry::Inherit(inh) => {
2651 for attr in inh.attrs() {
2652 match attr {
2653 ast::Attr::Ident(i) => {
2654 s.insert(ident_text(&i));
2655 }
2656 _ => names_complete = false,
2657 }
2658 }
2659 }
2660 }
2661 }
2662 s
2663 } else {
2664 HashSet::new()
2665 };
2666 let narrow = names_complete;
2667
2668 // Phase 1: Create thunks with placeholder env and bind them.
2669 for entry in set.entries() {
2670 match entry {
2671 ast::Entry::AttrpathValue(apv) => {
2672 let attrpath = apv.attrpath().ok_or_else(|| {
2673 EvalError::ParseError("binding missing attrpath".to_string())
2674 })?;
2675 let value_expr = apv.value().ok_or_else(|| {
2676 EvalError::ParseError("binding missing value".to_string())
2677 })?;
2678 let mut path_keys: Vec<String> = attrpath
2679 .attrs()
2680 .filter_map(|a| eval_attr_maybe_null(&a, env).transpose())
2681 .collect::<Result<_, _>>()?;
2682 // Null dynamic attr name → skip entire binding (CppNix compat)
2683 if path_keys.is_empty() { continue; }
2684 if path_keys.len() == 1 {
2685 let key = path_keys.pop().unwrap();
2686 // Self-recursive detection in a `rec { … }` scope:
2687 // any binding whose value-expr references the
2688 // bound name OR any sibling key declared in this
2689 // rec scope is potentially self-recursive (the
2690 // siblings' thunks share the rec_env via Phase 2).
2691 // Mark as recursive so inner re-entrance during
2692 // force returns a Promise sentinel instead of
2693 // erroring with InfiniteRecursion.
2694 //
2695 // For simplicity we check `key` and all already-
2696 // defined siblings; siblings defined later are
2697 // covered when THEIR thunks force (they reference
2698 // back into this rec scope via Phase 2's env update).
2699 // O(N) not O(N²): one memoized referenced-name set,
2700 // intersected with key + already-defined siblings.
2701 // Byte-identical to the prior per-name walks.
2702 let referenced = referenced_idents(&value_expr);
2703 let is_recursive_binding = referenced.contains(key.as_str())
2704 || defined_so_far
2705 .iter()
2706 .any(|n| referenced.contains(n.as_str()));
2707 let value = if is_recursive_binding {
2708 Value::Thunk(Thunk::new_suspended_recursive(
2709 value_expr.clone(),
2710 env.clone(),
2711 ))
2712 } else {
2713 // maybeThunk: skip thunk for trivial exprs.
2714 // is_rec=true because rec attrset bindings
2715 // can reference each other.
2716 // Pass defined_so_far so backward refs
2717 // resolve directly.
2718 maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
2719 };
2720 // Forward-complete needs-scope test (see the pre-pass
2721 // above). `is_recursive_binding` is folded in as
2722 // belt-and-braces: it is a subset whenever `narrow`
2723 // holds, since every key it can name came from an
2724 // `Ident` head and so is in `rec_scope_names`.
2725 let needs_scope = !narrow
2726 || is_recursive_binding
2727 || rec_scope_names
2728 .iter()
2729 .any(|n| referenced.contains(n.as_str()));
2730 rec_env.bind(key.clone(), value.clone());
2731 attrs.insert(key.clone(), value.clone());
2732 if let Value::Thunk(t) = &value {
2733 if needs_scope {
2734 thunks.push((key.clone(), t.clone()));
2735 crate::value::census::scope_pinned();
2736 } else {
2737 crate::value::census::scope_narrowed();
2738 }
2739 }
2740 defined_so_far.insert(key);
2741 } else {
2742 // Multi-segment dotted path: build a nested attrset
2743 // with a thunk at the leaf so the value expression
2744 // can reference sibling rec-bindings.
2745 let key = path_keys[0].clone();
2746 let value =
2747 build_nested_attr_thunk(&path_keys[1..], &value_expr, env, &mut thunks);
2748 merge_nested_insert(&mut dotted_attrs, key, value);
2749 }
2750 }
2751 ast::Entry::Inherit(inherit) => {
2752 eval_inherit(&inherit, env, &mut attrs, Some(&mut rec_env), Some(&mut thunks))?;
2753 }
2754 }
2755 }
2756
2757 // Phase 1b: Bind accumulated dotted-path attrs into attrs and rec_env.
2758 // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
2759 // duplicate definition, so we do not attempt to merge with
2760 // existing inherit thunks — just bind directly.
2761 for (key, value) in dotted_attrs.iter() {
2762 attrs.insert(key.clone(), value.clone());
2763 rec_env.bind(key.clone(), value.clone());
2764 }
2765
2766 // Phase 2: Update all thunks (both Suspended and InheritSelect)
2767 // to capture the final rec_env (which now has all names bound).
2768 for (_key, thunk) in &thunks {
2769 thunk.update_env(&rec_env);
2770 }
2771 } else {
2772 for entry in set.entries() {
2773 match entry {
2774 ast::Entry::AttrpathValue(apv) => {
2775 let attrpath = apv.attrpath().ok_or_else(|| {
2776 EvalError::ParseError("binding missing attrpath".to_string())
2777 })?;
2778 let value_expr = apv.value().ok_or_else(|| {
2779 EvalError::ParseError("binding missing value".to_string())
2780 })?;
2781 let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2782 // CppNix defers a dynamic key that is NOT at the HEAD of the
2783 // attrpath: `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`,
2784 // so `e` never forces until `.a` is demanded. Evaluating the
2785 // whole path eagerly would force `e` at construction and — in
2786 // the module-system fixpoint — read `config.<x>` while `config`
2787 // is mid-force (the M2.6 divergence: `homes.null` instead of
2788 // `homes.<name>`). Only the head is eager; a lone dynamic tail
2789 // becomes a deferred thunk. A rarer collision under the same
2790 // head stays eager (forced) so static deep-merge still works.
2791 let tail_is_dynamic =
2792 path_attrs.len() > 1 && attrs_have_dynamic(&path_attrs[1..]);
2793 let head_key = match eval_attr_maybe_null(&path_attrs[0], env)? {
2794 Some(k) => k,
2795 // Null dynamic HEAD attr name → skip entire binding.
2796 None => continue,
2797 };
2798 if tail_is_dynamic && attrs.get(&head_key).is_none() {
2799 let value =
2800 build_deferred_tail_attr(&path_attrs[1..], &value_expr, env);
2801 attrs.insert(head_key, value);
2802 continue;
2803 }
2804 // M2.6 ROOT #3 (collision case): the tail has a dynamic key
2805 // AND the head already exists (a sibling binding wrote it,
2806 // e.g. osquery's `systemd.services.… = …` then
2807 // `systemd.tmpfiles.settings."10-osquery".${dirname …}.d`).
2808 // The plain deferral above bails (head present), and the
2809 // eager path below would force the dynamic key at
2810 // construction — re-reading `config.<x>` mid-fixpoint →
2811 // the empty-Promise partial. Instead, descend the existing
2812 // head along the tail's STATIC prefix and splice a DEFERRED
2813 // thunk at the first dynamic level, so the dynamic key
2814 // stays lazy exactly as CppNix's nested-literal desugaring
2815 // does — while preserving the static deep-merge with the
2816 // sibling binding.
2817 if tail_is_dynamic {
2818 if let Some(existing) = attrs.get(&head_key).cloned() {
2819 let merged = merge_deferred_dynamic_tail(
2820 existing,
2821 &path_attrs[1..],
2822 &value_expr,
2823 env,
2824 )?;
2825 attrs.insert(head_key, merged);
2826 continue;
2827 }
2828 }
2829 // Eager path: evaluate the remaining (static, or collision)
2830 // keys now. A null dynamic tail key skips the binding.
2831 let mut path_keys: Vec<String> = {
2832 let mut v = Vec::with_capacity(path_attrs.len());
2833 v.push(head_key);
2834 let mut skip = false;
2835 for a in &path_attrs[1..] {
2836 match eval_attr_maybe_null(a, env)? {
2837 Some(k) => v.push(k),
2838 None => { skip = true; break; }
2839 }
2840 }
2841 if skip { v.clear(); }
2842 v
2843 };
2844 // Null dynamic attr name → skip entire binding (CppNix compat)
2845 if path_keys.is_empty() { continue; }
2846 if path_keys.len() == 1 {
2847 let key = path_keys.pop().unwrap();
2848 // maybeThunk: skip thunk for trivial exprs.
2849 // is_rec=false — Ident lookups are safe.
2850 let value = maybe_thunk(&value_expr, env, false, None);
2851 // CppNix desugars `a.b = x; a = { c = y; };` into a single
2852 // merged `a = { b = x; c = y; }` at parse time. rnix keeps
2853 // the two bindings separate, so when a single-key binding
2854 // collides with an already-built (dotted) attrs for the
2855 // same key, deep-MERGE instead of overwrite. Force the RHS
2856 // to WHNF so merge_nested_insert (which needs concrete
2857 // Value::Attrs on both sides) can merge — forcing an
2858 // attrset to WHNF does NOT force its fields, so leaf values
2859 // stay lazy. Only fires on collision; non-colliding
2860 // single-key bindings keep the plain fast insert.
2861 // (This is the pkg-config-wrapper `env.addFlags` drop:
2862 // `env.addFlags = …` then `env = { wrapperName = …; … }`.)
2863 // If the earlier binding for this key is still a lazy
2864 // Thunk (an attrset literal inserted via maybe_thunk), force
2865 // it to WHNF FIRST so a `key = {..}; key = {..}` collision is
2866 // seen as attrs-vs-attrs and MERGES, matching nix
2867 // (`{ s = {a=1;}; s = {b=2;}; }` → `{ s = {a=1; b=2;}; }`).
2868 // Without this the `Some(Value::Attrs(_))` test below is false
2869 // on a Thunk and the second binding overwrites, dropping the
2870 // first's keys. The dotted branch below already does this; R3
2871 // (eval-okay-merge-dynamic-attrs set1/set2) needs it here too.
2872 // WHNF force does not force fields → leaf laziness preserved.
2873 // (A non-attrs dup like `s = 1; s = 2` still overwrites here,
2874 // unchanged — nix errors there, an eval-FAIL case out of scope.)
2875 if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2876 let existing = attrs.get(&key).cloned().unwrap();
2877 let forced_existing = force_value(&existing)?;
2878 attrs.insert(key.clone(), forced_existing);
2879 }
2880 if matches!(attrs.get(&key), Some(Value::Attrs(_))) {
2881 let forced = force_value(&value)?;
2882 merge_nested_insert(&mut attrs, key, forced);
2883 } else {
2884 attrs.insert(key, value);
2885 }
2886 } else {
2887 let key = path_keys[0].clone();
2888 let value = build_nested_attr(&path_keys[1..], &value_expr, env)?;
2889 // CppNix desugars `a = { x = …; }; a.y = …;` into a
2890 // single merged `a = { x = …; y = …; }`. When the
2891 // full-set binding for `a` was inserted FIRST it is a
2892 // lazy Thunk (attrset literals go through maybe_thunk),
2893 // so merge_nested_insert — which only merges when the
2894 // existing value is a concrete Value::Attrs — would
2895 // NOT see the earlier keys and would overwrite `a`
2896 // with just `{ y = … }`, silently dropping `x`. Force
2897 // the existing entry to WHNF on collision so the merge
2898 // sees the concrete attrs (forcing to WHNF does not
2899 // force the fields, so leaf laziness is preserved).
2900 // (This is the gst-plugins-base `passthru.waylandEnabled`
2901 // drop: `passthru = { … }; passthru.tests.x = …;`.)
2902 if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2903 let existing = attrs.get(&key).cloned().unwrap();
2904 let forced = force_value(&existing)?;
2905 attrs.insert(key.clone(), forced);
2906 }
2907 merge_nested_insert(&mut attrs, key, value);
2908 }
2909 }
2910 ast::Entry::Inherit(inherit) => {
2911 eval_inherit(&inherit, env, &mut attrs, None, None)?;
2912 }
2913 }
2914 }
2915 }
2916
2917 // Record the literal's static-key source positions for
2918 // `builtins.unsafeGetAttrPos` (the `attrTag` `declarations` — options.json
2919 // dock root). Cheap: one entry walk over static Ident/Str keys, no
2920 // forcing; attaches nothing (a pointer-sized `None`) when the set has no
2921 // single-static-key bindings.
2922 attach_attrset_positions(set, &mut attrs, env);
2923
2924 Ok(Value::Attrs(Rc::new(attrs)))
2925}
2926
2927fn eval_inherit(
2928 inherit: &ast::Inherit,
2929 env: &Env,
2930 attrs: &mut NixAttrs,
2931 bind_env: Option<&mut Env>,
2932 mut thunks: Option<&mut Vec<(String, Thunk)>>,
2933) -> Result<(), EvalError> {
2934 if let Some(from) = inherit.from() {
2935 // inherit (expr) a b c;
2936 //
2937 // The source expression must NOT be eagerly evaluated. nixpkgs
2938 // `lib/trivial.nix` has `inherit (lib.trivial) isFunction ...`
2939 // at the top of a file that itself defines `lib.trivial`. If
2940 // we eagerly force `lib.trivial`, we hit a self-referential
2941 // thunk blackhole. Instead: build a thunk per inherited
2942 // name that, when forced, evaluates the source and pulls
2943 // out that one attribute. This is what real Nix does.
2944 //
2945 // For `rec { inherit (X) name; ...; foo = name; }` we ALSO
2946 // need to bind the name in the enclosing rec env so the
2947 // sibling `foo = name` can reference it. The caller passes
2948 // its rec env in `bind_env`.
2949 //
2950 // When `thunks` is provided (rec attrsets), InheritSelect
2951 // thunks are collected so Phase 2 can update their captured
2952 // env to the full recursive scope. Without this, the source
2953 // expression cannot reference sibling bindings.
2954 let source_expr = from
2955 .expr()
2956 .ok_or_else(|| EvalError::ParseError("inherit from missing expr".to_string()))?;
2957 // Shared source thunk — all inherited names share one source
2958 // evaluation (the source thunk's own memoization ensures at
2959 // most one evaluation).
2960 let source_thunk = Thunk::new_suspended(source_expr, env.clone());
2961 let mut be = bind_env;
2962 for attr in inherit.attrs() {
2963 let name = eval_attr(&attr, env)?;
2964 let thunk = Thunk::new_inherit_select(source_thunk.clone(), name.clone());
2965 let value = Value::Thunk(thunk.clone());
2966 attrs.insert(name.clone(), value.clone());
2967 if let Some(ref mut e) = be {
2968 e.bind(name.clone(), value);
2969 }
2970 if let Some(ref mut t) = thunks {
2971 t.push((name, thunk));
2972 }
2973 }
2974 } else {
2975 // inherit a b c;
2976 //
2977 // CppNix resolves a bare `inherit x;` LAZILY, exactly like a plain
2978 // reference to `x` — it does NOT eagerly force the enclosing scope.
2979 // This matters when `x` is provided only by an enclosing `with`
2980 // scope whose value is a fixpoint still being constructed (a
2981 // blackhole): eager `env.lookup` returns None → spurious
2982 // `UndefinedVar`. nixpkgs `all-packages.nix` is
2983 // `… with pkgs; { nettle = import … { inherit callPackage; }; }`,
2984 // so `inherit callPackage` must resolve `callPackage` from the
2985 // `with pkgs` scope AT FORCE TIME, not eagerly at attrset
2986 // construction. Mirror `maybe_thunk`'s Ident path: try the fast
2987 // lookup, and on a miss defer to a WithIdent thunk (or a suspended
2988 // env lookup) so the resolution happens lazily against the settled
2989 // scope. (This was the `nettle` UndefinedVar('callPackage') drop.)
2990 let mut be = bind_env;
2991 for attr in inherit.attrs() {
2992 let name = eval_attr(&attr, env)?;
2993 let sym = crate::value::intern(&name);
2994 let value = if let Some(v) = env.lookup_fast(sym, &name) {
2995 v
2996 } else if let Some((scope_cache, scope_value)) =
2997 env.innermost_with_scope()
2998 {
2999 Value::Thunk(Thunk::new_with_ident(
3000 SmolStr::from(name.as_str()),
3001 scope_cache,
3002 scope_value,
3003 env.clone(),
3004 ))
3005 } else {
3006 return Err(EvalError::UndefinedVar(format!(
3007 "'{name}'{}",
3008 eval_file_ctx()
3009 )));
3010 };
3011 attrs.insert(name.clone(), value.clone());
3012 if let Some(ref mut e) = be {
3013 e.bind(name, value);
3014 }
3015 }
3016 }
3017 Ok(())
3018}
3019
3020fn build_nested_attr(
3021 path: &[String],
3022 expr: &ast::Expr,
3023 env: &Env,
3024) -> Result<Value, EvalError> {
3025 if path.is_empty() {
3026 // CRITICAL: Wrap leaf in a thunk instead of eagerly evaluating.
3027 // For dotted paths like `config.warnings = optionals config.x [...]`,
3028 // the leaf expression must be lazy — eagerly evaluating it during
3029 // attrset construction forces fixpoint thunks prematurely.
3030 return Ok(maybe_thunk(expr, env, false, None));
3031 }
3032 let key = path[0].clone();
3033 let inner = build_nested_attr(&path[1..], expr, env)?;
3034 let mut attrs = NixAttrs::new();
3035 attrs.insert(key, inner);
3036 Ok(Value::Attrs(Rc::new(attrs)))
3037}
3038
3039/// True if a single attr is a DYNAMIC key — one whose resolution runs
3040/// arbitrary expression code and therefore must not be forced at
3041/// attrset-construction time.
3042///
3043/// Two forms are dynamic:
3044/// * `ast::Attr::Dynamic` — a bare `${e}` antiquotation.
3045/// * `ast::Attr::Str` **containing an interpolation** — an interpolated
3046/// string key like `"iwd/${nm}"`. A `Str` with NO interpolation
3047/// (`"foo bar"`) is a plain static string literal and is NOT dynamic.
3048///
3049/// M2.6 ROOT #3: `attrs_have_dynamic` previously matched ONLY
3050/// `Attr::Dynamic`, so an interpolated-string tail key (`config.a."p${e}"`)
3051/// fell to the eager path and forced `e` at construction. In the module
3052/// system that forces a `config.<x>` read while `config` is mid-fixpoint
3053/// (`environment.etc."iwd/${configFile.name}"`, where `configFile` reads
3054/// `with config.networking.networkmanager`), yielding the empty-Promise
3055/// partial → the `set/null` softening. Treating an interpolated `Str` as
3056/// dynamic routes it through the same per-level deferral as `${e}`
3057/// (ROOT #1/#2), so `e` forces only when the enclosing head is demanded —
3058/// exactly CppNix's nested-attrset-literal desugaring.
3059fn attr_is_dynamic(attr: &ast::Attr) -> bool {
3060 match attr {
3061 ast::Attr::Dynamic(_) => true,
3062 // A string attr key is dynamic iff it has ≥1 interpolation part;
3063 // a purely-literal string key forces nothing and stays eager.
3064 ast::Attr::Str(s) => s
3065 .normalized_parts()
3066 .iter()
3067 .any(|p| matches!(p, InterpolPart::Interpolation(_))),
3068 ast::Attr::Ident(_) => false,
3069 }
3070}
3071
3072/// True if any attr in the slice is a dynamic (interpolated) key.
3073///
3074/// A dynamic key beyond the HEAD of an attrpath must NOT be evaluated at
3075/// attrset-construction time — CppNix defers it inside the head's lazy
3076/// value, so `{ a.${e} = v; }` never forces `e` until `.a` is demanded.
3077/// Static string/ident keys are cheap and force nothing, so they don't
3078/// need deferral.
3079fn attrs_have_dynamic(attrs: &[ast::Attr]) -> bool {
3080 attrs.iter().any(attr_is_dynamic)
3081}
3082
3083/// Build the nested attrset for the TAIL of an attrpath, deferring
3084/// evaluation of dynamic tail keys until the value is forced.
3085///
3086/// Given tail attrs `[b, ${e}, c]` and a value expr, produce a lazy
3087/// `Value::Thunk` that, when forced, evaluates each tail key (including
3088/// the dynamic `${e}`) against `env` and builds `{ b = { ${e} = { c =
3089/// <leaf-thunk> }; }; }`. This mirrors CppNix: the inner attrset (and
3090/// thus its dynamic keys) is constructed only when the enclosing head
3091/// attribute is demanded — never at construction of the outer attrset.
3092///
3093/// A dynamic key that evaluates to `null` skips the whole binding
3094/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3095fn build_deferred_tail_attr(
3096 tail: &[ast::Attr],
3097 value_expr: &ast::Expr,
3098 env: &Env,
3099) -> Value {
3100 let tail: Vec<ast::Attr> = tail.to_vec();
3101 let value_expr = value_expr.clone();
3102 let env = env.clone();
3103 Value::Thunk(Thunk::new_native(move || {
3104 build_tail_attrs_now(&tail, &value_expr, &env)
3105 }))
3106}
3107
3108/// Resolve ONE level of the deferred attrpath tail — used from inside
3109/// the deferred thunk above once the enclosing head is demanded.
3110///
3111/// M2.6 ROOT #2 (the OVER-FORCE fix): this resolves *only* `tail[0]`'s
3112/// key and wraps the remaining tail `tail[1..]` in another DEFERRED
3113/// thunk — it does NOT recurse eagerly through the whole tail. This is
3114/// exactly CppNix's desugaring of `a.b.c = v` into nested attrset
3115/// literals `a = { b = { c = v; }; }`, where forcing `a` to WHNF yields
3116/// `{ b = <thunk {c=v}> }` — the inner level (`b`, and any dynamic key
3117/// under it) stays lazy until `.b` is demanded.
3118///
3119/// Forcing the enclosing head therefore resolves ONE tail key, never
3120/// the whole chain: `config.homes.${cfg.pleme.userName} = 7` demanded
3121/// as `config` yields `{ homes = <deferred> }` WITHOUT forcing the
3122/// `${cfg.pleme.userName}` key. The prior implementation recursed the
3123/// whole tail eagerly, forcing that dynamic key while only `.config`
3124/// (or its `._type`) was demanded — the over-force cppnix never does.
3125///
3126/// A dynamic key that evaluates to `null` skips the whole binding
3127/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3128fn build_tail_attrs_now(
3129 tail: &[ast::Attr],
3130 value_expr: &ast::Expr,
3131 env: &Env,
3132) -> Result<Value, EvalError> {
3133 if tail.is_empty() {
3134 return Ok(maybe_thunk(value_expr, env, false, None));
3135 }
3136 if std::env::var_os("SUI_M26_TAILTRACE").is_some() {
3137 let t: String = tail[0].syntax().text().to_string().chars().take(40).collect();
3138 eprintln!("[M26 TAIL-RESOLVE] forcing dynamic tail key `{t}`");
3139 if attrs_have_dynamic(&tail[..1]) {
3140 crate::trace::dump_force_stack_ids();
3141 }
3142 }
3143 let key = match eval_attr_maybe_null(&tail[0], env)? {
3144 Some(k) => k,
3145 // Null dynamic key → the whole binding is skipped; an empty
3146 // attrset is the identity for merge_nested_insert.
3147 None => return Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
3148 };
3149 // Resolve ONE level: if more tail remains, defer it (a new lazy
3150 // thunk) rather than recursing eagerly. Only the leaf (empty tail)
3151 // is built here. This keeps each nested level lazy, exactly like
3152 // CppNix's nested-attrset-literal desugaring — so forcing this
3153 // level does NOT force the next level's (possibly dynamic) key.
3154 let inner = if tail.len() == 1 {
3155 maybe_thunk(value_expr, env, false, None)
3156 } else {
3157 build_deferred_tail_attr(&tail[1..], value_expr, env)
3158 };
3159 let mut attrs = NixAttrs::new();
3160 attrs.insert(key, inner);
3161 Ok(Value::Attrs(Rc::new(attrs)))
3162}
3163
3164/// M2.6 ROOT #3 (collision case): splice a DEFERRED dynamic-tail binding
3165/// into an ALREADY-PRESENT head value without forcing the dynamic key.
3166///
3167/// `existing` is the value already stored at the attrpath's head (written
3168/// by a sibling binding — e.g. `systemd.services.… = …`). `tail` is the
3169/// remaining attrpath (`path_attrs[1..]`) of the new binding, which
3170/// contains ≥1 dynamic attr (`systemd.tmpfiles.….${dirname …}.d`).
3171///
3172/// We descend `existing` along the LONGEST STATIC PREFIX of `tail`
3173/// (`tmpfiles`, `settings`, `"10-osquery"` — all static, forced-free
3174/// keys), forcing each already-present sub-attrset to WHNF so the merge
3175/// sees concrete keys (forcing to WHNF never forces leaf VALUES, so leaf
3176/// laziness is preserved), and at the first DYNAMIC level splice a
3177/// `build_deferred_tail_attr` thunk. The dynamic key therefore forces
3178/// only when that exact nested path is later demanded — CppNix's
3179/// nested-attrset-literal desugaring, now honoured through a sibling
3180/// collision too.
3181fn merge_deferred_dynamic_tail(
3182 existing: Value,
3183 tail: &[ast::Attr],
3184 value_expr: &ast::Expr,
3185 env: &Env,
3186) -> Result<Value, EvalError> {
3187 // `tail` is non-empty and contains a dynamic attr somewhere (the
3188 // caller guarantees `attrs_have_dynamic(tail)`).
3189 debug_assert!(!tail.is_empty());
3190
3191 // If the FIRST tail attr is itself dynamic, there is no static prefix
3192 // to descend — the whole tail is deferred and merged as a lazy
3193 // overlay onto the existing head (a `//`-style right-merge; the
3194 // deferred attrset only materialises its dynamic key on demand).
3195 if attr_is_dynamic(&tail[0]) {
3196 let deferred = build_deferred_tail_attr(tail, value_expr, env);
3197 return Ok(lazy_overlay_merge(existing, deferred));
3198 }
3199
3200 // The head static key of `tail`. Resolve it (static → forces nothing
3201 // relevant; a null dynamic can't occur here since tail[0] is static).
3202 let key = match eval_attr_maybe_null(&tail[0], env)? {
3203 Some(k) => k,
3204 None => return Ok(existing),
3205 };
3206
3207 // Force the existing head to a concrete attrset so we can descend +
3208 // merge on the resolved static key. Forcing to WHNF does NOT force
3209 // its field VALUES, so leaf laziness is preserved.
3210 let existing_forced = force_value(&existing)?;
3211 let mut base = match existing_forced {
3212 Value::Attrs(a) => (*a).clone(),
3213 // The existing head is not an attrset (a sibling wrote a leaf
3214 // here); CppNix would error on the merge, but to stay lazy we
3215 // defer the tail and let a later demand surface the real merge
3216 // conflict. Build the deferred tail as a fresh attrset.
3217 _ => {
3218 let deferred = build_deferred_tail_attr(tail, value_expr, env);
3219 return Ok(deferred);
3220 }
3221 };
3222
3223 // Recurse: merge the REMAINING tail (`tail[1..]`) under `key`.
3224 let child_existing = base.get(&key).cloned();
3225 let new_child = match child_existing {
3226 Some(child) if tail.len() > 1 => {
3227 // Deeper static/dynamic prefix under an existing sub-attrset.
3228 merge_deferred_dynamic_tail(child, &tail[1..], value_expr, env)?
3229 }
3230 Some(child) => {
3231 // tail == [key]; the leaf collides with an existing value.
3232 // Static leaf collision — build the leaf and lazy-merge.
3233 let leaf = maybe_thunk(value_expr, env, false, None);
3234 lazy_overlay_merge(child, leaf)
3235 }
3236 None if tail.len() > 1 => {
3237 // No existing child; the remaining tail may itself start with
3238 // a dynamic key — defer it whole (build_deferred_tail_attr
3239 // handles the static/dynamic split per-level).
3240 build_deferred_tail_attr(&tail[1..], value_expr, env)
3241 }
3242 None => maybe_thunk(value_expr, env, false, None),
3243 };
3244 base.insert(key, new_child);
3245 Ok(Value::Attrs(Rc::new(base)))
3246}
3247
3248/// Lazy right-merge of two values that are (or will force to) attrsets,
3249/// preserving leaf laziness. Used by [`merge_deferred_dynamic_tail`] to
3250/// combine a deferred dynamic-tail attrset with an existing value without
3251/// forcing either's dynamic keys eagerly. When both are concrete attrs we
3252/// deep-merge in place (reusing [`merge_nested_insert`]); otherwise we
3253/// build a lazy overlay thunk that merges on demand.
3254fn lazy_overlay_merge(left: Value, right: Value) -> Value {
3255 match (&left, &right) {
3256 (Value::Attrs(la), Value::Attrs(_)) => {
3257 crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3258 let mut merged = (**la).clone();
3259 if let Value::Attrs(ra) = &right {
3260 // Merging distinct override keys into `merged` is order-
3261 // independent (per-key right-wins), and the result map is
3262 // unordered storage — the sorted `iter()` was dead work.
3263 for (k, v) in ra.iter_unsorted() {
3264 merge_nested_insert(&mut merged, k.clone(), v.clone());
3265 }
3266 }
3267 Value::Attrs(Rc::new(merged))
3268 }
3269 _ => {
3270 // At least one side is a thunk (a deferred dynamic tail).
3271 // Defer the merge behind a Native thunk so neither side's
3272 // dynamic key forces until the merged attrset is demanded.
3273 Value::Thunk(Thunk::new_native(move || {
3274 let lf = force_value(&left)?;
3275 let rf = force_value(&right)?;
3276 let la = lf.as_attrs()?;
3277 let ra = rf.as_attrs()?;
3278 crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3279 let mut merged = (*la).clone();
3280 for (k, v) in ra.iter_unsorted() {
3281 merge_nested_insert(&mut merged, k.clone(), v.clone());
3282 }
3283 Ok(Value::Attrs(Rc::new(merged)))
3284 }))
3285 }
3286 }
3287}
3288
3289/// Like [`build_nested_attr`] but wraps the leaf in a [`Thunk`] instead of
3290/// eagerly evaluating it. Used inside `rec { ... }` and `let ... in` so
3291/// that dotted-path leaf expressions can reference sibling bindings
3292/// through the recursive env (which is finalised in Phase 2).
3293///
3294/// Every thunk created is appended to `thunks` so Phase 2 can update
3295/// its captured environment.
3296fn build_nested_attr_thunk(
3297 path: &[String],
3298 expr: &ast::Expr,
3299 env: &Env,
3300 thunks: &mut Vec<(String, Thunk)>,
3301) -> Value {
3302 if path.is_empty() {
3303 let thunk = Thunk::new_suspended(expr.clone(), env.clone());
3304 let val = Value::Thunk(thunk.clone());
3305 thunks.push((String::new(), thunk));
3306 return val;
3307 }
3308 let key = path[0].clone();
3309 let inner = build_nested_attr_thunk(&path[1..], expr, env, thunks);
3310 let mut attrs = NixAttrs::new();
3311 attrs.insert(key, inner);
3312 Value::Attrs(Rc::new(attrs))
3313}
3314
3315/// Insert `value` at `key` in `target`. If `target` already has a
3316/// concrete `Value::Attrs` at that key AND `value` is also a
3317/// concrete `Value::Attrs`, deep-merge them rather than overwriting.
3318/// This is what makes `{ a.b.c = 1; a.b.d = 2; a.e = 3; }` produce
3319/// `{ a = { b = { c = 1; d = 2; }; e = 3; }; }` instead of
3320/// dropping siblings — every nixpkgs module relies on this.
3321fn merge_nested_insert(target: &mut NixAttrs, key: String, value: Value) {
3322 // Fast path: no existing entry at this key → plain insert, keeping the
3323 // value lazy (the overwhelmingly common non-colliding case, so we never
3324 // force a thunk here).
3325 let existing = match target.get(&key) {
3326 Some(e) => e.clone(),
3327 None => {
3328 target.insert(key, value);
3329 return;
3330 }
3331 };
3332 // A collision exists. A deep merge is warranted only when BOTH the
3333 // existing entry AND the new value are attrset-shaped. M2.6 ROOT #4b
3334 // (byte-verified): either side may be a lazy `Thunk` wrapping a
3335 // full-set leaf — both dotted-path orderings hit this:
3336 // forward `o.a = { x = 1; }; o.a.y = 2;` → EXISTING `a` is a thunk
3337 // (`build_nested_attr` puts the `{x=1}` leaf through
3338 // `maybe_thunk`), NEW `a` is `{ y = … }`;
3339 // reverse `o.a.y = 2; o.a = { x = 1; };` → EXISTING `a` is `{y}`,
3340 // NEW `a` is the `<thunk {x=1}>`.
3341 // The old `should_merge` required BOTH sides to already be concrete
3342 // `Value::Attrs`, so a Thunk-vs-Attrs collision fell to the overwrite
3343 // path and silently dropped the earlier leaf's keys. cppnix desugars
3344 // BOTH orderings into one merged `o.a = { x = 1; y = 2; }`. Force each
3345 // side's thunk to WHNF ON COLLISION ONLY (forcing an attrset to WHNF
3346 // does NOT force its fields, so leaf laziness is preserved); a thunk
3347 // that forces to a non-attrset (or errors) makes the merge a plain
3348 // overwrite (leaf last-write-wins).
3349 // Symptom this closes: nixpkgs' alsa module declares
3350 // `options.hardware.alsa = { enable = …; cardAliases = …; … }` AND
3351 // `options.hardware.alsa.enablePersistence = …`; sui merged them to
3352 // only `{enablePersistence}`, so `hardware.alsa.cardAliases` "does not
3353 // exist" — the M2.6 frontier once the `with`-namespace over-force (#4a)
3354 // was fixed.
3355 let value = match value {
3356 Value::Thunk(_) => match force_value(&value) {
3357 Ok(v @ Value::Attrs(_)) => v,
3358 _ => value,
3359 },
3360 other => other,
3361 };
3362 if !matches!(value, Value::Attrs(_)) {
3363 target.insert(key, value);
3364 return;
3365 }
3366 // Normalize the existing side to concrete attrs too (forcing a thunk
3367 // to WHNF if needed); if it isn't attrset-shaped, the new attrs wins.
3368 let existing_concrete = match &existing {
3369 Value::Attrs(_) => existing.clone(),
3370 Value::Thunk(_) => match force_value(&existing) {
3371 Ok(v @ Value::Attrs(_)) => v,
3372 _ => {
3373 target.insert(key, value);
3374 return;
3375 }
3376 },
3377 _ => {
3378 target.insert(key, value);
3379 return;
3380 }
3381 };
3382 // Both sides are concrete attrs — merge in place. We pop the
3383 // existing entry, then walk the new attrs and recursively
3384 // merge each child onto it.
3385 let mut existing_attrs = match existing_concrete {
3386 Value::Attrs(a) => (*a).clone(),
3387 _ => unreachable!(),
3388 };
3389 let new_attrs = match value {
3390 Value::Attrs(ref a) => a,
3391 _ => unreachable!(),
3392 };
3393 for (k, v) in new_attrs.iter_unsorted() {
3394 merge_nested_insert(&mut existing_attrs, k.clone(), v.clone());
3395 }
3396 target.insert(key, Value::Attrs(Rc::new(existing_attrs)));
3397}
3398
3399/// Evaluate entries from any HasEntry node (LegacyLet).
3400fn eval_entries<N: HasEntry + AstNode>(node: &N, env: &mut Env) -> Result<(), EvalError> {
3401 for entry in node.entries() {
3402 match entry {
3403 ast::Entry::AttrpathValue(apv) => {
3404 let attrpath = apv.attrpath().ok_or_else(|| {
3405 EvalError::ParseError("binding missing attrpath".to_string())
3406 })?;
3407 let value_expr = apv.value().ok_or_else(|| {
3408 EvalError::ParseError("binding missing value".to_string())
3409 })?;
3410 let mut path_keys: Vec<String> = attrpath
3411 .attrs()
3412 .map(|a| eval_attr(&a, env))
3413 .collect::<Result<_, _>>()?;
3414 if path_keys.len() == 1 {
3415 let key = path_keys.pop().unwrap();
3416 let value = eval_expr(&value_expr, env)?;
3417 env.bind(key, value);
3418 }
3419 // Multi-key paths in let are not standard; skip for now.
3420 }
3421 ast::Entry::Inherit(inherit) => {
3422 if let Some(from) = inherit.from() {
3423 let source_expr = from.expr().ok_or_else(|| {
3424 EvalError::ParseError("inherit from missing expr".to_string())
3425 })?;
3426 let source = force_value(&eval_expr(&source_expr, env)?)?;
3427 let source_attrs = source.as_attrs()?;
3428 for attr in inherit.attrs() {
3429 let name = eval_attr(&attr, env)?;
3430 let value = source_attrs
3431 .get(&name)
3432 .cloned()
3433 .ok_or_else(|| EvalError::AttrNotFound(
3434 format!("'{name}' in inherit{}", eval_file_ctx()),
3435 ))?;
3436 env.bind(name, value);
3437 }
3438 } else {
3439 for attr in inherit.attrs() {
3440 let name = eval_attr(&attr, env)?;
3441 let value = env
3442 .lookup(&name)
3443 .ok_or_else(|| EvalError::UndefinedVar(
3444 format!("'{name}'{}", eval_file_ctx()),
3445 ))?;
3446 env.bind(name, value);
3447 }
3448 }
3449 }
3450 }
3451 }
3452 Ok(())
3453}
3454
3455fn eval_binop(
3456 op: ast::BinOpKind,
3457 lhs: &ast::Expr,
3458 rhs: &ast::Expr,
3459 env: &Env,
3460) -> Result<Value, EvalError> {
3461 // Short-circuit for && and ||
3462 match op {
3463 ast::BinOpKind::And => {
3464 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3465 if !l {
3466 return Ok(Value::Bool(false));
3467 }
3468 return eval_expr(rhs, env);
3469 }
3470 ast::BinOpKind::Or => {
3471 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3472 if l {
3473 return Ok(Value::Bool(true));
3474 }
3475 return eval_expr(rhs, env);
3476 }
3477 ast::BinOpKind::Implication => {
3478 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3479 if !l {
3480 return Ok(Value::Bool(true));
3481 }
3482 return eval_expr(rhs, env);
3483 }
3484 _ => {}
3485 }
3486
3487 let lc = force_concrete(&eval_expr(lhs, env)?)?;
3488 let rc = force_concrete(&eval_expr(rhs, env)?)?;
3489 // Consume the Concretes (move, don't clone) so `l`/`r` hold the sole Rc to
3490 // any heap payload. This is byte-neutral — `into_value` yields the identical
3491 // `Value` as `to_value` — but it drops `lc`/`rc`, which is what lets the
3492 // `Concat` arm's structural-share fast path see a uniquely-owned left list
3493 // for a fresh `++` temporary (`Rc::try_unwrap` → append in place). Keeping
3494 // `lc` alive via `to_value` pinned the refcount at ≥2 and defeated reuse.
3495 let l = lc.into_value();
3496 let r = rc.into_value();
3497
3498 match op {
3499 ast::BinOpKind::Add => match (&l, &r) {
3500 (Value::Int(a), Value::Int(b)) => a
3501 .checked_add(*b)
3502 .map(Value::Int)
3503 .ok_or_else(|| int_overflow("adding", *a, '+', *b)),
3504 (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
3505 (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
3506 (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
3507 (Value::String(a), Value::String(b)) => {
3508 let mut ctx = a.context.clone();
3509 ctx.merge(&b.context);
3510 // Byte-identical to `format!("{}{}", a.chars, b.chars)` but
3511 // routes around the `core::fmt` runtime (its dispatch was the
3512 // #1 self-time frame on the string-concat hot path): a single
3513 // exact-capacity `String` + two `push_str` reserves the final
3514 // size once, so the left operand is copied exactly once instead
3515 // of copied-then-regrown. Result string + context unchanged →
3516 // ByteSufficient. (Also removes a `format!` — TYPED EMISSION.)
3517 let mut s = String::with_capacity(a.chars.len() + b.chars.len());
3518 s.push_str(&a.chars);
3519 s.push_str(&b.chars);
3520 Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
3521 }
3522 (Value::Path(a), Value::String(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}{}", b.chars).as_str())))),
3523 (Value::Path(a), Value::Path(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}/{b}").as_str())))),
3524 // CppNix coerces attrsets with outPath when used with +
3525 (Value::Attrs(_), _) | (_, Value::Attrs(_)) => {
3526 let (ls, lctx) = l.coerce_to_string()?;
3527 let (rs, rctx) = r.coerce_to_string()?;
3528 let mut ctx = lctx;
3529 ctx.merge(&rctx);
3530 Ok(Value::String(Rc::new(NixString::with_context(
3531 format!("{ls}{rs}"),
3532 ctx,
3533 ))))
3534 }
3535 _ => Err(EvalError::op_type("add", l.type_name(), r.type_name())),
3536 },
3537 ast::BinOpKind::Sub => num_op(
3538 &l,
3539 &r,
3540 |a, b| a.checked_sub(b),
3541 |a, b| a - b,
3542 |a, b| int_overflow("subtracting", a, '-', b),
3543 ),
3544 ast::BinOpKind::Mul => num_op(
3545 &l,
3546 &r,
3547 |a, b| a.checked_mul(b),
3548 |a, b| a * b,
3549 |a, b| int_overflow("multiplying", a, '*', b),
3550 ),
3551 ast::BinOpKind::Div => {
3552 // CppNix rejects division by zero for both int and float
3553 // operands; Rust's native int-div-by-0 panics (we handle
3554 // that below) but float-div-by-0 silently returns `inf`
3555 // or `NaN`, which sui was then serializing as `null` —
3556 // an invisible silent-Ok bug surfaced by the error-case
3557 // differential corpus.
3558 //
3559 // Cover every zero-denominator case explicitly.
3560 let rhs_is_zero = match &r {
3561 Value::Int(0) => true,
3562 Value::Float(f) => *f == 0.0,
3563 _ => false,
3564 };
3565 if rhs_is_zero {
3566 return Err(EvalError::DivisionByZero);
3567 }
3568 num_op(
3569 &l,
3570 &r,
3571 |a, b| a.checked_div(b),
3572 |a, b| a / b,
3573 |a, b| int_overflow("dividing", a, '/', b),
3574 )
3575 }
3576 // `eq_operator`, NOT `==`: at the operator both operands were just
3577 // materialized by independent `force_concrete` calls, so sui can prove
3578 // they are distinct cells and must answer `false` for two lambdas —
3579 // exactly as CppNix's `ExprOpEq::eval` does. Nested comparisons keep
3580 // `PartialEq`. See `value::eq_operator`.
3581 ast::BinOpKind::Equal => Ok(Value::Bool(crate::value::eq_operator(&l, &r))),
3582 ast::BinOpKind::NotEqual => Ok(Value::Bool(!crate::value::eq_operator(&l, &r))),
3583 ast::BinOpKind::Less => compare(&l, &r, |o| o == std::cmp::Ordering::Less),
3584 ast::BinOpKind::LessOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Greater),
3585 ast::BinOpKind::More => compare(&l, &r, |o| o == std::cmp::Ordering::Greater),
3586 ast::BinOpKind::MoreOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Less),
3587 ast::BinOpKind::Update => {
3588 let la = l.to_attrs()?;
3589 let ra = r.to_attrs()?;
3590 // O(1) lazy overlay — defers merge until attribute access.
3591 Ok(Value::Attrs(Rc::new(la.overlay(ra))))
3592 }
3593 ast::BinOpKind::Concat => {
3594 // Structural-share fast path: when the left operand's `Rc<Vec>` is
3595 // uniquely owned (a fresh temporary, as in a left-associative `++`
3596 // fold `acc ++ [x]`), append the right elements IN PLACE instead of
3597 // cloning the whole accumulator. This turns an O(n) copy per concat
3598 // into amortized O(1), byte-identically — the result is the same
3599 // ordered sequence of the same Rc-shared lazy thunks (no forcing,
3600 // no reordering, no identity change). When the Rc is shared (the
3601 // left came from a still-live binding/thunk) we fall back to the
3602 // clone-extend path, preserving the shared list unchanged.
3603 crate::value::concat_lists(l, r.as_list()?)
3604 }
3605 ast::BinOpKind::And | ast::BinOpKind::Or | ast::BinOpKind::Implication => {
3606 unreachable!("handled above")
3607 }
3608 ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
3609 Err(EvalError::NotImplemented("pipe operators".to_string()))
3610 }
3611 }
3612}
3613
3614/// CppNix aborts (uncatchably) on i64 arithmetic overflow, e.g.
3615/// `integer overflow in adding 9223372036854775807 + 1`. `EvalError::Abort` is
3616/// the uncatchable variant (`tryEval` catches only `Throw`/`AssertionFailed`),
3617/// matching nix — a wrapping result would silently produce a wrong drvPath.
3618#[inline]
3619fn int_overflow(verb: &str, a: i64, sym: char, b: i64) -> EvalError {
3620 EvalError::Abort(format!("integer overflow in {verb} {a} {sym} {b}"))
3621}
3622
3623fn num_op(
3624 l: &Value,
3625 r: &Value,
3626 int_op: impl Fn(i64, i64) -> Option<i64>,
3627 float_op: impl Fn(f64, f64) -> f64,
3628 overflow: impl Fn(i64, i64) -> EvalError,
3629) -> Result<Value, EvalError> {
3630 match (l, r) {
3631 (Value::Int(a), Value::Int(b)) => {
3632 int_op(*a, *b).map(Value::Int).ok_or_else(|| overflow(*a, *b))
3633 }
3634 (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
3635 (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
3636 (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
3637 _ => Err(EvalError::op_type("perform arithmetic on", l.type_name(), r.type_name())),
3638 }
3639}
3640
3641fn compare(
3642 l: &Value,
3643 r: &Value,
3644 pred: impl Fn(std::cmp::Ordering) -> bool,
3645) -> Result<Value, EvalError> {
3646 let ord = match (l, r) {
3647 (Value::Int(a), Value::Int(b)) => a.cmp(b),
3648 (Value::Float(a), Value::Float(b)) => {
3649 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
3650 }
3651 (Value::Int(a), Value::Float(b)) => (*a as f64)
3652 .partial_cmp(b)
3653 .unwrap_or(std::cmp::Ordering::Equal),
3654 (Value::Float(a), Value::Int(b)) => a
3655 .partial_cmp(&(*b as f64))
3656 .unwrap_or(std::cmp::Ordering::Equal),
3657 (Value::String(a), Value::String(b)) => a.chars.cmp(&b.chars),
3658 _ => {
3659 return Err(EvalError::op_type("compare", l.type_name(), r.type_name()));
3660 }
3661 };
3662 Ok(Value::Bool(pred(ord)))
3663}
3664
3665/// Apply a function to an argument.
3666///
3667/// Supports `__functor`: if `func` is an attrset with a `__functor` key,
3668/// calls `__functor self arg` (the Nix `__functor` protocol).
3669///
3670/// For lambda with a simple ident parameter, the argument is NOT forced
3671/// before binding -- this enables fixpoint combinators (`lib.fix`) where
3672/// the argument is a self-referential thunk.
3673/// Apply a function and force the result.
3674///
3675/// Builtins that inspect the return value (via `as_list`, `as_bool`, etc.)
3676/// must use this instead of bare `apply` — otherwise a thunk-wrapped result
3677/// will cause "thunk in as_list: force first" errors.
3678pub fn apply_and_force(func: Value, arg: Value) -> Result<Value, EvalError> {
3679 force_value(&apply(func, arg)?)
3680}
3681
3682pub fn apply(func: Value, arg: Value) -> Result<Value, EvalError> {
3683 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || apply_inner(func, arg))
3684}
3685
3686fn apply_inner(func: Value, arg: Value) -> Result<Value, EvalError> {
3687 crate::perf::inc(crate::perf::Counter::Apply);
3688 let func = force_concrete(&func)?.into_value();
3689 match func {
3690 Value::Lambda(closure) => {
3691 // Hot function tracker: log source file + param name for each lambda call
3692 if crate::perf::enabled() {
3693 APPLY_SITES.with(|sites| {
3694 let file = closure.env.eval_file()
3695 .map(|p| p.display().to_string())
3696 .unwrap_or_else(|| "<eval>".into());
3697 // Include param info for identification
3698 let param_name = match &closure.param {
3699 rnix::ast::Param::IdentParam(ip) => ip.ident().map(|i| ident_text(&i)).unwrap_or_default(),
3700 rnix::ast::Param::Pattern(pat) => {
3701 let mut names: Vec<String> = pat.pat_entries()
3702 .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3703 .take(3)
3704 .collect();
3705 if pat.pat_entries().count() > 3 { names.push("...".to_string()); }
3706 format!("{{{}}}", names.join(","))
3707 }
3708 };
3709 let key = format!("{}:{}", file.rsplit_once("-source/").map_or(file.as_str(), |(_,s)| s), param_name);
3710 *sites.borrow_mut().entry(key).or_insert(0u64) += 1;
3711 });
3712 }
3713 let mut call_env = closure.env.child();
3714 // ALWAYS push a frame, even when the closure captured no file:
3715 // `.map(push_eval_file)` pushed nothing for `None`, leaving the
3716 // CALLER's file on top, so a literal written in a fileless
3717 // context got stamped with the callee's path. CppNix returns
3718 // `null` there. See `EVAL_FILE_STACK`.
3719 let _file_guard = push_eval_frame(closure.env.eval_file().cloned());
3720 // Push Nix-level trace frame for function calls. Lazy: stores
3721 // only the raw ingredients (O(1) Rc-clone of the closure env +
3722 // the current-eval-file snapshot) and defers the format!/strip
3723 // work to the cold `attach_trace` path. Renders byte-identical
3724 // to the eager form.
3725 let _trace = push_nix_trace_lambda(&closure.env);
3726 match &closure.param {
3727 rnix::ast::Param::IdentParam(_) => {
3728 // Simple ident param: bind argument WITHOUT forcing.
3729 // This is critical for fixpoint / call-by-need semantics.
3730 bind_param(&closure.param, &arg, &mut call_env)?;
3731 }
3732 rnix::ast::Param::Pattern(_) => {
3733 // Pattern param needs the arg to be an attrset, so force.
3734 let forced_arg = force_concrete(&arg)?.into_value();
3735 bind_param(&closure.param, &forced_arg, &mut call_env)?;
3736 }
3737 }
3738 eval_expr(&closure.body, &call_env)
3739 }
3740 Value::Builtin(b) => {
3741 let _trace = push_nix_trace(format!("while calling the '{}' builtin", b.name));
3742 // Special builtins that must receive UNFORCED arguments:
3743 // - tryEval: must catch throw/abort during its own forcing
3744 // - addErrorContext<partial>: wraps value with error context
3745 // without forcing (the value is the fixpoint `config` which
3746 // causes infinite recursion if forced during collectModules)
3747 // - seq<partial>: forces first arg but returns second UNFORCED
3748 // Same lazy-arg set as `eval_apply` (single source of truth) — these
3749 // builtins receive the arg UNFORCED. foldl'<p1> is the nul accumulator
3750 // (nix's foldl' is strict in each op RESULT, NOT in the nul).
3751 if builtin_takes_lazy_arg(&b.name) {
3752 (b.func)(&[arg])
3753 } else {
3754 let forced_arg = force_value(&arg)?;
3755 (b.func)(&[forced_arg])
3756 }
3757 }
3758 Value::Attrs(ref attrs) => {
3759 if let Some(functor) = attrs.get("__functor") {
3760 let functor = force_value(functor)?;
3761 // __functor protocol: (functor self) arg
3762 let partial = apply(functor, func.clone())?;
3763 apply(partial, arg)
3764 } else if crate::value::in_promise_eval() {
3765 // M2.6 Promise softening: an attrset without __functor
3766 // being called as a function — typically the empty-
3767 // attrset sentinel inside a fix-point body. Return
3768 // null so eval can proceed.
3769 Ok(Value::Null)
3770 } else {
3771 Err(EvalError::type_error(
3772 format!("cannot call {} (missing __functor){}", func.type_name(), eval_file_ctx()),
3773 ))
3774 }
3775 }
3776 _ if crate::value::in_promise_eval() => {
3777 // M2.6 Promise softening: calling null / int / string / list
3778 // as a function inside a Promise body is the sentinel
3779 // cascade landing somewhere it doesn't belong. Return null
3780 // so the fix-point continues instead of erroring.
3781 Ok(Value::Null)
3782 }
3783 _ => Err(EvalError::type_error(
3784 format!("cannot call {}{}", func.type_name(), eval_file_ctx()),
3785 )),
3786 }
3787}
3788
3789/// Dark-side lever `batch-bind` (byte-SAFE, `RedundantWrite`) — OFF by default.
3790/// When `SUI_BATCH_BIND=1`, an N-formal pattern binds in ONE copy-on-write step
3791/// (`Env::bind_many`) instead of N successive `env.bind()` calls. Byte-identical
3792/// either way (same intern, same insert order, same final HAMT — Phase 2's
3793/// `update_env` makes each default thunk's initial env capture unobservable).
3794/// Gated because the extra `Vec` allocation could regress the common small-pattern
3795/// case, and the win is unmeasured under load — never change the default path on a
3796/// hunch (never-ship-a-regression). Cached so the default path pays zero per call.
3797/// Ledger: `sui-spec/specs/darkside.lisp` (`batch-bind`, DarkGated).
3798static SUI_BATCH_BIND: std::sync::LazyLock<bool> =
3799 std::sync::LazyLock::new(|| std::env::var_os("SUI_BATCH_BIND").is_some());
3800
3801fn bind_param(param: &ast::Param, arg: &Value, env: &mut Env) -> Result<(), EvalError> {
3802 match param {
3803 ast::Param::IdentParam(ip) => {
3804 let ident = ip
3805 .ident()
3806 .ok_or_else(|| EvalError::ParseError("ident param missing ident".to_string()))?;
3807 let name = ident_text(&ident);
3808 env.bind(name, arg.clone());
3809 }
3810 ast::Param::Pattern(pat) => {
3811 let attrs = arg.as_attrs()?;
3812
3813 // @-binding (either `args @ { ... }` or `{ ... } @ args`)
3814 if let Some(pat_bind) = pat.pat_bind()
3815 && let Some(ident) = pat_bind.ident()
3816 {
3817 let name = ident_text(&ident);
3818 env.bind(name, arg.clone());
3819 }
3820
3821 let has_ellipsis = pat.ellipsis_token().is_some();
3822 let entries: Vec<ast::PatEntry> = pat.pat_entries().collect();
3823
3824 // Two-phase binding (matching CppNix semantics):
3825 // Phase 1: Bind all formals. Defaults get thunks with a
3826 // preliminary env. We collect thunks for Phase 2 update.
3827 // Phase 2: Update default thunks to capture the final env
3828 // (which now has ALL formals bound). This allows defaults
3829 // to reference any other formal — including forward refs.
3830 let mut default_thunks: Vec<Thunk> = Vec::new();
3831 // batch-bind (byte-SAFE `RedundantWrite`, OFF unless `SUI_BATCH_BIND=1`):
3832 // the flag path collects every formal's (name, value) pair and binds
3833 // them in ONE copy-on-write step (`bind_many`) instead of N successive
3834 // `env.bind()` calls. Byte-identical either way — the default thunks
3835 // capture `env.clone()` (pre-batch) and Phase 2's `update_env` re-points
3836 // every one to the final all-formals-bound env, so a thunk's *initial*
3837 // capture is unobservable (overwritten before any force); same intern,
3838 // same insert order, same final HAMT. The default path (flag unset) is
3839 // the original per-formal loop, byte- AND perf-identical (no Vec alloc).
3840 let use_batch = *SUI_BATCH_BIND;
3841 let mut pairs: Vec<(String, Value)> =
3842 if use_batch { Vec::with_capacity(entries.len()) } else { Vec::new() };
3843
3844 // D3 (`SUI_SCOPE_NARROW>=1`) — the highest-yield arm of the fix,
3845 // because it fires on every `callPackage`'d
3846 // `{ stdenv, lib, foo ? null }` and every
3847 // `{ config, lib, pkgs, ... }` module in the fleet.
3848 //
3849 // Today EVERY default thunk is re-pointed at the final all-formals
3850 // env by Phase 2, so `{ a, b ? 1 }` closes
3851 // `b-thunk -> env -> b-thunk` and the whole call frame is immortal.
3852 // But a default only NEEDS the final env if it can reach a formal
3853 // that is itself satisfied by a default — those are the only names
3854 // still unbound when the default is built. Everything else (an
3855 // argument-supplied formal, the `@`-bind, any outer name) is
3856 // already in scope, so the capture is complete on the spot and the
3857 // cycle never has to be closed.
3858 //
3859 // Splitting the single pass in two is what makes that true:
3860 // pass A binds every argument-supplied formal FIRST, so pass B's
3861 // captures see all of them regardless of declaration order.
3862 //
3863 // The reorder is byte-safe: formal names are unique (a duplicate
3864 // is a parse error), `bindings` is a hash map read only by key, and
3865 // building a thunk has no side effects — so nothing observes the
3866 // order in which the two passes populate the env, only its final
3867 // contents, which are unchanged.
3868 let narrow = scope_narrow_enabled();
3869 // The formals that will be satisfied BY A DEFAULT — i.e. exactly
3870 // the names not yet bound when pass B runs.
3871 let default_names: HashSet<String> = if narrow {
3872 entries
3873 .iter()
3874 .filter(|e| e.default().is_some())
3875 .filter_map(ast::PatEntry::ident)
3876 .map(|i| ident_text(&i))
3877 .filter(|n| attrs.get(n).is_none())
3878 .collect()
3879 } else {
3880 HashSet::new()
3881 };
3882
3883 if narrow {
3884 // PASS A — argument-supplied formals only. The
3885 // `missing argument` error still fires here, in entry order,
3886 // exactly where the single pass raised it.
3887 let mut deferred: Vec<(String, ast::Expr)> =
3888 Vec::with_capacity(default_names.len());
3889 for entry in &entries {
3890 let ident = entry.ident().ok_or_else(|| {
3891 EvalError::ParseError("pat entry missing ident".to_string())
3892 })?;
3893 let name = ident_text(&ident);
3894 if let Some(v) = attrs.get(&name) {
3895 env.bind(name, v.clone());
3896 } else if let Some(default_expr) = entry.default() {
3897 deferred.push((
3898 name,
3899 ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3900 ));
3901 } else {
3902 return Err(EvalError::type_error(
3903 format!("missing argument '{name}'{}", eval_file_ctx()),
3904 ));
3905 }
3906 }
3907 // PASS B — the defaults, capturing an env that already carries
3908 // every argument-supplied formal and the `@`-bind.
3909 for (name, default_expr) in deferred {
3910 let thunk =
3911 Thunk::new_suspended(default_expr.clone(), env.clone());
3912 let referenced = referenced_idents(&default_expr);
3913 if default_names.iter().any(|n| referenced.contains(n.as_str())) {
3914 // Reaches another DEFAULTED formal, which may not be
3915 // bound yet — it needs Phase 2's re-point, and pays
3916 // the cycle.
3917 default_thunks.push(thunk.clone());
3918 crate::value::census::scope_pinned();
3919 } else {
3920 crate::value::census::scope_narrowed();
3921 }
3922 env.bind(name, Value::Thunk(thunk));
3923 }
3924 } else {
3925 for entry in &entries {
3926 let ident = entry.ident().ok_or_else(|| {
3927 EvalError::ParseError("pat entry missing ident".to_string())
3928 })?;
3929 let name = ident_text(&ident);
3930 let value = if let Some(v) = attrs.get(&name) {
3931 v.clone()
3932 } else if let Some(default_expr) = entry.default() {
3933 // Default values in pattern parameters must be lazy
3934 // (wrapped in thunks), matching CppNix semantics.
3935 // Patterns like `vendor ? assert false; null` rely on
3936 // the default never being forced when the body checks
3937 // `args ? vendor` instead of using `vendor` directly.
3938 let thunk = Thunk::new_suspended(
3939 ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3940 env.clone(),
3941 );
3942 default_thunks.push(thunk.clone());
3943 Value::Thunk(thunk)
3944 } else {
3945 return Err(EvalError::type_error(
3946 format!("missing argument '{name}'{}", eval_file_ctx()),
3947 ));
3948 };
3949 if use_batch {
3950 pairs.push((name, value));
3951 } else {
3952 env.bind(name, value);
3953 }
3954 }
3955 if use_batch {
3956 env.bind_many(pairs);
3957 }
3958 }
3959
3960 // Phase 2: Update default thunks to see ALL formals.
3961 for thunk in &default_thunks {
3962 thunk.update_env(env);
3963 }
3964
3965 if !has_ellipsis {
3966 let entry_names: std::collections::HashSet<String> = entries
3967 .iter()
3968 .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3969 .collect();
3970 for key in attrs.keys() {
3971 if !entry_names.contains(key.as_str()) {
3972 return Err(EvalError::type_error(
3973 format!("unexpected argument '{key}'{}", eval_file_ctx()),
3974 ));
3975 }
3976 }
3977 }
3978 }
3979 }
3980 Ok(())
3981}
3982
3983#[cfg(test)]
3984mod tests {
3985 use super::*;
3986
3987 fn ev(input: &str) -> Value {
3988 eval(input).unwrap()
3989 }
3990
3991 // Regression (2026-07-10): the let-scope fix-point detector must count
3992 // only GENUINE variable references, not attribute names / attrset keys
3993 // (which sit under a `NODE_ATTRPATH`). nixpkgs `lib/types.nix` has
3994 // `placeholder = if lhs.placeholder == …` whose RHS mentions the
3995 // *attribute* `.placeholder`; the old raw-token match falsely flagged
3996 // the binding self-recursive and routed it through the Promise path.
3997 #[test]
3998 fn is_self_recursive_binding_ignores_attribute_names() {
3999 fn expr(s: &str) -> ast::Expr {
4000 rnix::Root::parse(s).tree().expr().expect("parse")
4001 }
4002 // attribute names / keys are NOT references to the binding
4003 assert!(!is_self_recursive_binding(&expr("lhs.placeholder"), "placeholder"));
4004 assert!(!is_self_recursive_binding(&expr("{ placeholder = 1; }"), "placeholder"));
4005 assert!(!is_self_recursive_binding(
4006 &expr("if lhs.placeholder == rhs.placeholder then lhs.placeholder else null"),
4007 "placeholder",
4008 ));
4009 // genuine variable references ARE detected
4010 assert!(is_self_recursive_binding(&expr("placeholder + 1"), "placeholder"));
4011 assert!(is_self_recursive_binding(
4012 &expr("if placeholder then 1 else 2"),
4013 "placeholder"
4014 ));
4015 }
4016
4017 // M2 thunk-waste (byte-safe eager constant): a NON-interpolated string in a
4018 // maybe_thunk site is evaluated directly (no suspended thunk). The value +
4019 // its (empty) context must be byte-identical to forcing a thunk of it.
4020 #[test]
4021 fn maybe_thunk_eager_constant_str_is_byte_identical() {
4022 fn expr(s: &str) -> ast::Expr {
4023 rnix::Root::parse(s).tree().expr().expect("parse")
4024 }
4025 let env = Env::new();
4026 // Constant string → returned as a concrete String, NOT a Thunk.
4027 let v = maybe_thunk(&expr(r#""abc""#), &env, false, None);
4028 assert!(matches!(v, Value::String(_)), "constant str should be eager, got {v:?}");
4029 assert_eq!(force_value(&v).unwrap(), Value::string("abc"));
4030 // Interpolated string → MUST stay a thunk (lazy `${…}` force).
4031 let vi = maybe_thunk(&expr(r#""a${b}c""#), &env, false, None);
4032 assert!(matches!(vi, Value::Thunk(_)), "interpolated str must stay thunked");
4033 }
4034
4035 // The pure-constant arg classifier admits ONLY literals + non-interpolated
4036 // strings/paths, and rejects everything that could throw/diverge/observe a
4037 // fixpoint — the laziness safety boundary of the apply-arg optimization.
4038 #[test]
4039 fn eval_pure_constant_arg_classification() {
4040 fn expr(s: &str) -> ast::Expr {
4041 rnix::Root::parse(s).tree().expr().expect("parse")
4042 }
4043 // ADMIT: pure constants (byte-safe to eval eagerly in an arg position).
4044 assert!(eval_pure_constant_arg(&expr("42")).is_some());
4045 assert!(eval_pure_constant_arg(&expr("3.14")).is_some());
4046 assert!(eval_pure_constant_arg(&expr(r#""const""#)).is_some());
4047 assert!(eval_pure_constant_arg(&expr("/abs/path")).is_some());
4048 // REJECT: anything that could throw / diverge / observe laziness.
4049 assert!(eval_pure_constant_arg(&expr(r#""a${b}c""#)).is_none(), "interpolated str");
4050 // `true`/`false`/`null` are IDENTS in nix (shadowable), not literals —
4051 // rejected to avoid a with-scope force, correctly conservative.
4052 assert!(eval_pure_constant_arg(&expr("true")).is_none(), "bool is an ident");
4053 assert!(eval_pure_constant_arg(&expr("x")).is_none(), "ident (with-scope force)");
4054 assert!(eval_pure_constant_arg(&expr("a.b")).is_none(), "select (fixpoint)");
4055 assert!(eval_pure_constant_arg(&expr("f x")).is_none(), "apply (may throw)");
4056 assert!(eval_pure_constant_arg(&expr("1 + 1")).is_none(), "binop (may throw)");
4057 assert!(eval_pure_constant_arg(&expr("throw \"x\"")).is_none(), "throw stays lazy");
4058 }
4059
4060 // LAZINESS GUARD: a lambda that IGNORES its arg must NOT force it — even a
4061 // throwing arg. The pure-constant optimization only touches inert constants,
4062 // so a `throw`-ing arg stays fully thunked and the ignoring lambda succeeds.
4063 #[test]
4064 fn ignored_throwing_arg_stays_lazy() {
4065 assert_eq!(ev(r#"(x: 7) (throw "boom")"#), Value::Int(7));
4066 // And an ignored constant arg is equally invisible.
4067 assert_eq!(ev(r#"(x: 7) "const""#), Value::Int(7));
4068 // A USED constant arg produces the right value.
4069 assert_eq!(ev(r#"(x: x) "used""#), Value::string("used"));
4070 }
4071
4072 #[test]
4073 fn eval_int() { assert_eq!(ev("42"), Value::Int(42)); }
4074
4075 #[test]
4076 fn eval_float() { assert_eq!(ev("3.14"), Value::Float(3.14)); }
4077
4078 #[test]
4079 fn eval_string() { assert_eq!(ev(r#""hello""#), Value::string("hello")); }
4080
4081 #[test]
4082 fn eval_bool() { assert_eq!(ev("true"), Value::Bool(true)); }
4083
4084 #[test]
4085 fn eval_null() { assert_eq!(ev("null"), Value::Null); }
4086
4087 #[test]
4088 fn eval_arithmetic() {
4089 assert_eq!(ev("1 + 2"), Value::Int(3));
4090 assert_eq!(ev("10 - 3"), Value::Int(7));
4091 assert_eq!(ev("2 * 3"), Value::Int(6));
4092 assert_eq!(ev("10 / 3"), Value::Int(3));
4093 }
4094
4095 #[test]
4096 fn eval_precedence() {
4097 assert_eq!(ev("1 + 2 * 3"), Value::Int(7));
4098 assert_eq!(ev("(1 + 2) * 3"), Value::Int(9));
4099 }
4100
4101 #[test]
4102 fn eval_comparison() {
4103 assert_eq!(ev("1 == 1"), Value::Bool(true));
4104 assert_eq!(ev("1 == 2"), Value::Bool(false));
4105 assert_eq!(ev("1 < 2"), Value::Bool(true));
4106 assert_eq!(ev("2 <= 2"), Value::Bool(true));
4107 }
4108
4109 #[test]
4110 fn eval_logic() {
4111 assert_eq!(ev("true && false"), Value::Bool(false));
4112 assert_eq!(ev("true || false"), Value::Bool(true));
4113 assert_eq!(ev("!true"), Value::Bool(false));
4114 }
4115
4116 #[test]
4117 fn eval_string_concat() {
4118 assert_eq!(ev(r#""hello" + " " + "world""#), Value::string("hello world"));
4119 }
4120
4121 #[test]
4122 fn eval_if() {
4123 assert_eq!(ev("if true then 1 else 2"), Value::Int(1));
4124 assert_eq!(ev("if false then 1 else 2"), Value::Int(2));
4125 }
4126
4127 #[test]
4128 fn eval_let() {
4129 assert_eq!(ev("let x = 1; in x"), Value::Int(1));
4130 assert_eq!(ev("let x = 1; y = 2; in x + y"), Value::Int(3));
4131 }
4132
4133 #[test]
4134 fn eval_let_dotted_simple() {
4135 // Two dotted bindings sharing the top-level key `a`.
4136 assert_eq!(ev("let a.b = 1; a.c = 2; in a.b + a.c"), Value::Int(3));
4137 }
4138
4139 #[test]
4140 fn eval_let_dotted_deep() {
4141 // Deeply nested dotted path.
4142 assert_eq!(ev("let a.b.c = 1; in a.b.c"), Value::Int(1));
4143 }
4144
4145 #[test]
4146 fn eval_let_dotted_mixed() {
4147 // Mix of simple and dotted bindings.
4148 assert_eq!(
4149 ev("let a.x = 1; b = 2; a.y = 3; in a.x + a.y + b"),
4150 Value::Int(6),
4151 );
4152 }
4153
4154 #[test]
4155 fn eval_let_dotted_produces_attrset() {
4156 // Dotted let bindings produce a real attrset.
4157 let v = ev("let a.b = 1; a.c = 2; in a");
4158 if let Value::Attrs(attrs) = v {
4159 assert_eq!(attrs.get("b"), Some(&Value::Int(1)));
4160 assert_eq!(attrs.get("c"), Some(&Value::Int(2)));
4161 } else {
4162 panic!("expected Attrs, got {v:?}");
4163 }
4164 }
4165
4166 // ── Inner dynamic attrpath key laziness ──────────────────
4167 // CppNix defers a dynamic key that is NOT at the head of an attrpath:
4168 // `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`, so `e` never
4169 // forces until `.a` is demanded. Reading a sibling must not force the
4170 // inner dynamic key. Root fix: `build_deferred_tail_attr` in eval.rs.
4171 // This is the pure-builtins reduction of the NixOS module-system
4172 // `config.homes.${cfg.userName}` fixpoint divergence.
4173 #[test]
4174 fn dynamic_inner_attr_key_is_lazy_on_sibling_read() {
4175 // The dynamic key throws; reading the SIBLING must NOT force it.
4176 assert_eq!(
4177 ev(r#"let s = { a.${throw "KEYFORCED"} = 7; other = 9; }; in s.other"#),
4178 Value::Int(9),
4179 );
4180 }
4181
4182 #[test]
4183 fn dynamic_inner_attr_key_resolves_on_head_demand() {
4184 // Demanding the head DOES resolve the deferred dynamic key.
4185 let v = ev(r#"let u = "bob"; s = { homes.${u} = 7; }; in s.homes"#);
4186 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4187 assert_eq!(attrs.get("bob"), Some(&Value::Int(7)));
4188 } else {
4189 panic!("expected Attrs");
4190 }
4191 }
4192
4193 #[test]
4194 fn dynamic_inner_attr_key_merges_with_static_sibling() {
4195 // Collision under one head still deep-merges (static + dynamic).
4196 let v = ev(r#"let u = "x"; s = { a.${u} = 1; a.b = 2; }; in s.a"#);
4197 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4198 assert_eq!(attrs.get("x"), Some(&Value::Int(1)));
4199 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4200 } else {
4201 panic!("expected Attrs");
4202 }
4203 }
4204
4205 #[test]
4206 fn dynamic_inner_attr_key_null_skips_binding() {
4207 // A null dynamic inner key skips the definition (CppNix rule):
4208 // `a` becomes an empty attrset, the sibling stays.
4209 let v = ev(
4210 r#"let c = true; s = { a.${if c then null else "n"} = 5; b = 1; }; in s.b"#,
4211 );
4212 assert_eq!(v, Value::Int(1));
4213 }
4214
4215 // ── M2.6 ROOT #3: interpolated-STRING tail keys are dynamic too ──────
4216 // `{ a."p${e}" = v; }` must build `{ a = <thunk {"p${e}"=v}>; }` — an
4217 // interpolated-string attr key references `e` and so must defer like a
4218 // bare `${e}`, never force at construction. Reading a sibling must NOT
4219 // force it (the KEYFORCE discriminator, now for a `Str` key).
4220 #[test]
4221 fn interpolated_string_attr_key_is_lazy_on_sibling_read() {
4222 assert_eq!(
4223 ev(r#"let s = { a."p/${throw "KEYFORCED"}" = 7; other = 9; }; in s.other"#),
4224 Value::Int(9),
4225 );
4226 }
4227
4228 #[test]
4229 fn interpolated_string_attr_key_resolves_on_head_demand() {
4230 // Demanding the head DOES resolve the deferred interpolated key.
4231 let v = ev(r#"let u = "bob"; s = { homes."u/${u}" = 7; }; in s.homes"#);
4232 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4233 assert_eq!(attrs.get("u/bob"), Some(&Value::Int(7)));
4234 } else {
4235 panic!("expected Attrs");
4236 }
4237 }
4238
4239 #[test]
4240 fn purely_literal_string_attr_key_stays_eager_static() {
4241 // A `Str` key with NO interpolation is a plain static key and must
4242 // NOT be treated as dynamic (it forces nothing, deep-merges).
4243 let v = ev(r#"let s = { a."foo bar" = 1; a.b = 2; }; in s.a"#);
4244 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4245 assert_eq!(attrs.get("foo bar"), Some(&Value::Int(1)));
4246 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4247 } else {
4248 panic!("expected Attrs");
4249 }
4250 }
4251
4252 // ── M2.6 ROOT #3 (collision case): dynamic tail key under a head that
4253 // a sibling binding already wrote must stay lazy AND deep-merge.
4254 #[test]
4255 fn dynamic_tail_key_under_colliding_head_is_lazy() {
4256 // `sd.services.x` writes head `sd`; the second binding's dynamic
4257 // key must NOT force when a SIBLING (`sd.services`) is read.
4258 let v = ev(
4259 r#"let s = { sd.services.x = 1; sd.tmpfiles.${throw "KEYFORCED"}.d = 2; }; in s.sd.services.x"#,
4260 );
4261 assert_eq!(v, Value::Int(1));
4262 }
4263
4264 #[test]
4265 fn dynamic_tail_key_under_colliding_head_resolves_and_merges() {
4266 // Demanding the dynamic branch resolves the key; the sibling
4267 // static branch (`sd.services`) survives the merge intact.
4268 let v = ev(
4269 r#"let k = "z"; s = { sd.services.x = 1; sd.tmpfiles.${k}.d = 2; }; in s.sd"#,
4270 );
4271 let sd = force_value(&v).unwrap();
4272 if let Value::Attrs(sd_attrs) = &sd {
4273 // static sibling intact
4274 let services = force_value(sd_attrs.get("services").unwrap()).unwrap();
4275 if let Value::Attrs(a) = &services {
4276 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4277 } else { panic!("expected services attrs"); }
4278 // dynamic branch resolved to key "z"
4279 let tmpfiles = force_value(sd_attrs.get("tmpfiles").unwrap()).unwrap();
4280 if let Value::Attrs(a) = &tmpfiles {
4281 let z = force_value(a.get("z").unwrap()).unwrap();
4282 if let Value::Attrs(zd) = &z {
4283 assert_eq!(force_value(zd.get("d").unwrap()).unwrap(), Value::Int(2));
4284 } else { panic!("expected z attrs"); }
4285 } else { panic!("expected tmpfiles attrs"); }
4286 } else {
4287 panic!("expected sd attrs");
4288 }
4289 }
4290
4291 // ── M2.6 ROOT #4a — `with` namespace must be LAZY ─────────────────
4292 // `with X; body` stores the namespace as a thunk forced only on a
4293 // bare-ident fallthrough lookup; demanding only the body's WHNF/keys
4294 // must NOT force X. cppnix: `attrNames (with (throw "X"); {a=1;})`
4295 // → ["a"]. Before the fix, sui EVALUATED the namespace at `with`-entry
4296 // and threw. This is the load-bearing over-force behind the M2.6
4297 // `concatLists null` (nixpkgs' `config = mkIf … (with config.services.X;
4298 // { … })` module shape forced `config.services.X` during collection).
4299 #[test]
4300 fn with_namespace_is_lazy_on_body_whnf() {
4301 let v = ev(r#"builtins.attrNames (with (throw "WITH-FORCED"); { a = 1; b = 2; })"#);
4302 if let Value::List(items) = force_value(&v).unwrap() {
4303 let names: Vec<String> = items
4304 .iter()
4305 .map(|i| match force_value(i).unwrap() {
4306 Value::String(s) => s.as_str().to_string(),
4307 other => panic!("expected string, got {}", other.type_name()),
4308 })
4309 .collect();
4310 assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
4311 } else {
4312 panic!("expected list");
4313 }
4314 }
4315
4316 #[test]
4317 fn with_namespace_forces_only_on_fallthrough() {
4318 // A bare ident that falls through lexical scope DOES resolve via
4319 // the namespace (correct cppnix semantics) — proves the deferred
4320 // thunk is real and gets forced on demand, not an accidental no-op.
4321 assert_eq!(ev(r#"with { x = 42; }; x"#), Value::Int(42));
4322 // A lexical binding shadows the with-scope, so the (throwing)
4323 // namespace is never forced — the laziness we rely on for M2.6.
4324 assert_eq!(ev(r#"let x = 7; in with (throw "NS"); x"#), Value::Int(7));
4325 }
4326
4327 // ── M2.6 ROOT #4b — depth-≥2 dotted full-set leaf must deep-merge ──
4328 // `o.a = { x = 1; }` inserts `o = { a = <thunk {x=1}> }` (leaf goes
4329 // through maybe_thunk); a deeper sibling `o.a.y = 2` recurses
4330 // merge_nested_insert down to key `a` where the existing value is that
4331 // thunk. Before the fix, merge_nested_insert required BOTH sides to be
4332 // concrete Attrs, so the Thunk-vs-Attrs collision OVERWROTE — dropping
4333 // `x`. cppnix desugars both orderings into `o.a = { x = 1; y = 2; }`.
4334 // This is the M2.6 post-`with`-fix frontier (nixpkgs alsa's
4335 // `options.hardware.alsa = { … }` + `options.hardware.alsa.enablePersistence
4336 // = …` merged to only {enablePersistence} → `cardAliases` "does not exist").
4337 #[test]
4338 fn dotted_fullset_leaf_deep_merges_with_deeper_sibling() {
4339 let v = ev(r#"{ o.a = { x = 1; }; o.a.y = 2; }.o.a"#);
4340 if let Value::Attrs(a) = force_value(&v).unwrap() {
4341 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4342 assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4343 } else {
4344 panic!("expected attrs");
4345 }
4346 }
4347
4348 #[test]
4349 fn dotted_fullset_leaf_deep_merge_reverse_order() {
4350 // Deeper sibling FIRST, full-set leaf SECOND — the NEW value is the
4351 // `<thunk {x=1}>`; must still merge (the collision forces it).
4352 let v = ev(r#"{ o.a.y = 2; o.a = { x = 1; }; }.o.a"#);
4353 if let Value::Attrs(a) = force_value(&v).unwrap() {
4354 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4355 assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4356 } else {
4357 panic!("expected attrs");
4358 }
4359 }
4360
4361 #[test]
4362 fn dotted_fullset_leaf_merge_preserves_leaf_laziness() {
4363 // The merge forces the existing/new leaf to WHNF (keys) but MUST
4364 // NOT force the leaf VALUES — a throwing sibling value that is never
4365 // demanded stays lazy.
4366 assert_eq!(ev(r#"{ o.a = { x = throw "X-NEVER"; }; o.a.y = 2; }.o.a.y"#), Value::Int(2));
4367 }
4368
4369 #[test]
4370 fn eval_nested_let() {
4371 assert_eq!(ev("let a = 1; b = let c = 2; in c; in a + b"), Value::Int(3));
4372 }
4373
4374 #[test]
4375 fn eval_lambda() {
4376 assert_eq!(ev("(x: x + 1) 41"), Value::Int(42));
4377 }
4378
4379 #[test]
4380 fn eval_lambda_multi_arg() {
4381 assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4382 }
4383
4384 #[test]
4385 fn eval_list() {
4386 let v = ev("[1 2 3]");
4387 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]));
4388 }
4389
4390 #[test]
4391 fn eval_list_concat() {
4392 let v = ev("[1 2] ++ [3 4]");
4393 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]));
4394 }
4395
4396 #[test]
4397 fn eval_attrset() {
4398 let v = ev("{ a = 1; b = 2; }");
4399 if let Value::Attrs(attrs) = v {
4400 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4401 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4402 } else {
4403 panic!("expected attrset");
4404 }
4405 }
4406
4407 #[test]
4408 fn eval_select() {
4409 assert_eq!(ev("{ a = 42; }.a"), Value::Int(42));
4410 }
4411
4412 #[test]
4413 fn eval_select_or() {
4414 assert_eq!(ev("{ a = 42; }.b or 0"), Value::Int(0));
4415 }
4416
4417 #[test]
4418 fn eval_has_attr() {
4419 assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
4420 assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
4421 }
4422
4423 #[test]
4424 fn eval_update() {
4425 let v = ev("{ a = 1; b = 2; } // { b = 3; c = 4; }");
4426 if let Value::Attrs(attrs) = v {
4427 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4428 assert_eq!(attrs.get("b"), Some(&Value::Int(3)));
4429 assert_eq!(attrs.get("c"), Some(&Value::Int(4)));
4430 } else {
4431 panic!("expected attrset");
4432 }
4433 }
4434
4435 #[test]
4436 fn eval_with() {
4437 assert_eq!(ev("with { x = 42; }; x"), Value::Int(42));
4438 }
4439
4440 #[test]
4441 fn eval_assert() {
4442 assert_eq!(ev("assert true; 42"), Value::Int(42));
4443 assert!(eval("assert false; 42").is_err());
4444 }
4445
4446 #[test]
4447 fn eval_formals() {
4448 assert_eq!(ev("({ a, b }: a + b) { a = 1; b = 2; }"), Value::Int(3));
4449 }
4450
4451 #[test]
4452 fn eval_formals_default() {
4453 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 1; }"), Value::Int(11));
4454 }
4455
4456 #[test]
4457 fn eval_formals_ellipsis() {
4458 assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; }"), Value::Int(1));
4459 }
4460
4461 #[test]
4462 fn eval_named_formals() {
4463 assert_eq!(ev("(args @ { a }: args.a) { a = 42; }"), Value::Int(42));
4464 }
4465
4466 #[test]
4467 fn eval_rec_attrset() {
4468 assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
4469 }
4470
4471 #[test]
4472 fn eval_negation() {
4473 assert_eq!(ev("-42"), Value::Int(-42));
4474 }
4475
4476 #[test]
4477 fn eval_float_arithmetic() {
4478 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4479 assert_eq!(ev("1 + 1.5"), Value::Float(2.5));
4480 }
4481
4482 #[test]
4483 fn eval_division_by_zero() {
4484 assert!(eval("1 / 0").is_err());
4485 }
4486
4487 #[test]
4488 fn eval_builtins_available() {
4489 assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
4490 assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
4491 }
4492
4493 #[test]
4494 fn eval_builtins_length() {
4495 assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4496 }
4497
4498 #[test]
4499 fn eval_builtins_head_tail() {
4500 assert_eq!(ev("builtins.head [1 2 3]"), Value::Int(1));
4501 assert_eq!(ev("builtins.length (builtins.tail [1 2 3])"), Value::Int(2));
4502 }
4503
4504 #[test]
4505 fn eval_builtins_add() {
4506 assert_eq!(ev("builtins.add 1 2"), Value::Int(3));
4507 }
4508
4509 #[test]
4510 fn eval_builtins_to_string() {
4511 assert_eq!(ev("builtins.toString 42"), Value::string("42"));
4512 }
4513
4514 #[test]
4515 fn eval_implication() {
4516 assert_eq!(ev("false -> true"), Value::Bool(true));
4517 assert_eq!(ev("true -> false"), Value::Bool(false));
4518 assert_eq!(ev("true -> true"), Value::Bool(true));
4519 }
4520
4521 // ── New tests ────────────────────────────────────────
4522
4523 #[test]
4524 fn eval_error_undefined_variable() {
4525 let result = eval("nonexistent");
4526 assert!(result.is_err());
4527 let msg = format!("{}", result.unwrap_err());
4528 assert!(msg.contains("undefined variable"));
4529 }
4530
4531 #[test]
4532 fn eval_error_type_mismatch_arithmetic() {
4533 let result = eval(r#"1 + "hello""#);
4534 assert!(result.is_err());
4535 let msg = format!("{}", result.unwrap_err());
4536 assert!(msg.contains("cannot add") || msg.contains("type"));
4537 }
4538
4539 #[test]
4540 fn eval_error_unexpected_argument() {
4541 let result = eval("({ a }: a) { a = 1; b = 2; }");
4542 assert!(result.is_err());
4543 let msg = format!("{}", result.unwrap_err());
4544 assert!(msg.contains("unexpected argument"));
4545 }
4546
4547 #[test]
4548 fn eval_error_missing_required_argument() {
4549 let result = eval("({ a, b }: a + b) { a = 1; }");
4550 assert!(result.is_err());
4551 let msg = format!("{}", result.unwrap_err());
4552 assert!(msg.contains("missing argument"));
4553 }
4554
4555 #[test]
4556 fn eval_builtins_attr_names_sorted() {
4557 let v = ev("builtins.attrNames { z = 1; a = 2; m = 3; }");
4558 // BTreeMap keys are already sorted
4559 assert_eq!(
4560 v,
4561 Value::list(vec![
4562 Value::string("a"),
4563 Value::string("m"),
4564 Value::string("z"),
4565 ]),
4566 );
4567 }
4568
4569 #[test]
4570 fn eval_builtins_attr_values() {
4571 let v = ev("builtins.attrValues { a = 1; b = 2; }");
4572 // BTreeMap iteration is sorted by key, so a=1 first, b=2 second
4573 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
4574 }
4575
4576 #[test]
4577 fn eval_builtins_is_null() {
4578 assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
4579 assert_eq!(ev("builtins.isNull 1"), Value::Bool(false));
4580 }
4581
4582 #[test]
4583 fn eval_builtins_is_int() {
4584 assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
4585 assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
4586 }
4587
4588 #[test]
4589 fn eval_builtins_is_bool() {
4590 assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
4591 assert_eq!(ev("builtins.isBool 0"), Value::Bool(false));
4592 }
4593
4594 #[test]
4595 fn eval_builtins_is_string() {
4596 assert_eq!(ev(r#"builtins.isString "hi""#), Value::Bool(true));
4597 assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
4598 }
4599
4600 #[test]
4601 fn eval_builtins_is_list() {
4602 assert_eq!(ev("builtins.isList [1 2]"), Value::Bool(true));
4603 assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
4604 }
4605
4606 #[test]
4607 fn eval_builtins_is_attrs() {
4608 assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
4609 assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
4610 }
4611
4612 #[test]
4613 fn eval_builtins_string_length() {
4614 assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
4615 assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
4616 }
4617
4618 #[test]
4619 fn eval_builtins_to_json_roundtrip() {
4620 // toJSON produces a JSON string; fromJSON parses it back
4621 assert_eq!(
4622 ev(r#"builtins.fromJSON (builtins.toJSON 42)"#),
4623 Value::Int(42),
4624 );
4625 assert_eq!(
4626 ev(r#"builtins.fromJSON (builtins.toJSON [1 2 3])"#),
4627 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4628 );
4629 }
4630
4631 #[test]
4632 fn eval_builtins_from_json() {
4633 assert_eq!(
4634 ev(r#"builtins.fromJSON "{\"a\": 1}""#),
4635 {
4636 let mut attrs = NixAttrs::new();
4637 attrs.insert("a".to_string(), Value::Int(1));
4638 Value::Attrs(Rc::new(attrs))
4639 },
4640 );
4641 assert_eq!(ev(r#"builtins.fromJSON "null""#), Value::Null);
4642 assert_eq!(ev(r#"builtins.fromJSON "true""#), Value::Bool(true));
4643 }
4644
4645 #[test]
4646 fn eval_nested_function_application() {
4647 // (f 1) 2 where f = x: y: x + y
4648 assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4649 // equivalent parenthesized form
4650 assert_eq!(ev("((x: y: x + y) 1) 2"), Value::Int(3));
4651 }
4652
4653 #[test]
4654 fn eval_recursive_let() {
4655 assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4656 assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4657 }
4658
4659 #[test]
4660 fn eval_string_comparison() {
4661 assert_eq!(ev(r#""a" < "b""#), Value::Bool(true));
4662 assert_eq!(ev(r#""b" < "a""#), Value::Bool(false));
4663 assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4664 assert_eq!(ev(r#""abc" != "def""#), Value::Bool(true));
4665 }
4666
4667 #[test]
4668 fn eval_list_in_attrset() {
4669 let v = ev("{ x = [1 2 3]; }.x");
4670 assert_eq!(
4671 v,
4672 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4673 );
4674 }
4675
4676 #[test]
4677 fn eval_nested_attrset_select() {
4678 assert_eq!(ev("{ a = { b = 42; }; }.a.b"), Value::Int(42));
4679 }
4680
4681 #[test]
4682 fn eval_let_shadows_outer() {
4683 assert_eq!(
4684 ev("let x = 1; in let x = 2; in x"),
4685 Value::Int(2),
4686 );
4687 }
4688
4689 #[test]
4690 fn eval_with_provides_scope() {
4691 // `with` scope is available for name resolution
4692 assert_eq!(
4693 ev("with { x = 42; y = 10; }; x + y"),
4694 Value::Int(52),
4695 );
4696 }
4697
4698 #[test]
4699 fn eval_list_equality() {
4700 assert_eq!(ev("[1 2] == [1 2]"), Value::Bool(true));
4701 assert_eq!(ev("[1 2] == [1 3]"), Value::Bool(false));
4702 }
4703
4704 #[test]
4705 fn eval_attrset_equality() {
4706 assert_eq!(ev("{ a = 1; } == { a = 1; }"), Value::Bool(true));
4707 assert_eq!(ev("{ a = 1; } == { a = 2; }"), Value::Bool(false));
4708 }
4709
4710 // ═══════════════════════════════════════════════════════════
4711 // 1. LITERAL TYPES
4712 // ═══════════════════════════════════════════════════════════
4713
4714 #[test]
4715 fn literal_int_large_zero_negative() {
4716 // Large positive integer (within i64 range)
4717 assert_eq!(ev("9223372036854775807"), Value::Int(i64::MAX));
4718 // Zero
4719 assert_eq!(ev("0"), Value::Int(0));
4720 // Negative via unary negate
4721 assert_eq!(ev("-1"), Value::Int(-1));
4722 assert_eq!(ev("-999999"), Value::Int(-999999));
4723 }
4724
4725 #[test]
4726 fn literal_float_small_large() {
4727 assert_eq!(ev("0.001"), Value::Float(0.001));
4728 assert_eq!(ev("999999.999"), Value::Float(999999.999));
4729 // Float with scientific notation via expression (1e6 parsed by rnix)
4730 assert_eq!(ev("1.0e3"), Value::Float(1000.0));
4731 assert_eq!(ev("1.5e2"), Value::Float(150.0));
4732 }
4733
4734 #[test]
4735 fn literal_string_empty_and_escapes() {
4736 assert_eq!(ev(r#""""#), Value::string(""));
4737 // Escape sequences within strings
4738 assert_eq!(ev(r#""hello\nworld""#), Value::string("hello\nworld"));
4739 assert_eq!(ev(r#""tab\there""#), Value::string("tab\there"));
4740 }
4741
4742 #[test]
4743 fn literal_multiline_string() {
4744 // Indented string ('' ... '')
4745 assert_eq!(
4746 ev("''hello''"),
4747 Value::string("hello"),
4748 );
4749 // Multiline indented string strips common indentation
4750 assert_eq!(
4751 ev("''\n line1\n line2\n''"),
4752 Value::string("line1\nline2\n"),
4753 );
4754 }
4755
4756 #[test]
4757 fn literal_paths() {
4758 // Relative path
4759 assert_eq!(ev("./foo"), Value::Path(Box::new(SmolStr::from("./foo"))));
4760 // Absolute path
4761 assert_eq!(ev("/nix/store/abc"), Value::Path(Box::new(SmolStr::from("/nix/store/abc"))));
4762 // Home path
4763 assert_eq!(ev("~/myfile"), Value::Path(Box::new(SmolStr::from("~/myfile"))));
4764 }
4765
4766 // ── Interpolated path literals (cid-marquee root, 2026-07-12) ──
4767 //
4768 // CppNix path literals may contain `${e}` antiquotations: `./${x}.nix`,
4769 // `/a/${e}`, `~/${e}`. sui previously flattened the whole path token to
4770 // raw text and dropped the interpolation (`import ./${x}.nix` →
4771 // `No such file or directory`). The `${e}` must be evaluated,
4772 // string-coerced (plain, no copy-to-store), spliced, and the result is
4773 // still a `path` value. Oracles taken from cppnix.
4774
4775 #[test]
4776 fn interp_path_abs_splices_and_types_path() {
4777 // /a/${x}/b with x="foo" → /a/foo/b, type path (nix oracle).
4778 let v = ev(r#"let x = "foo"; in /a/${x}/b"#);
4779 assert_eq!(v, Value::Path(Box::new(SmolStr::from("/a/foo/b"))));
4780 }
4781
4782 #[test]
4783 fn interp_path_abs_multi_and_slash_in_value() {
4784 // Multiple interpolations + a slash inside the spliced value.
4785 assert_eq!(
4786 ev(r#"let a = "x"; b = "y/z"; in /p/${a}/${b}.nix"#),
4787 Value::Path(Box::new(SmolStr::from("/p/x/y/z.nix"))),
4788 );
4789 }
4790
4791 #[test]
4792 fn interp_path_abs_normalizes_double_slash_seam() {
4793 // A path-typed interpolation splices the raw path (no copy-to-store)
4794 // and the `/` seam is normalized: `/bar/` + `/tmp/foo` → /bar/tmp/foo.
4795 assert_eq!(
4796 ev(r#"/bar/${/tmp/foo}"#),
4797 Value::Path(Box::new(SmolStr::from("/bar/tmp/foo"))),
4798 );
4799 }
4800
4801 #[test]
4802 fn interp_path_rel_resolves_against_eval_dir() {
4803 // The spicetify `map (x: ./${x}.nix) [...]` root: a relative
4804 // interpolated path resolves against the defining file's directory,
4805 // exactly like a plain `./foo.nix` literal.
4806 let _g = push_eval_file(std::path::PathBuf::from("/tmp/example/default.nix"));
4807 assert_eq!(
4808 ev(r#"let x = "foo"; in ./${x}.nix"#),
4809 Value::Path(Box::new(SmolStr::from("/tmp/example/foo.nix"))),
4810 );
4811 }
4812
4813 #[test]
4814 fn interp_path_rel_no_eval_dir_keeps_relative_text() {
4815 // With no eval-file context the plain branch keeps the raw relative
4816 // text; the interpolated branch splices then does the same.
4817 assert_eq!(
4818 ev(r#"let x = "foo"; in ./${x}.nix"#),
4819 Value::Path(Box::new(SmolStr::from("./foo.nix"))),
4820 );
4821 }
4822
4823 #[test]
4824 fn interp_path_home_splices_leading_tilde_preserved() {
4825 // Home paths splice their `${e}`; the leading `~` is carried as-is
4826 // (matching sui's plain `~/foo` behavior — `~`-expansion is a
4827 // separate, pre-existing concern, not introduced here).
4828 assert_eq!(
4829 ev(r#"let x = "foo"; in ~/${x}/bar"#),
4830 Value::Path(Box::new(SmolStr::from("~/foo/bar"))),
4831 );
4832 }
4833
4834 #[test]
4835 fn interp_path_non_interpolated_still_raw() {
4836 // A path with no `${…}` must keep the trivial raw-text shortcut
4837 // (byte-for-byte identical to the plain branch).
4838 assert_eq!(ev("/a/b/c"), Value::Path(Box::new(SmolStr::from("/a/b/c"))));
4839 assert_eq!(ev("~/plain"), Value::Path(Box::new(SmolStr::from("~/plain"))));
4840 }
4841
4842 #[test]
4843 fn literal_null_true_false_standalone() {
4844 assert_eq!(ev("null"), Value::Null);
4845 assert_eq!(ev("true"), Value::Bool(true));
4846 assert_eq!(ev("false"), Value::Bool(false));
4847 }
4848
4849 // ═══════════════════════════════════════════════════════════
4850 // 2. OPERATORS — COMPLETE COVERAGE
4851 // ═══════════════════════════════════════════════════════════
4852
4853 #[test]
4854 fn op_arithmetic_int() {
4855 assert_eq!(ev("100 + 200"), Value::Int(300));
4856 assert_eq!(ev("50 - 30"), Value::Int(20));
4857 assert_eq!(ev("7 * 8"), Value::Int(56));
4858 assert_eq!(ev("17 / 3"), Value::Int(5)); // integer division
4859 }
4860
4861 #[test]
4862 fn op_arithmetic_float() {
4863 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4864 assert_eq!(ev("5.0 - 1.5"), Value::Float(3.5));
4865 assert_eq!(ev("2.0 * 3.0"), Value::Float(6.0));
4866 assert_eq!(ev("7.0 / 2.0"), Value::Float(3.5));
4867 }
4868
4869 #[test]
4870 fn op_arithmetic_mixed_int_float() {
4871 // int + float => float
4872 assert_eq!(ev("1 + 2.5"), Value::Float(3.5));
4873 assert_eq!(ev("2.5 + 1"), Value::Float(3.5));
4874 // int * float => float
4875 assert_eq!(ev("2 * 1.5"), Value::Float(3.0));
4876 // float - int => float
4877 assert_eq!(ev("5.5 - 2"), Value::Float(3.5));
4878 }
4879
4880 #[test]
4881 fn op_string_concat() {
4882 assert_eq!(ev(r#""foo" + "bar""#), Value::string("foobar"));
4883 assert_eq!(ev(r#""" + "x""#), Value::string("x"));
4884 assert_eq!(ev(r#""a" + "" + "b""#), Value::string("ab"));
4885 }
4886
4887 #[test]
4888 fn op_path_concat() {
4889 // path + string
4890 assert_eq!(ev(r#"./foo + "/bar""#), Value::Path(Box::new(SmolStr::from("./foo/bar"))));
4891 // path + path (should join with /)
4892 assert_eq!(ev("./a + ./b"), Value::Path(Box::new(SmolStr::from("./a/./b"))));
4893 }
4894
4895 #[test]
4896 fn op_comparison_ints() {
4897 assert_eq!(ev("1 < 2"), Value::Bool(true));
4898 assert_eq!(ev("2 < 1"), Value::Bool(false));
4899 assert_eq!(ev("2 > 1"), Value::Bool(true));
4900 assert_eq!(ev("1 > 2"), Value::Bool(false));
4901 assert_eq!(ev("2 <= 2"), Value::Bool(true));
4902 assert_eq!(ev("3 <= 2"), Value::Bool(false));
4903 assert_eq!(ev("2 >= 2"), Value::Bool(true));
4904 assert_eq!(ev("1 >= 2"), Value::Bool(false));
4905 }
4906
4907 #[test]
4908 fn op_comparison_floats() {
4909 assert_eq!(ev("1.5 < 2.5"), Value::Bool(true));
4910 assert_eq!(ev("2.5 > 1.5"), Value::Bool(true));
4911 assert_eq!(ev("1.5 <= 1.5"), Value::Bool(true));
4912 assert_eq!(ev("1.5 >= 1.5"), Value::Bool(true));
4913 }
4914
4915 #[test]
4916 fn op_comparison_strings() {
4917 assert_eq!(ev(r#""apple" < "banana""#), Value::Bool(true));
4918 assert_eq!(ev(r#""banana" > "apple""#), Value::Bool(true));
4919 assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4920 assert_eq!(ev(r#""abc" != "xyz""#), Value::Bool(true));
4921 assert_eq!(ev(r#""abc" <= "abd""#), Value::Bool(true));
4922 assert_eq!(ev(r#""abc" >= "abb""#), Value::Bool(true));
4923 }
4924
4925 #[test]
4926 fn op_equality_various_types() {
4927 assert_eq!(ev("null == null"), Value::Bool(true));
4928 assert_eq!(ev("true == true"), Value::Bool(true));
4929 assert_eq!(ev("false == false"), Value::Bool(true));
4930 assert_eq!(ev("true == false"), Value::Bool(false));
4931 assert_eq!(ev("1 == 1"), Value::Bool(true));
4932 assert_eq!(ev("1 != 2"), Value::Bool(true));
4933 // Different types are not equal
4934 assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
4935 assert_eq!(ev("null == false"), Value::Bool(false));
4936 }
4937
4938 #[test]
4939 fn op_logic_short_circuit() {
4940 // false && <error> should NOT evaluate the RHS
4941 assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
4942 // true || <error> should NOT evaluate the RHS
4943 assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
4944 }
4945
4946 #[test]
4947 fn op_logic_full() {
4948 assert_eq!(ev("true && true"), Value::Bool(true));
4949 assert_eq!(ev("true && false"), Value::Bool(false));
4950 assert_eq!(ev("false && true"), Value::Bool(false));
4951 assert_eq!(ev("false && false"), Value::Bool(false));
4952 assert_eq!(ev("true || true"), Value::Bool(true));
4953 assert_eq!(ev("true || false"), Value::Bool(true));
4954 assert_eq!(ev("false || true"), Value::Bool(true));
4955 assert_eq!(ev("false || false"), Value::Bool(false));
4956 assert_eq!(ev("!true"), Value::Bool(false));
4957 assert_eq!(ev("!false"), Value::Bool(true));
4958 }
4959
4960 #[test]
4961 fn op_implication_truth_table() {
4962 // false -> anything = true
4963 assert_eq!(ev("false -> false"), Value::Bool(true));
4964 assert_eq!(ev("false -> true"), Value::Bool(true));
4965 // true -> x = x
4966 assert_eq!(ev("true -> true"), Value::Bool(true));
4967 assert_eq!(ev("true -> false"), Value::Bool(false));
4968 }
4969
4970 #[test]
4971 fn op_implication_short_circuit() {
4972 // false -> <error> should NOT evaluate the RHS
4973 assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
4974 }
4975
4976 #[test]
4977 fn op_update_merge() {
4978 let v = ev("{ a = 1; } // { b = 2; }");
4979 if let Value::Attrs(attrs) = v {
4980 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4981 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4982 } else {
4983 panic!("expected attrs");
4984 }
4985 }
4986
4987 #[test]
4988 fn op_update_right_wins() {
4989 assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4990 }
4991
4992 #[test]
4993 fn op_list_concat() {
4994 assert_eq!(
4995 ev("[1 2] ++ [3 4]"),
4996 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]),
4997 );
4998 // Empty list concat
4999 assert_eq!(ev("[] ++ [1]"), Value::list(vec![Value::Int(1)]));
5000 assert_eq!(ev("[1] ++ []"), Value::list(vec![Value::Int(1)]));
5001 }
5002
5003 #[test]
5004 fn op_has_attr_present_and_absent() {
5005 assert_eq!(ev("{ x = 1; y = 2; } ? x"), Value::Bool(true));
5006 assert_eq!(ev("{ x = 1; } ? z"), Value::Bool(false));
5007 assert_eq!(ev("{} ? anything"), Value::Bool(false));
5008 }
5009
5010 #[test]
5011 fn op_unary_negate() {
5012 assert_eq!(ev("-42"), Value::Int(-42));
5013 assert_eq!(ev("-3.14"), Value::Float(-3.14));
5014 // Double negate
5015 assert_eq!(ev("- -5"), Value::Int(5));
5016 }
5017
5018 // ═══════════════════════════════════════════════════════════
5019 // 3. CONTROL FLOW
5020 // ═══════════════════════════════════════════════════════════
5021
5022 #[test]
5023 fn control_if_true_branch() {
5024 assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
5025 }
5026
5027 #[test]
5028 fn control_if_false_branch() {
5029 assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
5030 }
5031
5032 #[test]
5033 fn control_if_nested() {
5034 assert_eq!(
5035 ev("if true then (if false then 1 else 2) else 3"),
5036 Value::Int(2),
5037 );
5038 assert_eq!(
5039 ev("if false then 1 else (if true then 2 else 3)"),
5040 Value::Int(2),
5041 );
5042 }
5043
5044 #[test]
5045 fn control_assert_passing() {
5046 assert_eq!(ev("assert 1 == 1; 42"), Value::Int(42));
5047 assert_eq!(ev("assert true; true"), Value::Bool(true));
5048 }
5049
5050 #[test]
5051 fn control_assert_failing() {
5052 assert!(eval("assert false; 42").is_err());
5053 assert!(eval("assert 1 == 2; 42").is_err());
5054 }
5055
5056 #[test]
5057 fn control_with_basic_scope() {
5058 assert_eq!(ev("with { a = 1; b = 2; }; a + b"), Value::Int(3));
5059 }
5060
5061 #[test]
5062 fn control_with_lexical_precedence() {
5063 // let binding takes precedence over with scope
5064 assert_eq!(
5065 ev("let x = 10; in with { x = 99; }; x"),
5066 Value::Int(10),
5067 );
5068 }
5069
5070 #[test]
5071 fn control_with_nested() {
5072 assert_eq!(
5073 ev("with { a = 1; }; with { b = 2; }; a + b"),
5074 Value::Int(3),
5075 );
5076 }
5077
5078 #[test]
5079 fn control_with_lazy_fix_self() {
5080 // THE critical pattern that nixpkgs requires:
5081 // fix (self: with self; { a = 1; b = a + 1; })
5082 // Before the lazy-with fix, this would hit the blackhole detector
5083 // because `with` eagerly forced `self`.
5084 let result = eval(
5085 "let fix = f: let x = f x; in x; in fix (self: with self; { a = 1; b = a + 1; })"
5086 );
5087 assert!(result.is_ok(), "fix with self should work: {:?}", result);
5088 if let Ok(Value::Attrs(attrs)) = result {
5089 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5090 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5091 } else {
5092 panic!("expected Attrs, got {:?}", result);
5093 }
5094 }
5095
5096 #[test]
5097 fn control_with_lazy_fix_self_lib_pattern() {
5098 // The nixpkgs pattern: self-referential package set with lib.
5099 // Access via select to force through the thunk layer.
5100 let result = eval(r#"
5101 let fix = f: let x = f x; in x;
5102 in (fix (self: with self; {
5103 lib = { version = "1.0"; };
5104 hello = "hello ${lib.version}";
5105 })).hello
5106 "#);
5107 assert!(result.is_ok(), "nixpkgs-style lib pattern: {:?}", result);
5108 assert_eq!(
5109 result.unwrap(),
5110 Value::String(Rc::new(NixString::plain("hello 1.0"))),
5111 );
5112 }
5113
5114 #[test]
5115 fn control_with_non_attrset_errors() {
5116 // CppNix errors when with-scope is not an attrset and a lookup hits it
5117 let result = eval("with 42; 1");
5118 // The body `1` is a literal and doesn't look up anything in the
5119 // with-scope, so this should succeed (the scope is never forced).
5120 assert_eq!(result.unwrap(), Value::Int(1));
5121 }
5122
5123 #[test]
5124 fn control_with_non_attrset_lookup_falls_through() {
5125 // If the with scope is not an attrset, lookups should fall through
5126 // to outer scopes rather than crashing.
5127 let result = eval("let x = 1; in with 42; x");
5128 assert_eq!(result.unwrap(), Value::Int(1));
5129 }
5130
5131 #[test]
5132 fn control_let_simple_and_multiple() {
5133 assert_eq!(ev("let x = 5; in x"), Value::Int(5));
5134 assert_eq!(ev("let x = 1; y = 2; z = 3; in x + y + z"), Value::Int(6));
5135 }
5136
5137 #[test]
5138 fn control_let_shadow_outer() {
5139 assert_eq!(
5140 ev("let x = 1; in let x = 2; in x"),
5141 Value::Int(2),
5142 );
5143 }
5144
5145 #[test]
5146 fn control_let_recursive_reference() {
5147 assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
5148 assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
5149 }
5150
5151 #[test]
5152 fn control_nested_let_expression() {
5153 assert_eq!(
5154 ev("let a = let b = 1; in b; in a"),
5155 Value::Int(1),
5156 );
5157 assert_eq!(
5158 ev("let a = let b = 10; in b + 5; in a * 2"),
5159 Value::Int(30),
5160 );
5161 }
5162
5163 // ═══════════════════════════════════════════════════════════
5164 // 4. FUNCTIONS — COMPLETE COVERAGE
5165 // ═══════════════════════════════════════════════════════════
5166
5167 #[test]
5168 fn func_identity_lambda() {
5169 assert_eq!(ev("(x: x) 42"), Value::Int(42));
5170 assert_eq!(ev(r#"(x: x) "hello""#), Value::string("hello"));
5171 }
5172
5173 #[test]
5174 fn func_curried_two_args() {
5175 assert_eq!(ev("(x: y: x + y) 3 4"), Value::Int(7));
5176 }
5177
5178 #[test]
5179 fn func_curried_three_args() {
5180 assert_eq!(ev("(a: b: c: a + b + c) 1 2 3"), Value::Int(6));
5181 }
5182
5183 #[test]
5184 fn func_formals_basic() {
5185 assert_eq!(ev("({ a, b }: a + b) { a = 3; b = 7; }"), Value::Int(10));
5186 }
5187
5188 #[test]
5189 fn func_formals_with_defaults() {
5190 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; }"), Value::Int(15));
5191 // Providing the default-able argument overrides the default
5192 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; b = 20; }"), Value::Int(25));
5193 }
5194
5195 #[test]
5196 fn func_formals_with_ellipsis() {
5197 assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; c = 3; }"), Value::Int(1));
5198 }
5199
5200 #[test]
5201 fn func_named_formals_at_before() {
5202 // args @ { a, b }: ...
5203 assert_eq!(
5204 ev("(args @ { a, b }: args.a + args.b) { a = 3; b = 4; }"),
5205 Value::Int(7),
5206 );
5207 }
5208
5209 #[test]
5210 fn func_named_formals_at_after() {
5211 // { a, b } @ args: ...
5212 assert_eq!(
5213 ev("({ a, b } @ args: args.a + args.b) { a = 10; b = 20; }"),
5214 Value::Int(30),
5215 );
5216 }
5217
5218 #[test]
5219 fn func_nested_application() {
5220 // Explicit parenthesized application
5221 assert_eq!(ev("((x: y: x * y) 3) 4"), Value::Int(12));
5222 }
5223
5224 #[test]
5225 fn func_higher_order_map() {
5226 assert_eq!(
5227 ev("builtins.map (x: x * 2) [1 2 3]"),
5228 Value::list(vec![Value::Int(2), Value::Int(4), Value::Int(6)]),
5229 );
5230 }
5231
5232 #[test]
5233 fn func_higher_order_filter() {
5234 assert_eq!(
5235 ev("builtins.filter (x: x > 2) [1 2 3 4 5]"),
5236 Value::list(vec![Value::Int(3), Value::Int(4), Value::Int(5)]),
5237 );
5238 }
5239
5240 #[test]
5241 fn func_higher_order_foldl() {
5242 // Sum of list via foldl'
5243 assert_eq!(
5244 ev("builtins.foldl' (acc: x: acc + x) 0 [1 2 3 4]"),
5245 Value::Int(10),
5246 );
5247 }
5248
5249 #[test]
5250 fn func_as_attrset_value() {
5251 assert_eq!(
5252 ev("let s = { f = x: x + 1; }; in s.f 5"),
5253 Value::Int(6),
5254 );
5255 }
5256
5257 #[test]
5258 fn func_immediate_application() {
5259 assert_eq!(ev("(x: x * x) 7"), Value::Int(49));
5260 }
5261
5262 #[test]
5263 fn func_in_let_binding() {
5264 assert_eq!(
5265 ev("let double = x: x * 2; in double 21"),
5266 Value::Int(42),
5267 );
5268 }
5269
5270 // ═══════════════════════════════════════════════════════════
5271 // 5. ATTRIBUTE SETS — COMPLETE COVERAGE
5272 // ═══════════════════════════════════════════════════════════
5273
5274 #[test]
5275 fn attrs_empty_set() {
5276 let v = ev("{}");
5277 if let Value::Attrs(attrs) = v {
5278 assert!(attrs.is_empty());
5279 } else {
5280 panic!("expected attrs");
5281 }
5282 }
5283
5284 #[test]
5285 fn attrs_simple() {
5286 assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
5287 }
5288
5289 #[test]
5290 fn attrs_nested_access() {
5291 assert_eq!(ev("{ a = { b = { c = 42; }; }; }.a.b.c"), Value::Int(42));
5292 }
5293
5294 #[test]
5295 fn attrs_recursive_set() {
5296 assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
5297 }
5298
5299 #[test]
5300 fn attrs_update_disjoint() {
5301 let v = ev("{ a = 1; } // { b = 2; }");
5302 if let Value::Attrs(attrs) = v {
5303 assert_eq!(attrs.len(), 2);
5304 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5305 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5306 } else {
5307 panic!("expected attrs");
5308 }
5309 }
5310
5311 #[test]
5312 fn attrs_update_override() {
5313 assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
5314 }
5315
5316 #[test]
5317 fn attrs_has_attr_operator() {
5318 assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
5319 assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
5320 }
5321
5322 #[test]
5323 fn attrs_select_with_default() {
5324 assert_eq!(ev("{ a = 1; }.a or 99"), Value::Int(1));
5325 assert_eq!(ev("{}.missing or 99"), Value::Int(99));
5326 assert_eq!(ev("{ a = 1; }.b or 42"), Value::Int(42));
5327 }
5328
5329 #[test]
5330 fn attrs_nested_attr_path_in_binding() {
5331 // { a.b = 1; } creates { a = { b = 1; }; }
5332 assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
5333 }
5334
5335 #[test]
5336 fn attrs_inherit_from_scope() {
5337 assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.x"), Value::Int(1));
5338 assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.y"), Value::Int(2));
5339 }
5340
5341 #[test]
5342 fn attrs_inherit_from_expr() {
5343 assert_eq!(
5344 ev("{ inherit ({ a = 42; b = 10; }) a; }.a"),
5345 Value::Int(42),
5346 );
5347 }
5348
5349 #[test]
5350 fn attrs_dynamic_attr_name() {
5351 assert_eq!(
5352 ev(r#"let name = "x"; in { ${name} = 42; }.x"#),
5353 Value::Int(42),
5354 );
5355 }
5356
5357 #[test]
5358 fn attrs_attr_names_sorted() {
5359 assert_eq!(
5360 ev("builtins.attrNames { z = 1; m = 2; a = 3; }"),
5361 Value::list(vec![
5362 Value::string("a"),
5363 Value::string("m"),
5364 Value::string("z"),
5365 ]),
5366 );
5367 }
5368
5369 #[test]
5370 fn attrs_attr_values_follow_key_order() {
5371 // BTreeMap iteration order: a=1, b=2, c=3
5372 assert_eq!(
5373 ev("builtins.attrValues { c = 3; a = 1; b = 2; }"),
5374 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5375 );
5376 }
5377
5378 #[test]
5379 fn attrs_update_is_shallow() {
5380 // // is a shallow merge; nested attrs are replaced, not merged
5381 assert_eq!(
5382 ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a ? x"),
5383 Value::Bool(false),
5384 );
5385 assert_eq!(
5386 ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a.y"),
5387 Value::Int(2),
5388 );
5389 }
5390
5391 // ═══════════════════════════════════════════════════════════
5392 // 6. LISTS — COMPLETE COVERAGE
5393 // ═══════════════════════════════════════════════════════════
5394
5395 #[test]
5396 fn list_empty() {
5397 assert_eq!(ev("[]"), Value::list(vec![]));
5398 }
5399
5400 #[test]
5401 fn list_single_element() {
5402 assert_eq!(ev("[1]"), Value::list(vec![Value::Int(1)]));
5403 }
5404
5405 #[test]
5406 fn list_mixed_types() {
5407 assert_eq!(
5408 ev(r#"[1 "two" true null]"#),
5409 Value::list(vec![
5410 Value::Int(1),
5411 Value::string("two"),
5412 Value::Bool(true),
5413 Value::Null,
5414 ]),
5415 );
5416 }
5417
5418 #[test]
5419 fn list_nested() {
5420 assert_eq!(
5421 ev("[[1 2] [3 4]]"),
5422 Value::list(vec![
5423 Value::list(vec![Value::Int(1), Value::Int(2)]),
5424 Value::list(vec![Value::Int(3), Value::Int(4)]),
5425 ]),
5426 );
5427 }
5428
5429 #[test]
5430 fn list_concat_operator() {
5431 assert_eq!(
5432 ev("[1] ++ [2] ++ [3]"),
5433 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5434 );
5435 }
5436
5437 #[test]
5438 fn list_builtins_length() {
5439 assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
5440 assert_eq!(ev("builtins.length []"), Value::Int(0));
5441 }
5442
5443 #[test]
5444 fn list_builtins_elem_at() {
5445 assert_eq!(ev("builtins.elemAt [10 20 30] 0"), Value::Int(10));
5446 assert_eq!(ev("builtins.elemAt [10 20 30] 1"), Value::Int(20));
5447 assert_eq!(ev("builtins.elemAt [10 20 30] 2"), Value::Int(30));
5448 }
5449
5450 #[test]
5451 fn list_equality() {
5452 assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
5453 assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
5454 assert_eq!(ev("[] == []"), Value::Bool(true));
5455 }
5456
5457 // ═══════════════════════════════════════════════════════════
5458 // 7. STRING INTERPOLATION
5459 // ═══════════════════════════════════════════════════════════
5460
5461 #[test]
5462 fn interp_simple_variable() {
5463 assert_eq!(
5464 ev(r#"let name = "world"; in "hello ${name}""#),
5465 Value::string("hello world"),
5466 );
5467 }
5468
5469 #[test]
5470 fn interp_nested_expression() {
5471 assert_eq!(
5472 ev(r#""result: ${builtins.toString (1 + 2)}""#),
5473 Value::string("result: 3"),
5474 );
5475 }
5476
5477 #[test]
5478 fn interp_int_coercion() {
5479 // Ints are coerced to string in interpolation
5480 assert_eq!(
5481 ev(r#"let x = 42; in "count: ${builtins.toString x}""#),
5482 Value::string("count: 42"),
5483 );
5484 }
5485
5486 #[test]
5487 fn interp_multiple() {
5488 assert_eq!(
5489 ev(r#"let a = "foo"; b = "bar"; in "${a} and ${b}""#),
5490 Value::string("foo and bar"),
5491 );
5492 }
5493
5494 #[test]
5495 fn interp_in_let() {
5496 assert_eq!(
5497 ev(r#"let x = "world"; in "hello ${x}""#),
5498 Value::string("hello world"),
5499 );
5500 }
5501
5502 #[test]
5503 fn interp_empty_result() {
5504 assert_eq!(
5505 ev(r#"let x = ""; in "a${x}b""#),
5506 Value::string("ab"),
5507 );
5508 }
5509
5510 #[test]
5511 fn interp_path_in_string_context() {
5512 // CppNix string interpolation is copy-to-store coercion: a nonexistent
5513 // path errors "path '…' does not exist" (previously sui spliced the raw
5514 // relative path "./foo" verbatim, diverging from nix). The positive
5515 // copy-to-store case is byte-verified in
5516 // interp_path_copies_to_store_byte_matches_cppnix below.
5517 assert!(eval(r#""path: ${./foo-nonexistent-xyz}""#).is_err());
5518 }
5519
5520 #[test]
5521 fn interp_adjacent_interpolations() {
5522 assert_eq!(
5523 ev(r#"let a = "x"; b = "y"; in "${a}${b}""#),
5524 Value::string("xy"),
5525 );
5526 }
5527
5528 // ═══════════════════════════════════════════════════════════
5529 // 8. BUILTINS — VERIFY ALL MAJOR ONES
5530 // ═══════════════════════════════════════════════════════════
5531
5532 #[test]
5533 fn builtins_map_filter_foldl() {
5534 // map
5535 assert_eq!(
5536 ev("builtins.map (x: x + 10) [1 2 3]"),
5537 Value::list(vec![Value::Int(11), Value::Int(12), Value::Int(13)]),
5538 );
5539 // filter
5540 assert_eq!(
5541 ev("builtins.filter (x: x > 1) [1 2 3]"),
5542 Value::list(vec![Value::Int(2), Value::Int(3)]),
5543 );
5544 // foldl' — product
5545 assert_eq!(
5546 ev("builtins.foldl' (a: b: a * b) 1 [2 3 4]"),
5547 Value::Int(24),
5548 );
5549 }
5550
5551 #[test]
5552 fn builtins_map_attrs() {
5553 assert_eq!(
5554 ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).a"),
5555 Value::Int(2),
5556 );
5557 assert_eq!(
5558 ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).b"),
5559 Value::Int(4),
5560 );
5561 }
5562
5563 #[test]
5564 fn builtins_list_to_attrs() {
5565 assert_eq!(
5566 ev(r#"(builtins.listToAttrs [{ name = "x"; value = 1; } { name = "y"; value = 2; }]).x"#),
5567 Value::Int(1),
5568 );
5569 }
5570
5571 #[test]
5572 fn builtins_list_to_attrs_duplicate_key_first_wins() {
5573 // Nix `listToAttrs` keeps the FIRST occurrence of a duplicate `name`
5574 // (later duplicates are ignored). cppnix returns 1 here, not 2.
5575 // Byte-parity root (cid darwin): a Cargo.lock listing a crate twice
5576 // (registry entry then git entry of the same name+version) must
5577 // resolve to the FIRST source, so `substrate/lockfile-delta.nix`'s
5578 // `lockByKey` picks the registry crate exactly as nix does. Last-wins
5579 // silently switched the source to git and produced a structurally
5580 // different `rust_<crate>` derivation.
5581 assert_eq!(
5582 ev(r#"(builtins.listToAttrs [{ name = "k"; value = 1; } { name = "k"; value = 2; }]).k"#),
5583 Value::Int(1),
5584 );
5585 }
5586
5587 #[test]
5588 fn builtins_concat_map() {
5589 assert_eq!(
5590 ev("builtins.concatMap (x: [x (x * 2)]) [1 2 3]"),
5591 Value::list(vec![
5592 Value::Int(1), Value::Int(2),
5593 Value::Int(2), Value::Int(4),
5594 Value::Int(3), Value::Int(6),
5595 ]),
5596 );
5597 }
5598
5599 #[test]
5600 fn builtins_concat_lists() {
5601 assert_eq!(
5602 ev("builtins.concatLists [[1 2] [3] [4 5]]"),
5603 Value::list(vec![
5604 Value::Int(1), Value::Int(2), Value::Int(3),
5605 Value::Int(4), Value::Int(5),
5606 ]),
5607 );
5608 }
5609
5610 #[test]
5611 fn builtins_concat_strings_sep() {
5612 assert_eq!(
5613 ev(r#"builtins.concatStringsSep ", " ["a" "b" "c"]"#),
5614 Value::string("a, b, c"),
5615 );
5616 assert_eq!(
5617 ev(r#"builtins.concatStringsSep "" ["x" "y"]"#),
5618 Value::string("xy"),
5619 );
5620 }
5621
5622 #[test]
5623 fn builtins_replace_strings() {
5624 assert_eq!(
5625 ev(r#"builtins.replaceStrings ["o"] ["0"] "foobar""#),
5626 Value::string("f00bar"),
5627 );
5628 assert_eq!(
5629 ev(r#"builtins.replaceStrings ["hello"] ["goodbye"] "hello world""#),
5630 Value::string("goodbye world"),
5631 );
5632 }
5633
5634 /// `hasPrefix`/`hasSuffix` are nixpkgs `lib.strings` functions, NOT CppNix
5635 /// builtins — so sui must not have them either. This test used to assert
5636 /// they worked; it now asserts they are absent, which is the same test
5637 /// pointed the correct way.
5638 #[test]
5639 fn builtins_has_prefix_has_suffix_are_not_builtins() {
5640 assert_eq!(ev(r#"builtins ? hasPrefix"#), Value::Bool(false));
5641 assert_eq!(ev(r#"builtins ? hasSuffix"#), Value::Bool(false));
5642 assert!(
5643 eval(r#"builtins.hasPrefix "he" "hello""#).is_err(),
5644 "builtins.hasPrefix must fail the way real nix fails it"
5645 );
5646 assert!(
5647 eval(r#"builtins.hasSuffix "lo" "hello""#).is_err(),
5648 "builtins.hasSuffix must fail the way real nix fails it"
5649 );
5650 }
5651
5652 #[test]
5653 fn builtins_all_any() {
5654 assert_eq!(ev("builtins.all (x: x > 0) [1 2 3]"), Value::Bool(true));
5655 assert_eq!(ev("builtins.all (x: x > 1) [1 2 3]"), Value::Bool(false));
5656 assert_eq!(ev("builtins.any (x: x > 2) [1 2 3]"), Value::Bool(true));
5657 assert_eq!(ev("builtins.any (x: x > 5) [1 2 3]"), Value::Bool(false));
5658 }
5659
5660 #[test]
5661 fn builtins_sort() {
5662 assert_eq!(
5663 ev("builtins.sort (a: b: a < b) [3 1 2]"),
5664 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5665 );
5666 }
5667
5668 #[test]
5669 fn builtins_remove_attrs() {
5670 let v = ev(r#"builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b" "c"]"#);
5671 if let Value::Attrs(attrs) = v {
5672 assert_eq!(attrs.len(), 1);
5673 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5674 assert!(attrs.get("b").is_none());
5675 } else {
5676 panic!("expected attrs");
5677 }
5678 }
5679
5680 #[test]
5681 fn builtins_intersect_attrs() {
5682 let v = ev("builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }");
5683 if let Value::Attrs(attrs) = v {
5684 assert_eq!(attrs.len(), 1);
5685 // intersectAttrs returns values from the second set
5686 assert_eq!(attrs.get("b"), Some(&Value::Int(20)));
5687 } else {
5688 panic!("expected attrs");
5689 }
5690 }
5691
5692 #[test]
5693 fn builtins_type_of_all_types() {
5694 assert_eq!(ev("builtins.typeOf null"), Value::string("null"));
5695 assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
5696 assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
5697 assert_eq!(ev("builtins.typeOf 3.14"), Value::string("float"));
5698 assert_eq!(ev(r#"builtins.typeOf "hi""#), Value::string("string"));
5699 assert_eq!(ev("builtins.typeOf [1]"), Value::string("list"));
5700 assert_eq!(ev("builtins.typeOf {}"), Value::string("set"));
5701 assert_eq!(ev("builtins.typeOf (x: x)"), Value::string("lambda"));
5702 }
5703
5704 #[test]
5705 fn builtins_is_type_checks() {
5706 assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
5707 assert_eq!(ev("builtins.isNull 0"), Value::Bool(false));
5708 assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
5709 assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
5710 assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
5711 assert_eq!(ev("builtins.isBool 1"), Value::Bool(false));
5712 assert_eq!(ev(r#"builtins.isString "x""#), Value::Bool(true));
5713 assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
5714 assert_eq!(ev("builtins.isList []"), Value::Bool(true));
5715 assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
5716 assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
5717 assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
5718 assert_eq!(ev("builtins.isFunction (x: x)"), Value::Bool(true));
5719 assert_eq!(ev("builtins.isFunction 1"), Value::Bool(false));
5720 assert_eq!(ev("builtins.isFloat 3.14"), Value::Bool(true));
5721 assert_eq!(ev("builtins.isFloat 1"), Value::Bool(false));
5722 }
5723
5724 #[test]
5725 fn builtins_to_json_from_json_roundtrip() {
5726 // int roundtrip
5727 assert_eq!(ev("builtins.fromJSON (builtins.toJSON 42)"), Value::Int(42));
5728 // string roundtrip
5729 assert_eq!(
5730 ev(r#"builtins.fromJSON (builtins.toJSON "hello")"#),
5731 Value::string("hello"),
5732 );
5733 // list roundtrip
5734 assert_eq!(
5735 ev("builtins.fromJSON (builtins.toJSON [1 2 3])"),
5736 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5737 );
5738 // null roundtrip
5739 assert_eq!(ev("builtins.fromJSON (builtins.toJSON null)"), Value::Null);
5740 // bool roundtrip
5741 assert_eq!(ev("builtins.fromJSON (builtins.toJSON true)"), Value::Bool(true));
5742 }
5743
5744 #[test]
5745 fn builtins_to_string_various() {
5746 assert_eq!(ev("builtins.toString 42"), Value::string("42"));
5747 assert_eq!(ev("builtins.toString true"), Value::string("1"));
5748 assert_eq!(ev("builtins.toString false"), Value::string(""));
5749 assert_eq!(ev("builtins.toString null"), Value::string(""));
5750 assert_eq!(ev(r#"builtins.toString "hello""#), Value::string("hello"));
5751 }
5752
5753 #[test]
5754 fn builtins_function_args() {
5755 let v = ev("builtins.functionArgs ({ a, b ? 1 }: a)");
5756 if let Value::Attrs(attrs) = v {
5757 assert_eq!(attrs.get("a"), Some(&Value::Bool(false))); // no default
5758 assert_eq!(attrs.get("b"), Some(&Value::Bool(true))); // has default
5759 } else {
5760 panic!("expected attrs");
5761 }
5762 }
5763
5764 #[test]
5765 fn builtins_gen_list() {
5766 assert_eq!(
5767 ev("builtins.genList (x: x * x) 5"),
5768 Value::list(vec![
5769 Value::Int(0), Value::Int(1), Value::Int(4),
5770 Value::Int(9), Value::Int(16),
5771 ]),
5772 );
5773 assert_eq!(ev("builtins.genList (x: x) 0"), Value::list(vec![]));
5774 }
5775
5776 #[test]
5777 fn builtins_elem() {
5778 assert_eq!(ev("builtins.elem 2 [1 2 3]"), Value::Bool(true));
5779 assert_eq!(ev("builtins.elem 5 [1 2 3]"), Value::Bool(false));
5780 assert_eq!(ev("builtins.elem 1 []"), Value::Bool(false));
5781 }
5782
5783 #[test]
5784 fn builtins_head_tail() {
5785 assert_eq!(ev("builtins.head [10 20 30]"), Value::Int(10));
5786 assert_eq!(
5787 ev("builtins.tail [10 20 30]"),
5788 Value::list(vec![Value::Int(20), Value::Int(30)]),
5789 );
5790 }
5791
5792 #[test]
5793 fn builtins_string_length() {
5794 assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
5795 assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
5796 assert_eq!(ev(r#"builtins.stringLength "abc def""#), Value::Int(7));
5797 }
5798
5799 #[test]
5800 fn builtins_ceil_floor() {
5801 assert_eq!(ev("builtins.ceil 2.3"), Value::Int(3));
5802 assert_eq!(ev("builtins.ceil 2.0"), Value::Int(2));
5803 assert_eq!(ev("builtins.floor 2.9"), Value::Int(2));
5804 assert_eq!(ev("builtins.floor 2.0"), Value::Int(2));
5805 // Int coercion: ceil/floor on int should work via to_float()
5806 assert_eq!(ev("builtins.ceil 5"), Value::Int(5));
5807 assert_eq!(ev("builtins.floor 5"), Value::Int(5));
5808 }
5809
5810 #[test]
5811 fn builtins_try_eval() {
5812 let v = ev("builtins.tryEval 42");
5813 if let Value::Attrs(attrs) = v {
5814 assert_eq!(attrs.get("success"), Some(&Value::Bool(true)));
5815 assert_eq!(attrs.get("value"), Some(&Value::Int(42)));
5816 } else {
5817 panic!("expected attrs");
5818 }
5819 }
5820
5821 #[test]
5822 fn builtins_throw() {
5823 let result = eval(r#"builtins.throw "oops""#);
5824 assert!(result.is_err());
5825 let msg = format!("{}", result.unwrap_err());
5826 assert!(msg.contains("oops"));
5827 }
5828
5829 #[test]
5830 fn builtins_seq_deep_seq() {
5831 // seq forces first arg, returns second
5832 assert_eq!(ev("builtins.seq 1 42"), Value::Int(42));
5833 // deepSeq similarly
5834 assert_eq!(ev("builtins.deepSeq [1 2 3] 99"), Value::Int(99));
5835 }
5836
5837 #[test]
5838 fn builtins_current_system() {
5839 let v = ev("builtins.currentSystem");
5840 if let Value::String(ns) = v {
5841 let s = &ns.chars;
5842 // Should be a valid system string
5843 assert!(
5844 s == "aarch64-darwin"
5845 || s == "x86_64-darwin"
5846 || s == "aarch64-linux"
5847 || s == "x86_64-linux",
5848 "unexpected system: {s}",
5849 );
5850 } else {
5851 panic!("expected string");
5852 }
5853 }
5854
5855 // ═══════════════════════════════════════════════════════════
5856 // 9. REAL-WORLD NIXPKGS PATTERNS
5857 // ═══════════════════════════════════════════════════════════
5858
5859 #[test]
5860 fn pattern_mkif_like() {
5861 // lib.mkIf pattern: if condition then { key = value; } else {}
5862 assert_eq!(
5863 ev("(if true then { x = 1; } else {}).x"),
5864 Value::Int(1),
5865 );
5866 let v = ev("if false then { x = 1; } else {}");
5867 if let Value::Attrs(attrs) = v {
5868 assert!(attrs.is_empty());
5869 } else {
5870 panic!("expected attrs");
5871 }
5872 }
5873
5874 #[test]
5875 fn pattern_optional_attrs() {
5876 // lib.optionalAttrs pattern
5877 assert_eq!(
5878 ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in (optionalAttrs true { a = 1; }).a"),
5879 Value::Int(1),
5880 );
5881 let v = ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in optionalAttrs false { a = 1; }");
5882 if let Value::Attrs(attrs) = v {
5883 assert!(attrs.is_empty());
5884 } else {
5885 panic!("expected attrs");
5886 }
5887 }
5888
5889 #[test]
5890 fn pattern_filter_attrs_via_remove() {
5891 // lib.filterAttrs pattern via removeAttrs
5892 assert_eq!(
5893 ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]).a"#),
5894 Value::Int(1),
5895 );
5896 assert_eq!(
5897 ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]) ? b"#),
5898 Value::Bool(false),
5899 );
5900 }
5901
5902 #[test]
5903 fn pattern_override() {
5904 // default // overrides pattern
5905 let v = ev(r#"
5906 let
5907 defaults = { debug = false; port = 8080; host = "localhost"; };
5908 overrides = { debug = true; port = 9090; };
5909 in defaults // overrides
5910 "#);
5911 if let Value::Attrs(attrs) = v {
5912 assert_eq!(attrs.get("debug"), Some(&Value::Bool(true)));
5913 assert_eq!(attrs.get("port"), Some(&Value::Int(9090)));
5914 assert_eq!(attrs.get("host"), Some(&Value::string("localhost")));
5915 } else {
5916 panic!("expected attrs");
5917 }
5918 }
5919
5920 #[test]
5921 fn pattern_functor() {
5922 // { __functor = self: x: self.value + x; value = 10; } 5
5923 assert_eq!(
5924 ev("let s = { __functor = self: x: self.value + x; value = 10; }; in s 5"),
5925 Value::Int(15),
5926 );
5927 }
5928
5929 #[test]
5930 fn pattern_platform_check() {
5931 // Check pattern: if builtins.currentSystem == "..." then ... else ...
5932 let v = ev(r#"if builtins.currentSystem == "aarch64-darwin" then "arm" else "other""#);
5933 // We just verify it evaluates without error and produces a string
5934 if let Value::String(_) = v {
5935 // ok
5936 } else {
5937 panic!("expected string");
5938 }
5939 }
5940
5941 #[test]
5942 fn pattern_recursive_overlay_lambda_structure() {
5943 // Test the lambda structure of an overlay (self: super: { ... })
5944 let v = ev("let overlay = self: super: { pkg = 42; }; in overlay {} {}");
5945 if let Value::Attrs(attrs) = v {
5946 assert_eq!(attrs.get("pkg"), Some(&Value::Int(42)));
5947 } else {
5948 panic!("expected attrs");
5949 }
5950 }
5951
5952 #[test]
5953 fn pattern_call_package_simplified() {
5954 // Simplified callPackage: f: f { inherit lib; }
5955 assert_eq!(
5956 ev("let callPkg = f: f { lib = { id = x: x; }; }; lib = { id = x: x; }; in callPkg ({ lib }: lib.id 42)"),
5957 Value::Int(42),
5958 );
5959 }
5960
5961 #[test]
5962 fn pattern_derivation_like_attrset() {
5963 let v = ev(r#"{ type = "derivation"; name = "hello"; system = builtins.currentSystem; builder = "/bin/sh"; }"#);
5964 if let Value::Attrs(attrs) = v {
5965 assert_eq!(attrs.get("type"), Some(&Value::string("derivation")));
5966 assert_eq!(attrs.get("name"), Some(&Value::string("hello")));
5967 assert_eq!(attrs.get("builder"), Some(&Value::string("/bin/sh")));
5968 // system should be a string (may be a thunk that forces to string)
5969 let system = force_value(attrs.get("system").unwrap()).unwrap();
5970 assert!(matches!(system, Value::String(_)), "expected string, got {system:?}");
5971 } else {
5972 panic!("expected attrs");
5973 }
5974 }
5975
5976 #[test]
5977 fn pattern_module_system_simplified() {
5978 // Simplified NixOS module evaluation
5979 assert_eq!(
5980 ev(r#"
5981 let
5982 eval = m: m { config = {}; lib = { mkDefault = x: x; }; };
5983 in eval ({ config, lib }: { result = lib.mkDefault 42; })
5984 "#),
5985 {
5986 let mut attrs = NixAttrs::new();
5987 attrs.insert("result".to_string(), Value::Int(42));
5988 Value::Attrs(Rc::new(attrs))
5989 },
5990 );
5991 }
5992
5993 // ═══════════════════════════════════════════════════════════
5994 // 10. ERROR HANDLING
5995 // ═══════════════════════════════════════════════════════════
5996
5997 #[test]
5998 fn error_undefined_variable() {
5999 let result = eval("nonexistent_var");
6000 assert!(result.is_err());
6001 let msg = format!("{}", result.unwrap_err());
6002 assert!(msg.contains("undefined variable") || msg.contains("nonexistent_var"));
6003 }
6004
6005 #[test]
6006 fn error_type_mismatch_arithmetic() {
6007 let result = eval(r#"1 + "hello""#);
6008 assert!(result.is_err());
6009 }
6010
6011 #[test]
6012 fn error_missing_attribute() {
6013 let result = eval("{}.nonexistent");
6014 assert!(result.is_err());
6015 let msg = format!("{}", result.unwrap_err());
6016 assert!(msg.contains("nonexistent") || msg.contains("not found"));
6017 }
6018
6019 #[test]
6020 fn error_division_by_zero() {
6021 assert!(eval("1 / 0").is_err());
6022 assert!(eval("100 / 0").is_err());
6023 }
6024
6025 #[test]
6026 fn error_missing_required_function_arg() {
6027 let result = eval("({ a, b }: a + b) { a = 1; }");
6028 assert!(result.is_err());
6029 let msg = format!("{}", result.unwrap_err());
6030 assert!(msg.contains("missing argument"));
6031 }
6032
6033 #[test]
6034 fn error_unexpected_function_arg() {
6035 let result = eval("({ a }: a) { a = 1; b = 2; }");
6036 assert!(result.is_err());
6037 let msg = format!("{}", result.unwrap_err());
6038 assert!(msg.contains("unexpected argument"));
6039 }
6040
6041 #[test]
6042 fn error_assertion_failure() {
6043 assert!(eval("assert false; 1").is_err());
6044 assert!(eval("assert 1 == 2; 1").is_err());
6045 }
6046
6047 #[test]
6048 fn error_infinite_recursion() {
6049 // `let x = x; in x` should either hit the depth guard or fail on
6050 // undefined variable (since sequential let can't see its own binding).
6051 let result = eval("let x = x; in x");
6052 assert!(result.is_err());
6053 }
6054
6055 #[test]
6056 fn error_infinite_recursion_via_lambda() {
6057 // A true infinite recursion via self-application -- depth guard catches this.
6058 let result = eval("let f = x: f x; in f 1");
6059 assert!(result.is_err());
6060 let msg = format!("{}", result.unwrap_err());
6061 assert!(
6062 msg.contains("infinite recursion") || msg.contains("eval depth") || msg.contains("undefined"),
6063 );
6064 }
6065
6066 // ═══════════════════════════════════════════════════════════
6067 // ADDITIONAL COVERAGE: edge cases and integration
6068 // ═══════════════════════════════════════════════════════════
6069
6070 #[test]
6071 fn integration_let_with_function_returning_attrset() {
6072 assert_eq!(
6073 ev("let mkPkg = name: { inherit name; version = 1; }; in (mkPkg \"hello\").name"),
6074 Value::string("hello"),
6075 );
6076 }
6077
6078 #[test]
6079 fn integration_chained_updates() {
6080 assert_eq!(
6081 ev("({ a = 1; } // { b = 2; } // { c = 3; }).c"),
6082 Value::Int(3),
6083 );
6084 }
6085
6086 #[test]
6087 fn integration_map_over_attrnames() {
6088 // Common nixpkgs pattern: map over attrNames
6089 assert_eq!(
6090 ev(r#"
6091 let
6092 set = { a = 1; b = 2; };
6093 names = builtins.attrNames set;
6094 in builtins.length names
6095 "#),
6096 Value::Int(2),
6097 );
6098 }
6099
6100 #[test]
6101 fn integration_compose_functions() {
6102 // Function composition
6103 assert_eq!(
6104 ev("let compose = f: g: x: f (g x); double = x: x * 2; inc = x: x + 1; in compose double inc 5"),
6105 Value::Int(12), // (5 + 1) * 2
6106 );
6107 }
6108
6109 #[test]
6110 fn integration_recursive_list_building() {
6111 // Build a list using genList and map
6112 assert_eq!(
6113 ev("builtins.map (x: x * x) (builtins.genList (x: x + 1) 4)"),
6114 Value::list(vec![Value::Int(1), Value::Int(4), Value::Int(9), Value::Int(16)]),
6115 );
6116 }
6117
6118 #[test]
6119 fn integration_attrset_from_list() {
6120 // Convert list to attrset via listToAttrs + map
6121 let v = ev(r#"
6122 builtins.listToAttrs (builtins.map (x: { name = x; value = true; }) ["a" "b" "c"])
6123 "#);
6124 if let Value::Attrs(attrs) = v {
6125 assert_eq!(attrs.get("a"), Some(&Value::Bool(true)));
6126 assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));
6127 assert_eq!(attrs.get("c"), Some(&Value::Bool(true)));
6128 } else {
6129 panic!("expected attrs");
6130 }
6131 }
6132
6133 #[test]
6134 fn integration_nested_with_and_let() {
6135 assert_eq!(
6136 ev("let x = 10; in with { y = 20; }; x + y"),
6137 Value::Int(30),
6138 );
6139 }
6140
6141 #[test]
6142 fn integration_complex_pattern_match() {
6143 // Complex function with defaults, ellipsis, and @ pattern
6144 assert_eq!(
6145 ev("(args @ { a, b ? 5, ... }: a + b + (if args ? c then args.c else 0)) { a = 1; c = 10; }"),
6146 Value::Int(16), // 1 + 5 + 10
6147 );
6148 }
6149
6150 #[test]
6151 fn integration_substring() {
6152 assert_eq!(
6153 ev(r#"builtins.substring 0 5 "hello world""#),
6154 Value::string("hello"),
6155 );
6156 assert_eq!(
6157 ev(r#"builtins.substring 6 5 "hello world""#),
6158 Value::string("world"),
6159 );
6160 }
6161
6162 #[test]
6163 fn integration_has_attr_on_nested() {
6164 // ? on nested attr paths
6165 assert_eq!(ev("{ a = { b = 1; }; } ? a"), Value::Bool(true));
6166 assert_eq!(
6167 ev("({ a = { b = 1; }; }.a) ? b"),
6168 Value::Bool(true),
6169 );
6170 }
6171
6172 #[test]
6173 fn integration_cat_attrs() {
6174 assert_eq!(
6175 ev(r#"builtins.catAttrs "x" [{ x = 1; } { y = 2; } { x = 3; }]"#),
6176 Value::list(vec![Value::Int(1), Value::Int(3)]),
6177 );
6178 }
6179
6180 #[test]
6181 fn integration_get_attr_builtin() {
6182 assert_eq!(
6183 ev(r#"builtins.getAttr "a" { a = 42; b = 10; }"#),
6184 Value::Int(42),
6185 );
6186 }
6187
6188 #[test]
6189 fn integration_has_attr_builtin() {
6190 assert_eq!(
6191 ev(r#"builtins.hasAttr "a" { a = 1; }"#),
6192 Value::Bool(true),
6193 );
6194 assert_eq!(
6195 ev(r#"builtins.hasAttr "z" { a = 1; }"#),
6196 Value::Bool(false),
6197 );
6198 }
6199
6200 #[test]
6201 fn integration_is_path() {
6202 assert_eq!(ev("builtins.isPath ./foo"), Value::Bool(true));
6203 assert_eq!(ev("builtins.isPath 42"), Value::Bool(false));
6204 }
6205
6206 #[test]
6207 fn integration_builtins_trace() {
6208 // trace prints the first arg (as debug) and returns the second
6209 assert_eq!(ev(r#"builtins.trace "debug msg" 42"#), Value::Int(42));
6210 }
6211
6212 #[test]
6213 fn integration_builtins_split() {
6214 // Nix spec: split returns alternating non-match strings and match group lists.
6215 // When the regex has no capture groups, separator positions get empty lists.
6216 // split "/" "a/b/c" => ["a" [] "b" [] "c"]
6217 assert_eq!(
6218 ev(r#"builtins.split "/" "a/b/c""#),
6219 Value::list(vec![
6220 Value::string("a"),
6221 Value::list(vec![]),
6222 Value::string("b"),
6223 Value::list(vec![]),
6224 Value::string("c"),
6225 ]),
6226 );
6227 // With a capture group, the captured text appears in the list.
6228 // split "(/)" "a/b/c" => ["a" ["/"] "b" ["/"] "c"]
6229 assert_eq!(
6230 ev(r#"builtins.split "(/)" "a/b/c""#),
6231 Value::list(vec![
6232 Value::string("a"),
6233 Value::list(vec![Value::string("/")]),
6234 Value::string("b"),
6235 Value::list(vec![Value::string("/")]),
6236 Value::string("c"),
6237 ]),
6238 );
6239 }
6240
6241 #[test]
6242 fn integration_builtins_split_no_capture_groups() {
6243 // builtins.split with no capture groups returns empty lists
6244 // at separator positions — matches CppNix behavior.
6245 // This is critical for nixpkgs lib.splitString which uses
6246 // builtins.filter builtins.isString on the result.
6247 assert_eq!(
6248 ev(r#"builtins.split "-" "aarch64-darwin""#),
6249 Value::list(vec![
6250 Value::string("aarch64"),
6251 Value::list(vec![]),
6252 Value::string("darwin"),
6253 ]),
6254 );
6255 }
6256
6257 #[test]
6258 fn integration_builtins_split_system_string_filter() {
6259 // Simulates nixpkgs lib.splitString: filter isString (split pattern string)
6260 // This is the exact pattern that parses system strings like "aarch64-darwin".
6261 assert_eq!(
6262 ev(r#"builtins.filter builtins.isString (builtins.split "-" "aarch64-darwin")"#),
6263 Value::list(vec![
6264 Value::string("aarch64"),
6265 Value::string("darwin"),
6266 ]),
6267 );
6268 }
6269
6270 #[test]
6271 fn integration_deeply_nested_let() {
6272 // Deeply nested let-in expressions
6273 assert_eq!(
6274 ev("let a = let b = let c = 10; in c * 2; in b + 1; in a"),
6275 Value::Int(21),
6276 );
6277 }
6278
6279 #[test]
6280 fn integration_if_in_attrset_value() {
6281 assert_eq!(
6282 ev("{ x = if true then 1 else 2; }.x"),
6283 Value::Int(1),
6284 );
6285 }
6286
6287 #[test]
6288 fn integration_lambda_in_list() {
6289 // Store lambdas in a list and apply them
6290 assert_eq!(
6291 ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 0) 5"),
6292 Value::Int(6),
6293 );
6294 assert_eq!(
6295 ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 1) 5"),
6296 Value::Int(10),
6297 );
6298 }
6299
6300 #[test]
6301 fn integration_nixpkgs_lib_id() {
6302 // lib.id = x: x
6303 assert_eq!(
6304 ev("let lib = { id = x: x; const = a: b: a; }; in lib.id 42"),
6305 Value::Int(42),
6306 );
6307 assert_eq!(
6308 ev("let lib = { id = x: x; const = a: b: a; }; in lib.const 1 2"),
6309 Value::Int(1),
6310 );
6311 }
6312
6313 #[test]
6314 fn integration_multiple_inherit() {
6315 assert_eq!(
6316 ev("let a = 1; b = 2; c = 3; in { inherit a b c; }.b"),
6317 Value::Int(2),
6318 );
6319 }
6320
6321 #[test]
6322 fn integration_rec_set_with_builtins() {
6323 assert_eq!(
6324 ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6325 Value::Int(5),
6326 );
6327 }
6328
6329 // ═══════════════════════════════════════════════════════════
6330 // 11. __FUNCTOR PROTOCOL
6331 // ═══════════════════════════════════════════════════════════
6332
6333 #[test]
6334 fn functor_simple_callable_attrset() {
6335 assert_eq!(
6336 ev("let s = { __functor = self: x: x + 1; }; in s 41"),
6337 Value::Int(42),
6338 );
6339 }
6340
6341 #[test]
6342 fn functor_with_self_reference() {
6343 assert_eq!(
6344 ev("let s = { __functor = self: x: self.base + x; base = 100; }; in s 23"),
6345 Value::Int(123),
6346 );
6347 }
6348
6349 #[test]
6350 fn functor_updated_attrset() {
6351 // Override a field in the attrset, functor still works
6352 assert_eq!(
6353 ev(r#"
6354 let
6355 mk = { __functor = self: x: self.n + x; n = 0; };
6356 s = mk // { n = 50; };
6357 in s 7
6358 "#),
6359 Value::Int(57),
6360 );
6361 }
6362
6363 #[test]
6364 fn functor_error_on_non_callable_attrset() {
6365 // Attrset without __functor should produce error when called
6366 let result = eval("let s = { a = 1; }; in s 5");
6367 assert!(result.is_err());
6368 }
6369
6370 // ═══════════════════════════════════════════════════════════
6371 // 12. __TOSTRING PROTOCOL
6372 // ═══════════════════════════════════════════════════════════
6373
6374 #[test]
6375 fn to_string_protocol_in_interpolation() {
6376 assert_eq!(
6377 ev(r#"let s = { __toString = self: "world"; }; in "hello ${s}""#),
6378 Value::string("hello world"),
6379 );
6380 }
6381
6382 #[test]
6383 fn to_string_protocol_accesses_self() {
6384 assert_eq!(
6385 ev(r#"let s = { __toString = self: self.val; val = "abc"; }; in "${s}""#),
6386 Value::string("abc"),
6387 );
6388 }
6389
6390 #[test]
6391 fn to_string_protocol_via_builtin_to_string() {
6392 assert_eq!(
6393 ev(r#"builtins.toString { __toString = self: "via-builtin"; }"#),
6394 Value::string("via-builtin"),
6395 );
6396 }
6397
6398 #[test]
6399 fn to_string_protocol_attrset_without_toString_fails() {
6400 // An attrset without __toString should fail in string context
6401 let result = eval(r#""${{}}"#);
6402 assert!(result.is_err());
6403 }
6404
6405 // ═══════════════════════════════════════════════════════════
6406 // 13. NEWLY IMPLEMENTED BUILTINS (eval-level tests)
6407 // ═══════════════════════════════════════════════════════════
6408
6409 /// `concatStrings` is nixpkgs `lib.strings.concatStrings`, not a CppNix
6410 /// builtin. The CAPABILITY is not lost — `concatStringsSep ""` is the real
6411 /// builtin spelling and is asserted here to still produce the same bytes,
6412 /// so this test proves both halves: the invented name is gone, and nothing
6413 /// a nix program can legally write got worse.
6414 #[test]
6415 fn eval_builtins_concat_strings_is_not_a_builtin() {
6416 assert_eq!(ev(r#"builtins ? concatStrings"#), Value::Bool(false));
6417 assert!(
6418 eval(r#"builtins.concatStrings ["a" "b" "c"]"#).is_err(),
6419 "builtins.concatStrings must fail the way real nix fails it"
6420 );
6421 assert_eq!(
6422 ev(r#"builtins.concatStringsSep "" ["a" "b" "c"]"#),
6423 Value::string("abc"),
6424 );
6425 assert_eq!(
6426 ev(r#"builtins.concatStringsSep "" []"#),
6427 Value::string(""),
6428 );
6429 }
6430
6431 #[test]
6432 fn eval_builtins_partition() {
6433 let v = ev("builtins.partition (x: x > 3) [1 2 3 4 5]");
6434 if let Value::Attrs(a) = v {
6435 assert_eq!(a.get("right"), Some(&Value::list(vec![Value::Int(4), Value::Int(5)])));
6436 assert_eq!(a.get("wrong"), Some(&Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)])));
6437 } else {
6438 panic!("expected attrs");
6439 }
6440 }
6441
6442 #[test]
6443 fn eval_builtins_group_by() {
6444 let v = ev(r#"builtins.groupBy (x: if x > 0 then "pos" else "neg") [1 (0 - 2) 3 (0 - 4)]"#);
6445 if let Value::Attrs(a) = v {
6446 assert_eq!(a.get("pos"), Some(&Value::list(vec![Value::Int(1), Value::Int(3)])));
6447 assert_eq!(a.get("neg"), Some(&Value::list(vec![Value::Int(-2), Value::Int(-4)])));
6448 } else {
6449 panic!("expected attrs");
6450 }
6451 }
6452
6453 #[test]
6454 fn eval_builtins_zip_attrs_with() {
6455 let v = ev("builtins.zipAttrsWith (n: vs: builtins.head vs) [{ a = 1; } { a = 2; b = 3; }]");
6456 if let Value::Attrs(a) = v {
6457 assert_eq!(a.get("a"), Some(&Value::Int(1)));
6458 assert_eq!(a.get("b"), Some(&Value::Int(3)));
6459 } else {
6460 panic!("expected attrs");
6461 }
6462 }
6463
6464 #[test]
6465 fn eval_builtins_compare_versions() {
6466 assert_eq!(ev(r#"builtins.compareVersions "2.0" "1.0""#), Value::Int(1));
6467 assert_eq!(ev(r#"builtins.compareVersions "1.0" "2.0""#), Value::Int(-1));
6468 assert_eq!(ev(r#"builtins.compareVersions "1.0" "1.0""#), Value::Int(0));
6469 }
6470
6471 #[test]
6472 fn eval_builtins_parse_drv_name() {
6473 let v = ev(r#"builtins.parseDrvName "nix-2.3.4""#);
6474 if let Value::Attrs(a) = v {
6475 assert_eq!(a.get("name"), Some(&Value::string("nix")));
6476 assert_eq!(a.get("version"), Some(&Value::string("2.3.4")));
6477 } else {
6478 panic!("expected attrs");
6479 }
6480 }
6481
6482 #[test]
6483 fn eval_builtins_base_name_of() {
6484 assert_eq!(
6485 ev(r#"builtins.baseNameOf "/foo/bar/baz""#),
6486 Value::string("baz"),
6487 );
6488 }
6489
6490 #[test]
6491 fn eval_builtins_dir_of() {
6492 assert_eq!(
6493 ev(r#"builtins.dirOf "/foo/bar/baz""#),
6494 Value::string("/foo/bar"),
6495 );
6496 }
6497
6498 #[test]
6499 fn eval_builtins_add_error_context() {
6500 assert_eq!(
6501 ev(r#"builtins.addErrorContext "some context" 42"#),
6502 Value::Int(42),
6503 );
6504 }
6505
6506 #[test]
6507 fn eval_builtins_abort() {
6508 let result = eval(r#"builtins.abort "fatal error""#);
6509 assert!(result.is_err());
6510 let msg = format!("{}", result.unwrap_err());
6511 assert!(msg.contains("fatal error"));
6512 }
6513
6514 // ═══════════════════════════════════════════════════════════
6515 // 14. INDENTED STRINGS ('' ... '')
6516 // ═══════════════════════════════════════════════════════════
6517
6518 #[test]
6519 fn indented_string_simple() {
6520 assert_eq!(ev("''hello''"), Value::string("hello"));
6521 }
6522
6523 #[test]
6524 fn indented_string_multiline_strips_indent() {
6525 assert_eq!(
6526 ev("''\n line1\n line2\n''"),
6527 Value::string("line1\nline2\n"),
6528 );
6529 }
6530
6531 #[test]
6532 fn indented_string_with_interpolation() {
6533 let code = "let x = \"world\"; in ''hello ${x}''";
6534 assert_eq!(
6535 ev(code),
6536 Value::string("hello world"),
6537 );
6538 }
6539
6540 #[test]
6541 fn indented_string_deeper_indent_preserved() {
6542 // Common indent is 2 spaces; the 4-space line keeps 2 extra
6543 assert_eq!(
6544 ev("''\n a\n b\n''"),
6545 Value::string("a\n b\n"),
6546 );
6547 }
6548
6549 // ═══════════════════════════════════════════════════════════
6550 // 15. DYNAMIC ATTRIBUTE NAMES
6551 // ═══════════════════════════════════════════════════════════
6552
6553 #[test]
6554 fn dynamic_attr_name_in_set() {
6555 assert_eq!(
6556 ev(r#"let key = "mykey"; in { ${key} = 42; }.mykey"#),
6557 Value::Int(42),
6558 );
6559 }
6560
6561 #[test]
6562 fn dynamic_attr_name_with_expression() {
6563 assert_eq!(
6564 ev(r#"let prefix = "foo"; in { ${"${prefix}bar"} = 1; }.foobar"#),
6565 Value::Int(1),
6566 );
6567 }
6568
6569 // ═══════════════════════════════════════════════════════════
6570 // 16. IGNORED TESTS — features needing major infrastructure
6571 // ═══════════════════════════════════════════════════════════
6572
6573 #[test]
6574 fn eval_builtins_match() {
6575 assert_eq!(
6576 ev(r#"builtins.match "([0-9]+)" "42""#),
6577 Value::list(vec![Value::string("42")]),
6578 );
6579 }
6580
6581 #[test]
6582 fn eval_builtins_hash_string() {
6583 let v = ev(r#"builtins.hashString "sha256" "hello""#);
6584 if let Value::String(ns) = v {
6585 assert_eq!(ns.chars.len(), 64);
6586 } else {
6587 panic!("expected string");
6588 }
6589 }
6590
6591 #[test]
6592 fn eval_builtins_import() {
6593 let dir = std::env::temp_dir();
6594 let path = dir.join("sui_eval_test_import_eval.nix");
6595 std::fs::write(&path, "42").unwrap();
6596 let expr = format!(r#"import "{}""#, path.display());
6597 let v = eval(&expr).unwrap();
6598 assert_eq!(v, Value::Int(42));
6599 std::fs::remove_file(&path).ok();
6600 }
6601
6602 #[test]
6603 fn eval_builtins_derivation() {
6604 let v = eval(r#"builtins.derivation { name = "test"; system = "x86_64-linux"; builder = "/bin/sh"; }"#).unwrap();
6605 if let Value::Attrs(a) = v {
6606 assert_eq!(a.get("type"), Some(&Value::string("derivation")));
6607 } else {
6608 panic!("expected attrs");
6609 }
6610 }
6611
6612 #[test]
6613 fn eval_mutual_recursive_let() {
6614 // Multi-pass evaluation allows forward references in let bindings.
6615 // After 3 passes (placeholder + eval + re-eval), `a.x` resolves to
6616 // the value of `b` from the previous pass, and `a.x.y` is an attrset.
6617 // Full semantic equivalence with Nix (a.x.y == a) requires lazy
6618 // thunks, but the multi-pass approach is sufficient for common
6619 // patterns like mutual module references.
6620 let v = eval("let a = { x = b; }; b = { y = a; }; in a.x.y");
6621 assert!(v.is_ok(), "mutual recursive let should not error: {v:?}");
6622 // a.x.y should be an attrset (it's a's value from a prior pass)
6623 let val = v.unwrap();
6624 assert!(
6625 matches!(val, Value::Attrs(_)),
6626 "a.x.y should be an attrset, got: {val:?}",
6627 );
6628 }
6629
6630 #[test]
6631 fn eval_mutual_recursive_let_simple() {
6632 // Simpler case: forward reference in sequential let bindings
6633 let v = eval("let a = b; b = 42; in a");
6634 assert!(v.is_ok());
6635 // After multi-pass: pass 2 sets a=Null (b not yet bound), b=42
6636 // pass 3 sets a=42, b=42
6637 assert_eq!(v.unwrap(), Value::Int(42));
6638 }
6639
6640 #[test]
6641 fn eval_builtins_read_dir() {
6642 let dir = std::env::temp_dir().join("sui_eval_test_readdir_eval");
6643 let _ = std::fs::remove_dir_all(&dir);
6644 std::fs::create_dir_all(&dir).unwrap();
6645 std::fs::write(dir.join("a.txt"), "").unwrap();
6646 let expr = format!(r#"builtins.readDir "{}""#, dir.display());
6647 let v = eval(&expr).unwrap();
6648 if let Value::Attrs(a) = v {
6649 assert_eq!(a.get("a.txt"), Some(&Value::string("regular")));
6650 } else {
6651 panic!("expected attrs");
6652 }
6653 let _ = std::fs::remove_dir_all(&dir);
6654 }
6655
6656 // ═══════════════════════════════════════════════════════════
6657 // 17. THUNK / LAZY EVALUATION
6658 // ═══════════════════════════════════════════════════════════
6659
6660 #[test]
6661 fn thunk_basic_let() {
6662 // Simple let binding through thunk.
6663 assert_eq!(ev("let x = 1; in x"), Value::Int(1));
6664 }
6665
6666 #[test]
6667 fn thunk_forward_ref() {
6668 // Forward reference: `a` references `b` which is defined later.
6669 assert_eq!(ev("let a = b; b = 1; in a"), Value::Int(1));
6670 }
6671
6672 #[test]
6673 fn thunk_mutual_rec_attrset_in_let() {
6674 // Mutual recursion through attrsets in let bindings.
6675 assert_eq!(ev("let a = { x = b; }; b = { y = 1; }; in a.x.y"), Value::Int(1));
6676 }
6677
6678 #[test]
6679 fn thunk_rec_attrset() {
6680 // rec { a = b; b = 1; } -- forward ref within rec set.
6681 assert_eq!(ev("(rec { a = b; b = 1; }).a"), Value::Int(1));
6682 }
6683
6684 #[test]
6685 fn thunk_rec_attrset_chain() {
6686 // Longer chain: c depends on b depends on a.
6687 assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
6688 }
6689
6690 #[test]
6691 fn thunk_fixpoint() {
6692 // Classic fixpoint combinator -- the core of nixpkgs' `lib.fix`.
6693 assert_eq!(
6694 ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; })).b"),
6695 Value::Int(2),
6696 );
6697 }
6698
6699 #[test]
6700 fn thunk_blackhole_self_reference() {
6701 // `let x = x; in x` is infinite recursion -- blackhole detection.
6702 let result = eval("let x = x; in x");
6703 assert!(result.is_err());
6704 let msg = format!("{}", result.unwrap_err());
6705 assert!(
6706 msg.contains("infinite recursion") || msg.contains("blackhole"),
6707 "expected blackhole error, got: {msg}",
6708 );
6709 }
6710
6711 #[test]
6712 fn thunk_mutual_blackhole() {
6713 // `let a = b; b = a; in a` -- mutual infinite recursion.
6714 let result = eval("let a = b; b = a; in a");
6715 assert!(result.is_err());
6716 }
6717
6718 #[test]
6719 fn thunk_let_body_forces_correctly() {
6720 // The let body should be able to use thunked bindings in arithmetic.
6721 assert_eq!(ev("let a = 10; b = 20; in a + b"), Value::Int(30));
6722 }
6723
6724 #[test]
6725 fn thunk_only_forced_when_needed() {
6726 // The binding `bad` would error if forced, but it is never used.
6727 assert_eq!(ev("let bad = 1 / 0; good = 42; in good"), Value::Int(42));
6728 }
6729
6730 #[test]
6731 fn thunk_forward_ref_in_function_body() {
6732 // Forward reference used inside a function body.
6733 assert_eq!(
6734 ev("let f = x: x + b; b = 10; in f 5"),
6735 Value::Int(15),
6736 );
6737 }
6738
6739 #[test]
6740 fn thunk_rec_set_self_ref_through_self() {
6741 // rec set where `b` references `a` which is in the same set.
6742 assert_eq!(
6743 ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6744 Value::Int(5),
6745 );
6746 }
6747
6748 #[test]
6749 fn thunk_nested_let_forward_ref() {
6750 // Forward reference in nested let.
6751 assert_eq!(
6752 ev("let a = b + 1; b = 2; in a"),
6753 Value::Int(3),
6754 );
6755 }
6756
6757 #[test]
6758 fn thunk_deep_chain() {
6759 // Chain of forward references: e -> d -> c -> b -> a.
6760 assert_eq!(
6761 ev("let a = 1; b = a; c = b; d = c; e = d; in e"),
6762 Value::Int(1),
6763 );
6764 }
6765
6766 #[test]
6767 fn thunk_rec_set_fixpoint() {
6768 // Fixpoint through rec set -- common nixpkgs pattern.
6769 assert_eq!(
6770 ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; c = self.b + 1; })).c"),
6771 Value::Int(3),
6772 );
6773 }
6774
6775 #[test]
6776 fn thunk_let_with_inherit() {
6777 // Inherit in let should work alongside thunked bindings.
6778 assert_eq!(
6779 ev("let a = 1; in let inherit a; b = a + 1; in b"),
6780 Value::Int(2),
6781 );
6782 }
6783
6784 #[test]
6785 fn thunk_attrset_value_lazy() {
6786 // Values in non-rec attrsets are evaluated eagerly, but the test
6787 // verifies that thunked let bindings inside attrset values work.
6788 assert_eq!(
6789 ev("let x = 42; in { a = x; }.a"),
6790 Value::Int(42),
6791 );
6792 }
6793
6794 #[test]
6795 fn thunk_unused_error_not_forced() {
6796 // Multiple bindings, only `ok` is used. `bad` throws but is never forced.
6797 assert_eq!(
6798 ev(r#"let bad = builtins.throw "boom"; ok = 1; in ok"#),
6799 Value::Int(1),
6800 );
6801 }
6802
6803 #[test]
6804 fn thunk_rec_set_mutual_reference() {
6805 // Mutual reference within rec set.
6806 let v = ev("rec { a = { val = b.val + 1; }; b = { val = 10; }; }");
6807 if let Value::Attrs(attrs) = v {
6808 let a = attrs.get("a").unwrap();
6809 let a_forced = force_value(a).unwrap();
6810 if let Value::Attrs(a_attrs) = a_forced {
6811 assert_eq!(a_attrs.get("val"), Some(&Value::Int(11)));
6812 } else {
6813 panic!("expected attrs for a");
6814 }
6815 } else {
6816 panic!("expected attrs");
6817 }
6818 }
6819
6820 // ── let-rec self-reference corner cases ───────────────
6821
6822 #[test]
6823 fn let_rec_self_reference_simple() {
6824 assert_eq!(
6825 ev("let x = 1; y = x + 1; in y"),
6826 Value::Int(2),
6827 );
6828 }
6829
6830 #[test]
6831 fn let_rec_self_reference_chain() {
6832 assert_eq!(
6833 ev("let a = 1; b = a + 1; c = b + 1; in c"),
6834 Value::Int(3),
6835 );
6836 }
6837
6838 #[test]
6839 fn let_rec_self_reference_with_function() {
6840 assert_eq!(
6841 ev("let f = x: x + 1; y = f 10; in y"),
6842 Value::Int(11),
6843 );
6844 }
6845
6846 #[test]
6847 fn let_rec_mutual_recursion_via_if() {
6848 assert_eq!(
6849 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"),
6850 Value::Bool(true),
6851 );
6852 }
6853
6854 #[test]
6855 fn let_rec_forward_ref_in_list() {
6856 assert_eq!(
6857 ev("let xs = [a b]; a = 1; b = 2; in builtins.length xs"),
6858 Value::Int(2),
6859 );
6860 }
6861
6862 // ── with-shadowing corner cases ───────────────────────
6863
6864 #[test]
6865 fn with_shadowing_let_wins_over_with() {
6866 assert_eq!(
6867 ev("let x = 1; in with { x = 2; }; x"),
6868 Value::Int(1),
6869 );
6870 }
6871
6872 #[test]
6873 fn with_shadowing_inner_with_wins() {
6874 assert_eq!(
6875 ev("with { x = 1; }; with { x = 2; }; x"),
6876 Value::Int(2),
6877 );
6878 }
6879
6880 #[test]
6881 fn with_shadowing_outer_provides_missing() {
6882 assert_eq!(
6883 ev("with { x = 1; y = 10; }; with { x = 2; }; x + y"),
6884 Value::Int(12),
6885 );
6886 }
6887
6888 #[test]
6889 fn with_shadowing_lambda_arg_wins() {
6890 assert_eq!(
6891 ev("(x: with { x = 99; }; x) 42"),
6892 Value::Int(42),
6893 );
6894 }
6895
6896 #[test]
6897 fn with_shadowing_nested_let_wins_over_with() {
6898 assert_eq!(
6899 ev("with { x = 1; }; let x = 2; in x"),
6900 Value::Int(2),
6901 );
6902 }
6903
6904 #[test]
6905 fn with_scope_dynamic_attrs() {
6906 assert_eq!(
6907 ev(r#"with { x = 1; y = 2; z = 3; }; x + y + z"#),
6908 Value::Int(6),
6909 );
6910 }
6911
6912 #[test]
6913 fn with_scope_over_lazy_thunk_chain_resolves() {
6914 // A `with`-head that resolves through a NESTED thunk chain
6915 // (`Thunk(Thunk(Attrs))`) must still be searched: the lookup
6916 // has to FULLY force the head (chase the chain), not take a
6917 // single force step. A single step leaves a `Value::Thunk`
6918 // that `type_name()` reports as "set" but the `Value::Attrs`
6919 // match rejects — the scope is skipped and a bare ident
6920 // through it fails with a spurious UndefinedVar. This corners
6921 // the nixpkgs `platforms = with lib.platforms; unix;` shape.
6922 assert_eq!(
6923 ev(r#"let outer = if true then (if true then { unix = 42; } else {}) else {};
6924 # force a two-deep lazy wrap of the with-head
6925 head = (x: x) ((y: y) outer);
6926 in with head; unix"#),
6927 Value::Int(42),
6928 );
6929 }
6930
6931 #[test]
6932 fn with_scope_head_from_deep_select_resolves() {
6933 // `with a.b.c; key` where a.b.c is a lazily-selected attrset —
6934 // the bare-ident body must find `key` through the forced head.
6935 assert_eq!(
6936 ev(r#"let a = { b = { c = { key = 7; }; }; }; in with a.b.c; key"#),
6937 Value::Int(7),
6938 );
6939 }
6940
6941 // ── attrset deep merge ────────────────────────────────
6942
6943 #[test]
6944 fn attrset_deep_merge_simple() {
6945 let v = ev("{ a.b = 1; a.c = 2; }");
6946 if let Value::Attrs(attrs) = v {
6947 let a = force_value(attrs.get("a").unwrap()).unwrap();
6948 if let Value::Attrs(inner) = a {
6949 assert_eq!(force_value(inner.get("b").unwrap()).unwrap(), Value::Int(1));
6950 assert_eq!(force_value(inner.get("c").unwrap()).unwrap(), Value::Int(2));
6951 } else {
6952 panic!("expected nested attrs");
6953 }
6954 } else {
6955 panic!("expected attrs");
6956 }
6957 }
6958
6959 #[test]
6960 fn attrset_deep_merge_three_levels() {
6961 let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
6962 if let Value::Attrs(attrs) = v {
6963 let a = force_value(attrs.get("a").unwrap()).unwrap();
6964 if let Value::Attrs(a_inner) = a {
6965 let e = force_value(a_inner.get("e").unwrap()).unwrap();
6966 assert_eq!(e, Value::Int(3));
6967 let b = force_value(a_inner.get("b").unwrap()).unwrap();
6968 if let Value::Attrs(b_inner) = b {
6969 assert_eq!(force_value(b_inner.get("c").unwrap()).unwrap(), Value::Int(1));
6970 assert_eq!(force_value(b_inner.get("d").unwrap()).unwrap(), Value::Int(2));
6971 } else {
6972 panic!("expected nested attrs for b");
6973 }
6974 } else {
6975 panic!("expected nested attrs for a");
6976 }
6977 } else {
6978 panic!("expected attrs");
6979 }
6980 }
6981
6982 #[test]
6983 fn attrset_deep_merge_preserves_siblings() {
6984 assert_eq!(
6985 ev("{ a.x = 1; b = 2; a.y = 3; }.b"),
6986 Value::Int(2),
6987 );
6988 }
6989
6990 #[test]
6991 fn attrset_deep_merge_in_let() {
6992 let v = ev("let s = { a.b = 1; a.c = 2; }; in s.a.b + s.a.c");
6993 assert_eq!(v, Value::Int(3));
6994 }
6995
6996 #[test]
6997 fn attrset_deep_merge_fullset_then_dotted() {
6998 // General root (gst-plugins-base `passthru.waylandEnabled` drop):
6999 // `a = { x = 1; }; a.y = 2;` — the full-set binding is a lazy
7000 // Thunk (attrset literals go through maybe_thunk), so a naive
7001 // merge_nested_insert (which only merges concrete Value::Attrs)
7002 // overwrote `a` with `{ y = 2 }`, silently dropping `x`. The
7003 // collision must force the existing thunk to WHNF first.
7004 let v = ev("let s = { a = { x = 1; }; a.y = 2; }; in s.a.x + s.a.y");
7005 assert_eq!(v, Value::Int(3));
7006 // both keys must survive (not just their sum)
7007 let both = ev("let s = { a = { x = 1; }; a.y = 2; }; in [ s.a.x s.a.y ]");
7008 if let Value::List(items) = both {
7009 assert_eq!(force_value(&items[0]).unwrap(), Value::Int(1));
7010 assert_eq!(force_value(&items[1]).unwrap(), Value::Int(2));
7011 } else {
7012 panic!("expected list");
7013 }
7014 }
7015
7016 // ── inherit-from patterns ─────────────────────────────
7017
7018 #[test]
7019 fn inherit_from_basic() {
7020 assert_eq!(
7021 ev("let s = { x = 1; y = 2; }; in let inherit (s) x y; in x + y"),
7022 Value::Int(3),
7023 );
7024 }
7025
7026 #[test]
7027 fn inherit_from_with_shadowing() {
7028 assert_eq!(
7029 ev("let x = 10; in let inherit ({ x = 20; }) x; in x"),
7030 Value::Int(20),
7031 );
7032 }
7033
7034 #[test]
7035 fn inherit_from_in_attrset() {
7036 let v = ev(r#"let s = { a = 1; b = 2; }; in { inherit (s) a b; c = 3; }"#);
7037 if let Value::Attrs(attrs) = v {
7038 assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7039 assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7040 assert_eq!(force_value(attrs.get("c").unwrap()).unwrap(), Value::Int(3));
7041 } else {
7042 panic!("expected attrs");
7043 }
7044 }
7045
7046 #[test]
7047 fn inherit_from_rec_set() {
7048 assert_eq!(
7049 ev("rec { inherit ({ x = 42; }) x; y = x; }.y"),
7050 Value::Int(42),
7051 );
7052 }
7053
7054 #[test]
7055 fn inherit_plain_from_scope() {
7056 assert_eq!(
7057 ev("let x = 1; in { inherit x; }.x"),
7058 Value::Int(1),
7059 );
7060 }
7061
7062 // Regression (2026-07-11): a bare `inherit x;` must resolve LAZILY, like
7063 // a plain reference to `x` — not eagerly at attrset construction. When
7064 // `x` is provided only by an enclosing `with` scope whose value is a
7065 // fixpoint still being constructed, eager resolution spuriously threw
7066 // `UndefinedVar`. nixpkgs `all-packages.nix` is
7067 // `with pkgs; { nettle = import … { inherit callPackage; }; }`, so
7068 // `inherit callPackage` must resolve from the `with pkgs` scope at force
7069 // time. (This was the nettle UndefinedVar('callPackage') drop.)
7070 #[test]
7071 fn inherit_plain_from_with_scope_lazy() {
7072 // `inherit cp` reads `cp` from a `with self` fixpoint scope; the
7073 // attr forcing it (`a`) must resolve `cp` lazily against the settled
7074 // scope, not eagerly during attrset construction.
7075 assert_eq!(
7076 ev("let fix = f: let x = f x; in x;
7077 self = fix (self: with self; {
7078 a = use { inherit cp; };
7079 use = { cp }: cp 5;
7080 cp = x: x + 100;
7081 });
7082 in self.a"),
7083 Value::Int(105),
7084 );
7085 // Simpler: bare inherit from a plain (non-blackhole) with scope.
7086 assert_eq!(
7087 ev("with { y = 7; }; { inherit y; }.y"),
7088 Value::Int(7),
7089 );
7090 }
7091
7092 #[test]
7093 fn inherit_multiple_from_expr() {
7094 assert_eq!(
7095 ev("let s = { a = 10; b = 20; c = 30; }; in let inherit (s) a b c; in a + b + c"),
7096 Value::Int(60),
7097 );
7098 }
7099
7100 // ── string interpolation edge cases ───────────────────
7101
7102 #[test]
7103 fn interp_nested_attrset_access() {
7104 assert_eq!(
7105 ev(r#"let x = { a = "hello"; }; in "${x.a} world""#),
7106 Value::string("hello world"),
7107 );
7108 }
7109
7110 #[test]
7111 fn interp_with_let_expression() {
7112 assert_eq!(
7113 ev(r#""${let x = "inner"; in x}""#),
7114 Value::string("inner"),
7115 );
7116 }
7117
7118 #[test]
7119 fn interp_float_coercion() {
7120 // CppNix %f-format: always 6 decimal places.
7121 assert_eq!(
7122 ev(r#""${toString 3.14}""#),
7123 Value::string("3.140000"),
7124 );
7125 }
7126
7127 // ── comparison edge cases ─────────────────────────────
7128
7129 #[test]
7130 fn compare_mixed_int_float() {
7131 assert_eq!(ev("1 < 1.5"), Value::Bool(true));
7132 assert_eq!(ev("1.5 > 1"), Value::Bool(true));
7133 assert_eq!(ev("2.0 == 2"), Value::Bool(true));
7134 }
7135
7136 #[test]
7137 fn compare_string_lexicographic() {
7138 assert_eq!(ev(r#""abc" < "abd""#), Value::Bool(true));
7139 assert_eq!(ev(r#""abc" < "abc""#), Value::Bool(false));
7140 assert_eq!(ev(r#""abc" <= "abc""#), Value::Bool(true));
7141 }
7142
7143 // ── update operator edge cases ────────────────────────
7144
7145 #[test]
7146 fn update_empty_sets() {
7147 let v = ev("{} // {}");
7148 if let Value::Attrs(a) = v { assert!(a.is_empty()); } else { panic!(); }
7149 }
7150
7151 #[test]
7152 fn update_right_overrides_completely() {
7153 assert_eq!(
7154 ev("{ a = 1; b = 2; } // { a = 10; c = 30; }"),
7155 ev("{ a = 10; b = 2; c = 30; }"),
7156 );
7157 }
7158
7159 #[test]
7160 fn update_chained() {
7161 assert_eq!(
7162 ev("{ a = 1; } // { b = 2; } // { c = 3; }"),
7163 ev("{ a = 1; b = 2; c = 3; }"),
7164 );
7165 }
7166
7167 // ── force_value edge cases ────────────────────────────
7168
7169 #[test]
7170 fn force_value_concrete_unchanged() {
7171 let v = Value::Int(42);
7172 assert_eq!(force_value(&v).unwrap(), Value::Int(42));
7173 }
7174
7175 #[test]
7176 fn force_value_null() {
7177 assert_eq!(force_value(&Value::Null).unwrap(), Value::Null);
7178 }
7179
7180 // ── eval_with_file ────────────────────────────────────
7181
7182 #[test]
7183 fn eval_with_file_none() {
7184 let result = eval_with_file("1 + 2", None).unwrap();
7185 assert_eq!(result, Value::Int(3));
7186 }
7187
7188 // ── error messages ────────────────────────────────────
7189
7190 #[test]
7191 fn error_type_mismatch_in_comparison() {
7192 let result = eval(r#"1 < "a""#);
7193 assert!(result.is_err());
7194 }
7195
7196 #[test]
7197 fn error_select_from_non_set() {
7198 let result = eval("42.x");
7199 assert!(result.is_err());
7200 }
7201
7202 #[test]
7203 fn error_call_non_function() {
7204 let result = eval("42 1");
7205 assert!(result.is_err());
7206 }
7207
7208 #[test]
7209 fn error_negate_string() {
7210 let result = eval(r#"-"hello""#);
7211 assert!(result.is_err());
7212 }
7213
7214 // ── multiline string edge cases ───────────────────────
7215
7216 #[test]
7217 fn multiline_string_empty() {
7218 assert_eq!(ev("''''"), Value::string(""));
7219 }
7220
7221 #[test]
7222 fn multiline_string_with_trailing_newline() {
7223 let v = ev("''\n hello\n''");
7224 assert_eq!(v, Value::string("hello\n"));
7225 }
7226
7227 // ── list operations ───────────────────────────────────
7228
7229 #[test]
7230 fn list_concat_empty_left() {
7231 assert_eq!(ev("[] ++ [1 2]"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7232 }
7233
7234 #[test]
7235 fn list_concat_empty_right() {
7236 assert_eq!(ev("[1 2] ++ []"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7237 }
7238
7239 #[test]
7240 fn list_concat_both_empty() {
7241 assert_eq!(ev("[] ++ []"), Value::list(vec![]));
7242 }
7243
7244 // ── pattern matching / formals edge cases ─────────────
7245
7246 #[test]
7247 fn formals_at_pattern_accessible() {
7248 assert_eq!(
7249 ev("({ x, ... } @ args: builtins.length (builtins.attrNames args)) { x = 1; y = 2; z = 3; }"),
7250 Value::Int(3),
7251 );
7252 }
7253
7254 #[test]
7255 fn formals_default_uses_other_arg() {
7256 assert_eq!(
7257 ev("({ x, y ? x + 1 }: y) { x = 10; }"),
7258 Value::Int(11),
7259 );
7260 }
7261
7262 #[test]
7263 fn formals_default_lazy_assert_false() {
7264 // nixpkgs parse.nix pattern: default is `assert false; null` but
7265 // the body checks `args ? vendor` instead of using `vendor`
7266 // directly, so the default must never be forced.
7267 assert_eq!(
7268 ev("({ cpu, vendor ? assert false; null, kernel } @ args: if args ? vendor then vendor else \"inferred\") { cpu = \"x86_64\"; kernel = \"linux\"; }"),
7269 Value::String(Rc::new(NixString::plain("inferred"))),
7270 );
7271 }
7272
7273 #[test]
7274 fn formals_default_lazy_only_forced_when_accessed() {
7275 // When the default IS accessed, it should still evaluate correctly.
7276 assert_eq!(
7277 ev("({ a, b ? 42 }: b) { a = 1; }"),
7278 Value::Int(42),
7279 );
7280 }
7281
7282 #[test]
7283 fn formals_ellipsis_ignores_extra() {
7284 assert_eq!(
7285 ev("({ x, ... }: x) { x = 1; y = 2; z = 3; }"),
7286 Value::Int(1),
7287 );
7288 }
7289
7290 // ── pure mode ─────────────────────────────────────────
7291
7292 #[test]
7293 fn pure_mode_roundtrip() {
7294 let was_pure = is_pure_mode();
7295 set_pure_mode(true);
7296 assert!(is_pure_mode());
7297 set_pure_mode(false);
7298 assert!(!is_pure_mode());
7299 set_pure_mode(was_pure);
7300 }
7301
7302 // ── path operations ───────────────────────────────────
7303
7304 #[test]
7305 fn path_concat_with_string() {
7306 assert_eq!(
7307 ev(r#"/foo + "bar""#),
7308 Value::Path(Box::new(SmolStr::from("/foobar"))),
7309 );
7310 }
7311
7312 #[test]
7313 fn path_concat_with_path() {
7314 assert_eq!(
7315 ev("/foo + /bar"),
7316 Value::Path(Box::new(SmolStr::from("/foo//bar"))),
7317 );
7318 }
7319
7320 // ── EvalFileGuard / current_eval_dir ───────────────────
7321
7322 #[test]
7323 fn current_eval_dir_empty_when_no_file_pushed() {
7324 // Without a push, current_eval_dir should yield None.
7325 // (Note: this test is order-dependent; we accept whatever the
7326 // top of the stack happens to be when called.)
7327 let snapshot = current_eval_dir();
7328 // At minimum the API doesn't panic and returns Option.
7329 let _ = snapshot;
7330 }
7331
7332 #[test]
7333 fn push_eval_file_sets_current_dir() {
7334 let p = std::path::PathBuf::from("/tmp/example/file.nix");
7335 {
7336 let _g = push_eval_file(p.clone());
7337 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/tmp/example")));
7338 }
7339 // Guard dropped, stack popped — current dir is whatever was below.
7340 // We can't assert exact value without snapshotting first, but the
7341 // value before push should be restored.
7342 }
7343
7344 #[test]
7345 fn push_eval_file_nested_stack() {
7346 let outer = std::path::PathBuf::from("/a/x.nix");
7347 let inner = std::path::PathBuf::from("/b/y.nix");
7348 {
7349 let _g_outer = push_eval_file(outer.clone());
7350 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7351 {
7352 let _g_inner = push_eval_file(inner.clone());
7353 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/b")));
7354 }
7355 // Inner dropped — outer is back on top.
7356 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7357 }
7358 }
7359
7360 /// A fileless frame MASKS the parent's file rather than being skipped.
7361 ///
7362 /// Regression: the stack used to be `Vec<PathBuf>`, so a thunk captured in
7363 /// a `--expr` context pushed nothing when it forced and the callee's file
7364 /// stayed visible. `builtins.unsafeGetAttrPos` then reported the callee's
7365 /// path where CppNix reports `null`, which set `eval-config.nix`'s
7366 /// `modulesLocation` and permuted NixOS module definition order.
7367 #[test]
7368 fn fileless_frame_masks_parent_file() {
7369 let outer = std::path::PathBuf::from("/a/x.nix");
7370 let _g_outer = push_eval_file(outer.clone());
7371 assert_eq!(current_eval_file(), Some(outer.clone()));
7372 {
7373 let _g_none = push_eval_frame(None);
7374 // The whole point: NOT Some("/a/x.nix").
7375 assert_eq!(current_eval_file(), None);
7376 assert_eq!(current_eval_dir(), None);
7377 assert_eq!(eval_file_stack_snapshot().last().map(String::as_str), Some("<no-file>"));
7378 }
7379 // Popped — the parent is visible again.
7380 assert_eq!(current_eval_file(), Some(outer));
7381 }
7382
7383 // ── Source-mapped error context ────────────────────────
7384
7385 #[test]
7386 fn error_undefined_var_includes_file_context() {
7387 let p = std::path::PathBuf::from("/nix/store/abc-default.nix");
7388 let _g = push_eval_file(p);
7389 let result = eval("nonexistent_xyz");
7390 let msg = format!("{}", result.unwrap_err());
7391 assert!(msg.contains("undefined variable"), "msg: {msg}");
7392 assert!(msg.contains("nonexistent_xyz"), "msg: {msg}");
7393 assert!(msg.contains("abc-default.nix"), "msg: {msg}");
7394 }
7395
7396 #[test]
7397 fn error_attr_not_found_includes_file_context() {
7398 let p = std::path::PathBuf::from("/nix/store/xyz-module.nix");
7399 let _g = push_eval_file(p);
7400 let result = eval("{}.missing_key");
7401 let msg = format!("{}", result.unwrap_err());
7402 assert!(msg.contains("not found") || msg.contains("missing_key"), "msg: {msg}");
7403 assert!(msg.contains("xyz-module.nix"), "msg: {msg}");
7404 }
7405
7406 #[test]
7407 fn error_assertion_failed_includes_file_context() {
7408 let p = std::path::PathBuf::from("/nix/store/test-assert.nix");
7409 let _g = push_eval_file(p);
7410 let result = eval("assert false; 1");
7411 let msg = format!("{}", result.unwrap_err());
7412 assert!(msg.contains("assertion failed"), "msg: {msg}");
7413 assert!(msg.contains("test-assert.nix"), "msg: {msg}");
7414 }
7415
7416 /// `inherit` binds an attribute, so it carries a position.
7417 ///
7418 /// Regression: `attach_attrset_positions` matched only
7419 /// `Entry::AttrpathValue`, so every inherited key was position-less — most
7420 /// of nixpkgs' `lib`, which re-exports via `inherit (self.options) mkOption
7421 /// …`, and it fed a null into `eval-config.nix`'s `modulesLocation`.
7422 ///
7423 /// Shaped exactly like `unsafe_get_attr_pos_reports_file_and_offset_column`
7424 /// (ONE direct `eval`, no lambda, no second evaluation) because the
7425 /// in-process harness is fragile here: the source-text registry is a
7426 /// thread-local that `pos.rs`'s tests clear, so a multi-eval version passes
7427 /// standalone and fails in the full suite. The CLI path is not affected —
7428 /// verified against `nix eval` on both shapes, both engines agreeing on
7429 /// column 18.
7430 #[test]
7431 fn inherit_bindings_carry_positions() {
7432 let dir = tempfile::tempdir().unwrap();
7433 // A PLAIN attrset, no `let ... in` wrapper: with the wrapper the
7434 // result is built lazily AFTER `import` returns, and the in-process
7435 // harness then resolves it without the file on the eval stack. The CLI
7436 // handles both (measured), the harness only this one.
7437 let body = "{ inherit ({ x = 1; }) x; }\n";
7438 let f = dir.path().join("inh.nix");
7439 std::fs::write(&f, body).unwrap();
7440 let v = eval(&format!("builtins.unsafeGetAttrPos \"x\" (import {})", f.display())).unwrap();
7441 let attrs = match v {
7442 Value::Attrs(a) => a,
7443 Value::Null => panic!("null — the inherit binding carried no position"),
7444 o => panic!("expected attrs, got {o:?}"),
7445 };
7446 // Computed from the fixture, never hardcoded: a hardcoded expectation is
7447 // how `pos::line_col`'s own "verified" comment came to agree with the
7448 // bug it documented.
7449 let off = body.rfind("x; }").unwrap();
7450 let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7451 assert_eq!(*attrs.get("line").unwrap(), Value::Int(1));
7452 assert_eq!(*attrs.get("column").unwrap(), Value::Int((off - bol) as i64 + 1));
7453 }
7454
7455 /// Corpus gate: every attribute-BINDING form carries a position.
7456 ///
7457 /// Seals the class the three position bugs came from, rather than the three
7458 /// instances: `//` dropping positions wholesale, `pos::line_col` returning a
7459 /// constant, and `inherit` never being recorded. Each was found only because
7460 /// a NixOS toplevel drvPath diverged — an expensive way to learn that an
7461 /// attribute lost its position.
7462 ///
7463 /// Expectations are DERIVED from the fixture, never written out, so the test
7464 /// cannot drift into agreeing with whatever the implementation emits. That
7465 /// is exactly how `line_col`'s own "verified against nix eval" comment came
7466 /// to document the bug it contained.
7467 ///
7468 /// Anti-vacuity: the row count is asserted, and any `NULL` fails. A change
7469 /// that stops attaching positions altogether makes every row `NULL` — which
7470 /// must be a failure, not an empty-set pass.
7471 #[test]
7472 fn every_binding_form_carries_a_position() {
7473 let dir = tempfile::tempdir().unwrap();
7474 // One line per key so the expected line number is its 1-based index.
7475 let body = concat!(
7476 "let src = { i = 1; j = 2; }; in {\n",
7477 " plain = 1;\n",
7478 " \"quoted\" = 2;\n",
7479 " inherit (src) i;\n",
7480 " inherit src;\n",
7481 " nested.deep = 3;\n",
7482 "}\n",
7483 );
7484 let f = dir.path().join("forms.nix");
7485 std::fs::write(&f, body).unwrap();
7486
7487 // `nested` is the head of a dotted path; CppNix points at the head.
7488 let keys = ["plain", "quoted", "i", "src", "nested"];
7489 let probe = keys
7490 .iter()
7491 .map(|k| format!(
7492 "(let q = builtins.unsafeGetAttrPos \"{k}\" t; \
7493 in if q == null then \"{k}=NULL\" \
7494 else \"{k}=${{toString q.line}}:${{toString q.column}}\")"
7495 ))
7496 .collect::<Vec<_>>()
7497 .join(" + \" \" + ");
7498 let got = eval(&format!("let t = import {}; in {probe}", f.display()))
7499 .unwrap()
7500 .as_string()
7501 .unwrap()
7502 .to_string();
7503
7504 assert!(!got.contains("NULL"), "a binding form lost its position: {got}");
7505 let rows: Vec<&str> = got.split(' ').collect();
7506 assert_eq!(rows.len(), keys.len(), "corpus shrank — gate would be vacuous: {got}");
7507
7508 // Derive each expectation by locating the key token in the fixture.
7509 for (k, row) in keys.iter().zip(&rows) {
7510 let needle = match *k {
7511 "quoted" => "\"quoted\"".to_string(),
7512 "i" => "i;".to_string(),
7513 "src" => "src;".to_string(),
7514 // A dotted path's head is followed by `.`, not ` =` — CppNix
7515 // reports the HEAD token's position for the outer key.
7516 "nested" => "nested.".to_string(),
7517 other => format!("{other} ="),
7518 };
7519 let off = body.find(&needle).unwrap();
7520 let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7521 let line = 1 + body[..off].matches('\n').count();
7522 let col = off - bol + 1;
7523 assert_eq!(*row, format!("{k}={line}:{col}"), "wrong position for `{k}` in:\n{body}");
7524 }
7525 }
7526
7527 /// A missing-argument error names the file the LAMBDA came from.
7528 ///
7529 /// Evaluated with `eval_with_file`, not `push_eval_file` + bare `eval`, and
7530 /// the difference is the point. Calling a closure now pushes the closure's
7531 /// OWN file — including a fileless frame when it has none — so a lambda
7532 /// defined in a fileless string no longer borrows whatever unrelated file
7533 /// happens to sit on the stack. That borrowing is what the old form
7534 /// asserted, and CppNix does not do it: an `--expr` lambda has no file.
7535 /// Associating the source with a file, as every real `import` does, keeps
7536 /// the original intent (errors carry file context) while testing the path
7537 /// production actually takes. Verified against CppNix: for a lambda in a
7538 /// real file both engines name that file.
7539 #[test]
7540 fn error_missing_argument_includes_file_context() {
7541 let p = std::path::PathBuf::from("/nix/store/func.nix");
7542 let result = eval_with_file("({ a, b }: a) { a = 1; }", Some(p));
7543 let msg = format!("{}", result.unwrap_err());
7544 assert!(msg.contains("missing argument"), "msg: {msg}");
7545 assert!(msg.contains("func.nix"), "msg: {msg}");
7546 }
7547
7548 #[test]
7549 fn error_cannot_call_includes_file_context() {
7550 let p = std::path::PathBuf::from("/nix/store/call.nix");
7551 let _g = push_eval_file(p);
7552 let result = eval("42 99");
7553 let msg = format!("{}", result.unwrap_err());
7554 assert!(msg.contains("cannot call"), "msg: {msg}");
7555 assert!(msg.contains("call.nix"), "msg: {msg}");
7556 }
7557
7558 #[test]
7559 fn error_without_file_has_no_in_prefix() {
7560 // When no file is on the eval stack, error messages should
7561 // not contain ", in" context.
7562 let result = eval("nonexistent_xyz");
7563 let msg = format!("{}", result.unwrap_err());
7564 assert!(msg.contains("undefined variable"), "msg: {msg}");
7565 assert!(!msg.contains(", in"), "msg should not contain file context: {msg}");
7566 }
7567
7568 // ── pure mode getter/setter independence ───────────────
7569
7570 #[test]
7571 fn pure_mode_set_get_independence() {
7572 let was = is_pure_mode();
7573 set_pure_mode(true);
7574 assert!(is_pure_mode());
7575 set_pure_mode(false);
7576 assert!(!is_pure_mode());
7577 set_pure_mode(was);
7578 }
7579
7580 // ── eval_with_file with file path ──────────────────────
7581
7582 #[test]
7583 fn eval_with_file_some_path_arithmetic() {
7584 let p = std::path::PathBuf::from("/tmp/imaginary.nix");
7585 let result = eval_with_file("1 + 2", Some(p)).unwrap();
7586 assert_eq!(result, Value::Int(3));
7587 }
7588
7589 // ── unsafeGetAttrPos — the options.json `attrTag` declarations root ──
7590 //
7591 // Seals the CppNix-matching behavior: for a literal attrset built in a
7592 // FILE, `builtins.unsafeGetAttrPos <key> <set>` returns
7593 // `{ file; line=1; column=<key byte offset>+1; }`; for a `<string>` eval
7594 // (no file) it returns `null`. Byte-verified against `nix eval`.
7595
7596 #[test]
7597 fn unsafe_get_attr_pos_reports_file_and_offset_column() {
7598 // The real `attrTag` path: a literal attrset built in an IMPORTED file.
7599 // `import` registers the file's source text + pushes it on the eval
7600 // stack, so `eval_attrset` captures the key positions against that file
7601 // and `unsafeGetAttrPos` resolves them. CppNix reports the file plus a
7602 // real newline-resolved line and BYTE column.
7603 //
7604 // Re-baselined: this used to assert line 1 and column = the key's
7605 // 1-based byte offset in the whole file, citing "verified against nix
7606 // eval". It was not — that was sui's own output taken as the oracle,
7607 // and the same false rule was pinned in pos.rs. Measured on nix 2.31.5:
7608 // for `{ a = 1;\n b = 2; }` the `b` key is 2:3, not 1:12.
7609 let dir = tempfile::tempdir().unwrap();
7610 // The literal's `b` key sits at a known byte offset in this file.
7611 let file_body = "{ a = 1;\n b = 2; }\n";
7612 let f = dir.path().join("lit.nix");
7613 std::fs::write(&f, file_body).unwrap();
7614 let src = format!("builtins.unsafeGetAttrPos \"b\" (import {})", f.display());
7615 let v = eval(&src).unwrap();
7616 let attrs = match v { Value::Attrs(a) => a, other => panic!("expected attrs, got {other:?}") };
7617 assert_eq!(
7618 attrs.get("file").unwrap().as_string().unwrap(),
7619 f.to_string_lossy(),
7620 );
7621 // `b` is on the SECOND line, at byte column 3.
7622 let off = file_body.find("b = 2").unwrap();
7623 let bol = file_body[..off].rfind('\n').map_or(0, |i| i + 1);
7624 let expected_line = 1 + file_body[..off].matches('\n').count() as i64;
7625 let expected_col = (off - bol) as i64 + 1;
7626 assert_eq!(expected_line, 2, "fixture must put `b` on line 2");
7627 assert_eq!(*attrs.get("line").unwrap(), Value::Int(expected_line));
7628 let col = match attrs.get("column").unwrap() { Value::Int(n) => *n, o => panic!("{o:?}") };
7629 assert_eq!(col, expected_col, "column must be the 1-based BYTE column");
7630 }
7631
7632 #[test]
7633 fn unsafe_get_attr_pos_null_for_string_origin() {
7634 // A `<string>`-eval'd literal (no file on the stack) has no position → null.
7635 let v = eval("builtins.unsafeGetAttrPos \"a\" { a = 1; }").unwrap();
7636 assert_eq!(v, Value::Null);
7637 }
7638
7639 #[test]
7640 fn unsafe_get_attr_pos_null_for_missing_key() {
7641 // A key absent from an imported set → null.
7642 let dir = tempfile::tempdir().unwrap();
7643 let f = dir.path().join("lit.nix");
7644 std::fs::write(&f, "{ a = 1; }\n").unwrap();
7645 let src = format!("builtins.unsafeGetAttrPos \"zzz\" (import {})", f.display());
7646 let v = eval(&src).unwrap();
7647 assert_eq!(v, Value::Null);
7648 }
7649
7650 // ── String interpolation primitive coercions ───────────
7651
7652 #[test]
7653 fn interp_int_into_string() {
7654 // Integer interpolated into a string is coerced to its decimal repr.
7655 assert_eq!(ev(r#""val=${toString 42}""#), Value::string("val=42"));
7656 }
7657
7658 #[test]
7659 fn interp_bool_true_becomes_one() {
7660 // Per eval_str: Bool(true) → "1", Bool(false) → "" (empty)
7661 let v = ev(r#"let x = true; in "${builtins.toString x}""#);
7662 assert_eq!(v, Value::string("1"));
7663 }
7664
7665 #[test]
7666 fn interp_null_becomes_empty() {
7667 // Null in interpolation is empty.
7668 let v = ev(r#"let x = null; in "${builtins.toString x}""#);
7669 assert_eq!(v, Value::string(""));
7670 }
7671
7672 #[test]
7673 fn interp_attrset_without_to_string_errors() {
7674 // An attrset interpolated without __toString is a type error.
7675 let result = eval(r#"let s = { x = 1; }; in "${s}""#);
7676 assert!(result.is_err());
7677 }
7678
7679 #[test]
7680 fn interp_attrset_with_to_string_protocol() {
7681 // __toString protocol returns a string when called with self.
7682 let v = ev(r#""${{ __toString = self: "ok"; }}""#);
7683 assert_eq!(v, Value::string("ok"));
7684 }
7685
7686 // ── Path PathRel / PathHome / PathAbs ─────────────────
7687
7688 #[test]
7689 fn eval_path_absolute_literal() {
7690 let v = ev("/tmp/foo");
7691 match v {
7692 Value::Path(p) => assert!(p.contains("/tmp/foo")),
7693 _ => panic!("expected Path"),
7694 }
7695 }
7696
7697 #[test]
7698 fn eval_path_home_literal() {
7699 let v = ev("~/foo.nix");
7700 match v {
7701 Value::Path(p) => assert!(p.contains("~/foo.nix") || p.ends_with("foo.nix")),
7702 _ => panic!("expected Path"),
7703 }
7704 }
7705
7706 // ── search path miss ──────────────────────────────────
7707
7708 #[test]
7709 fn path_search_unmatched_errors() {
7710 // Without NIX_PATH entries matching, <nonexistent> errors out.
7711 // We unset NIX_PATH locally to ensure no entries match.
7712 let saved = std::env::var("NIX_PATH").ok();
7713 // SAFETY: tests run sequentially in single-threaded mode by
7714 // default? The thread_local NIX_PATH is per-thread but std::env
7715 // is process-global. We restore it after.
7716 unsafe {
7717 std::env::remove_var("NIX_PATH");
7718 }
7719 let result = eval("<this_should_not_resolve>");
7720 if let Some(v) = saved {
7721 unsafe {
7722 std::env::set_var("NIX_PATH", v);
7723 }
7724 }
7725 assert!(result.is_err());
7726 }
7727
7728 // ── Unary operators ────────────────────────────────────
7729
7730 #[test]
7731 fn unary_negate_int() {
7732 assert_eq!(ev("-7"), Value::Int(-7));
7733 }
7734
7735 #[test]
7736 fn unary_negate_float() {
7737 assert_eq!(ev("-2.5"), Value::Float(-2.5));
7738 }
7739
7740 #[test]
7741 fn unary_invert_true() {
7742 assert_eq!(ev("!true"), Value::Bool(false));
7743 }
7744
7745 #[test]
7746 fn unary_invert_false() {
7747 assert_eq!(ev("!false"), Value::Bool(true));
7748 }
7749
7750 #[test]
7751 fn unary_negate_bool_errors() {
7752 let result = eval("-true");
7753 assert!(result.is_err());
7754 }
7755
7756 #[test]
7757 fn unary_invert_int_errors() {
7758 let result = eval("!42");
7759 assert!(result.is_err());
7760 }
7761
7762 // ── Binary op type errors ──────────────────────────────
7763
7764 #[test]
7765 fn binop_add_attrs_errors() {
7766 let result = eval("{a=1;} + {b=2;}");
7767 assert!(result.is_err());
7768 }
7769
7770 #[test]
7771 fn binop_sub_string_errors() {
7772 let result = eval(r#""a" - "b""#);
7773 assert!(result.is_err());
7774 }
7775
7776 #[test]
7777 fn binop_mul_string_errors() {
7778 let result = eval(r#""a" * "b""#);
7779 assert!(result.is_err());
7780 }
7781
7782 #[test]
7783 fn binop_div_string_errors() {
7784 let result = eval(r#""a" / "b""#);
7785 assert!(result.is_err());
7786 }
7787
7788 #[test]
7789 fn binop_compare_attrs_errors() {
7790 let result = eval("{a=1;} < {b=2;}");
7791 assert!(result.is_err());
7792 }
7793
7794 #[test]
7795 fn binop_div_float_by_zero_int() {
7796 // Float / int(0) is NOT a DivisionByZero error in this evaluator —
7797 // only int/int matches the DivisionByZero branch. This documents
7798 // that branch.
7799 let result = eval("1.0 / 0");
7800 // Either inf or error is acceptable; the documented branch is
7801 // the int/int(0) → DivisionByZero one.
7802 let _ = result;
7803 }
7804
7805 #[test]
7806 fn binop_int_div_zero_is_division_by_zero() {
7807 let result = eval("5 / 0");
7808 match result {
7809 Err(EvalError::DivisionByZero) => {}
7810 other => panic!("expected DivisionByZero, got {other:?}"),
7811 }
7812 }
7813
7814 // ── if/then/else laziness ──────────────────────────────
7815
7816 #[test]
7817 fn if_else_only_chosen_branch_evaluated_then() {
7818 // The else branch contains a divide-by-zero that would error
7819 // if eagerly evaluated. Choosing the then branch must skip it.
7820 assert_eq!(ev("if true then 42 else 1 / 0"), Value::Int(42));
7821 }
7822
7823 #[test]
7824 fn if_else_only_chosen_branch_evaluated_else() {
7825 assert_eq!(ev("if false then 1 / 0 else 99"), Value::Int(99));
7826 }
7827
7828 #[test]
7829 fn if_condition_must_be_bool() {
7830 let result = eval("if 1 then 1 else 2");
7831 assert!(result.is_err());
7832 }
7833
7834 #[test]
7835 fn if_condition_lazy_does_not_force_unused() {
7836 // Lazy `let` ensures that `bad` is only forced if the chosen
7837 // branch references it.
7838 assert_eq!(
7839 ev("let bad = 1 / 0; in if true then 42 else bad"),
7840 Value::Int(42),
7841 );
7842 }
7843
7844 // ── Logic short-circuit laziness ───────────────────────
7845
7846 #[test]
7847 fn and_short_circuits_on_false() {
7848 // RHS contains an error; should never run.
7849 assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
7850 }
7851
7852 #[test]
7853 fn or_short_circuits_on_true() {
7854 assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
7855 }
7856
7857 #[test]
7858 fn implication_short_circuits_on_false_lhs() {
7859 // false -> anything is true; RHS not evaluated.
7860 assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
7861 }
7862
7863 // ── Lambda fixpoint via let ────────────────────────────
7864
7865 #[test]
7866 fn lambda_fix_combinator_returns_attrset() {
7867 // The classic `fix = f: let x = f x; in x` shape.
7868 let v = ev(
7869 "let fix = f: let x = f x; in x; in
7870 (fix (self: { val = 1; double = self.val * 2; })).double",
7871 );
7872 assert_eq!(v, Value::Int(2));
7873 }
7874
7875 // ── eval_attrset rec scope details ─────────────────────
7876
7877 #[test]
7878 fn rec_attrset_self_reference() {
7879 // rec set with simple forward reference.
7880 let v = ev("(rec { a = b; b = 1; }).a");
7881 assert_eq!(v, Value::Int(1));
7882 }
7883
7884 #[test]
7885 fn rec_attrset_inherit_from_uses_outer_scope() {
7886 // inherit-from in rec uses the OUTER (lexical) scope to evaluate
7887 // the source expression, not the rec scope. We bind `src` in
7888 // an outer let so the inherit can find it.
7889 let v = ev(
7890 "let src = { a = 10; }; in
7891 rec {
7892 inherit (src) a;
7893 b = a + 1;
7894 }",
7895 );
7896 if let Value::Attrs(attrs) = v {
7897 let b = attrs.get("b").unwrap();
7898 let b_forced = force_value(b).unwrap();
7899 assert_eq!(b_forced, Value::Int(11));
7900 } else {
7901 panic!("expected attrs");
7902 }
7903 }
7904
7905 #[test]
7906 fn nonrec_attrset_no_self_reference() {
7907 // In a non-rec set, a name doesn't see its sibling. The error
7908 // surfaces as an UndefinedVar when the thunk is forced.
7909 let result = eval("({ a = 1; b = a + 1; }).b");
7910 assert!(result.is_err());
7911 }
7912
7913 // ── eval_attrset deep merge edge cases ─────────────────
7914
7915 #[test]
7916 fn dotted_binding_three_segments_then_sibling() {
7917 let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
7918 if let Value::Attrs(attrs) = v {
7919 let a = attrs.get("a").unwrap();
7920 let a_forced = force_value(a).unwrap();
7921 if let Value::Attrs(a_attrs) = a_forced {
7922 let b = a_attrs.get("b").unwrap();
7923 let b_forced = force_value(b).unwrap();
7924 if let Value::Attrs(b_attrs) = b_forced {
7925 assert_eq!(force_value(b_attrs.get("c").unwrap()).unwrap(), Value::Int(1));
7926 assert_eq!(force_value(b_attrs.get("d").unwrap()).unwrap(), Value::Int(2));
7927 } else {
7928 panic!("expected b to be attrs");
7929 }
7930 assert_eq!(force_value(a_attrs.get("e").unwrap()).unwrap(), Value::Int(3));
7931 } else {
7932 panic!("expected a to be attrs");
7933 }
7934 } else {
7935 panic!("expected outer attrs");
7936 }
7937 }
7938
7939 // ── rec/let dotted bindings in recursive scope ────────
7940
7941 #[test]
7942 fn rec_dotted_bindings_visible_to_siblings() {
7943 // Dotted bindings in rec blocks must be visible to sibling
7944 // bindings -- this is the nixpkgs lib/systems/parse.nix pattern.
7945 let v = ev("rec { types.openSB = 1; types.openCpu = 2; foo = types.openSB; }.foo");
7946 assert_eq!(v, Value::Int(1));
7947 }
7948
7949 #[test]
7950 fn rec_dotted_leaf_uses_rec_scope() {
7951 // Leaf expressions in dotted bindings must see sibling
7952 // rec-bindings, not just the parent scope.
7953 let v = ev("rec { types.a = f 1; f = x: x + 1; }.types.a");
7954 assert_eq!(v, Value::Int(2));
7955 }
7956
7957 #[test]
7958 fn rec_dotted_multiple_keys_merge() {
7959 // Multiple dotted bindings sharing a top-level key must merge.
7960 let v = ev("rec { types.a = 1; types.b = 2; x = types; }.x");
7961 if let Value::Attrs(attrs) = v {
7962 assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7963 assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7964 } else {
7965 panic!("expected attrs");
7966 }
7967 }
7968
7969 #[test]
7970 fn rec_nixpkgs_parse_pattern() {
7971 // Simplified nixpkgs lib/systems/parse.nix pattern:
7972 // rec block with dotted types.xxx bindings that reference
7973 // each other through the rec scope.
7974 let v = ev(r#"
7975 let
7976 mkOptionType = x: x;
7977 mergeOneOption = "merge";
7978 attrValues = builtins.attrValues;
7979 setType = name: value: { __type = name; } // value;
7980 mapAttrs = builtins.mapAttrs;
7981 enum = xs: mkOptionType { name = "enum"; check = x: builtins.elem x xs; };
7982 setTypes = type: mapAttrs (name: value: setType type.name ({ inherit name; } // value));
7983 in
7984 rec {
7985 types.openSB = mkOptionType { name = "sb"; merge = mergeOneOption; };
7986 types.significantByte = enum (attrValues significantBytes);
7987 significantBytes = setTypes types.openSB { bigEndian = {}; littleEndian = {}; };
7988 types.openCpuType = mkOptionType { name = "cpu-type"; };
7989 types.cpuType = enum (attrValues cpuTypes);
7990 cpuTypes = setTypes types.openCpuType { arm = { bits = 32; }; };
7991 }.types.openCpuType
7992 "#);
7993 if let Value::Attrs(attrs) = v {
7994 assert_eq!(
7995 force_value(attrs.get("name").unwrap()).unwrap(),
7996 Value::string("cpu-type")
7997 );
7998 } else {
7999 panic!("expected attrs");
8000 }
8001 }
8002
8003 #[test]
8004 fn let_dotted_leaf_uses_let_scope() {
8005 // Dotted binding leaf in a let block sees sibling let-bindings.
8006 let v = ev("let a.x = f 1; f = x: x + 1; in a.x");
8007 assert_eq!(v, Value::Int(2));
8008 }
8009
8010 #[test]
8011 fn let_inherit_from_plus_dotted_is_rejected() {
8012 // `inherit (src) types;` and `types.added = true;` in one `let` are two
8013 // definitions of `types`, and an inherited name can never merge — it
8014 // binds the name outright. CppNix rejects it at parse time:
8015 //
8016 // error: attribute 'types' already defined at «string»:1:60
8017 //
8018 // ★ THIS TEST USED TO ASSERT THE WRONG ANSWER, on purpose, and said so:
8019 // "Sui currently lets the dotted binding win (last-write-wins). This
8020 // test documents the current behaviour -- when we add duplicate
8021 // detection it should change to assert an error." That is this change.
8022 // The old expectation was `added = true` with `existing` SILENTLY GONE,
8023 // at exit 0.
8024 let err = eval(
8025 r#"
8026 let
8027 src = { types = { existing = true; }; };
8028 inherit (src) types;
8029 types.added = true;
8030 in types
8031 "#,
8032 )
8033 .expect_err("a duplicate definition must be refused, not resolved by last-write-wins");
8034 let msg = err.to_string();
8035 assert!(
8036 msg.contains("attribute 'types' already defined"),
8037 "the refusal must name the attribute: {msg}"
8038 );
8039 }
8040
8041 // ── Function pattern variations ────────────────────────
8042
8043 #[test]
8044 fn pattern_empty_no_args_no_ellipsis() {
8045 // {} pattern accepts only an empty attrset.
8046 assert_eq!(ev("({}: 1) {}"), Value::Int(1));
8047 }
8048
8049 #[test]
8050 fn pattern_empty_with_ellipsis_accepts_extra() {
8051 assert_eq!(ev("({...}: 1) { a = 1; b = 2; }"), Value::Int(1));
8052 }
8053
8054 #[test]
8055 fn pattern_all_defaults() {
8056 assert_eq!(
8057 ev("({a ? 1, b ? 2}: a + b) {}"),
8058 Value::Int(3),
8059 );
8060 }
8061
8062 #[test]
8063 fn pattern_at_bind_before() {
8064 // args @ { x }: args.x — bind name comes before pattern.
8065 assert_eq!(ev("(args @ { x }: args.x) { x = 7; }"), Value::Int(7));
8066 }
8067
8068 #[test]
8069 fn pattern_at_bind_after() {
8070 // { x } @ args: args.x — bind name comes after pattern.
8071 assert_eq!(ev("({ x } @ args: args.x) { x = 7; }"), Value::Int(7));
8072 }
8073
8074 #[test]
8075 fn pattern_default_references_other_arg() {
8076 // The default for `b` references `a` (which exists).
8077 assert_eq!(ev("({a, b ? a + 1}: b) {a = 10;}"), Value::Int(11));
8078 }
8079
8080 #[test]
8081 fn pattern_required_missing_errors() {
8082 let result = eval("({ a, b }: a) { a = 1; }");
8083 assert!(result.is_err());
8084 }
8085
8086 #[test]
8087 fn pattern_unexpected_errors_without_ellipsis() {
8088 let result = eval("({ a }: a) { a = 1; b = 2; }");
8089 assert!(result.is_err());
8090 }
8091
8092 // ── apply: error on non-callable ───────────────────────
8093
8094 #[test]
8095 fn apply_int_errors() {
8096 let result = eval("42 5");
8097 assert!(result.is_err());
8098 }
8099
8100 #[test]
8101 fn apply_string_errors() {
8102 let result = eval(r#""hi" 5"#);
8103 assert!(result.is_err());
8104 }
8105
8106 #[test]
8107 fn apply_attrset_without_functor_errors() {
8108 let result = eval("{ x = 1; } 5");
8109 assert!(result.is_err());
8110 let msg = format!("{}", result.unwrap_err());
8111 assert!(msg.contains("__functor") || msg.contains("cannot call"));
8112 }
8113
8114 // ── Select with multi-segment + default ────────────────
8115
8116 #[test]
8117 fn select_multi_segment_with_default() {
8118 // a.b.missing or 99 -- the missing segment yields the default.
8119 assert_eq!(ev("{ a = { b = 1; }; }.a.c or 99"), Value::Int(99));
8120 }
8121
8122 #[test]
8123 fn select_from_int_errors() {
8124 let result = eval("(1).x");
8125 assert!(result.is_err());
8126 }
8127
8128 // ── HasAttr edge cases ─────────────────────────────────
8129
8130 #[test]
8131 fn has_attr_on_non_set_returns_false() {
8132 // `expr ? a` where expr is not a set returns false (not error).
8133 assert_eq!(ev("1 ? x"), Value::Bool(false));
8134 }
8135
8136 #[test]
8137 fn has_attr_nested_path_present() {
8138 assert_eq!(ev("{ a = { b = 1; }; } ? a.b"), Value::Bool(true));
8139 }
8140
8141 #[test]
8142 fn has_attr_nested_path_missing() {
8143 assert_eq!(ev("{ a = { b = 1; }; } ? a.c"), Value::Bool(false));
8144 }
8145
8146 #[test]
8147 fn has_attr_intermediate_missing_returns_false() {
8148 assert_eq!(ev("{} ? a.b.c"), Value::Bool(false));
8149 }
8150
8151 // ── List eval edge cases ───────────────────────────────
8152
8153 #[test]
8154 fn list_with_function_value() {
8155 let v = ev("[(x: x + 1)]");
8156 if let Value::List(items) = v {
8157 assert_eq!(items.len(), 1);
8158 // List elements are now lazy (thunked). Force to check type.
8159 let forced = force_value(&items[0]).unwrap();
8160 assert!(matches!(forced, Value::Lambda(_)));
8161 } else {
8162 panic!("expected list");
8163 }
8164 }
8165
8166 // ── eval_inherit edge: inherit from missing var ────────
8167
8168 #[test]
8169 fn inherit_unknown_name_errors() {
8170 let result = eval("let x = 1; in let inherit nonexistent; in nonexistent");
8171 assert!(result.is_err());
8172 }
8173
8174 // ── String op: string concat preserves context ─────────
8175
8176 #[test]
8177 fn string_concat_no_context_when_both_plain() {
8178 let v = ev(r#""abc" + "def""#);
8179 if let Value::String(ns) = v {
8180 assert_eq!(ns.chars, "abcdef");
8181 assert!(!ns.has_context());
8182 } else {
8183 panic!("expected string");
8184 }
8185 }
8186
8187 // ── Parens / Root ──────────────────────────────────────
8188
8189 #[test]
8190 fn parens_around_expression() {
8191 assert_eq!(ev("(1 + 2)"), Value::Int(3));
8192 }
8193
8194 #[test]
8195 fn nested_parens() {
8196 assert_eq!(ev("(((42)))"), Value::Int(42));
8197 }
8198
8199 // ── Throw via builtins ─────────────────────────────────
8200
8201 #[test]
8202 fn throw_propagates_as_error() {
8203 let result = eval(r#"builtins.throw "kaboom""#);
8204 match result {
8205 Err(EvalError::Throw(s)) => assert!(s.contains("kaboom")),
8206 other => panic!("expected Throw, got {other:?}"),
8207 }
8208 }
8209
8210 #[test]
8211 fn assert_failed_propagates_as_error() {
8212 let result = eval("assert false; 1");
8213 match result {
8214 Err(EvalError::AssertionFailed(_)) => {}
8215 other => panic!("expected AssertionFailed, got {other:?}"),
8216 }
8217 }
8218
8219 // ── eval_str InterpolPart::Literal only ────────────────
8220
8221 #[test]
8222 fn string_no_interp_yields_no_context() {
8223 let v = ev(r#""just literal""#);
8224 if let Value::String(ns) = v {
8225 assert!(!ns.has_context());
8226 } else {
8227 panic!("expected string");
8228 }
8229 }
8230
8231 // ── Path interpolation adds context ───────────────────
8232
8233 // Byte-parity root #5: interpolating a source path is CppNix copy-to-store
8234 // coercion — the path is NAR-copied into /nix/store/<hash>-<name> and the
8235 // store path (with store-path context) is spliced in, not the raw path.
8236 // NAR of a single regular file is content+basename only (location-
8237 // independent), so a temp <dir>/data.txt of "hello\n" yields the exact
8238 // store path nix 2.34 produced: /nix/store/y9dmv…-data.txt.
8239 #[test]
8240 fn interp_path_copies_to_store_byte_matches_cppnix() {
8241 let dir = std::env::temp_dir().join(format!("sui-r5-interp-{}", std::process::id()));
8242 let _ = std::fs::remove_dir_all(&dir);
8243 std::fs::create_dir_all(&dir).unwrap();
8244 let f = dir.join("data.txt");
8245 std::fs::write(&f, b"hello\n").unwrap();
8246 let expr = format!(r#""${{{}}}""#, f.display());
8247 let v = eval(&expr).unwrap();
8248 if let Value::String(ns) = v {
8249 assert_eq!(
8250 ns.chars.to_string(),
8251 "/nix/store/y9dmvfhip31hg8ia4njwjz9vfa3ndphr-data.txt",
8252 );
8253 assert!(ns.has_context());
8254 } else {
8255 panic!("expected string");
8256 }
8257 let _ = std::fs::remove_dir_all(&dir);
8258 }
8259
8260 // ── pipe operators (NotImplemented) ────────────────────
8261 // Pipe operators (|>, <|) are parsed as PipeRight/PipeLeft and
8262 // currently return NotImplemented. We can't easily evaluate them
8263 // here because rnix may not even parse them, so we just rely on
8264 // the binop branch existing.
8265
8266 // ── ParseError surface ─────────────────────────────────
8267
8268 #[test]
8269 fn parse_error_unbalanced_braces() {
8270 let result = eval("{ a = 1");
8271 assert!(result.is_err());
8272 let err = result.unwrap_err();
8273 assert!(matches!(err, EvalError::ParseError(_)));
8274 }
8275
8276 #[test]
8277 fn parse_error_dangling_let() {
8278 let result = eval("let in");
8279 assert!(result.is_err());
8280 }
8281
8282 #[test]
8283 fn parse_error_empty_input() {
8284 let result = eval("");
8285 assert!(result.is_err());
8286 }
8287
8288 // ── num_op coverage via float ops ──────────────────────
8289
8290 #[test]
8291 fn float_int_subtraction() {
8292 assert_eq!(ev("3.5 - 1"), Value::Float(2.5));
8293 }
8294
8295 #[test]
8296 fn int_float_subtraction() {
8297 assert_eq!(ev("3 - 0.5"), Value::Float(2.5));
8298 }
8299
8300 #[test]
8301 fn float_float_division() {
8302 assert_eq!(ev("6.0 / 2.0"), Value::Float(3.0));
8303 }
8304
8305 #[test]
8306 fn int_float_multiplication() {
8307 assert_eq!(ev("3 * 2.5"), Value::Float(7.5));
8308 }
8309
8310 // ── compare with mixed numerics ────────────────────────
8311
8312 #[test]
8313 fn compare_int_float_less() {
8314 assert_eq!(ev("1 < 1.5"), Value::Bool(true));
8315 }
8316
8317 #[test]
8318 fn compare_float_int_more() {
8319 assert_eq!(ev("3.5 > 3"), Value::Bool(true));
8320 }
8321
8322 #[test]
8323 fn compare_equal_int_float() {
8324 assert_eq!(ev("3 <= 3.0"), Value::Bool(true));
8325 }
8326
8327 // ── Equality ──────────────────────────────────────────
8328
8329 #[test]
8330 fn equal_lists_same() {
8331 assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
8332 }
8333
8334 #[test]
8335 fn equal_lists_diff_length() {
8336 assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
8337 }
8338
8339 #[test]
8340 fn not_equal_lists() {
8341 assert_eq!(ev("[1] != [2]"), Value::Bool(true));
8342 }
8343
8344 #[test]
8345 fn equal_attrsets_same() {
8346 assert_eq!(ev("{a = 1; b = 2;} == {b = 2; a = 1;}"), Value::Bool(true));
8347 }
8348
8349 // ── Lambda identity equality (Rc ptr_eq) ────────────────
8350 // Regression test: same lambda via Rc must compare equal.
8351 // Without this, nixpkgs stdenv evaluation enters an infinite loop
8352 // because `crossSystem != localSystem` returns true even when both
8353 // are the same elaborate result (containing shared function attrs).
8354
8355 #[test]
8356 fn lambda_self_equality_in_attrset() {
8357 // Same closure shared via let → inherit must be equal
8358 assert_eq!(
8359 ev("let f = x: x; in { a = 1; inherit f; } == { a = 1; inherit f; }"),
8360 Value::Bool(true),
8361 );
8362 }
8363
8364 #[test]
8365 fn lambda_self_reference_attrset_equality() {
8366 // Attrset with function attr: x == x must be true
8367 assert_eq!(
8368 ev("let x = { a = 1; f = y: y; }; in x == x"),
8369 Value::Bool(true),
8370 );
8371 }
8372
8373 #[test]
8374 fn lambda_different_closures_not_equal() {
8375 // Different lambda closures (even structurally identical) must be false
8376 assert_eq!(
8377 ev("{ f = x: x; } == { f = x: x; }"),
8378 Value::Bool(false),
8379 );
8380 }
8381
8382 #[test]
8383 fn lambda_ne_does_not_force_unused_branch() {
8384 // If crossSystem == localSystem (same obj), != returns false,
8385 // and the then-branch (with throw) is never forced.
8386 assert_eq!(
8387 ev("let ls = { a = 1; f = x: x; }; in if ls != ls then builtins.throw \"bug\" else 42"),
8388 Value::Int(42),
8389 );
8390 }
8391
8392 // ── force_value chains thunks ──────────────────────────
8393
8394 #[test]
8395 fn force_value_through_thunk() {
8396 let root = rnix::Root::parse("1 + 2");
8397 let expr = root.tree().expr().unwrap();
8398 let thunk = Thunk::new_suspended(expr, Env::new());
8399 let val = Value::Thunk(thunk);
8400 assert_eq!(force_value(&val).unwrap(), Value::Int(3));
8401 }
8402
8403 // ── Builtin name "tryEval" lazy arg path ──────────────
8404
8405 #[test]
8406 fn try_eval_catches_thrown_error() {
8407 // tryEval wraps the thunk and catches throws inside.
8408 let v = ev(r#"(builtins.tryEval (builtins.throw "oops")).success"#);
8409 assert_eq!(v, Value::Bool(false));
8410 }
8411
8412 #[test]
8413 fn try_eval_returns_value_on_success() {
8414 let v = ev("(builtins.tryEval 42).value");
8415 assert_eq!(v, Value::Int(42));
8416 }
8417
8418 // ── LegacyLet (`let { body = ...; ...}`) ───────────────
8419
8420 #[test]
8421 fn legacy_let_returns_body_attr() {
8422 // `let { x = 1; body = x + 41; }` is the legacy let form: it
8423 // is desugared as a recursive set whose `body` attr is the
8424 // result.
8425 assert_eq!(ev("let { x = 1; body = x + 41; }"), Value::Int(42));
8426 }
8427
8428 #[test]
8429 fn legacy_let_missing_body_errors() {
8430 let result = eval("let { x = 1; }");
8431 assert!(result.is_err());
8432 }
8433
8434 #[test]
8435 fn legacy_let_with_inherit_from_scope() {
8436 assert_eq!(
8437 ev("let outer = 5; in let { inherit outer; body = outer * 2; }"),
8438 Value::Int(10),
8439 );
8440 }
8441
8442 // ── eval_str interpolation more cases ──────────────────
8443
8444 #[test]
8445 fn interp_with_string_concat_preserves_order() {
8446 assert_eq!(
8447 ev(r#"let a = "x"; b = "y"; in "${a}-${b}""#),
8448 Value::string("x-y"),
8449 );
8450 }
8451
8452 #[test]
8453 fn interp_only_literal_part() {
8454 assert_eq!(ev(r#""no interp here""#), Value::string("no interp here"));
8455 }
8456
8457 // ── eval_attr dynamic / string keys ────────────────────
8458
8459 #[test]
8460 fn dynamic_attr_via_string_key_in_set() {
8461 // `{ "a" = 1; }.a` works because attr keys can be string literals.
8462 assert_eq!(ev(r#"{ "a" = 1; }.a"#), Value::Int(1));
8463 }
8464
8465 #[test]
8466 fn dynamic_attr_via_interpolated_key() {
8467 let v = ev(r#"let k = "foo"; in { ${k} = 99; }.foo"#);
8468 assert_eq!(v, Value::Int(99));
8469 }
8470
8471 // ── String key access via select with dynamic ──────────
8472
8473 #[test]
8474 fn select_with_string_key() {
8475 let v = ev(r#"{ a = 42; }."a""#);
8476 assert_eq!(v, Value::Int(42));
8477 }
8478
8479 // ── Apply via __functor on attrset ─────────────────────
8480
8481 #[test]
8482 fn apply_attrset_with_functor_works() {
8483 let v = ev("let s = { __functor = self: x: x + 1; }; in s 5");
8484 assert_eq!(v, Value::Int(6));
8485 }
8486
8487 // ── Negation of negative ───────────────────────────────
8488
8489 #[test]
8490 fn double_negate_int() {
8491 assert_eq!(ev("- (-5)"), Value::Int(5));
8492 }
8493
8494 // ── Inherit from rec scope binding visibility ──────────
8495
8496 #[test]
8497 fn inherit_in_let_makes_name_available() {
8498 assert_eq!(
8499 ev("let src = { a = 7; }; in let inherit (src) a; in a"),
8500 Value::Int(7),
8501 );
8502 }
8503
8504 // ── String + path ──────────────────────────────────────
8505
8506 #[test]
8507 fn path_plus_string_yields_path() {
8508 let v = ev(r#"/foo + "/bar""#);
8509 match v {
8510 Value::Path(p) => assert_eq!(&*p, "/foo/bar"),
8511 _ => panic!("expected path"),
8512 }
8513 }
8514
8515 // ── Lazy attrset value not forced unless selected ──────
8516
8517 #[test]
8518 fn attrset_value_not_forced_unless_selected() {
8519 // `bad` is an attr whose value would error if forced, but we
8520 // only ever select `good`, so it's never touched.
8521 assert_eq!(
8522 ev(r#"{ bad = builtins.throw "boom"; good = 42; }.good"#),
8523 Value::Int(42),
8524 );
8525 }
8526
8527 // ── Lambda calling itself via let ──────────────────────
8528
8529 #[test]
8530 fn lambda_recursive_via_let() {
8531 // factorial via let-bound recursive function
8532 assert_eq!(
8533 ev("let fact = n: if n == 0 then 1 else n * fact (n - 1); in fact 5"),
8534 Value::Int(120),
8535 );
8536 }
8537
8538 // ── Dynamic key in select ──────────────────────────────
8539
8540 #[test]
8541 fn select_with_dynamic_key_via_var() {
8542 // ${k} interpolation in select position is not standard Nix
8543 // syntax, but a string-literal key works for select.
8544 assert_eq!(ev(r#"let k = { x = 1; }; in k.x"#), Value::Int(1));
8545 }
8546
8547 // ── Compare strings ────────────────────────────────────
8548
8549 #[test]
8550 fn compare_string_lex_greater_or_equal() {
8551 assert_eq!(ev(r#""b" >= "a""#), Value::Bool(true));
8552 assert_eq!(ev(r#""a" >= "a""#), Value::Bool(true));
8553 assert_eq!(ev(r#""a" >= "b""#), Value::Bool(false));
8554 }
8555
8556 // ── PartialEq across types ─────────────────────────────
8557
8558 #[test]
8559 fn equal_int_string_false() {
8560 assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
8561 }
8562
8563 #[test]
8564 fn equal_null_int_false() {
8565 assert_eq!(ev("null == 0"), Value::Bool(false));
8566 }
8567
8568 // ── Update operator on thunked operands ────────────────
8569
8570 #[test]
8571 fn update_with_let_bound_operands() {
8572 assert_eq!(
8573 ev("let a = { x = 1; }; b = { y = 2; }; in (a // b).y"),
8574 Value::Int(2),
8575 );
8576 }
8577
8578 // ── Concat on let-bound lists ──────────────────────────
8579
8580 #[test]
8581 fn concat_lists_from_let() {
8582 assert_eq!(
8583 ev("let a = [1 2]; b = [3 4]; in builtins.length (a ++ b)"),
8584 Value::Int(4),
8585 );
8586 }
8587
8588 // ── String interpolation: list coercion ─────────────────
8589
8590 #[test]
8591 fn interp_list_coerces_with_spaces() {
8592 // Lists in interpolation are now coerced via coerce_to_string
8593 // (space-joined elements).
8594 assert_eq!(
8595 ev(r#""${toString [1 2 3]}""#),
8596 Value::string("1 2 3"),
8597 );
8598 }
8599
8600 #[test]
8601 fn interp_list_directly_coerces() {
8602 // Direct list interpolation space-joins elements via coerce_to_string.
8603 assert_eq!(
8604 ev(r#""${[1 2]}""#),
8605 Value::string("1 2"),
8606 );
8607 }
8608
8609 // ── String interpolation: outPath ─────────────────────
8610
8611 #[test]
8612 fn interp_outpath_attrset() {
8613 assert_eq!(
8614 ev(r#"let x = { outPath = "/nix/store/abc"; }; in "${x}""#),
8615 Value::string("/nix/store/abc"),
8616 );
8617 }
8618
8619 #[test]
8620 fn interp_tostring_takes_priority_over_outpath() {
8621 assert_eq!(
8622 ev(r#"let x = { __toString = self: "custom"; outPath = "/ignored"; }; in "${x}""#),
8623 Value::string("custom"),
8624 );
8625 }
8626
8627 #[test]
8628 fn interp_derivation_coerces_to_outpath() {
8629 // derivation produces an attrset with outPath
8630 let result = eval(r#"
8631 let drv = builtins.derivation {
8632 name = "test";
8633 system = "x86_64-linux";
8634 builder = "/bin/sh";
8635 };
8636 in "${drv}"
8637 "#).unwrap();
8638 if let Value::String(s) = result {
8639 assert!(s.chars.starts_with("/nix/store/"), "got: {}", s.chars);
8640 } else {
8641 panic!("expected string");
8642 }
8643 }
8644
8645 // ── String interpolation: lambda error ─────────────────
8646
8647 #[test]
8648 fn interp_lambda_errors() {
8649 let result = eval(r#""${x: x}""#);
8650 assert!(result.is_err());
8651 }
8652
8653 // ── force_value tests ────────────────────────────────────
8654
8655 #[test]
8656 fn force_value_int_returns_same() {
8657 let v = Value::Int(42);
8658 assert_eq!(force_value(&v).unwrap(), Value::Int(42));
8659 }
8660
8661 #[test]
8662 fn force_value_bool_returns_same() {
8663 let v = Value::Bool(true);
8664 assert_eq!(force_value(&v).unwrap(), Value::Bool(true));
8665 }
8666
8667 #[test]
8668 fn force_value_string_returns_same() {
8669 let v = Value::string("hello");
8670 assert_eq!(force_value(&v).unwrap(), Value::string("hello"));
8671 }
8672
8673 #[test]
8674 fn force_value_attrs_returns_same() {
8675 let mut a = NixAttrs::new();
8676 a.insert("x".to_string(), Value::Int(1));
8677 let v = Value::Attrs(Rc::new(a.clone()));
8678 assert_eq!(force_value(&v).unwrap(), Value::Attrs(Rc::new(a)));
8679 }
8680
8681 #[test]
8682 fn force_value_list_returns_same() {
8683 let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
8684 assert_eq!(
8685 force_value(&v).unwrap(),
8686 Value::list(vec![Value::Int(1), Value::Int(2)]),
8687 );
8688 }
8689
8690 #[test]
8691 fn force_value_null_returns_null() {
8692 let v = Value::Null;
8693 assert_eq!(force_value(&v).unwrap(), Value::Null);
8694 }
8695
8696 #[test]
8697 fn force_value_evaluated_thunk_returns_cached() {
8698 // Thunk wrapping a simple expression should evaluate and cache
8699 let v = ev("let x = 1 + 2; in x");
8700 assert_eq!(v, Value::Int(3));
8701 // Force again — should return the cached value
8702 assert_eq!(force_value(&v).unwrap(), Value::Int(3));
8703 }
8704
8705 // ── Tail-call loop tests ─────────────────────────────────
8706
8707 #[test]
8708 fn tco_if_true_condition() {
8709 assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
8710 }
8711
8712 #[test]
8713 fn tco_if_false_condition() {
8714 assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
8715 }
8716
8717 #[test]
8718 fn tco_deeply_nested_if_else_chain() {
8719 // Build a chain: if false then 1 else if false then 2 else ... else 150
8720 // All conditions are false except the final else, which produces 150.
8721 let mut expr = String::from("150");
8722 for i in (1..150).rev() {
8723 expr = format!("if false then {} else {}", i, expr);
8724 }
8725 let v = ev(&expr);
8726 assert_eq!(v, Value::Int(150));
8727 }
8728
8729 #[test]
8730 fn tco_assert_true_passes_through() {
8731 assert_eq!(ev("assert true; 42"), Value::Int(42));
8732 }
8733
8734 #[test]
8735 fn tco_assert_false_throws_assertion_failed() {
8736 let result = eval("assert false; 42");
8737 assert!(result.is_err());
8738 let err = result.unwrap_err();
8739 assert!(
8740 matches!(err, EvalError::AssertionFailed(_)),
8741 "expected AssertionFailed, got: {err}",
8742 );
8743 }
8744
8745 #[test]
8746 fn tco_with_makes_scope_available() {
8747 assert_eq!(ev("with { x = 10; y = 20; }; x + y"), Value::Int(30));
8748 }
8749
8750 #[test]
8751 fn tco_let_in_creates_bindings() {
8752 assert_eq!(ev("let a = 5; in a"), Value::Int(5));
8753 }
8754
8755 #[test]
8756 fn tco_let_in_multiple_bindings() {
8757 assert_eq!(ev("let a = 1; b = 2; c = 3; in a + b + c"), Value::Int(6));
8758 }
8759
8760 // ── eval_attrset tests ───────────────────────────────────
8761
8762 #[test]
8763 fn eval_attrset_empty() {
8764 let v = ev("{}");
8765 if let Value::Attrs(attrs) = v {
8766 assert!(attrs.is_empty(), "expected empty attrset");
8767 } else {
8768 panic!("expected attrset, got {v:?}");
8769 }
8770 }
8771
8772 #[test]
8773 fn eval_attrset_simple_kv() {
8774 let v = ev("{ a = 1; b = 2; }");
8775 if let Value::Attrs(attrs) = v {
8776 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8777 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8778 } else {
8779 panic!("expected attrset, got {v:?}");
8780 }
8781 }
8782
8783 #[test]
8784 fn eval_attrset_recursive() {
8785 assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
8786 assert_eq!(ev("(rec { a = 1; b = a + 1; }).a"), Value::Int(1));
8787 }
8788
8789 #[test]
8790 fn eval_attrset_inherit_from_scope() {
8791 assert_eq!(ev("let x = 1; in { inherit x; }.x"), Value::Int(1));
8792 }
8793
8794 #[test]
8795 fn eval_attrset_inherit_from_expr() {
8796 assert_eq!(
8797 ev("{ inherit (builtins) true; }.true"),
8798 Value::Bool(true),
8799 );
8800 }
8801
8802 #[test]
8803 fn eval_attrset_dotted_path() {
8804 assert_eq!(ev("{ a.b.c = 1; }.a.b.c"), Value::Int(1));
8805 }
8806
8807 #[test]
8808 fn eval_attrset_update_merge() {
8809 let v = ev("{ a = 1; } // { b = 2; }");
8810 if let Value::Attrs(attrs) = v {
8811 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8812 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8813 } else {
8814 panic!("expected attrset, got {v:?}");
8815 }
8816 }
8817
8818 // ── eval_apply tests ─────────────────────────────────────
8819
8820 #[test]
8821 fn eval_apply_simple_function() {
8822 assert_eq!(ev("(x: x + 1) 2"), Value::Int(3));
8823 }
8824
8825 #[test]
8826 fn eval_apply_pattern_destructuring() {
8827 assert_eq!(ev("({a, b}: a + b) { a = 1; b = 2; }"), Value::Int(3));
8828 }
8829
8830 #[test]
8831 fn eval_apply_default_arguments() {
8832 assert_eq!(ev("({a, b ? 0}: a + b) { a = 1; }"), Value::Int(1));
8833 }
8834
8835 #[test]
8836 fn eval_apply_ellipsis() {
8837 assert_eq!(ev("({a, ...}: a) { a = 1; b = 2; }"), Value::Int(1));
8838 }
8839
8840 // ── eval_select tests ────────────────────────────────────
8841
8842 #[test]
8843 fn eval_select_single_key() {
8844 assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
8845 }
8846
8847 #[test]
8848 fn eval_select_multi_level() {
8849 assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
8850 }
8851
8852 #[test]
8853 fn eval_select_with_or_default() {
8854 assert_eq!(ev("{}.a or 42"), Value::Int(42));
8855 }
8856
8857 #[test]
8858 fn eval_select_missing_key_without_default_throws() {
8859 let result = eval("{}.a");
8860 assert!(result.is_err());
8861 }
8862
8863 // ── BinOp tests ──────────────────────────────────────────
8864
8865 #[test]
8866 fn binop_add_ints() {
8867 assert_eq!(ev("1 + 2"), Value::Int(3));
8868 }
8869
8870 #[test]
8871 fn binop_sub_ints() {
8872 assert_eq!(ev("3 - 1"), Value::Int(2));
8873 }
8874
8875 #[test]
8876 fn binop_mul_ints() {
8877 assert_eq!(ev("2 * 3"), Value::Int(6));
8878 }
8879
8880 #[test]
8881 fn binop_div_ints() {
8882 assert_eq!(ev("6 / 2"), Value::Int(3));
8883 }
8884
8885 #[test]
8886 fn binop_float_arithmetic() {
8887 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
8888 }
8889
8890 #[test]
8891 fn binop_string_concat() {
8892 assert_eq!(
8893 ev(r#""hello" + " " + "world""#),
8894 Value::string("hello world"),
8895 );
8896 }
8897
8898 #[test]
8899 fn binop_list_concat() {
8900 assert_eq!(
8901 ev("[1 2] ++ [3 4]"),
8902 Value::list(vec![
8903 Value::Int(1),
8904 Value::Int(2),
8905 Value::Int(3),
8906 Value::Int(4),
8907 ]),
8908 );
8909 }
8910
8911 #[test]
8912 fn binop_attrset_update() {
8913 let v = ev("{ a = 1; } // { b = 2; }");
8914 if let Value::Attrs(attrs) = v {
8915 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8916 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8917 } else {
8918 panic!("expected attrset, got {v:?}");
8919 }
8920 }
8921
8922 #[test]
8923 fn binop_less_than() {
8924 assert_eq!(ev("1 < 2"), Value::Bool(true));
8925 assert_eq!(ev("2 < 1"), Value::Bool(false));
8926 }
8927
8928 #[test]
8929 fn binop_greater_than() {
8930 assert_eq!(ev("2 > 1"), Value::Bool(true));
8931 assert_eq!(ev("1 > 2"), Value::Bool(false));
8932 }
8933
8934 #[test]
8935 fn binop_equal() {
8936 assert_eq!(ev("1 == 1"), Value::Bool(true));
8937 assert_eq!(ev("1 == 2"), Value::Bool(false));
8938 }
8939
8940 #[test]
8941 fn binop_not_equal() {
8942 assert_eq!(ev("1 != 2"), Value::Bool(true));
8943 assert_eq!(ev("1 != 1"), Value::Bool(false));
8944 }
8945
8946 #[test]
8947 fn binop_logical_and() {
8948 assert_eq!(ev("true && false"), Value::Bool(false));
8949 assert_eq!(ev("true && true"), Value::Bool(true));
8950 }
8951
8952 #[test]
8953 fn binop_logical_or() {
8954 assert_eq!(ev("true || false"), Value::Bool(true));
8955 assert_eq!(ev("false || false"), Value::Bool(false));
8956 }
8957
8958 #[test]
8959 fn binop_logical_not() {
8960 assert_eq!(ev("!true"), Value::Bool(false));
8961 assert_eq!(ev("!false"), Value::Bool(true));
8962 }
8963
8964 #[test]
8965 fn binop_implication() {
8966 assert_eq!(ev("false -> true"), Value::Bool(true));
8967 assert_eq!(ev("false -> false"), Value::Bool(true));
8968 assert_eq!(ev("true -> true"), Value::Bool(true));
8969 assert_eq!(ev("true -> false"), Value::Bool(false));
8970 }
8971}
8972
8973/// A `sui-normalize` rejection, as the walker's error.
8974///
8975/// ★ `ParseError`, not a new variant, and not an eval error: nix rejects a
8976/// duplicate attribute during PARSING. Measured — `nix-instantiate --parse`
8977/// on `{ a = 1; a = 2; }` fails with `attribute 'a' already defined` and never
8978/// evaluates it. Filing this as an eval error would misreport WHEN it happens.
8979///
8980/// The message carries the attribute PATH but not nix's `«string»:1:3`
8981/// position or its caret block. That is a source-span formatter sui does not
8982/// have, and coupling it here would turn a small change into an
8983/// error-rendering project; the contract this stage signs up to is the exit
8984/// code and the attribute path.
8985fn reject(e: sui_normalize::NormalizeError) -> EvalError {
8986 EvalError::ParseError(format!("{e}{}", eval_file_ctx()))
8987}
8988
8989/// Build an attrset from a `sui-normalize` [`GroupPlan`].
8990///
8991/// This is the plan-driven replacement for the entry loops in
8992/// [`eval_attrset`] / the `LetIn` arm / `eval_entries`. It exists because
8993/// nix's duplicate-key merge is a **parse-time splice into the first-declared
8994/// node**, not a value-level union: the second side's bindings become
8995/// bindings *of the first node*, so they are scoped by it and the later
8996/// `rec` is discarded. `sui-normalize` performed that splice; this function
8997/// only evaluates the result.
8998///
8999/// The consequence worth stating: there is no merging here, and no collision
9000/// to resolve. `attrs.insert` is a plain insert because the plan's
9001/// postcondition is that no name appears twice. That is what retires
9002/// `merge_nested_insert` from the construction path — and with it the
9003/// force-to-WHNF-on-collision that turned
9004/// `let f = x: x+1; a.b = {x = f 1;}; a.b.y = 2; in a.b.x` into
9005/// `UndefinedVar 'f'` on an expression nix evaluates to `2`.
9006pub fn eval_plan_group(
9007 plan: &sui_normalize::GroupPlan,
9008 env: &Env,
9009) -> Result<Value, EvalError> {
9010 let (attrs, _scope) = bind_plan_group(plan, env)?;
9011 Ok(Value::Attrs(std::rc::Rc::new(attrs)))
9012}
9013
9014/// Build a plan's bindings, returning BOTH the attrset and the scope they were
9015/// bound in.
9016///
9017/// Two consumers need different halves of this. An attrset literal wants the
9018/// attrs; a `let` wants the scope, because a `let` is a binder for a body and
9019/// produces no attrset at all. Legacy-`let` (`let { … body = …; }`) wants the
9020/// attrs and then selects `body` from them.
9021fn bind_plan_group(
9022 plan: &sui_normalize::GroupPlan,
9023 env: &Env,
9024) -> Result<(NixAttrs, Env), EvalError> {
9025 use sui_normalize::Binding;
9026
9027 let mut attrs = NixAttrs::new();
9028 // A recursive group binds its own names; a non-recursive one does not.
9029 // `rec`-ness came from the FIRST declaration — see `sui-normalize`.
9030 let mut scope_env = if plan.recursive { env.child() } else { env.clone() };
9031 let mut thunks: Vec<Thunk> = Vec::new();
9032
9033 // `inherit (e)` sources: ONE thunk per clause, shared across every name
9034 // that clause binds, so `e` is evaluated at most once. Built against the
9035 // group's OWN scope — measured on nix: `rec { b = {x=99;}; inherit (b) x; }`
9036 // is `x = 99`, so the source sees the group it is being bound into.
9037 let from_thunks: Vec<Thunk> = plan
9038 .inherit_froms
9039 .iter()
9040 .map(|e| Thunk::new_suspended(e.clone(), scope_env.clone()))
9041 .collect();
9042
9043 for b in &plan.statics {
9044 let name = sui_intern::resolve(b.name).to_string();
9045 let value = match &b.binding {
9046 Binding::Leaf(expr) => {
9047 let t = Thunk::new_suspended(expr.clone(), scope_env.clone());
9048 thunks.push(t.clone());
9049 Value::Thunk(t)
9050 }
9051 Binding::Group(sub) => {
9052 let t = Thunk::new_plan_group(sub.clone(), scope_env.clone());
9053 thunks.push(t.clone());
9054 Value::Thunk(t)
9055 }
9056 // `inherit x` resolves in the ENCLOSING scope, never the group's
9057 // own rec scope — that is what makes it shadow rather than
9058 // self-reference, and why it can never merge.
9059 Binding::Inherit => env
9060 .lookup(&name)
9061 .ok_or_else(|| EvalError::UndefinedVar(format!("'{name}'")))?,
9062 Binding::InheritFrom { from } => {
9063 let t = Thunk::new_inherit_select(from_thunks[*from].clone(), &name);
9064 thunks.push(t.clone());
9065 Value::Thunk(t)
9066 }
9067 };
9068 // PLAIN insert: the plan guarantees no repeated name.
9069 attrs.insert(name.clone(), value.clone());
9070 if plan.recursive {
9071 scope_env.bind(name, value);
9072 }
9073 }
9074
9075 // Phase 2: re-point every thunk at the completed scope, so a binding that
9076 // references a LATER sibling resolves. `PlanGroup` is re-pointable for
9077 // exactly this reason.
9078 if plan.recursive {
9079 for t in &thunks {
9080 t.update_env(&scope_env);
9081 }
9082 }
9083
9084 // ── dynamic keys ─────────────────────────────────────────────────────
9085 //
9086 // `${e}` keys that did not constant-fold. They are resolved AFTER every
9087 // static key, in source order, in the group's own scope — nix's ordering,
9088 // and the reason a dynamic key can never participate in the parse-time
9089 // merge. Omitting this dropped them entirely: two corpus fixtures built
9090 // `{ a = {}; }` where nix builds `{ a = { b = …; c = …; }; }`.
9091 //
9092 // A key evaluating to `null` SKIPS the binding (CppNix), rather than
9093 // inserting a `"null"` name.
9094 for d in &plan.dynamics {
9095 let key_val = eval_expr(&d.key, &scope_env)?;
9096 let key_concrete = key_val.demand()?;
9097 if matches!(key_concrete, Concrete::Null) {
9098 continue;
9099 }
9100 let name = key_concrete.into_value().as_string()?.to_string();
9101 let value = match &d.value {
9102 sui_normalize::Binding::Leaf(expr) => {
9103 Value::Thunk(Thunk::new_suspended(expr.clone(), scope_env.clone()))
9104 }
9105 sui_normalize::Binding::Group(sub) => {
9106 Value::Thunk(Thunk::new_plan_group(sub.clone(), scope_env.clone()))
9107 }
9108 sui_normalize::Binding::Inherit => env
9109 .lookup(&name)
9110 .ok_or_else(|| EvalError::UndefinedVar(format!("'{name}'")))?,
9111 sui_normalize::Binding::InheritFrom { from } => {
9112 Value::Thunk(Thunk::new_inherit_select(from_thunks[*from].clone(), &name))
9113 }
9114 };
9115 attrs.insert(name, value);
9116 }
9117
9118 // ★ Positions, which `builtins.unsafeGetAttrPos` reads. Dropping this was
9119 // a real regression caught by `every_binding_form_carries_a_position` —
9120 // the plan path built the right VALUES with every key position NULL.
9121 //
9122 // `StaticBinding::pos` is already the offset the AST path records: an
9123 // `AttrpathValue` starts at its head attr (`a` in `a.b = 1`, which is what
9124 // CppNix reports for the outer key), and an inherited name carries its own
9125 // ident's offset. And because the splice keeps the FIRST declaration's
9126 // `pos`, a merged key reports where it was first defined — which is what
9127 // nix reports too.
9128 if !plan.statics.is_empty() {
9129 let mut table = crate::pos::AttrPositions::new(current_eval_file());
9130 for b in &plan.statics {
9131 table.insert(b.name, b.pos.into());
9132 }
9133 attrs.set_positions(std::rc::Rc::new(table));
9134 }
9135
9136 Ok((attrs, scope_env))
9137}