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 let mut new_env = env.child();
1543
1544 // Phase 1: Create thunks with a dummy env and bind them.
1545 // Collect (key, thunk) pairs so we can update envs later.
1546 let mut thunks: Vec<(String, Thunk)> = Vec::new();
1547
1548 // Track which names have been defined so far in this scope.
1549 // Used by maybe_thunk to resolve backward references directly
1550 // instead of creating wasteful thunks.
1551 let mut defined_so_far: HashSet<String> = HashSet::new();
1552
1553 // Accumulator for dotted-path bindings (`let a.b = 1; a.c = 2; ...`).
1554 // Leaf values are wrapped in thunks so they can reference
1555 // sibling let-bindings (the let scope is recursive in Nix).
1556 let mut dotted_attrs: NixAttrs = NixAttrs::new();
1557
1558 // Pre-pass: collect every binding name in this let-scope
1559 // (single-key bindings + top-level keys of dotted paths +
1560 // names from inherit clauses). Used by the recursive-thunk
1561 // detector below — a binding is part of the mutual fix-point
1562 // if its RHS references ANY of these names.
1563 //
1564 // D1 (`SUI_SCOPE_NARROW>=1`) — `names_complete` is the honesty half
1565 // of the narrowing. Narrowing is only sound while
1566 // `let_scope_names` is a COMPLETE list of what this scope binds: a
1567 // binding is judged "reaches no sibling" by intersecting its RHS's
1568 // free variables with that set, so a name MISSING from it reads as
1569 // an outer reference and the binding wrongly keeps the outer env.
1570 // A head that does not resolve here contributes nothing, so the
1571 // whole scope forfeits narrowing rather than narrow on a partial
1572 // set. (`Dynamic` heads are excluded even when they do resolve —
1573 // the name is computed, so it is not a syntactic property of the
1574 // scope.) Nothing about the EVALUATION below changes; this only
1575 // decides whether the optimisation is allowed to apply.
1576 let mut names_complete = true;
1577 let let_scope_names: HashSet<String> = {
1578 let mut s = HashSet::new();
1579 for entry in letin.entries() {
1580 match entry {
1581 ast::Entry::AttrpathValue(apv) => {
1582 if let Some(attrpath) = apv.attrpath() {
1583 if let Some(first) = attrpath.attrs().next() {
1584 if let ast::Attr::Dynamic(_) = &first {
1585 names_complete = false;
1586 }
1587 if let Ok(name) = eval_attr(&first, env) {
1588 s.insert(name);
1589 } else {
1590 names_complete = false;
1591 }
1592 } else {
1593 names_complete = false;
1594 }
1595 } else {
1596 names_complete = false;
1597 }
1598 }
1599 ast::Entry::Inherit(inherit) => {
1600 for attr in inherit.attrs() {
1601 if let ast::Attr::Dynamic(_) = &attr {
1602 names_complete = false;
1603 }
1604 if let Ok(name) = eval_attr(&attr, env) {
1605 s.insert(name);
1606 } else {
1607 names_complete = false;
1608 }
1609 }
1610 }
1611 }
1612 }
1613 s
1614 };
1615 let narrow = scope_narrow_enabled() && names_complete;
1616
1617 // D2 (`SUI_SCOPE_NARROW=2`) — the CLUSTER env.
1618 //
1619 // D1 alone is not enough, and the reason is the shape of the
1620 // graph: free-variable analysis is per-binding on the
1621 // `thunk -> env` edge, but the `env -> thunk` edge is SHARED. One
1622 // binding that really does reach a sibling keeps `new_env` alive,
1623 // and `new_env` holds EVERY binding in the scope — so a single
1624 // recursive `f` re-pins all fifty innocent leaves and the footprint
1625 // is unchanged. (That is the P4 row, and it is why the headline
1626 // gate is too easy: D1 greens it while doing nothing here.)
1627 //
1628 // The fix is to stop pointing the survivors at the whole scope.
1629 // Phase 2 re-points them at a `fix_env` carrying ONLY the names the
1630 // pinned bindings can actually reach — their own names plus
1631 // `refs ∩ scope_names`. The body still gets the full `new_env`, so
1632 // nothing the LET EXPRESSION evaluates to can change; only the
1633 // envs captured by thunks shrink.
1634 let cluster = narrow && scope_cluster_enabled();
1635 // Every (name, value) bound into `new_env`, so the pinned subset can
1636 // be re-bound into `fix_env`. Allocated only under D2.
1637 let mut all_bound: Vec<(String, Value)> = Vec::new();
1638 // The names that stayed pinned, and the free-variable sets of the
1639 // bindings behind them. `pin` needs only the UNION of those sets, so
1640 // no name→refs association is required — and that union already IS
1641 // the fixpoint: a name added to `pin` that is not itself a pinned
1642 // binding contributes no further refs, and one that is has its refs
1643 // in the union already.
1644 let mut pinned_names: HashSet<String> = HashSet::new();
1645 let mut pinned_refs: Vec<HashSet<SmolStr>> = Vec::new();
1646 // A dotted path (`let a.b = 1;`) pushes LEAF thunks whose names are
1647 // inner path segments, not scope names, and whose free variables are
1648 // never computed here — so `fix_env` cannot be shown to carry what
1649 // they need. Such a scope forfeits D2 (D1 still applies).
1650 let mut has_dotted = false;
1651
1652 for entry in letin.entries() {
1653 match entry {
1654 ast::Entry::AttrpathValue(ref apv) => {
1655 let attrpath = apv.attrpath().ok_or_else(|| {
1656 EvalError::ParseError("binding missing attrpath".to_string())
1657 })?;
1658 let value_expr = apv.value().ok_or_else(|| {
1659 EvalError::ParseError("binding missing value".to_string())
1660 })?;
1661 let mut path_keys: Vec<String> = attrpath
1662 .attrs()
1663 .map(|a| eval_attr(&a, env))
1664 .collect::<Result<_, _>>()?;
1665 if path_keys.len() == 1 {
1666 let key = path_keys.pop().unwrap();
1667 // Self/mutual-recursive detection: any binding
1668 // whose RHS references its own name OR any
1669 // SIBLING let-scope name is part of the let's
1670 // mutual fix-point. Mark as recursive so
1671 // inner re-entrance during force returns a
1672 // Promise sentinel instead of erroring with
1673 // InfiniteRecursion. This is the M2.6
1674 // module-system fix path (cppnix's
1675 // lib/modules.nix uses a deep let-scope with
1676 // declaredConfig / options / matchedOptions /
1677 // resultsByName / modules all transitively
1678 // cycling through each other).
1679 //
1680 // `let_scope_names` is collected upfront in a
1681 // pre-pass so each binding sees every other
1682 // binding name (not just earlier ones).
1683 // O(N) not O(N²): compute the RHS's referenced-name
1684 // set ONCE (memoized), then intersect with the
1685 // let-scope names. Byte-identical to the prior
1686 // `references(key) OR references(any sibling)`:
1687 // chaining `key` covers the self-reference case
1688 // regardless of whether `key ∈ let_scope_names`.
1689 let referenced = referenced_idents(&value_expr);
1690 let in_mutual_cycle = std::iter::once(&key)
1691 .chain(let_scope_names.iter())
1692 .any(|n| referenced.contains(n.as_str()));
1693 let value = if in_mutual_cycle {
1694 Value::Thunk(Thunk::new_suspended_recursive(
1695 value_expr.clone(),
1696 env.clone(),
1697 ))
1698 } else {
1699 maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
1700 };
1701 new_env.bind(key.clone(), value.clone());
1702 if cluster {
1703 all_bound.push((key.clone(), value.clone()));
1704 }
1705 if let Value::Thunk(t) = &value {
1706 // D1: `in_mutual_cycle` is ALREADY the
1707 // forward-complete "reaches a sibling"
1708 // predicate here (`let_scope_names` is a full
1709 // pre-pass, unlike the `rec` arm's
1710 // backward-only one), so it doubles as the
1711 // needs-scope test at zero extra cost — no
1712 // second tree walk.
1713 //
1714 // When it is false the RHS references nothing
1715 // this scope binds, so every name it CAN
1716 // resolve resolves identically in `env` and in
1717 // `new_env`: `Env::child` copies `with_scopes`,
1718 // `eval_file` and `source_id` verbatim, and the
1719 // only added bindings are the let-scope names
1720 // this RHS provably does not mention. Skipping
1721 // the re-point is therefore byte-neutral, and
1722 // it is what leaves the thunk holding the OUTER
1723 // env instead of closing
1724 // `thunk -> new_env -> thunk`.
1725 if in_mutual_cycle || !narrow {
1726 thunks.push((key.clone(), t.clone()));
1727 if cluster {
1728 pinned_names.insert(key.clone());
1729 pinned_refs.push(referenced);
1730 }
1731 crate::value::census::scope_pinned();
1732 } else {
1733 crate::value::census::scope_narrowed();
1734 }
1735 }
1736 defined_so_far.insert(key);
1737 } else if path_keys.len() > 1 {
1738 // Multi-segment dotted path: build a nested
1739 // attrset with thunks at the leaves so the
1740 // value expression can reference sibling
1741 // let-bindings.
1742 has_dotted = true;
1743 let key = path_keys[0].clone();
1744 let value = build_nested_attr_thunk(
1745 &path_keys[1..],
1746 &value_expr,
1747 env,
1748 &mut thunks,
1749 );
1750 merge_nested_insert(&mut dotted_attrs, key, value);
1751 }
1752 }
1753 ast::Entry::Inherit(ref inherit) => {
1754 if let Some(from) = inherit.from() {
1755 let source_expr = from.expr().ok_or_else(|| {
1756 EvalError::ParseError(
1757 "inherit from missing expr".to_string(),
1758 )
1759 })?;
1760 // D1: every `InheritSelect` in this clause shares
1761 // ONE source thunk, and `Thunk::update_env`
1762 // delegates straight through to it — so all N
1763 // pushes re-point the SAME env. Whether that
1764 // re-point is needed is therefore a property of the
1765 // source expression alone, computed ONCE above the
1766 // loop instead of N times inside it. Guarded by
1767 // `!narrow ||` so the default path does not pay the
1768 // walk at all.
1769 let source_refs: Option<HashSet<SmolStr>> = if narrow {
1770 Some(referenced_idents(&source_expr))
1771 } else {
1772 None
1773 };
1774 let source_needs_scope = match &source_refs {
1775 Some(refs) => let_scope_names
1776 .iter()
1777 .any(|n| refs.contains(n.as_str())),
1778 None => true,
1779 };
1780 // Create ONE shared source thunk per
1781 // `inherit (source)` clause. All inherited
1782 // names share it via Rc clone — the source
1783 // is evaluated at most once.
1784 let source_thunk = Thunk::new_suspended(
1785 source_expr, env.clone(),
1786 );
1787 for attr in inherit.attrs() {
1788 let name = eval_attr(&attr, env)?;
1789 let thunk = Thunk::new_inherit_select(
1790 source_thunk.clone(),
1791 name.clone(),
1792 );
1793 new_env.bind(name.clone(), Value::Thunk(thunk.clone()));
1794 if cluster {
1795 all_bound.push((
1796 name.clone(),
1797 Value::Thunk(thunk.clone()),
1798 ));
1799 }
1800 if source_needs_scope {
1801 if cluster {
1802 pinned_names.insert(name.clone());
1803 }
1804 thunks.push((name, thunk));
1805 crate::value::census::scope_pinned();
1806 } else {
1807 crate::value::census::scope_narrowed();
1808 }
1809 }
1810 // One refs set for the whole clause — every name in
1811 // it re-points the SAME shared source thunk.
1812 if cluster
1813 && source_needs_scope
1814 && let Some(refs) = source_refs
1815 {
1816 pinned_refs.push(refs);
1817 }
1818 } else {
1819 // `inherit name1 name2 ...` from the
1820 // enclosing lexical scope. This stays
1821 // eager because the names already exist
1822 // in `env` — no fixpoint involved.
1823 for attr in inherit.attrs() {
1824 let name = eval_attr(&attr, env)?;
1825 let value = env.lookup(&name).ok_or_else(|| {
1826 EvalError::UndefinedVar(
1827 format!("'{name}'{}", eval_file_ctx()),
1828 )
1829 })?;
1830 if cluster {
1831 all_bound.push((name.clone(), value.clone()));
1832 }
1833 new_env.bind(name, value);
1834 }
1835 }
1836 }
1837 }
1838 }
1839
1840 // Phase 1b: Bind accumulated dotted-path attrs into new_env.
1841 // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
1842 // duplicate definition, so we do not attempt to merge with
1843 // existing inherit thunks — just bind directly.
1844 for (key, value) in dotted_attrs.iter() {
1845 new_env.bind(key.clone(), value.clone());
1846 if cluster {
1847 all_bound.push((key.clone(), value.clone()));
1848 }
1849 }
1850
1851 // D2: the cluster env the survivors get re-pointed at, in place of
1852 // the whole scope. Built only when it can actually shrink anything
1853 // — some binding pinned, some binding not, and no dotted path (see
1854 // `has_dotted`).
1855 let fix_env: Option<Env> = if cluster && !has_dotted && !thunks.is_empty() {
1856 // `pin` = the pinned names, plus every scope name they can
1857 // reach. This union is already the fixpoint: a name pulled in
1858 // that is not itself pinned contributes no further refs (its
1859 // own thunk still holds the OUTER env and so resolves entirely
1860 // outside this scope), and one that is pinned had its refs in
1861 // the union from the start.
1862 let mut pin = pinned_names;
1863 for refs in &pinned_refs {
1864 for n in &let_scope_names {
1865 if refs.contains(n.as_str()) {
1866 pin.insert(n.clone());
1867 }
1868 }
1869 }
1870 if pin.len() < all_bound.len() {
1871 let mut fe = env.child();
1872 for (name, value) in &all_bound {
1873 if pin.contains(name) {
1874 fe.bind(name.clone(), value.clone());
1875 }
1876 }
1877 Some(fe)
1878 } else {
1879 None
1880 }
1881 } else {
1882 None
1883 };
1884
1885 // Phase 2: Update all thunks to capture the final env
1886 // (which now has all names bound).
1887 let phase2_env: &Env = fix_env.as_ref().unwrap_or(&new_env);
1888 for (_key, thunk) in &thunks {
1889 thunk.update_env(phase2_env);
1890 }
1891
1892 let body = letin
1893 .body()
1894 .ok_or_else(|| EvalError::ParseError("let missing body".to_string()))?;
1895 cur_expr = body;
1896 cur_env = new_env;
1897 continue;
1898 }
1899
1900 ast::Expr::Lambda(lam) => {
1901 let param = lam
1902 .param()
1903 .ok_or_else(|| EvalError::ParseError("lambda missing param".to_string()))?;
1904 let body = lam
1905 .body()
1906 .ok_or_else(|| EvalError::ParseError("lambda missing body".to_string()))?;
1907 return Ok(Value::Lambda(Rc::new(Closure {
1908 param,
1909 body,
1910 env: env.clone(),
1911 })));
1912 }
1913
1914 ast::Expr::Paren(p) => {
1915 let inner = p
1916 .expr()
1917 .ok_or_else(|| EvalError::ParseError("paren missing expr".to_string()))?;
1918 cur_expr = inner;
1919 continue;
1920 }
1921
1922 ast::Expr::Root(r) => {
1923 let inner = r
1924 .expr()
1925 .ok_or_else(|| EvalError::ParseError("root missing expr".to_string()))?;
1926 cur_expr = inner;
1927 continue;
1928 }
1929
1930 ast::Expr::LegacyLet(ll) => {
1931 let mut new_env = env.child();
1932 eval_entries(ll, &mut new_env)?;
1933 // legacy let returns the `body` attr from its bindings
1934 return new_env
1935 .lookup("body")
1936 .ok_or_else(|| EvalError::AttrNotFound(
1937 format!("'body' in legacy let{}", eval_file_ctx()),
1938 ));
1939 }
1940
1941 ast::Expr::CurPos(_) => return Err(EvalError::NotImplemented("__curPos".to_string())),
1942 ast::Expr::Error(_) => return Err(EvalError::ParseError("parse error node".to_string())),
1943 } // match
1944 } // loop — unreachable, all arms either return or continue
1945}
1946
1947fn eval_literal(lit: &ast::Literal) -> Result<Value, EvalError> {
1948 use ast::LiteralKind;
1949 match lit.kind() {
1950 LiteralKind::Integer(tok) => {
1951 let n = tok
1952 .value()
1953 .map_err(|e| EvalError::ParseError(format!("invalid integer: {e}")))?;
1954 Ok(Value::Int(n))
1955 }
1956 LiteralKind::Float(tok) => {
1957 let f = tok
1958 .value()
1959 .map_err(|e| EvalError::ParseError(format!("invalid float: {e}")))?;
1960 Ok(Value::Float(f))
1961 }
1962 LiteralKind::Uri(tok) => Ok(Value::string(tok.syntax().text().to_string())),
1963 }
1964}
1965
1966/// Result of walking an attrpath on a base value.
1967enum TraverseResult {
1968 /// All keys found; contains the leaf value.
1969 Found(Value),
1970 /// A key was missing; contains the missing key name.
1971 Missing(String),
1972 /// A non-attrset value was encountered during traversal.
1973 NotAttrs(Value),
1974}
1975
1976/// Walk an attrpath on a base value, forcing at each level.
1977///
1978/// Returns `Found(leaf)` when every key exists, `Missing(key)` when
1979/// a key is absent, or `NotAttrs(v)` when a non-attrset is encountered.
1980fn traverse_attrpath(
1981 base: Value,
1982 attrpath: &rnix::ast::Attrpath,
1983 env: &Env,
1984) -> Result<TraverseResult, EvalError> {
1985 let attrs: Vec<_> = attrpath.attrs().collect();
1986 let mut value = base;
1987 for (i, attr) in attrs.iter().enumerate() {
1988 let key = eval_attr(attr, env)?;
1989 // Force the current value to an attrset to select from it.
1990 let forced = force_value(&value)?;
1991 match forced {
1992 Value::Attrs(ref a) => match a.get(&key) {
1993 Some(v) => {
1994 if i < attrs.len() - 1 {
1995 // Intermediate step: force to attrset for next selection.
1996 value = force_value(v)?;
1997 } else {
1998 // Final step: return WITHOUT forcing — let the caller
1999 // decide when to force. Matches CppNix's lazy attr access.
2000 value = v.clone();
2001 }
2002 }
2003 None => return Ok(TraverseResult::Missing(key)),
2004 },
2005 _ => return Ok(TraverseResult::NotAttrs(forced)),
2006 }
2007 }
2008 Ok(TraverseResult::Found(value))
2009}
2010
2011fn eval_select(sel: &ast::Select, env: &Env) -> Result<Value, EvalError> {
2012 crate::perf::inc(crate::perf::Counter::Select);
2013 let base_expr = sel.expr().ok_or_else(|| {
2014 EvalError::ParseError("select missing expression".to_string())
2015 })?;
2016 // M2.6 bridge: in `expr.path or default`, an `InfiniteRecursion`
2017 // hit while forcing the LEFT side falls back to the default —
2018 // operationally matches cppnix, which avoids the cycle entirely
2019 // via lazy attribute access during fix-point evaluation. Without
2020 // a default, the recursion propagates as a real error. Other
2021 // error kinds (Throw, TypeError, …) always propagate so user
2022 // bugs aren't masked. Removed when the underlying fix-point /
2023 // lazy-access semantics land — see docs/M2.6-MODULE-SYSTEM-FIXPOINT.md.
2024 let base_result = eval_expr(&base_expr, env)
2025 .and_then(|v| force_concrete(&v).map(Concrete::into_value));
2026 let base = match base_result {
2027 Ok(v) => v,
2028 Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2029 return eval_expr(&sel.default_expr().expect("checked"), env);
2030 }
2031 Err(e) => return Err(e),
2032 };
2033 let base_type = base.type_name();
2034 let attrpath = sel.attrpath().ok_or_else(|| {
2035 EvalError::ParseError("select missing attrpath".to_string())
2036 })?;
2037 // M2.6 bridge: when the blackhole-bridge sentinels are active,
2038 // an attribute lookup that misses (`AttrNotFound`) or hits a
2039 // non-attrset intermediate (`NotAttrs`) on the bridge's empty
2040 // sentinel value gets resolved to `null` instead of erroring.
2041 // cppnix's partial attrset would have CARRIED the keys (with
2042 // their lazy values), so the lookup would succeed; null is the
2043 // cheapest sentinel that propagates through downstream code
2044 // without further type errors.
2045 //
2046 // M2.6 ROOT #4 CLOSED (2026-07-11): the `|| crate::value::in_promise_eval()`
2047 // clause that used to soften a mid-Promise `config.<x>` select-miss to
2048 // `null` is REMOVED. It was the band-aid masking the two real over-forces
2049 // that ROOT #4a (the `with`-namespace eager eval, above) and ROOT #4b (the
2050 // dropped full-set leaf in `merge_nested_insert`, below) now fix at their
2051 // load-bearing cause. Verified with the softening gone: both
2052 // `lib.nixosSystem { modules = []; }.config.system.name` → `"nixos"` and
2053 // `attrNames sys.options` → 53 (nix-parity), `sui parity` stays 35 match /
2054 // 0 regressions, 1324 sui-eval lib tests + 30 diff tests pass — nothing
2055 // depended on the sentinel any more. The two explicit operator-gated
2056 // bridges below stay as opt-in experiments (default-off); only the
2057 // always-on Promise softening is retired.
2058 let bridge_active = std::env::var_os("SUI_BLACKHOLE_AS_EMPTY_ATTRS").is_some()
2059 || std::env::var_os("SUI_BLACKHOLE_AS_NULL").is_some();
2060 let traversal = traverse_attrpath(base, &attrpath, env);
2061 match traversal {
2062 Ok(TraverseResult::Found(v)) => Ok(v),
2063 Ok(TraverseResult::Missing(key)) => {
2064 if let Some(def) = sel.default_expr() {
2065 eval_expr(&def, env)
2066 } else if bridge_active {
2067 if std::env::var_os("SUI_M26_SELTRACE").is_some() {
2068 let path: Vec<String> = sel.attrpath().map(|ap|
2069 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2070 ).unwrap_or_default();
2071 eprintln!("[M26 SEL-MISS→null] base_type={base_type} path={path:?} missing-key={key}{}", eval_file_ctx());
2072 }
2073 if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2074 let path: Vec<String> = sel.attrpath().map(|ap|
2075 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2076 ).unwrap_or_default();
2077 if path.iter().any(|p| p.contains(&filt)) {
2078 return Err(EvalError::type_error(format!(
2079 "M26-HARDSOFTEN path={path:?} key={key}"
2080 )));
2081 }
2082 }
2083 Ok(Value::Null)
2084 } else {
2085 Err(EvalError::AttrNotFound(
2086 format!("'{key}'{}", eval_file_ctx()),
2087 ))
2088 }
2089 }
2090 Ok(TraverseResult::NotAttrs(forced)) => {
2091 // CppNix: `expr.a.b or default` falls back to default for
2092 // ANY error in the path — including intermediate values
2093 // that aren't attrsets (e.g., null). The module system
2094 // relies on this: `x.options.type.name or null` must
2095 // return null when x.options is null, not throw.
2096 if let Some(def) = sel.default_expr() {
2097 eval_expr(&def, env)
2098 } else if bridge_active {
2099 if let Ok(filt) = std::env::var("SUI_M26_HARDSOFTEN") {
2100 let path: Vec<String> = sel.attrpath().map(|ap|
2101 ap.attrs().map(|a| a.syntax().text().to_string()).collect()
2102 ).unwrap_or_default();
2103 if path.iter().any(|p| p.contains(&filt)) {
2104 return Err(EvalError::type_error(format!(
2105 "M26-HARDSOFTEN-NOTATTRS path={path:?} base_type={base_type}"
2106 )));
2107 }
2108 }
2109 return Ok(Value::Null);
2110 } else {
2111 if std::env::var("SUI_DEBUG_SELECT").is_ok() {
2112 let path: Vec<String> = sel.attrpath().map(|ap|
2113 ap.attrs().filter_map(|a| match a {
2114 ast::Attr::Ident(i) => Some(i.to_string()),
2115 ast::Attr::Str(s) => Some(format!("\"{}\"", s.syntax().text())),
2116 ast::Attr::Dynamic(_) => Some("<dyn>".into()),
2117 }).collect()
2118 ).unwrap_or_default();
2119 let dbg = format!("{:?}", forced);
2120 let truncated = if dbg.len() > 200 { format!("{}…", &dbg[..200]) } else { dbg };
2121 eprintln!("[SUI_DEBUG_SELECT] base_type={base_type} path={path:?} base={truncated}{}", eval_file_ctx());
2122 }
2123 Err(attach_trace(EvalError::type_error(
2124 format!("cannot select from {base_type}"),
2125 )))
2126 }
2127 }
2128 // Same M2.6 bridge as on the base force above: if an
2129 // intermediate step in the attrpath traversal raises
2130 // InfiniteRecursion and `or default` was supplied, the
2131 // default is the operationally-correct value.
2132 Err(EvalError::InfiniteRecursion(_)) if sel.default_expr().is_some() => {
2133 eval_expr(&sel.default_expr().expect("checked"), env)
2134 }
2135 Err(e) => Err(e),
2136 }
2137}
2138
2139/// Evaluate `expr ? a.b.c` — check key presence without forcing value thunks.
2140fn eval_has_attr(ha: &ast::HasAttr, env: &Env) -> Result<Value, EvalError> {
2141 let base_expr = ha.expr().ok_or_else(|| {
2142 EvalError::ParseError("hasattr missing expression".to_string())
2143 })?;
2144 let base = force_concrete(&eval_expr(&base_expr, env)?)?.into_value();
2145 let attrpath = ha.attrpath().ok_or_else(|| {
2146 EvalError::ParseError("hasattr missing attrpath".to_string())
2147 })?;
2148 match traverse_attrpath(base, &attrpath, env)? {
2149 TraverseResult::Found(_) => Ok(Value::Bool(true)),
2150 TraverseResult::Missing(_) | TraverseResult::NotAttrs(_) => Ok(Value::Bool(false)),
2151 }
2152}
2153
2154fn eval_unary_op(op: &ast::UnaryOp, env: &Env) -> Result<Value, EvalError> {
2155 let inner = op
2156 .expr()
2157 .ok_or_else(|| EvalError::ParseError("unary op missing expr".to_string()))?;
2158 let val = force_value(&eval_expr(&inner, env)?)?;
2159 let kind = op
2160 .operator()
2161 .ok_or_else(|| EvalError::ParseError("unary op missing operator".to_string()))?;
2162 match kind {
2163 ast::UnaryOpKind::Negate => match val {
2164 Value::Int(n) => Ok(Value::Int(-n)),
2165 Value::Float(f) => Ok(Value::Float(-f)),
2166 _ => Err(EvalError::type_error(
2167 format!("cannot negate {}", val.type_name()),
2168 )),
2169 },
2170 ast::UnaryOpKind::Invert => Ok(Value::Bool(!val.as_bool()?)),
2171 }
2172}
2173
2174/// Builtins that must receive their argument UNFORCED (call-by-need). This is the
2175/// SINGLE source of truth consumed by BOTH `eval_apply` (which must THUNK the arg
2176/// instead of eager-evaluating it) AND the builtin apply arm (which must SKIP the
2177/// arg force). The two sites MUST agree: if `eval_apply` eager-evaluates the arg,
2178/// the apply-arm's force-skip is dead (the arg is already forced — or already
2179/// threw) upstream. They were previously inconsistent (only `tryEval` was thunked
2180/// in `eval_apply`), so `seq`/`deepSeq`/`addErrorContext`/`foldl'` silently got
2181/// eager args despite their apply-time exemption — the bug behind
2182/// `builtins.foldl' (_: x: x) (throw "…") […]` throwing instead of returning the
2183/// last element (nix's foldl' is NOT strict in the nul accumulator).
2184#[inline]
2185pub(crate) fn builtin_takes_lazy_arg(name: &str) -> bool {
2186 matches!(
2187 name,
2188 "tryEval" | "addErrorContext<partial>" | "seq<partial>" | "deepSeq<partial>" | "foldl'<p1>"
2189 )
2190}
2191
2192fn eval_apply(app: &ast::Apply, env: &Env) -> Result<Value, EvalError> {
2193 let func_expr = app
2194 .lambda()
2195 .ok_or_else(|| EvalError::ParseError("apply missing function".to_string()))?;
2196 let arg_expr = app
2197 .argument()
2198 .ok_or_else(|| EvalError::ParseError("apply missing argument".to_string()))?;
2199 let func = force_value(&eval_expr(&func_expr, env)?)?;
2200 // Lambda arguments are wrapped in a thunk for call-by-need semantics.
2201 // Thunk strategy depends on function type:
2202 // - Lambda: ALWAYS thunk (call-by-need, enables fixpoints)
2203 // - tryEval: ALWAYS thunk (must catch errors during force)
2204 // - Builtin: evaluate eagerly (builtins always force args anyway;
2205 // thunking wastes Rc + OnceCell allocation per call)
2206 // - __functor: evaluate eagerly (will be applied immediately)
2207 let arg = match &func {
2208 Value::Lambda(_) => {
2209 // Call-by-need: the arg is thunked so it forces lazily. But a
2210 // PURE-CONSTANT arg (a literal, a non-interpolated string, or a
2211 // non-interpolated path) can never throw or diverge, so producing
2212 // its value directly is byte-neutral whether or not the lambda ever
2213 // forces it — identical eval-order-observable behavior, one fewer
2214 // never-forced thunk. This is `arg_pure_constant` ONLY: any arg that
2215 // could throw/diverge/observe a fixpoint (Ident with-scope, Select,
2216 // Apply, BinOp, …) stays fully thunked to preserve laziness.
2217 if let Some(v) = eval_pure_constant_arg(&arg_expr) {
2218 v
2219 } else {
2220 crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2221 Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2222 }
2223 }
2224 Value::Builtin(b) if builtin_takes_lazy_arg(&b.name) => {
2225 // Call-by-need for the laziness-exempt builtins (tryEval / seq /
2226 // deepSeq / addErrorContext / foldl'<p1>): the arg MUST be thunked,
2227 // not eager-evaluated, so it forces only if/when the builtin demands
2228 // it. Kept in lockstep with the apply-arm skip via `builtin_takes_lazy_arg`.
2229 crate::perf::inc(crate::perf::Counter::ThunkSiteApplyArg);
2230 Value::Thunk(Thunk::new_suspended(arg_expr.clone(), env.clone()))
2231 }
2232 _ => eval_expr(&arg_expr, env)?,
2233 };
2234 apply(func, arg)
2235}
2236
2237/// If `arg_expr` is a PURE CONSTANT — a literal, a non-interpolated string, or
2238/// a non-interpolated absolute/home path — return its value directly (no thunk).
2239///
2240/// A pure constant has no free variables, cannot throw, cannot diverge, and has
2241/// no fixpoint/laziness interaction: `eval_expr(arg)` is total and produces the
2242/// exact value a suspended thunk of it would yield on force. Producing it
2243/// eagerly in a call-by-need arg position is therefore byte-neutral (the
2244/// lambda that never forces the arg observes no difference — the value is inert).
2245///
2246/// Returns `None` for EVERYTHING else (Ident — may hit a with-scope force;
2247/// Select/Apply/BinOp/If/… — may throw or diverge; interpolated Str/Path —
2248/// must force `${…}` lazily), which keeps those args fully thunked. `env` is
2249/// NOT threaded in because a pure constant needs no environment; if a match
2250/// arm ever needed `env`, it would not be a pure constant.
2251fn eval_pure_constant_arg(arg_expr: &ast::Expr) -> Option<Value> {
2252 match arg_expr {
2253 ast::Expr::Literal(lit) => eval_literal(lit).ok(),
2254 ast::Expr::Str(st) if !str_has_interpolation(st) => {
2255 // No interpolation ⇒ `eval_str` runs no force/coerce; env is unused.
2256 eval_str(st, &Env::new()).ok()
2257 }
2258 ast::Expr::PathAbs(p) if !parts_have_interpolation(&p.parts()) => {
2259 let text = crate::path::canon_abs(&p.syntax().text().to_string());
2260 Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2261 }
2262 ast::Expr::PathHome(p) if !parts_have_interpolation(&p.parts()) => {
2263 let text = p.syntax().text().to_string();
2264 Some(Value::Path(Box::new(SmolStr::from(text.as_str()))))
2265 }
2266 _ => None,
2267 }
2268}
2269
2270fn eval_str(s: &ast::Str, env: &Env) -> Result<Value, EvalError> {
2271 let mut result = String::new();
2272 let mut ctx = StringContext::new();
2273 for part in s.normalized_parts() {
2274 match part {
2275 InterpolPart::Literal(text) => result.push_str(&text),
2276 InterpolPart::Interpolation(interpol) => {
2277 let expr = interpol.expr().ok_or_else(|| {
2278 EvalError::ParseError("interpolation missing expr".to_string())
2279 })?;
2280 let val = force_value(&eval_expr(&expr, env)?)?;
2281 // CppNix string interpolation is copy-to-store coercion: an
2282 // interpolated source path (`"${./foo}"`) is NAR-copied into
2283 // the store and the store path is spliced in (with context),
2284 // never the raw filesystem path.
2285 let (s, c) = val.coerce_to_string_copy_to_store()?;
2286 result.push_str(&s);
2287 ctx.merge(&c);
2288 }
2289 }
2290 }
2291 Ok(Value::String(Rc::new(NixString::with_context(result, ctx))))
2292}
2293
2294/// Whether a list of path parts contains a `${…}` interpolation. When
2295/// it does not, the raw `.syntax().text()` shortcut is byte-identical
2296/// and cheaper, so the trivial fast paths stay on that shortcut.
2297fn parts_have_interpolation(parts: &[InterpolPart<rnix::ast::PathContent>]) -> bool {
2298 parts
2299 .iter()
2300 .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2301}
2302
2303/// Whether a string literal contains any `${…}` interpolation part. A `false`
2304/// result means the string is a pure constant (`eval_str` runs no force/coerce
2305/// and cannot throw), so `maybe_thunk` may evaluate it eagerly byte-neutrally.
2306fn str_has_interpolation(s: &ast::Str) -> bool {
2307 s.normalized_parts()
2308 .iter()
2309 .any(|p| matches!(p, InterpolPart::Interpolation(_)))
2310}
2311
2312/// Evaluate an interpolatable path literal that contains `${…}` parts.
2313///
2314/// CppNix path interpolation (`./${x}.nix`, `/a/${e}`, `~/x/${e}`):
2315/// * each literal segment is spliced verbatim,
2316/// * each `${e}` is **plain**-coerced to a string with context
2317/// (NOT copy-to-store — path-typed interpolations splice the raw
2318/// store/filesystem path, e.g. `/bar/${./foo}` → `/bar/tmp/foo`),
2319/// * the concatenated text is then resolved exactly like the plain
2320/// path literal of the same kind (relative → joined + normalized
2321/// against the defining file's directory; absolute/home → verbatim),
2322/// * the result is a `path` value.
2323///
2324/// Parts come from rnix's `<PathKind>::parts()` which splits the path
2325/// token stream into `Literal(PathContent)` / `Interpolation(Interpol)`.
2326fn eval_interpol_path_parts(
2327 parts: &[InterpolPart<rnix::ast::PathContent>],
2328 kind: PathKind,
2329 env: &Env,
2330) -> Result<Value, EvalError> {
2331 let mut text = String::new();
2332 for part in parts {
2333 match part {
2334 InterpolPart::Literal(content) => text.push_str(content.text()),
2335 InterpolPart::Interpolation(interpol) => {
2336 let expr = interpol.expr().ok_or_else(|| {
2337 EvalError::ParseError("path interpolation missing expr".to_string())
2338 })?;
2339 let val = force_value(&eval_expr(&expr, env)?)?;
2340 // Plain coercion (coerceMore = false): a path-typed
2341 // interpolation splices the raw path string, never a
2342 // copied-to-store hash path.
2343 let (s, _ctx) = val.coerce_to_string()?;
2344 text.push_str(&s);
2345 }
2346 }
2347 }
2348 let resolved = match kind {
2349 // Relative path: resolve against the defining file's directory,
2350 // mirroring the plain `PathRel` branch.
2351 PathKind::Rel => {
2352 if let Some(dir) = current_eval_dir() {
2353 let norm = normalize_path(&dir.join(&text));
2354 // Lift cache→store exactly like the plain `PathRel` branch (the
2355 // store↔cache seam value-half). Without this, an interpolated
2356 // relative-path literal (`./${x}`, `./modules/${name}.nix`)
2357 // inside a fetched flake input yielded a Value::Path holding the
2358 // fetcher CACHE dir instead of the input's `/nix/store/<h>-source`
2359 // path — so its `toString`/copy-to-store/inputSrc diverged from
2360 // CppNix (the plain `./x` sibling already dematerializes; the two
2361 // must agree).
2362 crate::path::dematerialize(&norm).to_string_lossy().into_owned()
2363 } else {
2364 // No eval-file context (top-level `sui eval -E`): the
2365 // plain branch keeps the raw text, so match it — but the
2366 // interpolation is still spliced.
2367 text
2368 }
2369 }
2370 // Absolute paths: canonicalize the concatenated text CppNix's way.
2371 // The `${e}` splice routinely introduces a `//` seam (`/bar/` +
2372 // `/tmp/foo`) or a `.`/`..` component that must collapse
2373 // (`/bar//tmp/foo` → `/bar/tmp/foo`), and `..` must clamp at root.
2374 // `canon_abs` is filesystem-free (works on not-yet-materialized
2375 // flake paths) and root-aware (unlike `normalize_path`, which pops
2376 // past root — the marquee-root divergence).
2377 PathKind::Abs => crate::path::canon_abs(&text),
2378 // Home paths (`~/…`) carry a leading `~` component, so they are
2379 // not absolute-rooted; keep the pre-existing normalization.
2380 PathKind::Home => normalize_path(std::path::Path::new(&text))
2381 .to_string_lossy()
2382 .into_owned(),
2383 };
2384 Ok(Value::Path(Box::new(SmolStr::from(resolved.as_str()))))
2385}
2386
2387/// Which kind of interpolatable path literal — governs how the
2388/// concatenated text is finally resolved.
2389#[derive(Clone, Copy)]
2390enum PathKind {
2391 Abs,
2392 Rel,
2393 Home,
2394}
2395
2396/// Evaluate an attribute name, requiring non-null.
2397/// Use `eval_attr_maybe_null` when null dynamic attrs should be skipped.
2398fn eval_attr(attr: &ast::Attr, env: &Env) -> Result<String, EvalError> {
2399 eval_attr_maybe_null(attr, env)?
2400 .ok_or_else(|| EvalError::TypeError("null dynamic attribute name".into()))
2401}
2402
2403/// Evaluate an attribute name. Returns `None` for null dynamic attrs
2404/// (CppNix silently omits attributes with null names).
2405fn eval_attr_maybe_null(attr: &ast::Attr, env: &Env) -> Result<Option<String>, EvalError> {
2406 match attr {
2407 ast::Attr::Ident(ident) => Ok(Some(ident_text(ident))),
2408 ast::Attr::Dynamic(dyn_) => {
2409 let expr = dyn_
2410 .expr()
2411 .ok_or_else(|| EvalError::ParseError("dynamic attr missing expr".to_string()))?;
2412 let val = force_value(&eval_expr(&expr, env)?)?;
2413 // CppNix: null dynamic attr name → skip the attribute entirely.
2414 // Used by nixpkgs module system: `${if cond then null else "name"} = value;`
2415 if val == Value::Null {
2416 return Ok(None);
2417 }
2418 Ok(Some(val.as_string()?.to_string()))
2419 }
2420 ast::Attr::Str(s) => {
2421 let val = eval_str(s, env)?;
2422 Ok(Some(val.as_string()?.to_string()))
2423 }
2424 }
2425}
2426
2427/// Get the text of an rnix Ident node.
2428pub(crate) fn ident_text(ident: &ast::Ident) -> String {
2429 // Fast path: a `NODE_IDENT` holds a single `TOKEN_IDENT`, whose `text()`
2430 // borrows the source `&str` directly from the green node — no
2431 // `PreorderWithTokens` cursor tree-walk and none of the `NodeData::new`
2432 // allocations that `syntax().text()` (a `SyntaxText` over the node's whole
2433 // descendant span) pays. Byte-identical fallback: the identifier `or` is
2434 // lexed as a nested `TOKEN_OR` (rnix quirk), so `ident_token()` is `None`
2435 // there — walk the full node text in that case, exactly as before.
2436 match ident.ident_token() {
2437 Some(tok) => tok.text().to_string(),
2438 None => ident.syntax().text().to_string(),
2439 }
2440}
2441
2442/// Byte offset of a STATIC attr key (`Ident` or `Str`) in its source text —
2443/// the position `builtins.unsafeGetAttrPos` reports for that key. Returns
2444/// `None` for a dynamic key (`${e}`), which has no fixed source position.
2445///
2446/// CppNix points a binding's position at the KEY token's start; rnix exposes
2447/// it via the syntax node's `text_range().start()`.
2448fn static_attr_offset(attr: &ast::Attr) -> Option<u32> {
2449 let node = match attr {
2450 ast::Attr::Ident(i) => i.syntax(),
2451 ast::Attr::Str(s) => s.syntax(),
2452 ast::Attr::Dynamic(_) => return None,
2453 };
2454 Some(u32::from(node.text_range().start()))
2455}
2456
2457/// Collect a literal attrset's static top-level KEY offsets into an
2458/// [`crate::pos::AttrPositions`] and attach it to `attrs` (behind the value's
2459/// `Rc<AttrPositions>` slot). Records only single-key static bindings — the
2460/// shape `attrTag`'s `tags_` (`{ app = …; file = …; }`) is built from and the
2461/// only shape `builtins.unsafeGetAttrPos` reads in nixpkgs. `None`-costs a
2462/// pointer when the set has no such keys (attaches nothing).
2463fn attach_attrset_positions(set: &ast::AttrSet, attrs: &mut NixAttrs, env: &Env) {
2464 // The FILE is the one the literal is being built in — from the eval-file
2465 // stack, which a thunk restores to its captured file when it forces. This
2466 // is correct under laziness: a `dock.nix` attrset literal forced later
2467 // records `dock.nix`, not whatever file is top-of-stack at force time.
2468 // (`current_source_id`/`CURRENT_SOURCE_ID` is per-`eval_with_file`, NOT
2469 // per-env, so it would mis-attribute a lazily-forced literal.)
2470 let mut table = crate::pos::AttrPositions::new(current_eval_file());
2471 for entry in set.entries() {
2472 if let ast::Entry::AttrpathValue(apv) = entry {
2473 let Some(attrpath) = apv.attrpath() else { continue };
2474 let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2475 // A dotted path `a.b = …` desugars to a nested set and CppNix gives
2476 // the OUTER key the position of the path's HEAD, so record
2477 // `path_attrs[0]` whatever the length. This previously skipped any
2478 // multi-segment path, on the assumption that nixpkgs never asks for
2479 // a dotted tag's position. Measured — for
2480 // `{ …; nested.deep = 3; }` at line 6:
2481 // nix nested=6:3 sui nested=NULL
2482 let Some(head) = path_attrs.first() else { continue };
2483 let Some(offset) = static_attr_offset(head) else { continue };
2484 // Resolve the static key name (Ident/Str) — never forces (a
2485 // dynamic key already returned None above).
2486 if let Ok(Some(name)) = eval_attr_maybe_null(&path_attrs[0], env) {
2487 table.insert(intern(&name), offset);
2488 }
2489 } else if let ast::Entry::Inherit(inh) = entry {
2490 // `inherit x;` and `inherit (src) x;` BIND an attribute exactly as
2491 // `x = …` does, and CppNix gives each inherited name the position of
2492 // its own ident. Skipping them left every inherited key
2493 // position-less — which is most of nixpkgs' `lib`, since
2494 // `lib/default.nix` re-exports through
2495 // `inherit (self.options) mkOption …`. Measured before the fix:
2496 // unsafeGetAttrPos "mkOption" nixpkgs.lib
2497 // nix …-source/lib/default.nix sui null
2498 //
2499 // An earlier attempt at this arm was reverted for reporting line 1;
2500 // that was `pos::line_col` returning a constant, NOT this arm. With
2501 // the real offset→line/column conversion in place it resolves
2502 // exactly.
2503 for attr in inh.attrs() {
2504 let Some(offset) = static_attr_offset(&attr) else { continue };
2505 if let Ok(Some(name)) = eval_attr_maybe_null(&attr, env) {
2506 table.insert(intern(&name), offset);
2507 }
2508 }
2509 }
2510 }
2511 if !table.is_empty() {
2512 attrs.set_positions(std::rc::Rc::new(table));
2513 }
2514}
2515
2516fn eval_attrset(set: &ast::AttrSet, env: &Env) -> Result<Value, EvalError> {
2517 crate::perf::inc(crate::perf::Counter::Attrset);
2518 let mut attrs = NixAttrs::new();
2519 let is_rec = set.rec_token().is_some();
2520
2521 if is_rec {
2522 let mut rec_env = env.child();
2523 let mut thunks: Vec<(String, Thunk)> = Vec::new();
2524
2525 // Track which names have been defined so far in this scope.
2526 // Used by maybe_thunk to resolve backward references directly
2527 // instead of creating wasteful thunks.
2528 let mut defined_so_far: HashSet<String> = HashSet::new();
2529
2530 // Accumulator for dotted-path bindings (`rec { a.b = 1; a.c = 2; ... }`).
2531 // Leaf values are wrapped in thunks so they participate in the
2532 // recursive env fixpoint, matching CppNix semantics where
2533 // `rec { types.a = f 1; f = x: x + 1; }` allows `f` to be a
2534 // sibling binding.
2535 let mut dotted_attrs: NixAttrs = NixAttrs::new();
2536
2537 // D1 (`SUI_SCOPE_NARROW>=1`) — a SECOND predicate, deliberately not a
2538 // widening of `is_recursive_binding` below.
2539 //
2540 // THE TRAP: `is_recursive_binding` is BACKWARD-BLIND on purpose — it
2541 // tests `key` plus the siblings seen SO FAR, so `rec { b = a; a = 1; }`
2542 // computes `false` for `b`. That verdict selects Promise semantics, so
2543 // widening it would change which bindings get the fix-point sentinel
2544 // and is not a refactor available here. Yet `b` genuinely does need the
2545 // rec scope, and today gets it from Phase 2's blanket `update_env`.
2546 // Narrowing therefore needs its own forward-complete question — "does
2547 // this RHS reach ANY key this scope binds, declared before or after?" —
2548 // answered against a full pre-pass, while `is_recursive_binding` stays
2549 // byte-identical.
2550 //
2551 // The pre-pass is PURELY SYNTACTIC, which is the second trap: the
2552 // Phase-1 loop below owns the evaluation order of `${…}` keys, and
2553 // calling `eval_attr` here would run that arbitrary code earlier. So a
2554 // head that is not a plain identifier forfeits narrowing for the whole
2555 // scope instead of being evaluated for its name. Starting the flag at
2556 // `scope_narrow_enabled()` also means the default path never walks the
2557 // entries at all.
2558 let mut names_complete = scope_narrow_enabled();
2559 let rec_scope_names: HashSet<String> = if names_complete {
2560 let mut s = HashSet::new();
2561 for entry in set.entries() {
2562 match entry {
2563 ast::Entry::AttrpathValue(apv) => {
2564 match apv.attrpath().and_then(|p| p.attrs().next()) {
2565 Some(ast::Attr::Ident(i)) => {
2566 s.insert(ident_text(&i));
2567 }
2568 _ => names_complete = false,
2569 }
2570 }
2571 ast::Entry::Inherit(inh) => {
2572 for attr in inh.attrs() {
2573 match attr {
2574 ast::Attr::Ident(i) => {
2575 s.insert(ident_text(&i));
2576 }
2577 _ => names_complete = false,
2578 }
2579 }
2580 }
2581 }
2582 }
2583 s
2584 } else {
2585 HashSet::new()
2586 };
2587 let narrow = names_complete;
2588
2589 // Phase 1: Create thunks with placeholder env and bind them.
2590 for entry in set.entries() {
2591 match entry {
2592 ast::Entry::AttrpathValue(apv) => {
2593 let attrpath = apv.attrpath().ok_or_else(|| {
2594 EvalError::ParseError("binding missing attrpath".to_string())
2595 })?;
2596 let value_expr = apv.value().ok_or_else(|| {
2597 EvalError::ParseError("binding missing value".to_string())
2598 })?;
2599 let mut path_keys: Vec<String> = attrpath
2600 .attrs()
2601 .filter_map(|a| eval_attr_maybe_null(&a, env).transpose())
2602 .collect::<Result<_, _>>()?;
2603 // Null dynamic attr name → skip entire binding (CppNix compat)
2604 if path_keys.is_empty() { continue; }
2605 if path_keys.len() == 1 {
2606 let key = path_keys.pop().unwrap();
2607 // Self-recursive detection in a `rec { … }` scope:
2608 // any binding whose value-expr references the
2609 // bound name OR any sibling key declared in this
2610 // rec scope is potentially self-recursive (the
2611 // siblings' thunks share the rec_env via Phase 2).
2612 // Mark as recursive so inner re-entrance during
2613 // force returns a Promise sentinel instead of
2614 // erroring with InfiniteRecursion.
2615 //
2616 // For simplicity we check `key` and all already-
2617 // defined siblings; siblings defined later are
2618 // covered when THEIR thunks force (they reference
2619 // back into this rec scope via Phase 2's env update).
2620 // O(N) not O(N²): one memoized referenced-name set,
2621 // intersected with key + already-defined siblings.
2622 // Byte-identical to the prior per-name walks.
2623 let referenced = referenced_idents(&value_expr);
2624 let is_recursive_binding = referenced.contains(key.as_str())
2625 || defined_so_far
2626 .iter()
2627 .any(|n| referenced.contains(n.as_str()));
2628 let value = if is_recursive_binding {
2629 Value::Thunk(Thunk::new_suspended_recursive(
2630 value_expr.clone(),
2631 env.clone(),
2632 ))
2633 } else {
2634 // maybeThunk: skip thunk for trivial exprs.
2635 // is_rec=true because rec attrset bindings
2636 // can reference each other.
2637 // Pass defined_so_far so backward refs
2638 // resolve directly.
2639 maybe_thunk(&value_expr, env, true, Some(&defined_so_far))
2640 };
2641 // Forward-complete needs-scope test (see the pre-pass
2642 // above). `is_recursive_binding` is folded in as
2643 // belt-and-braces: it is a subset whenever `narrow`
2644 // holds, since every key it can name came from an
2645 // `Ident` head and so is in `rec_scope_names`.
2646 let needs_scope = !narrow
2647 || is_recursive_binding
2648 || rec_scope_names
2649 .iter()
2650 .any(|n| referenced.contains(n.as_str()));
2651 rec_env.bind(key.clone(), value.clone());
2652 attrs.insert(key.clone(), value.clone());
2653 if let Value::Thunk(t) = &value {
2654 if needs_scope {
2655 thunks.push((key.clone(), t.clone()));
2656 crate::value::census::scope_pinned();
2657 } else {
2658 crate::value::census::scope_narrowed();
2659 }
2660 }
2661 defined_so_far.insert(key);
2662 } else {
2663 // Multi-segment dotted path: build a nested attrset
2664 // with a thunk at the leaf so the value expression
2665 // can reference sibling rec-bindings.
2666 let key = path_keys[0].clone();
2667 let value =
2668 build_nested_attr_thunk(&path_keys[1..], &value_expr, env, &mut thunks);
2669 merge_nested_insert(&mut dotted_attrs, key, value);
2670 }
2671 }
2672 ast::Entry::Inherit(inherit) => {
2673 eval_inherit(&inherit, env, &mut attrs, Some(&mut rec_env), Some(&mut thunks))?;
2674 }
2675 }
2676 }
2677
2678 // Phase 1b: Bind accumulated dotted-path attrs into attrs and rec_env.
2679 // Note: CppNix rejects `inherit (src) x; x.y = ...;` as a
2680 // duplicate definition, so we do not attempt to merge with
2681 // existing inherit thunks — just bind directly.
2682 for (key, value) in dotted_attrs.iter() {
2683 attrs.insert(key.clone(), value.clone());
2684 rec_env.bind(key.clone(), value.clone());
2685 }
2686
2687 // Phase 2: Update all thunks (both Suspended and InheritSelect)
2688 // to capture the final rec_env (which now has all names bound).
2689 for (_key, thunk) in &thunks {
2690 thunk.update_env(&rec_env);
2691 }
2692 } else {
2693 for entry in set.entries() {
2694 match entry {
2695 ast::Entry::AttrpathValue(apv) => {
2696 let attrpath = apv.attrpath().ok_or_else(|| {
2697 EvalError::ParseError("binding missing attrpath".to_string())
2698 })?;
2699 let value_expr = apv.value().ok_or_else(|| {
2700 EvalError::ParseError("binding missing value".to_string())
2701 })?;
2702 let path_attrs: Vec<ast::Attr> = attrpath.attrs().collect();
2703 // CppNix defers a dynamic key that is NOT at the HEAD of the
2704 // attrpath: `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`,
2705 // so `e` never forces until `.a` is demanded. Evaluating the
2706 // whole path eagerly would force `e` at construction and — in
2707 // the module-system fixpoint — read `config.<x>` while `config`
2708 // is mid-force (the M2.6 divergence: `homes.null` instead of
2709 // `homes.<name>`). Only the head is eager; a lone dynamic tail
2710 // becomes a deferred thunk. A rarer collision under the same
2711 // head stays eager (forced) so static deep-merge still works.
2712 let tail_is_dynamic =
2713 path_attrs.len() > 1 && attrs_have_dynamic(&path_attrs[1..]);
2714 let head_key = match eval_attr_maybe_null(&path_attrs[0], env)? {
2715 Some(k) => k,
2716 // Null dynamic HEAD attr name → skip entire binding.
2717 None => continue,
2718 };
2719 if tail_is_dynamic && attrs.get(&head_key).is_none() {
2720 let value =
2721 build_deferred_tail_attr(&path_attrs[1..], &value_expr, env);
2722 attrs.insert(head_key, value);
2723 continue;
2724 }
2725 // M2.6 ROOT #3 (collision case): the tail has a dynamic key
2726 // AND the head already exists (a sibling binding wrote it,
2727 // e.g. osquery's `systemd.services.… = …` then
2728 // `systemd.tmpfiles.settings."10-osquery".${dirname …}.d`).
2729 // The plain deferral above bails (head present), and the
2730 // eager path below would force the dynamic key at
2731 // construction — re-reading `config.<x>` mid-fixpoint →
2732 // the empty-Promise partial. Instead, descend the existing
2733 // head along the tail's STATIC prefix and splice a DEFERRED
2734 // thunk at the first dynamic level, so the dynamic key
2735 // stays lazy exactly as CppNix's nested-literal desugaring
2736 // does — while preserving the static deep-merge with the
2737 // sibling binding.
2738 if tail_is_dynamic {
2739 if let Some(existing) = attrs.get(&head_key).cloned() {
2740 let merged = merge_deferred_dynamic_tail(
2741 existing,
2742 &path_attrs[1..],
2743 &value_expr,
2744 env,
2745 )?;
2746 attrs.insert(head_key, merged);
2747 continue;
2748 }
2749 }
2750 // Eager path: evaluate the remaining (static, or collision)
2751 // keys now. A null dynamic tail key skips the binding.
2752 let mut path_keys: Vec<String> = {
2753 let mut v = Vec::with_capacity(path_attrs.len());
2754 v.push(head_key);
2755 let mut skip = false;
2756 for a in &path_attrs[1..] {
2757 match eval_attr_maybe_null(a, env)? {
2758 Some(k) => v.push(k),
2759 None => { skip = true; break; }
2760 }
2761 }
2762 if skip { v.clear(); }
2763 v
2764 };
2765 // Null dynamic attr name → skip entire binding (CppNix compat)
2766 if path_keys.is_empty() { continue; }
2767 if path_keys.len() == 1 {
2768 let key = path_keys.pop().unwrap();
2769 // maybeThunk: skip thunk for trivial exprs.
2770 // is_rec=false — Ident lookups are safe.
2771 let value = maybe_thunk(&value_expr, env, false, None);
2772 // CppNix desugars `a.b = x; a = { c = y; };` into a single
2773 // merged `a = { b = x; c = y; }` at parse time. rnix keeps
2774 // the two bindings separate, so when a single-key binding
2775 // collides with an already-built (dotted) attrs for the
2776 // same key, deep-MERGE instead of overwrite. Force the RHS
2777 // to WHNF so merge_nested_insert (which needs concrete
2778 // Value::Attrs on both sides) can merge — forcing an
2779 // attrset to WHNF does NOT force its fields, so leaf values
2780 // stay lazy. Only fires on collision; non-colliding
2781 // single-key bindings keep the plain fast insert.
2782 // (This is the pkg-config-wrapper `env.addFlags` drop:
2783 // `env.addFlags = …` then `env = { wrapperName = …; … }`.)
2784 // If the earlier binding for this key is still a lazy
2785 // Thunk (an attrset literal inserted via maybe_thunk), force
2786 // it to WHNF FIRST so a `key = {..}; key = {..}` collision is
2787 // seen as attrs-vs-attrs and MERGES, matching nix
2788 // (`{ s = {a=1;}; s = {b=2;}; }` → `{ s = {a=1; b=2;}; }`).
2789 // Without this the `Some(Value::Attrs(_))` test below is false
2790 // on a Thunk and the second binding overwrites, dropping the
2791 // first's keys. The dotted branch below already does this; R3
2792 // (eval-okay-merge-dynamic-attrs set1/set2) needs it here too.
2793 // WHNF force does not force fields → leaf laziness preserved.
2794 // (A non-attrs dup like `s = 1; s = 2` still overwrites here,
2795 // unchanged — nix errors there, an eval-FAIL case out of scope.)
2796 if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2797 let existing = attrs.get(&key).cloned().unwrap();
2798 let forced_existing = force_value(&existing)?;
2799 attrs.insert(key.clone(), forced_existing);
2800 }
2801 if matches!(attrs.get(&key), Some(Value::Attrs(_))) {
2802 let forced = force_value(&value)?;
2803 merge_nested_insert(&mut attrs, key, forced);
2804 } else {
2805 attrs.insert(key, value);
2806 }
2807 } else {
2808 let key = path_keys[0].clone();
2809 let value = build_nested_attr(&path_keys[1..], &value_expr, env)?;
2810 // CppNix desugars `a = { x = …; }; a.y = …;` into a
2811 // single merged `a = { x = …; y = …; }`. When the
2812 // full-set binding for `a` was inserted FIRST it is a
2813 // lazy Thunk (attrset literals go through maybe_thunk),
2814 // so merge_nested_insert — which only merges when the
2815 // existing value is a concrete Value::Attrs — would
2816 // NOT see the earlier keys and would overwrite `a`
2817 // with just `{ y = … }`, silently dropping `x`. Force
2818 // the existing entry to WHNF on collision so the merge
2819 // sees the concrete attrs (forcing to WHNF does not
2820 // force the fields, so leaf laziness is preserved).
2821 // (This is the gst-plugins-base `passthru.waylandEnabled`
2822 // drop: `passthru = { … }; passthru.tests.x = …;`.)
2823 if matches!(attrs.get(&key), Some(Value::Thunk(_))) {
2824 let existing = attrs.get(&key).cloned().unwrap();
2825 let forced = force_value(&existing)?;
2826 attrs.insert(key.clone(), forced);
2827 }
2828 merge_nested_insert(&mut attrs, key, value);
2829 }
2830 }
2831 ast::Entry::Inherit(inherit) => {
2832 eval_inherit(&inherit, env, &mut attrs, None, None)?;
2833 }
2834 }
2835 }
2836 }
2837
2838 // Record the literal's static-key source positions for
2839 // `builtins.unsafeGetAttrPos` (the `attrTag` `declarations` — options.json
2840 // dock root). Cheap: one entry walk over static Ident/Str keys, no
2841 // forcing; attaches nothing (a pointer-sized `None`) when the set has no
2842 // single-static-key bindings.
2843 attach_attrset_positions(set, &mut attrs, env);
2844
2845 Ok(Value::Attrs(Rc::new(attrs)))
2846}
2847
2848fn eval_inherit(
2849 inherit: &ast::Inherit,
2850 env: &Env,
2851 attrs: &mut NixAttrs,
2852 bind_env: Option<&mut Env>,
2853 mut thunks: Option<&mut Vec<(String, Thunk)>>,
2854) -> Result<(), EvalError> {
2855 if let Some(from) = inherit.from() {
2856 // inherit (expr) a b c;
2857 //
2858 // The source expression must NOT be eagerly evaluated. nixpkgs
2859 // `lib/trivial.nix` has `inherit (lib.trivial) isFunction ...`
2860 // at the top of a file that itself defines `lib.trivial`. If
2861 // we eagerly force `lib.trivial`, we hit a self-referential
2862 // thunk blackhole. Instead: build a thunk per inherited
2863 // name that, when forced, evaluates the source and pulls
2864 // out that one attribute. This is what real Nix does.
2865 //
2866 // For `rec { inherit (X) name; ...; foo = name; }` we ALSO
2867 // need to bind the name in the enclosing rec env so the
2868 // sibling `foo = name` can reference it. The caller passes
2869 // its rec env in `bind_env`.
2870 //
2871 // When `thunks` is provided (rec attrsets), InheritSelect
2872 // thunks are collected so Phase 2 can update their captured
2873 // env to the full recursive scope. Without this, the source
2874 // expression cannot reference sibling bindings.
2875 let source_expr = from
2876 .expr()
2877 .ok_or_else(|| EvalError::ParseError("inherit from missing expr".to_string()))?;
2878 // Shared source thunk — all inherited names share one source
2879 // evaluation (the source thunk's own memoization ensures at
2880 // most one evaluation).
2881 let source_thunk = Thunk::new_suspended(source_expr, env.clone());
2882 let mut be = bind_env;
2883 for attr in inherit.attrs() {
2884 let name = eval_attr(&attr, env)?;
2885 let thunk = Thunk::new_inherit_select(source_thunk.clone(), name.clone());
2886 let value = Value::Thunk(thunk.clone());
2887 attrs.insert(name.clone(), value.clone());
2888 if let Some(ref mut e) = be {
2889 e.bind(name.clone(), value);
2890 }
2891 if let Some(ref mut t) = thunks {
2892 t.push((name, thunk));
2893 }
2894 }
2895 } else {
2896 // inherit a b c;
2897 //
2898 // CppNix resolves a bare `inherit x;` LAZILY, exactly like a plain
2899 // reference to `x` — it does NOT eagerly force the enclosing scope.
2900 // This matters when `x` is provided only by an enclosing `with`
2901 // scope whose value is a fixpoint still being constructed (a
2902 // blackhole): eager `env.lookup` returns None → spurious
2903 // `UndefinedVar`. nixpkgs `all-packages.nix` is
2904 // `… with pkgs; { nettle = import … { inherit callPackage; }; }`,
2905 // so `inherit callPackage` must resolve `callPackage` from the
2906 // `with pkgs` scope AT FORCE TIME, not eagerly at attrset
2907 // construction. Mirror `maybe_thunk`'s Ident path: try the fast
2908 // lookup, and on a miss defer to a WithIdent thunk (or a suspended
2909 // env lookup) so the resolution happens lazily against the settled
2910 // scope. (This was the `nettle` UndefinedVar('callPackage') drop.)
2911 let mut be = bind_env;
2912 for attr in inherit.attrs() {
2913 let name = eval_attr(&attr, env)?;
2914 let sym = crate::value::intern(&name);
2915 let value = if let Some(v) = env.lookup_fast(sym, &name) {
2916 v
2917 } else if let Some((scope_cache, scope_value)) =
2918 env.innermost_with_scope()
2919 {
2920 Value::Thunk(Thunk::new_with_ident(
2921 SmolStr::from(name.as_str()),
2922 scope_cache,
2923 scope_value,
2924 env.clone(),
2925 ))
2926 } else {
2927 return Err(EvalError::UndefinedVar(format!(
2928 "'{name}'{}",
2929 eval_file_ctx()
2930 )));
2931 };
2932 attrs.insert(name.clone(), value.clone());
2933 if let Some(ref mut e) = be {
2934 e.bind(name, value);
2935 }
2936 }
2937 }
2938 Ok(())
2939}
2940
2941fn build_nested_attr(
2942 path: &[String],
2943 expr: &ast::Expr,
2944 env: &Env,
2945) -> Result<Value, EvalError> {
2946 if path.is_empty() {
2947 // CRITICAL: Wrap leaf in a thunk instead of eagerly evaluating.
2948 // For dotted paths like `config.warnings = optionals config.x [...]`,
2949 // the leaf expression must be lazy — eagerly evaluating it during
2950 // attrset construction forces fixpoint thunks prematurely.
2951 return Ok(maybe_thunk(expr, env, false, None));
2952 }
2953 let key = path[0].clone();
2954 let inner = build_nested_attr(&path[1..], expr, env)?;
2955 let mut attrs = NixAttrs::new();
2956 attrs.insert(key, inner);
2957 Ok(Value::Attrs(Rc::new(attrs)))
2958}
2959
2960/// True if a single attr is a DYNAMIC key — one whose resolution runs
2961/// arbitrary expression code and therefore must not be forced at
2962/// attrset-construction time.
2963///
2964/// Two forms are dynamic:
2965/// * `ast::Attr::Dynamic` — a bare `${e}` antiquotation.
2966/// * `ast::Attr::Str` **containing an interpolation** — an interpolated
2967/// string key like `"iwd/${nm}"`. A `Str` with NO interpolation
2968/// (`"foo bar"`) is a plain static string literal and is NOT dynamic.
2969///
2970/// M2.6 ROOT #3: `attrs_have_dynamic` previously matched ONLY
2971/// `Attr::Dynamic`, so an interpolated-string tail key (`config.a."p${e}"`)
2972/// fell to the eager path and forced `e` at construction. In the module
2973/// system that forces a `config.<x>` read while `config` is mid-fixpoint
2974/// (`environment.etc."iwd/${configFile.name}"`, where `configFile` reads
2975/// `with config.networking.networkmanager`), yielding the empty-Promise
2976/// partial → the `set/null` softening. Treating an interpolated `Str` as
2977/// dynamic routes it through the same per-level deferral as `${e}`
2978/// (ROOT #1/#2), so `e` forces only when the enclosing head is demanded —
2979/// exactly CppNix's nested-attrset-literal desugaring.
2980fn attr_is_dynamic(attr: &ast::Attr) -> bool {
2981 match attr {
2982 ast::Attr::Dynamic(_) => true,
2983 // A string attr key is dynamic iff it has ≥1 interpolation part;
2984 // a purely-literal string key forces nothing and stays eager.
2985 ast::Attr::Str(s) => s
2986 .normalized_parts()
2987 .iter()
2988 .any(|p| matches!(p, InterpolPart::Interpolation(_))),
2989 ast::Attr::Ident(_) => false,
2990 }
2991}
2992
2993/// True if any attr in the slice is a dynamic (interpolated) key.
2994///
2995/// A dynamic key beyond the HEAD of an attrpath must NOT be evaluated at
2996/// attrset-construction time — CppNix defers it inside the head's lazy
2997/// value, so `{ a.${e} = v; }` never forces `e` until `.a` is demanded.
2998/// Static string/ident keys are cheap and force nothing, so they don't
2999/// need deferral.
3000fn attrs_have_dynamic(attrs: &[ast::Attr]) -> bool {
3001 attrs.iter().any(attr_is_dynamic)
3002}
3003
3004/// Build the nested attrset for the TAIL of an attrpath, deferring
3005/// evaluation of dynamic tail keys until the value is forced.
3006///
3007/// Given tail attrs `[b, ${e}, c]` and a value expr, produce a lazy
3008/// `Value::Thunk` that, when forced, evaluates each tail key (including
3009/// the dynamic `${e}`) against `env` and builds `{ b = { ${e} = { c =
3010/// <leaf-thunk> }; }; }`. This mirrors CppNix: the inner attrset (and
3011/// thus its dynamic keys) is constructed only when the enclosing head
3012/// attribute is demanded — never at construction of the outer attrset.
3013///
3014/// A dynamic key that evaluates to `null` skips the whole binding
3015/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3016fn build_deferred_tail_attr(
3017 tail: &[ast::Attr],
3018 value_expr: &ast::Expr,
3019 env: &Env,
3020) -> Value {
3021 let tail: Vec<ast::Attr> = tail.to_vec();
3022 let value_expr = value_expr.clone();
3023 let env = env.clone();
3024 Value::Thunk(Thunk::new_native(move || {
3025 build_tail_attrs_now(&tail, &value_expr, &env)
3026 }))
3027}
3028
3029/// Resolve ONE level of the deferred attrpath tail — used from inside
3030/// the deferred thunk above once the enclosing head is demanded.
3031///
3032/// M2.6 ROOT #2 (the OVER-FORCE fix): this resolves *only* `tail[0]`'s
3033/// key and wraps the remaining tail `tail[1..]` in another DEFERRED
3034/// thunk — it does NOT recurse eagerly through the whole tail. This is
3035/// exactly CppNix's desugaring of `a.b.c = v` into nested attrset
3036/// literals `a = { b = { c = v; }; }`, where forcing `a` to WHNF yields
3037/// `{ b = <thunk {c=v}> }` — the inner level (`b`, and any dynamic key
3038/// under it) stays lazy until `.b` is demanded.
3039///
3040/// Forcing the enclosing head therefore resolves ONE tail key, never
3041/// the whole chain: `config.homes.${cfg.pleme.userName} = 7` demanded
3042/// as `config` yields `{ homes = <deferred> }` WITHOUT forcing the
3043/// `${cfg.pleme.userName}` key. The prior implementation recursed the
3044/// whole tail eagerly, forcing that dynamic key while only `.config`
3045/// (or its `._type`) was demanded — the over-force cppnix never does.
3046///
3047/// A dynamic key that evaluates to `null` skips the whole binding
3048/// (returns an empty attrset), matching CppNix's null-dynamic-attr rule.
3049fn build_tail_attrs_now(
3050 tail: &[ast::Attr],
3051 value_expr: &ast::Expr,
3052 env: &Env,
3053) -> Result<Value, EvalError> {
3054 if tail.is_empty() {
3055 return Ok(maybe_thunk(value_expr, env, false, None));
3056 }
3057 if std::env::var_os("SUI_M26_TAILTRACE").is_some() {
3058 let t: String = tail[0].syntax().text().to_string().chars().take(40).collect();
3059 eprintln!("[M26 TAIL-RESOLVE] forcing dynamic tail key `{t}`");
3060 if attrs_have_dynamic(&tail[..1]) {
3061 crate::trace::dump_force_stack_ids();
3062 }
3063 }
3064 let key = match eval_attr_maybe_null(&tail[0], env)? {
3065 Some(k) => k,
3066 // Null dynamic key → the whole binding is skipped; an empty
3067 // attrset is the identity for merge_nested_insert.
3068 None => return Ok(Value::Attrs(Rc::new(NixAttrs::new()))),
3069 };
3070 // Resolve ONE level: if more tail remains, defer it (a new lazy
3071 // thunk) rather than recursing eagerly. Only the leaf (empty tail)
3072 // is built here. This keeps each nested level lazy, exactly like
3073 // CppNix's nested-attrset-literal desugaring — so forcing this
3074 // level does NOT force the next level's (possibly dynamic) key.
3075 let inner = if tail.len() == 1 {
3076 maybe_thunk(value_expr, env, false, None)
3077 } else {
3078 build_deferred_tail_attr(&tail[1..], value_expr, env)
3079 };
3080 let mut attrs = NixAttrs::new();
3081 attrs.insert(key, inner);
3082 Ok(Value::Attrs(Rc::new(attrs)))
3083}
3084
3085/// M2.6 ROOT #3 (collision case): splice a DEFERRED dynamic-tail binding
3086/// into an ALREADY-PRESENT head value without forcing the dynamic key.
3087///
3088/// `existing` is the value already stored at the attrpath's head (written
3089/// by a sibling binding — e.g. `systemd.services.… = …`). `tail` is the
3090/// remaining attrpath (`path_attrs[1..]`) of the new binding, which
3091/// contains ≥1 dynamic attr (`systemd.tmpfiles.….${dirname …}.d`).
3092///
3093/// We descend `existing` along the LONGEST STATIC PREFIX of `tail`
3094/// (`tmpfiles`, `settings`, `"10-osquery"` — all static, forced-free
3095/// keys), forcing each already-present sub-attrset to WHNF so the merge
3096/// sees concrete keys (forcing to WHNF never forces leaf VALUES, so leaf
3097/// laziness is preserved), and at the first DYNAMIC level splice a
3098/// `build_deferred_tail_attr` thunk. The dynamic key therefore forces
3099/// only when that exact nested path is later demanded — CppNix's
3100/// nested-attrset-literal desugaring, now honoured through a sibling
3101/// collision too.
3102fn merge_deferred_dynamic_tail(
3103 existing: Value,
3104 tail: &[ast::Attr],
3105 value_expr: &ast::Expr,
3106 env: &Env,
3107) -> Result<Value, EvalError> {
3108 // `tail` is non-empty and contains a dynamic attr somewhere (the
3109 // caller guarantees `attrs_have_dynamic(tail)`).
3110 debug_assert!(!tail.is_empty());
3111
3112 // If the FIRST tail attr is itself dynamic, there is no static prefix
3113 // to descend — the whole tail is deferred and merged as a lazy
3114 // overlay onto the existing head (a `//`-style right-merge; the
3115 // deferred attrset only materialises its dynamic key on demand).
3116 if attr_is_dynamic(&tail[0]) {
3117 let deferred = build_deferred_tail_attr(tail, value_expr, env);
3118 return Ok(lazy_overlay_merge(existing, deferred));
3119 }
3120
3121 // The head static key of `tail`. Resolve it (static → forces nothing
3122 // relevant; a null dynamic can't occur here since tail[0] is static).
3123 let key = match eval_attr_maybe_null(&tail[0], env)? {
3124 Some(k) => k,
3125 None => return Ok(existing),
3126 };
3127
3128 // Force the existing head to a concrete attrset so we can descend +
3129 // merge on the resolved static key. Forcing to WHNF does NOT force
3130 // its field VALUES, so leaf laziness is preserved.
3131 let existing_forced = force_value(&existing)?;
3132 let mut base = match existing_forced {
3133 Value::Attrs(a) => (*a).clone(),
3134 // The existing head is not an attrset (a sibling wrote a leaf
3135 // here); CppNix would error on the merge, but to stay lazy we
3136 // defer the tail and let a later demand surface the real merge
3137 // conflict. Build the deferred tail as a fresh attrset.
3138 _ => {
3139 let deferred = build_deferred_tail_attr(tail, value_expr, env);
3140 return Ok(deferred);
3141 }
3142 };
3143
3144 // Recurse: merge the REMAINING tail (`tail[1..]`) under `key`.
3145 let child_existing = base.get(&key).cloned();
3146 let new_child = match child_existing {
3147 Some(child) if tail.len() > 1 => {
3148 // Deeper static/dynamic prefix under an existing sub-attrset.
3149 merge_deferred_dynamic_tail(child, &tail[1..], value_expr, env)?
3150 }
3151 Some(child) => {
3152 // tail == [key]; the leaf collides with an existing value.
3153 // Static leaf collision — build the leaf and lazy-merge.
3154 let leaf = maybe_thunk(value_expr, env, false, None);
3155 lazy_overlay_merge(child, leaf)
3156 }
3157 None if tail.len() > 1 => {
3158 // No existing child; the remaining tail may itself start with
3159 // a dynamic key — defer it whole (build_deferred_tail_attr
3160 // handles the static/dynamic split per-level).
3161 build_deferred_tail_attr(&tail[1..], value_expr, env)
3162 }
3163 None => maybe_thunk(value_expr, env, false, None),
3164 };
3165 base.insert(key, new_child);
3166 Ok(Value::Attrs(Rc::new(base)))
3167}
3168
3169/// Lazy right-merge of two values that are (or will force to) attrsets,
3170/// preserving leaf laziness. Used by [`merge_deferred_dynamic_tail`] to
3171/// combine a deferred dynamic-tail attrset with an existing value without
3172/// forcing either's dynamic keys eagerly. When both are concrete attrs we
3173/// deep-merge in place (reusing [`merge_nested_insert`]); otherwise we
3174/// build a lazy overlay thunk that merges on demand.
3175fn lazy_overlay_merge(left: Value, right: Value) -> Value {
3176 match (&left, &right) {
3177 (Value::Attrs(la), Value::Attrs(_)) => {
3178 crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3179 let mut merged = (**la).clone();
3180 if let Value::Attrs(ra) = &right {
3181 // Merging distinct override keys into `merged` is order-
3182 // independent (per-key right-wins), and the result map is
3183 // unordered storage — the sorted `iter()` was dead work.
3184 for (k, v) in ra.iter_unsorted() {
3185 merge_nested_insert(&mut merged, k.clone(), v.clone());
3186 }
3187 }
3188 Value::Attrs(Rc::new(merged))
3189 }
3190 _ => {
3191 // At least one side is a thunk (a deferred dynamic tail).
3192 // Defer the merge behind a Native thunk so neither side's
3193 // dynamic key forces until the merged attrset is demanded.
3194 Value::Thunk(Thunk::new_native(move || {
3195 let lf = force_value(&left)?;
3196 let rf = force_value(&right)?;
3197 let la = lf.as_attrs()?;
3198 let ra = rf.as_attrs()?;
3199 crate::perf::inc(crate::perf::Counter::SlashDeferredTailClone);
3200 let mut merged = (*la).clone();
3201 for (k, v) in ra.iter_unsorted() {
3202 merge_nested_insert(&mut merged, k.clone(), v.clone());
3203 }
3204 Ok(Value::Attrs(Rc::new(merged)))
3205 }))
3206 }
3207 }
3208}
3209
3210/// Like [`build_nested_attr`] but wraps the leaf in a [`Thunk`] instead of
3211/// eagerly evaluating it. Used inside `rec { ... }` and `let ... in` so
3212/// that dotted-path leaf expressions can reference sibling bindings
3213/// through the recursive env (which is finalised in Phase 2).
3214///
3215/// Every thunk created is appended to `thunks` so Phase 2 can update
3216/// its captured environment.
3217fn build_nested_attr_thunk(
3218 path: &[String],
3219 expr: &ast::Expr,
3220 env: &Env,
3221 thunks: &mut Vec<(String, Thunk)>,
3222) -> Value {
3223 if path.is_empty() {
3224 let thunk = Thunk::new_suspended(expr.clone(), env.clone());
3225 let val = Value::Thunk(thunk.clone());
3226 thunks.push((String::new(), thunk));
3227 return val;
3228 }
3229 let key = path[0].clone();
3230 let inner = build_nested_attr_thunk(&path[1..], expr, env, thunks);
3231 let mut attrs = NixAttrs::new();
3232 attrs.insert(key, inner);
3233 Value::Attrs(Rc::new(attrs))
3234}
3235
3236/// Insert `value` at `key` in `target`. If `target` already has a
3237/// concrete `Value::Attrs` at that key AND `value` is also a
3238/// concrete `Value::Attrs`, deep-merge them rather than overwriting.
3239/// This is what makes `{ a.b.c = 1; a.b.d = 2; a.e = 3; }` produce
3240/// `{ a = { b = { c = 1; d = 2; }; e = 3; }; }` instead of
3241/// dropping siblings — every nixpkgs module relies on this.
3242fn merge_nested_insert(target: &mut NixAttrs, key: String, value: Value) {
3243 // Fast path: no existing entry at this key → plain insert, keeping the
3244 // value lazy (the overwhelmingly common non-colliding case, so we never
3245 // force a thunk here).
3246 let existing = match target.get(&key) {
3247 Some(e) => e.clone(),
3248 None => {
3249 target.insert(key, value);
3250 return;
3251 }
3252 };
3253 // A collision exists. A deep merge is warranted only when BOTH the
3254 // existing entry AND the new value are attrset-shaped. M2.6 ROOT #4b
3255 // (byte-verified): either side may be a lazy `Thunk` wrapping a
3256 // full-set leaf — both dotted-path orderings hit this:
3257 // forward `o.a = { x = 1; }; o.a.y = 2;` → EXISTING `a` is a thunk
3258 // (`build_nested_attr` puts the `{x=1}` leaf through
3259 // `maybe_thunk`), NEW `a` is `{ y = … }`;
3260 // reverse `o.a.y = 2; o.a = { x = 1; };` → EXISTING `a` is `{y}`,
3261 // NEW `a` is the `<thunk {x=1}>`.
3262 // The old `should_merge` required BOTH sides to already be concrete
3263 // `Value::Attrs`, so a Thunk-vs-Attrs collision fell to the overwrite
3264 // path and silently dropped the earlier leaf's keys. cppnix desugars
3265 // BOTH orderings into one merged `o.a = { x = 1; y = 2; }`. Force each
3266 // side's thunk to WHNF ON COLLISION ONLY (forcing an attrset to WHNF
3267 // does NOT force its fields, so leaf laziness is preserved); a thunk
3268 // that forces to a non-attrset (or errors) makes the merge a plain
3269 // overwrite (leaf last-write-wins).
3270 // Symptom this closes: nixpkgs' alsa module declares
3271 // `options.hardware.alsa = { enable = …; cardAliases = …; … }` AND
3272 // `options.hardware.alsa.enablePersistence = …`; sui merged them to
3273 // only `{enablePersistence}`, so `hardware.alsa.cardAliases` "does not
3274 // exist" — the M2.6 frontier once the `with`-namespace over-force (#4a)
3275 // was fixed.
3276 let value = match value {
3277 Value::Thunk(_) => match force_value(&value) {
3278 Ok(v @ Value::Attrs(_)) => v,
3279 _ => value,
3280 },
3281 other => other,
3282 };
3283 if !matches!(value, Value::Attrs(_)) {
3284 target.insert(key, value);
3285 return;
3286 }
3287 // Normalize the existing side to concrete attrs too (forcing a thunk
3288 // to WHNF if needed); if it isn't attrset-shaped, the new attrs wins.
3289 let existing_concrete = match &existing {
3290 Value::Attrs(_) => existing.clone(),
3291 Value::Thunk(_) => match force_value(&existing) {
3292 Ok(v @ Value::Attrs(_)) => v,
3293 _ => {
3294 target.insert(key, value);
3295 return;
3296 }
3297 },
3298 _ => {
3299 target.insert(key, value);
3300 return;
3301 }
3302 };
3303 // Both sides are concrete attrs — merge in place. We pop the
3304 // existing entry, then walk the new attrs and recursively
3305 // merge each child onto it.
3306 let mut existing_attrs = match existing_concrete {
3307 Value::Attrs(a) => (*a).clone(),
3308 _ => unreachable!(),
3309 };
3310 let new_attrs = match value {
3311 Value::Attrs(ref a) => a,
3312 _ => unreachable!(),
3313 };
3314 for (k, v) in new_attrs.iter_unsorted() {
3315 merge_nested_insert(&mut existing_attrs, k.clone(), v.clone());
3316 }
3317 target.insert(key, Value::Attrs(Rc::new(existing_attrs)));
3318}
3319
3320/// Evaluate entries from any HasEntry node (LegacyLet).
3321fn eval_entries<N: HasEntry + AstNode>(node: &N, env: &mut Env) -> Result<(), EvalError> {
3322 for entry in node.entries() {
3323 match entry {
3324 ast::Entry::AttrpathValue(apv) => {
3325 let attrpath = apv.attrpath().ok_or_else(|| {
3326 EvalError::ParseError("binding missing attrpath".to_string())
3327 })?;
3328 let value_expr = apv.value().ok_or_else(|| {
3329 EvalError::ParseError("binding missing value".to_string())
3330 })?;
3331 let mut path_keys: Vec<String> = attrpath
3332 .attrs()
3333 .map(|a| eval_attr(&a, env))
3334 .collect::<Result<_, _>>()?;
3335 if path_keys.len() == 1 {
3336 let key = path_keys.pop().unwrap();
3337 let value = eval_expr(&value_expr, env)?;
3338 env.bind(key, value);
3339 }
3340 // Multi-key paths in let are not standard; skip for now.
3341 }
3342 ast::Entry::Inherit(inherit) => {
3343 if let Some(from) = inherit.from() {
3344 let source_expr = from.expr().ok_or_else(|| {
3345 EvalError::ParseError("inherit from missing expr".to_string())
3346 })?;
3347 let source = force_value(&eval_expr(&source_expr, env)?)?;
3348 let source_attrs = source.as_attrs()?;
3349 for attr in inherit.attrs() {
3350 let name = eval_attr(&attr, env)?;
3351 let value = source_attrs
3352 .get(&name)
3353 .cloned()
3354 .ok_or_else(|| EvalError::AttrNotFound(
3355 format!("'{name}' in inherit{}", eval_file_ctx()),
3356 ))?;
3357 env.bind(name, value);
3358 }
3359 } else {
3360 for attr in inherit.attrs() {
3361 let name = eval_attr(&attr, env)?;
3362 let value = env
3363 .lookup(&name)
3364 .ok_or_else(|| EvalError::UndefinedVar(
3365 format!("'{name}'{}", eval_file_ctx()),
3366 ))?;
3367 env.bind(name, value);
3368 }
3369 }
3370 }
3371 }
3372 }
3373 Ok(())
3374}
3375
3376fn eval_binop(
3377 op: ast::BinOpKind,
3378 lhs: &ast::Expr,
3379 rhs: &ast::Expr,
3380 env: &Env,
3381) -> Result<Value, EvalError> {
3382 // Short-circuit for && and ||
3383 match op {
3384 ast::BinOpKind::And => {
3385 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3386 if !l {
3387 return Ok(Value::Bool(false));
3388 }
3389 return eval_expr(rhs, env);
3390 }
3391 ast::BinOpKind::Or => {
3392 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3393 if l {
3394 return Ok(Value::Bool(true));
3395 }
3396 return eval_expr(rhs, env);
3397 }
3398 ast::BinOpKind::Implication => {
3399 let l = force_value(&eval_expr(lhs, env)?)?.as_bool()?;
3400 if !l {
3401 return Ok(Value::Bool(true));
3402 }
3403 return eval_expr(rhs, env);
3404 }
3405 _ => {}
3406 }
3407
3408 let lc = force_concrete(&eval_expr(lhs, env)?)?;
3409 let rc = force_concrete(&eval_expr(rhs, env)?)?;
3410 // Consume the Concretes (move, don't clone) so `l`/`r` hold the sole Rc to
3411 // any heap payload. This is byte-neutral — `into_value` yields the identical
3412 // `Value` as `to_value` — but it drops `lc`/`rc`, which is what lets the
3413 // `Concat` arm's structural-share fast path see a uniquely-owned left list
3414 // for a fresh `++` temporary (`Rc::try_unwrap` → append in place). Keeping
3415 // `lc` alive via `to_value` pinned the refcount at ≥2 and defeated reuse.
3416 let l = lc.into_value();
3417 let r = rc.into_value();
3418
3419 match op {
3420 ast::BinOpKind::Add => match (&l, &r) {
3421 (Value::Int(a), Value::Int(b)) => a
3422 .checked_add(*b)
3423 .map(Value::Int)
3424 .ok_or_else(|| int_overflow("adding", *a, '+', *b)),
3425 (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a + b)),
3426 (Value::Int(a), Value::Float(b)) => Ok(Value::Float(*a as f64 + b)),
3427 (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + *b as f64)),
3428 (Value::String(a), Value::String(b)) => {
3429 let mut ctx = a.context.clone();
3430 ctx.merge(&b.context);
3431 // Byte-identical to `format!("{}{}", a.chars, b.chars)` but
3432 // routes around the `core::fmt` runtime (its dispatch was the
3433 // #1 self-time frame on the string-concat hot path): a single
3434 // exact-capacity `String` + two `push_str` reserves the final
3435 // size once, so the left operand is copied exactly once instead
3436 // of copied-then-regrown. Result string + context unchanged →
3437 // ByteSufficient. (Also removes a `format!` — TYPED EMISSION.)
3438 let mut s = String::with_capacity(a.chars.len() + b.chars.len());
3439 s.push_str(&a.chars);
3440 s.push_str(&b.chars);
3441 Ok(Value::String(Rc::new(NixString::with_context(s, ctx))))
3442 }
3443 (Value::Path(a), Value::String(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}{}", b.chars).as_str())))),
3444 (Value::Path(a), Value::Path(b)) => Ok(Value::Path(Box::new(SmolStr::from(format!("{a}/{b}").as_str())))),
3445 // CppNix coerces attrsets with outPath when used with +
3446 (Value::Attrs(_), _) | (_, Value::Attrs(_)) => {
3447 let (ls, lctx) = l.coerce_to_string()?;
3448 let (rs, rctx) = r.coerce_to_string()?;
3449 let mut ctx = lctx;
3450 ctx.merge(&rctx);
3451 Ok(Value::String(Rc::new(NixString::with_context(
3452 format!("{ls}{rs}"),
3453 ctx,
3454 ))))
3455 }
3456 _ => Err(EvalError::op_type("add", l.type_name(), r.type_name())),
3457 },
3458 ast::BinOpKind::Sub => num_op(
3459 &l,
3460 &r,
3461 |a, b| a.checked_sub(b),
3462 |a, b| a - b,
3463 |a, b| int_overflow("subtracting", a, '-', b),
3464 ),
3465 ast::BinOpKind::Mul => num_op(
3466 &l,
3467 &r,
3468 |a, b| a.checked_mul(b),
3469 |a, b| a * b,
3470 |a, b| int_overflow("multiplying", a, '*', b),
3471 ),
3472 ast::BinOpKind::Div => {
3473 // CppNix rejects division by zero for both int and float
3474 // operands; Rust's native int-div-by-0 panics (we handle
3475 // that below) but float-div-by-0 silently returns `inf`
3476 // or `NaN`, which sui was then serializing as `null` —
3477 // an invisible silent-Ok bug surfaced by the error-case
3478 // differential corpus.
3479 //
3480 // Cover every zero-denominator case explicitly.
3481 let rhs_is_zero = match &r {
3482 Value::Int(0) => true,
3483 Value::Float(f) => *f == 0.0,
3484 _ => false,
3485 };
3486 if rhs_is_zero {
3487 return Err(EvalError::DivisionByZero);
3488 }
3489 num_op(
3490 &l,
3491 &r,
3492 |a, b| a.checked_div(b),
3493 |a, b| a / b,
3494 |a, b| int_overflow("dividing", a, '/', b),
3495 )
3496 }
3497 // `eq_operator`, NOT `==`: at the operator both operands were just
3498 // materialized by independent `force_concrete` calls, so sui can prove
3499 // they are distinct cells and must answer `false` for two lambdas —
3500 // exactly as CppNix's `ExprOpEq::eval` does. Nested comparisons keep
3501 // `PartialEq`. See `value::eq_operator`.
3502 ast::BinOpKind::Equal => Ok(Value::Bool(crate::value::eq_operator(&l, &r))),
3503 ast::BinOpKind::NotEqual => Ok(Value::Bool(!crate::value::eq_operator(&l, &r))),
3504 ast::BinOpKind::Less => compare(&l, &r, |o| o == std::cmp::Ordering::Less),
3505 ast::BinOpKind::LessOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Greater),
3506 ast::BinOpKind::More => compare(&l, &r, |o| o == std::cmp::Ordering::Greater),
3507 ast::BinOpKind::MoreOrEq => compare(&l, &r, |o| o != std::cmp::Ordering::Less),
3508 ast::BinOpKind::Update => {
3509 let la = l.to_attrs()?;
3510 let ra = r.to_attrs()?;
3511 // O(1) lazy overlay — defers merge until attribute access.
3512 Ok(Value::Attrs(Rc::new(la.overlay(ra))))
3513 }
3514 ast::BinOpKind::Concat => {
3515 // Structural-share fast path: when the left operand's `Rc<Vec>` is
3516 // uniquely owned (a fresh temporary, as in a left-associative `++`
3517 // fold `acc ++ [x]`), append the right elements IN PLACE instead of
3518 // cloning the whole accumulator. This turns an O(n) copy per concat
3519 // into amortized O(1), byte-identically — the result is the same
3520 // ordered sequence of the same Rc-shared lazy thunks (no forcing,
3521 // no reordering, no identity change). When the Rc is shared (the
3522 // left came from a still-live binding/thunk) we fall back to the
3523 // clone-extend path, preserving the shared list unchanged.
3524 crate::value::concat_lists(l, r.as_list()?)
3525 }
3526 ast::BinOpKind::And | ast::BinOpKind::Or | ast::BinOpKind::Implication => {
3527 unreachable!("handled above")
3528 }
3529 ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
3530 Err(EvalError::NotImplemented("pipe operators".to_string()))
3531 }
3532 }
3533}
3534
3535/// CppNix aborts (uncatchably) on i64 arithmetic overflow, e.g.
3536/// `integer overflow in adding 9223372036854775807 + 1`. `EvalError::Abort` is
3537/// the uncatchable variant (`tryEval` catches only `Throw`/`AssertionFailed`),
3538/// matching nix — a wrapping result would silently produce a wrong drvPath.
3539#[inline]
3540fn int_overflow(verb: &str, a: i64, sym: char, b: i64) -> EvalError {
3541 EvalError::Abort(format!("integer overflow in {verb} {a} {sym} {b}"))
3542}
3543
3544fn num_op(
3545 l: &Value,
3546 r: &Value,
3547 int_op: impl Fn(i64, i64) -> Option<i64>,
3548 float_op: impl Fn(f64, f64) -> f64,
3549 overflow: impl Fn(i64, i64) -> EvalError,
3550) -> Result<Value, EvalError> {
3551 match (l, r) {
3552 (Value::Int(a), Value::Int(b)) => {
3553 int_op(*a, *b).map(Value::Int).ok_or_else(|| overflow(*a, *b))
3554 }
3555 (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
3556 (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
3557 (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
3558 _ => Err(EvalError::op_type("perform arithmetic on", l.type_name(), r.type_name())),
3559 }
3560}
3561
3562fn compare(
3563 l: &Value,
3564 r: &Value,
3565 pred: impl Fn(std::cmp::Ordering) -> bool,
3566) -> Result<Value, EvalError> {
3567 let ord = match (l, r) {
3568 (Value::Int(a), Value::Int(b)) => a.cmp(b),
3569 (Value::Float(a), Value::Float(b)) => {
3570 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
3571 }
3572 (Value::Int(a), Value::Float(b)) => (*a as f64)
3573 .partial_cmp(b)
3574 .unwrap_or(std::cmp::Ordering::Equal),
3575 (Value::Float(a), Value::Int(b)) => a
3576 .partial_cmp(&(*b as f64))
3577 .unwrap_or(std::cmp::Ordering::Equal),
3578 (Value::String(a), Value::String(b)) => a.chars.cmp(&b.chars),
3579 _ => {
3580 return Err(EvalError::op_type("compare", l.type_name(), r.type_name()));
3581 }
3582 };
3583 Ok(Value::Bool(pred(ord)))
3584}
3585
3586/// Apply a function to an argument.
3587///
3588/// Supports `__functor`: if `func` is an attrset with a `__functor` key,
3589/// calls `__functor self arg` (the Nix `__functor` protocol).
3590///
3591/// For lambda with a simple ident parameter, the argument is NOT forced
3592/// before binding -- this enables fixpoint combinators (`lib.fix`) where
3593/// the argument is a self-referential thunk.
3594/// Apply a function and force the result.
3595///
3596/// Builtins that inspect the return value (via `as_list`, `as_bool`, etc.)
3597/// must use this instead of bare `apply` — otherwise a thunk-wrapped result
3598/// will cause "thunk in as_list: force first" errors.
3599pub fn apply_and_force(func: Value, arg: Value) -> Result<Value, EvalError> {
3600 force_value(&apply(func, arg)?)
3601}
3602
3603pub fn apply(func: Value, arg: Value) -> Result<Value, EvalError> {
3604 stacker::maybe_grow(64 * 1024, 2 * 1024 * 1024, || apply_inner(func, arg))
3605}
3606
3607fn apply_inner(func: Value, arg: Value) -> Result<Value, EvalError> {
3608 crate::perf::inc(crate::perf::Counter::Apply);
3609 let func = force_concrete(&func)?.into_value();
3610 match func {
3611 Value::Lambda(closure) => {
3612 // Hot function tracker: log source file + param name for each lambda call
3613 if crate::perf::enabled() {
3614 APPLY_SITES.with(|sites| {
3615 let file = closure.env.eval_file()
3616 .map(|p| p.display().to_string())
3617 .unwrap_or_else(|| "<eval>".into());
3618 // Include param info for identification
3619 let param_name = match &closure.param {
3620 rnix::ast::Param::IdentParam(ip) => ip.ident().map(|i| ident_text(&i)).unwrap_or_default(),
3621 rnix::ast::Param::Pattern(pat) => {
3622 let mut names: Vec<String> = pat.pat_entries()
3623 .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3624 .take(3)
3625 .collect();
3626 if pat.pat_entries().count() > 3 { names.push("...".to_string()); }
3627 format!("{{{}}}", names.join(","))
3628 }
3629 };
3630 let key = format!("{}:{}", file.rsplit_once("-source/").map_or(file.as_str(), |(_,s)| s), param_name);
3631 *sites.borrow_mut().entry(key).or_insert(0u64) += 1;
3632 });
3633 }
3634 let mut call_env = closure.env.child();
3635 // ALWAYS push a frame, even when the closure captured no file:
3636 // `.map(push_eval_file)` pushed nothing for `None`, leaving the
3637 // CALLER's file on top, so a literal written in a fileless
3638 // context got stamped with the callee's path. CppNix returns
3639 // `null` there. See `EVAL_FILE_STACK`.
3640 let _file_guard = push_eval_frame(closure.env.eval_file().cloned());
3641 // Push Nix-level trace frame for function calls. Lazy: stores
3642 // only the raw ingredients (O(1) Rc-clone of the closure env +
3643 // the current-eval-file snapshot) and defers the format!/strip
3644 // work to the cold `attach_trace` path. Renders byte-identical
3645 // to the eager form.
3646 let _trace = push_nix_trace_lambda(&closure.env);
3647 match &closure.param {
3648 rnix::ast::Param::IdentParam(_) => {
3649 // Simple ident param: bind argument WITHOUT forcing.
3650 // This is critical for fixpoint / call-by-need semantics.
3651 bind_param(&closure.param, &arg, &mut call_env)?;
3652 }
3653 rnix::ast::Param::Pattern(_) => {
3654 // Pattern param needs the arg to be an attrset, so force.
3655 let forced_arg = force_concrete(&arg)?.into_value();
3656 bind_param(&closure.param, &forced_arg, &mut call_env)?;
3657 }
3658 }
3659 eval_expr(&closure.body, &call_env)
3660 }
3661 Value::Builtin(b) => {
3662 let _trace = push_nix_trace(format!("while calling the '{}' builtin", b.name));
3663 // Special builtins that must receive UNFORCED arguments:
3664 // - tryEval: must catch throw/abort during its own forcing
3665 // - addErrorContext<partial>: wraps value with error context
3666 // without forcing (the value is the fixpoint `config` which
3667 // causes infinite recursion if forced during collectModules)
3668 // - seq<partial>: forces first arg but returns second UNFORCED
3669 // Same lazy-arg set as `eval_apply` (single source of truth) — these
3670 // builtins receive the arg UNFORCED. foldl'<p1> is the nul accumulator
3671 // (nix's foldl' is strict in each op RESULT, NOT in the nul).
3672 if builtin_takes_lazy_arg(&b.name) {
3673 (b.func)(&[arg])
3674 } else {
3675 let forced_arg = force_value(&arg)?;
3676 (b.func)(&[forced_arg])
3677 }
3678 }
3679 Value::Attrs(ref attrs) => {
3680 if let Some(functor) = attrs.get("__functor") {
3681 let functor = force_value(functor)?;
3682 // __functor protocol: (functor self) arg
3683 let partial = apply(functor, func.clone())?;
3684 apply(partial, arg)
3685 } else if crate::value::in_promise_eval() {
3686 // M2.6 Promise softening: an attrset without __functor
3687 // being called as a function — typically the empty-
3688 // attrset sentinel inside a fix-point body. Return
3689 // null so eval can proceed.
3690 Ok(Value::Null)
3691 } else {
3692 Err(EvalError::type_error(
3693 format!("cannot call {} (missing __functor){}", func.type_name(), eval_file_ctx()),
3694 ))
3695 }
3696 }
3697 _ if crate::value::in_promise_eval() => {
3698 // M2.6 Promise softening: calling null / int / string / list
3699 // as a function inside a Promise body is the sentinel
3700 // cascade landing somewhere it doesn't belong. Return null
3701 // so the fix-point continues instead of erroring.
3702 Ok(Value::Null)
3703 }
3704 _ => Err(EvalError::type_error(
3705 format!("cannot call {}{}", func.type_name(), eval_file_ctx()),
3706 )),
3707 }
3708}
3709
3710/// Dark-side lever `batch-bind` (byte-SAFE, `RedundantWrite`) — OFF by default.
3711/// When `SUI_BATCH_BIND=1`, an N-formal pattern binds in ONE copy-on-write step
3712/// (`Env::bind_many`) instead of N successive `env.bind()` calls. Byte-identical
3713/// either way (same intern, same insert order, same final HAMT — Phase 2's
3714/// `update_env` makes each default thunk's initial env capture unobservable).
3715/// Gated because the extra `Vec` allocation could regress the common small-pattern
3716/// case, and the win is unmeasured under load — never change the default path on a
3717/// hunch (never-ship-a-regression). Cached so the default path pays zero per call.
3718/// Ledger: `sui-spec/specs/darkside.lisp` (`batch-bind`, DarkGated).
3719static SUI_BATCH_BIND: std::sync::LazyLock<bool> =
3720 std::sync::LazyLock::new(|| std::env::var_os("SUI_BATCH_BIND").is_some());
3721
3722fn bind_param(param: &ast::Param, arg: &Value, env: &mut Env) -> Result<(), EvalError> {
3723 match param {
3724 ast::Param::IdentParam(ip) => {
3725 let ident = ip
3726 .ident()
3727 .ok_or_else(|| EvalError::ParseError("ident param missing ident".to_string()))?;
3728 let name = ident_text(&ident);
3729 env.bind(name, arg.clone());
3730 }
3731 ast::Param::Pattern(pat) => {
3732 let attrs = arg.as_attrs()?;
3733
3734 // @-binding (either `args @ { ... }` or `{ ... } @ args`)
3735 if let Some(pat_bind) = pat.pat_bind()
3736 && let Some(ident) = pat_bind.ident()
3737 {
3738 let name = ident_text(&ident);
3739 env.bind(name, arg.clone());
3740 }
3741
3742 let has_ellipsis = pat.ellipsis_token().is_some();
3743 let entries: Vec<ast::PatEntry> = pat.pat_entries().collect();
3744
3745 // Two-phase binding (matching CppNix semantics):
3746 // Phase 1: Bind all formals. Defaults get thunks with a
3747 // preliminary env. We collect thunks for Phase 2 update.
3748 // Phase 2: Update default thunks to capture the final env
3749 // (which now has ALL formals bound). This allows defaults
3750 // to reference any other formal — including forward refs.
3751 let mut default_thunks: Vec<Thunk> = Vec::new();
3752 // batch-bind (byte-SAFE `RedundantWrite`, OFF unless `SUI_BATCH_BIND=1`):
3753 // the flag path collects every formal's (name, value) pair and binds
3754 // them in ONE copy-on-write step (`bind_many`) instead of N successive
3755 // `env.bind()` calls. Byte-identical either way — the default thunks
3756 // capture `env.clone()` (pre-batch) and Phase 2's `update_env` re-points
3757 // every one to the final all-formals-bound env, so a thunk's *initial*
3758 // capture is unobservable (overwritten before any force); same intern,
3759 // same insert order, same final HAMT. The default path (flag unset) is
3760 // the original per-formal loop, byte- AND perf-identical (no Vec alloc).
3761 let use_batch = *SUI_BATCH_BIND;
3762 let mut pairs: Vec<(String, Value)> =
3763 if use_batch { Vec::with_capacity(entries.len()) } else { Vec::new() };
3764
3765 // D3 (`SUI_SCOPE_NARROW>=1`) — the highest-yield arm of the fix,
3766 // because it fires on every `callPackage`'d
3767 // `{ stdenv, lib, foo ? null }` and every
3768 // `{ config, lib, pkgs, ... }` module in the fleet.
3769 //
3770 // Today EVERY default thunk is re-pointed at the final all-formals
3771 // env by Phase 2, so `{ a, b ? 1 }` closes
3772 // `b-thunk -> env -> b-thunk` and the whole call frame is immortal.
3773 // But a default only NEEDS the final env if it can reach a formal
3774 // that is itself satisfied by a default — those are the only names
3775 // still unbound when the default is built. Everything else (an
3776 // argument-supplied formal, the `@`-bind, any outer name) is
3777 // already in scope, so the capture is complete on the spot and the
3778 // cycle never has to be closed.
3779 //
3780 // Splitting the single pass in two is what makes that true:
3781 // pass A binds every argument-supplied formal FIRST, so pass B's
3782 // captures see all of them regardless of declaration order.
3783 //
3784 // The reorder is byte-safe: formal names are unique (a duplicate
3785 // is a parse error), `bindings` is a hash map read only by key, and
3786 // building a thunk has no side effects — so nothing observes the
3787 // order in which the two passes populate the env, only its final
3788 // contents, which are unchanged.
3789 let narrow = scope_narrow_enabled();
3790 // The formals that will be satisfied BY A DEFAULT — i.e. exactly
3791 // the names not yet bound when pass B runs.
3792 let default_names: HashSet<String> = if narrow {
3793 entries
3794 .iter()
3795 .filter(|e| e.default().is_some())
3796 .filter_map(ast::PatEntry::ident)
3797 .map(|i| ident_text(&i))
3798 .filter(|n| attrs.get(n).is_none())
3799 .collect()
3800 } else {
3801 HashSet::new()
3802 };
3803
3804 if narrow {
3805 // PASS A — argument-supplied formals only. The
3806 // `missing argument` error still fires here, in entry order,
3807 // exactly where the single pass raised it.
3808 let mut deferred: Vec<(String, ast::Expr)> =
3809 Vec::with_capacity(default_names.len());
3810 for entry in &entries {
3811 let ident = entry.ident().ok_or_else(|| {
3812 EvalError::ParseError("pat entry missing ident".to_string())
3813 })?;
3814 let name = ident_text(&ident);
3815 if let Some(v) = attrs.get(&name) {
3816 env.bind(name, v.clone());
3817 } else if let Some(default_expr) = entry.default() {
3818 deferred.push((
3819 name,
3820 ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3821 ));
3822 } else {
3823 return Err(EvalError::type_error(
3824 format!("missing argument '{name}'{}", eval_file_ctx()),
3825 ));
3826 }
3827 }
3828 // PASS B — the defaults, capturing an env that already carries
3829 // every argument-supplied formal and the `@`-bind.
3830 for (name, default_expr) in deferred {
3831 let thunk =
3832 Thunk::new_suspended(default_expr.clone(), env.clone());
3833 let referenced = referenced_idents(&default_expr);
3834 if default_names.iter().any(|n| referenced.contains(n.as_str())) {
3835 // Reaches another DEFAULTED formal, which may not be
3836 // bound yet — it needs Phase 2's re-point, and pays
3837 // the cycle.
3838 default_thunks.push(thunk.clone());
3839 crate::value::census::scope_pinned();
3840 } else {
3841 crate::value::census::scope_narrowed();
3842 }
3843 env.bind(name, Value::Thunk(thunk));
3844 }
3845 } else {
3846 for entry in &entries {
3847 let ident = entry.ident().ok_or_else(|| {
3848 EvalError::ParseError("pat entry missing ident".to_string())
3849 })?;
3850 let name = ident_text(&ident);
3851 let value = if let Some(v) = attrs.get(&name) {
3852 v.clone()
3853 } else if let Some(default_expr) = entry.default() {
3854 // Default values in pattern parameters must be lazy
3855 // (wrapped in thunks), matching CppNix semantics.
3856 // Patterns like `vendor ? assert false; null` rely on
3857 // the default never being forced when the body checks
3858 // `args ? vendor` instead of using `vendor` directly.
3859 let thunk = Thunk::new_suspended(
3860 ast::Expr::cast(default_expr.syntax().clone()).unwrap(),
3861 env.clone(),
3862 );
3863 default_thunks.push(thunk.clone());
3864 Value::Thunk(thunk)
3865 } else {
3866 return Err(EvalError::type_error(
3867 format!("missing argument '{name}'{}", eval_file_ctx()),
3868 ));
3869 };
3870 if use_batch {
3871 pairs.push((name, value));
3872 } else {
3873 env.bind(name, value);
3874 }
3875 }
3876 if use_batch {
3877 env.bind_many(pairs);
3878 }
3879 }
3880
3881 // Phase 2: Update default thunks to see ALL formals.
3882 for thunk in &default_thunks {
3883 thunk.update_env(env);
3884 }
3885
3886 if !has_ellipsis {
3887 let entry_names: std::collections::HashSet<String> = entries
3888 .iter()
3889 .filter_map(|e| e.ident().map(|i| ident_text(&i)))
3890 .collect();
3891 for key in attrs.keys() {
3892 if !entry_names.contains(key.as_str()) {
3893 return Err(EvalError::type_error(
3894 format!("unexpected argument '{key}'{}", eval_file_ctx()),
3895 ));
3896 }
3897 }
3898 }
3899 }
3900 }
3901 Ok(())
3902}
3903
3904#[cfg(test)]
3905mod tests {
3906 use super::*;
3907
3908 fn ev(input: &str) -> Value {
3909 eval(input).unwrap()
3910 }
3911
3912 // Regression (2026-07-10): the let-scope fix-point detector must count
3913 // only GENUINE variable references, not attribute names / attrset keys
3914 // (which sit under a `NODE_ATTRPATH`). nixpkgs `lib/types.nix` has
3915 // `placeholder = if lhs.placeholder == …` whose RHS mentions the
3916 // *attribute* `.placeholder`; the old raw-token match falsely flagged
3917 // the binding self-recursive and routed it through the Promise path.
3918 #[test]
3919 fn is_self_recursive_binding_ignores_attribute_names() {
3920 fn expr(s: &str) -> ast::Expr {
3921 rnix::Root::parse(s).tree().expr().expect("parse")
3922 }
3923 // attribute names / keys are NOT references to the binding
3924 assert!(!is_self_recursive_binding(&expr("lhs.placeholder"), "placeholder"));
3925 assert!(!is_self_recursive_binding(&expr("{ placeholder = 1; }"), "placeholder"));
3926 assert!(!is_self_recursive_binding(
3927 &expr("if lhs.placeholder == rhs.placeholder then lhs.placeholder else null"),
3928 "placeholder",
3929 ));
3930 // genuine variable references ARE detected
3931 assert!(is_self_recursive_binding(&expr("placeholder + 1"), "placeholder"));
3932 assert!(is_self_recursive_binding(
3933 &expr("if placeholder then 1 else 2"),
3934 "placeholder"
3935 ));
3936 }
3937
3938 // M2 thunk-waste (byte-safe eager constant): a NON-interpolated string in a
3939 // maybe_thunk site is evaluated directly (no suspended thunk). The value +
3940 // its (empty) context must be byte-identical to forcing a thunk of it.
3941 #[test]
3942 fn maybe_thunk_eager_constant_str_is_byte_identical() {
3943 fn expr(s: &str) -> ast::Expr {
3944 rnix::Root::parse(s).tree().expr().expect("parse")
3945 }
3946 let env = Env::new();
3947 // Constant string → returned as a concrete String, NOT a Thunk.
3948 let v = maybe_thunk(&expr(r#""abc""#), &env, false, None);
3949 assert!(matches!(v, Value::String(_)), "constant str should be eager, got {v:?}");
3950 assert_eq!(force_value(&v).unwrap(), Value::string("abc"));
3951 // Interpolated string → MUST stay a thunk (lazy `${…}` force).
3952 let vi = maybe_thunk(&expr(r#""a${b}c""#), &env, false, None);
3953 assert!(matches!(vi, Value::Thunk(_)), "interpolated str must stay thunked");
3954 }
3955
3956 // The pure-constant arg classifier admits ONLY literals + non-interpolated
3957 // strings/paths, and rejects everything that could throw/diverge/observe a
3958 // fixpoint — the laziness safety boundary of the apply-arg optimization.
3959 #[test]
3960 fn eval_pure_constant_arg_classification() {
3961 fn expr(s: &str) -> ast::Expr {
3962 rnix::Root::parse(s).tree().expr().expect("parse")
3963 }
3964 // ADMIT: pure constants (byte-safe to eval eagerly in an arg position).
3965 assert!(eval_pure_constant_arg(&expr("42")).is_some());
3966 assert!(eval_pure_constant_arg(&expr("3.14")).is_some());
3967 assert!(eval_pure_constant_arg(&expr(r#""const""#)).is_some());
3968 assert!(eval_pure_constant_arg(&expr("/abs/path")).is_some());
3969 // REJECT: anything that could throw / diverge / observe laziness.
3970 assert!(eval_pure_constant_arg(&expr(r#""a${b}c""#)).is_none(), "interpolated str");
3971 // `true`/`false`/`null` are IDENTS in nix (shadowable), not literals —
3972 // rejected to avoid a with-scope force, correctly conservative.
3973 assert!(eval_pure_constant_arg(&expr("true")).is_none(), "bool is an ident");
3974 assert!(eval_pure_constant_arg(&expr("x")).is_none(), "ident (with-scope force)");
3975 assert!(eval_pure_constant_arg(&expr("a.b")).is_none(), "select (fixpoint)");
3976 assert!(eval_pure_constant_arg(&expr("f x")).is_none(), "apply (may throw)");
3977 assert!(eval_pure_constant_arg(&expr("1 + 1")).is_none(), "binop (may throw)");
3978 assert!(eval_pure_constant_arg(&expr("throw \"x\"")).is_none(), "throw stays lazy");
3979 }
3980
3981 // LAZINESS GUARD: a lambda that IGNORES its arg must NOT force it — even a
3982 // throwing arg. The pure-constant optimization only touches inert constants,
3983 // so a `throw`-ing arg stays fully thunked and the ignoring lambda succeeds.
3984 #[test]
3985 fn ignored_throwing_arg_stays_lazy() {
3986 assert_eq!(ev(r#"(x: 7) (throw "boom")"#), Value::Int(7));
3987 // And an ignored constant arg is equally invisible.
3988 assert_eq!(ev(r#"(x: 7) "const""#), Value::Int(7));
3989 // A USED constant arg produces the right value.
3990 assert_eq!(ev(r#"(x: x) "used""#), Value::string("used"));
3991 }
3992
3993 #[test]
3994 fn eval_int() { assert_eq!(ev("42"), Value::Int(42)); }
3995
3996 #[test]
3997 fn eval_float() { assert_eq!(ev("3.14"), Value::Float(3.14)); }
3998
3999 #[test]
4000 fn eval_string() { assert_eq!(ev(r#""hello""#), Value::string("hello")); }
4001
4002 #[test]
4003 fn eval_bool() { assert_eq!(ev("true"), Value::Bool(true)); }
4004
4005 #[test]
4006 fn eval_null() { assert_eq!(ev("null"), Value::Null); }
4007
4008 #[test]
4009 fn eval_arithmetic() {
4010 assert_eq!(ev("1 + 2"), Value::Int(3));
4011 assert_eq!(ev("10 - 3"), Value::Int(7));
4012 assert_eq!(ev("2 * 3"), Value::Int(6));
4013 assert_eq!(ev("10 / 3"), Value::Int(3));
4014 }
4015
4016 #[test]
4017 fn eval_precedence() {
4018 assert_eq!(ev("1 + 2 * 3"), Value::Int(7));
4019 assert_eq!(ev("(1 + 2) * 3"), Value::Int(9));
4020 }
4021
4022 #[test]
4023 fn eval_comparison() {
4024 assert_eq!(ev("1 == 1"), Value::Bool(true));
4025 assert_eq!(ev("1 == 2"), Value::Bool(false));
4026 assert_eq!(ev("1 < 2"), Value::Bool(true));
4027 assert_eq!(ev("2 <= 2"), Value::Bool(true));
4028 }
4029
4030 #[test]
4031 fn eval_logic() {
4032 assert_eq!(ev("true && false"), Value::Bool(false));
4033 assert_eq!(ev("true || false"), Value::Bool(true));
4034 assert_eq!(ev("!true"), Value::Bool(false));
4035 }
4036
4037 #[test]
4038 fn eval_string_concat() {
4039 assert_eq!(ev(r#""hello" + " " + "world""#), Value::string("hello world"));
4040 }
4041
4042 #[test]
4043 fn eval_if() {
4044 assert_eq!(ev("if true then 1 else 2"), Value::Int(1));
4045 assert_eq!(ev("if false then 1 else 2"), Value::Int(2));
4046 }
4047
4048 #[test]
4049 fn eval_let() {
4050 assert_eq!(ev("let x = 1; in x"), Value::Int(1));
4051 assert_eq!(ev("let x = 1; y = 2; in x + y"), Value::Int(3));
4052 }
4053
4054 #[test]
4055 fn eval_let_dotted_simple() {
4056 // Two dotted bindings sharing the top-level key `a`.
4057 assert_eq!(ev("let a.b = 1; a.c = 2; in a.b + a.c"), Value::Int(3));
4058 }
4059
4060 #[test]
4061 fn eval_let_dotted_deep() {
4062 // Deeply nested dotted path.
4063 assert_eq!(ev("let a.b.c = 1; in a.b.c"), Value::Int(1));
4064 }
4065
4066 #[test]
4067 fn eval_let_dotted_mixed() {
4068 // Mix of simple and dotted bindings.
4069 assert_eq!(
4070 ev("let a.x = 1; b = 2; a.y = 3; in a.x + a.y + b"),
4071 Value::Int(6),
4072 );
4073 }
4074
4075 #[test]
4076 fn eval_let_dotted_produces_attrset() {
4077 // Dotted let bindings produce a real attrset.
4078 let v = ev("let a.b = 1; a.c = 2; in a");
4079 if let Value::Attrs(attrs) = v {
4080 assert_eq!(attrs.get("b"), Some(&Value::Int(1)));
4081 assert_eq!(attrs.get("c"), Some(&Value::Int(2)));
4082 } else {
4083 panic!("expected Attrs, got {v:?}");
4084 }
4085 }
4086
4087 // ── Inner dynamic attrpath key laziness ──────────────────
4088 // CppNix defers a dynamic key that is NOT at the head of an attrpath:
4089 // `{ a.${e} = v; }` builds `{ a = <thunk {${e}=v}>; }`, so `e` never
4090 // forces until `.a` is demanded. Reading a sibling must not force the
4091 // inner dynamic key. Root fix: `build_deferred_tail_attr` in eval.rs.
4092 // This is the pure-builtins reduction of the NixOS module-system
4093 // `config.homes.${cfg.userName}` fixpoint divergence.
4094 #[test]
4095 fn dynamic_inner_attr_key_is_lazy_on_sibling_read() {
4096 // The dynamic key throws; reading the SIBLING must NOT force it.
4097 assert_eq!(
4098 ev(r#"let s = { a.${throw "KEYFORCED"} = 7; other = 9; }; in s.other"#),
4099 Value::Int(9),
4100 );
4101 }
4102
4103 #[test]
4104 fn dynamic_inner_attr_key_resolves_on_head_demand() {
4105 // Demanding the head DOES resolve the deferred dynamic key.
4106 let v = ev(r#"let u = "bob"; s = { homes.${u} = 7; }; in s.homes"#);
4107 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4108 assert_eq!(attrs.get("bob"), Some(&Value::Int(7)));
4109 } else {
4110 panic!("expected Attrs");
4111 }
4112 }
4113
4114 #[test]
4115 fn dynamic_inner_attr_key_merges_with_static_sibling() {
4116 // Collision under one head still deep-merges (static + dynamic).
4117 let v = ev(r#"let u = "x"; s = { a.${u} = 1; a.b = 2; }; in s.a"#);
4118 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4119 assert_eq!(attrs.get("x"), Some(&Value::Int(1)));
4120 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4121 } else {
4122 panic!("expected Attrs");
4123 }
4124 }
4125
4126 #[test]
4127 fn dynamic_inner_attr_key_null_skips_binding() {
4128 // A null dynamic inner key skips the definition (CppNix rule):
4129 // `a` becomes an empty attrset, the sibling stays.
4130 let v = ev(
4131 r#"let c = true; s = { a.${if c then null else "n"} = 5; b = 1; }; in s.b"#,
4132 );
4133 assert_eq!(v, Value::Int(1));
4134 }
4135
4136 // ── M2.6 ROOT #3: interpolated-STRING tail keys are dynamic too ──────
4137 // `{ a."p${e}" = v; }` must build `{ a = <thunk {"p${e}"=v}>; }` — an
4138 // interpolated-string attr key references `e` and so must defer like a
4139 // bare `${e}`, never force at construction. Reading a sibling must NOT
4140 // force it (the KEYFORCE discriminator, now for a `Str` key).
4141 #[test]
4142 fn interpolated_string_attr_key_is_lazy_on_sibling_read() {
4143 assert_eq!(
4144 ev(r#"let s = { a."p/${throw "KEYFORCED"}" = 7; other = 9; }; in s.other"#),
4145 Value::Int(9),
4146 );
4147 }
4148
4149 #[test]
4150 fn interpolated_string_attr_key_resolves_on_head_demand() {
4151 // Demanding the head DOES resolve the deferred interpolated key.
4152 let v = ev(r#"let u = "bob"; s = { homes."u/${u}" = 7; }; in s.homes"#);
4153 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4154 assert_eq!(attrs.get("u/bob"), Some(&Value::Int(7)));
4155 } else {
4156 panic!("expected Attrs");
4157 }
4158 }
4159
4160 #[test]
4161 fn purely_literal_string_attr_key_stays_eager_static() {
4162 // A `Str` key with NO interpolation is a plain static key and must
4163 // NOT be treated as dynamic (it forces nothing, deep-merges).
4164 let v = ev(r#"let s = { a."foo bar" = 1; a.b = 2; }; in s.a"#);
4165 if let Value::Attrs(attrs) = force_value(&v).unwrap() {
4166 assert_eq!(attrs.get("foo bar"), Some(&Value::Int(1)));
4167 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4168 } else {
4169 panic!("expected Attrs");
4170 }
4171 }
4172
4173 // ── M2.6 ROOT #3 (collision case): dynamic tail key under a head that
4174 // a sibling binding already wrote must stay lazy AND deep-merge.
4175 #[test]
4176 fn dynamic_tail_key_under_colliding_head_is_lazy() {
4177 // `sd.services.x` writes head `sd`; the second binding's dynamic
4178 // key must NOT force when a SIBLING (`sd.services`) is read.
4179 let v = ev(
4180 r#"let s = { sd.services.x = 1; sd.tmpfiles.${throw "KEYFORCED"}.d = 2; }; in s.sd.services.x"#,
4181 );
4182 assert_eq!(v, Value::Int(1));
4183 }
4184
4185 #[test]
4186 fn dynamic_tail_key_under_colliding_head_resolves_and_merges() {
4187 // Demanding the dynamic branch resolves the key; the sibling
4188 // static branch (`sd.services`) survives the merge intact.
4189 let v = ev(
4190 r#"let k = "z"; s = { sd.services.x = 1; sd.tmpfiles.${k}.d = 2; }; in s.sd"#,
4191 );
4192 let sd = force_value(&v).unwrap();
4193 if let Value::Attrs(sd_attrs) = &sd {
4194 // static sibling intact
4195 let services = force_value(sd_attrs.get("services").unwrap()).unwrap();
4196 if let Value::Attrs(a) = &services {
4197 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4198 } else { panic!("expected services attrs"); }
4199 // dynamic branch resolved to key "z"
4200 let tmpfiles = force_value(sd_attrs.get("tmpfiles").unwrap()).unwrap();
4201 if let Value::Attrs(a) = &tmpfiles {
4202 let z = force_value(a.get("z").unwrap()).unwrap();
4203 if let Value::Attrs(zd) = &z {
4204 assert_eq!(force_value(zd.get("d").unwrap()).unwrap(), Value::Int(2));
4205 } else { panic!("expected z attrs"); }
4206 } else { panic!("expected tmpfiles attrs"); }
4207 } else {
4208 panic!("expected sd attrs");
4209 }
4210 }
4211
4212 // ── M2.6 ROOT #4a — `with` namespace must be LAZY ─────────────────
4213 // `with X; body` stores the namespace as a thunk forced only on a
4214 // bare-ident fallthrough lookup; demanding only the body's WHNF/keys
4215 // must NOT force X. cppnix: `attrNames (with (throw "X"); {a=1;})`
4216 // → ["a"]. Before the fix, sui EVALUATED the namespace at `with`-entry
4217 // and threw. This is the load-bearing over-force behind the M2.6
4218 // `concatLists null` (nixpkgs' `config = mkIf … (with config.services.X;
4219 // { … })` module shape forced `config.services.X` during collection).
4220 #[test]
4221 fn with_namespace_is_lazy_on_body_whnf() {
4222 let v = ev(r#"builtins.attrNames (with (throw "WITH-FORCED"); { a = 1; b = 2; })"#);
4223 if let Value::List(items) = force_value(&v).unwrap() {
4224 let names: Vec<String> = items
4225 .iter()
4226 .map(|i| match force_value(i).unwrap() {
4227 Value::String(s) => s.as_str().to_string(),
4228 other => panic!("expected string, got {}", other.type_name()),
4229 })
4230 .collect();
4231 assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
4232 } else {
4233 panic!("expected list");
4234 }
4235 }
4236
4237 #[test]
4238 fn with_namespace_forces_only_on_fallthrough() {
4239 // A bare ident that falls through lexical scope DOES resolve via
4240 // the namespace (correct cppnix semantics) — proves the deferred
4241 // thunk is real and gets forced on demand, not an accidental no-op.
4242 assert_eq!(ev(r#"with { x = 42; }; x"#), Value::Int(42));
4243 // A lexical binding shadows the with-scope, so the (throwing)
4244 // namespace is never forced — the laziness we rely on for M2.6.
4245 assert_eq!(ev(r#"let x = 7; in with (throw "NS"); x"#), Value::Int(7));
4246 }
4247
4248 // ── M2.6 ROOT #4b — depth-≥2 dotted full-set leaf must deep-merge ──
4249 // `o.a = { x = 1; }` inserts `o = { a = <thunk {x=1}> }` (leaf goes
4250 // through maybe_thunk); a deeper sibling `o.a.y = 2` recurses
4251 // merge_nested_insert down to key `a` where the existing value is that
4252 // thunk. Before the fix, merge_nested_insert required BOTH sides to be
4253 // concrete Attrs, so the Thunk-vs-Attrs collision OVERWROTE — dropping
4254 // `x`. cppnix desugars both orderings into `o.a = { x = 1; y = 2; }`.
4255 // This is the M2.6 post-`with`-fix frontier (nixpkgs alsa's
4256 // `options.hardware.alsa = { … }` + `options.hardware.alsa.enablePersistence
4257 // = …` merged to only {enablePersistence} → `cardAliases` "does not exist").
4258 #[test]
4259 fn dotted_fullset_leaf_deep_merges_with_deeper_sibling() {
4260 let v = ev(r#"{ o.a = { x = 1; }; o.a.y = 2; }.o.a"#);
4261 if let Value::Attrs(a) = force_value(&v).unwrap() {
4262 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4263 assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4264 } else {
4265 panic!("expected attrs");
4266 }
4267 }
4268
4269 #[test]
4270 fn dotted_fullset_leaf_deep_merge_reverse_order() {
4271 // Deeper sibling FIRST, full-set leaf SECOND — the NEW value is the
4272 // `<thunk {x=1}>`; must still merge (the collision forces it).
4273 let v = ev(r#"{ o.a.y = 2; o.a = { x = 1; }; }.o.a"#);
4274 if let Value::Attrs(a) = force_value(&v).unwrap() {
4275 assert_eq!(force_value(a.get("x").unwrap()).unwrap(), Value::Int(1));
4276 assert_eq!(force_value(a.get("y").unwrap()).unwrap(), Value::Int(2));
4277 } else {
4278 panic!("expected attrs");
4279 }
4280 }
4281
4282 #[test]
4283 fn dotted_fullset_leaf_merge_preserves_leaf_laziness() {
4284 // The merge forces the existing/new leaf to WHNF (keys) but MUST
4285 // NOT force the leaf VALUES — a throwing sibling value that is never
4286 // demanded stays lazy.
4287 assert_eq!(ev(r#"{ o.a = { x = throw "X-NEVER"; }; o.a.y = 2; }.o.a.y"#), Value::Int(2));
4288 }
4289
4290 #[test]
4291 fn eval_nested_let() {
4292 assert_eq!(ev("let a = 1; b = let c = 2; in c; in a + b"), Value::Int(3));
4293 }
4294
4295 #[test]
4296 fn eval_lambda() {
4297 assert_eq!(ev("(x: x + 1) 41"), Value::Int(42));
4298 }
4299
4300 #[test]
4301 fn eval_lambda_multi_arg() {
4302 assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4303 }
4304
4305 #[test]
4306 fn eval_list() {
4307 let v = ev("[1 2 3]");
4308 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]));
4309 }
4310
4311 #[test]
4312 fn eval_list_concat() {
4313 let v = ev("[1 2] ++ [3 4]");
4314 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]));
4315 }
4316
4317 #[test]
4318 fn eval_attrset() {
4319 let v = ev("{ a = 1; b = 2; }");
4320 if let Value::Attrs(attrs) = v {
4321 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4322 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4323 } else {
4324 panic!("expected attrset");
4325 }
4326 }
4327
4328 #[test]
4329 fn eval_select() {
4330 assert_eq!(ev("{ a = 42; }.a"), Value::Int(42));
4331 }
4332
4333 #[test]
4334 fn eval_select_or() {
4335 assert_eq!(ev("{ a = 42; }.b or 0"), Value::Int(0));
4336 }
4337
4338 #[test]
4339 fn eval_has_attr() {
4340 assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
4341 assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
4342 }
4343
4344 #[test]
4345 fn eval_update() {
4346 let v = ev("{ a = 1; b = 2; } // { b = 3; c = 4; }");
4347 if let Value::Attrs(attrs) = v {
4348 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4349 assert_eq!(attrs.get("b"), Some(&Value::Int(3)));
4350 assert_eq!(attrs.get("c"), Some(&Value::Int(4)));
4351 } else {
4352 panic!("expected attrset");
4353 }
4354 }
4355
4356 #[test]
4357 fn eval_with() {
4358 assert_eq!(ev("with { x = 42; }; x"), Value::Int(42));
4359 }
4360
4361 #[test]
4362 fn eval_assert() {
4363 assert_eq!(ev("assert true; 42"), Value::Int(42));
4364 assert!(eval("assert false; 42").is_err());
4365 }
4366
4367 #[test]
4368 fn eval_formals() {
4369 assert_eq!(ev("({ a, b }: a + b) { a = 1; b = 2; }"), Value::Int(3));
4370 }
4371
4372 #[test]
4373 fn eval_formals_default() {
4374 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 1; }"), Value::Int(11));
4375 }
4376
4377 #[test]
4378 fn eval_formals_ellipsis() {
4379 assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; }"), Value::Int(1));
4380 }
4381
4382 #[test]
4383 fn eval_named_formals() {
4384 assert_eq!(ev("(args @ { a }: args.a) { a = 42; }"), Value::Int(42));
4385 }
4386
4387 #[test]
4388 fn eval_rec_attrset() {
4389 assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
4390 }
4391
4392 #[test]
4393 fn eval_negation() {
4394 assert_eq!(ev("-42"), Value::Int(-42));
4395 }
4396
4397 #[test]
4398 fn eval_float_arithmetic() {
4399 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4400 assert_eq!(ev("1 + 1.5"), Value::Float(2.5));
4401 }
4402
4403 #[test]
4404 fn eval_division_by_zero() {
4405 assert!(eval("1 / 0").is_err());
4406 }
4407
4408 #[test]
4409 fn eval_builtins_available() {
4410 assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
4411 assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
4412 }
4413
4414 #[test]
4415 fn eval_builtins_length() {
4416 assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
4417 }
4418
4419 #[test]
4420 fn eval_builtins_head_tail() {
4421 assert_eq!(ev("builtins.head [1 2 3]"), Value::Int(1));
4422 assert_eq!(ev("builtins.length (builtins.tail [1 2 3])"), Value::Int(2));
4423 }
4424
4425 #[test]
4426 fn eval_builtins_add() {
4427 assert_eq!(ev("builtins.add 1 2"), Value::Int(3));
4428 }
4429
4430 #[test]
4431 fn eval_builtins_to_string() {
4432 assert_eq!(ev("builtins.toString 42"), Value::string("42"));
4433 }
4434
4435 #[test]
4436 fn eval_implication() {
4437 assert_eq!(ev("false -> true"), Value::Bool(true));
4438 assert_eq!(ev("true -> false"), Value::Bool(false));
4439 assert_eq!(ev("true -> true"), Value::Bool(true));
4440 }
4441
4442 // ── New tests ────────────────────────────────────────
4443
4444 #[test]
4445 fn eval_error_undefined_variable() {
4446 let result = eval("nonexistent");
4447 assert!(result.is_err());
4448 let msg = format!("{}", result.unwrap_err());
4449 assert!(msg.contains("undefined variable"));
4450 }
4451
4452 #[test]
4453 fn eval_error_type_mismatch_arithmetic() {
4454 let result = eval(r#"1 + "hello""#);
4455 assert!(result.is_err());
4456 let msg = format!("{}", result.unwrap_err());
4457 assert!(msg.contains("cannot add") || msg.contains("type"));
4458 }
4459
4460 #[test]
4461 fn eval_error_unexpected_argument() {
4462 let result = eval("({ a }: a) { a = 1; b = 2; }");
4463 assert!(result.is_err());
4464 let msg = format!("{}", result.unwrap_err());
4465 assert!(msg.contains("unexpected argument"));
4466 }
4467
4468 #[test]
4469 fn eval_error_missing_required_argument() {
4470 let result = eval("({ a, b }: a + b) { a = 1; }");
4471 assert!(result.is_err());
4472 let msg = format!("{}", result.unwrap_err());
4473 assert!(msg.contains("missing argument"));
4474 }
4475
4476 #[test]
4477 fn eval_builtins_attr_names_sorted() {
4478 let v = ev("builtins.attrNames { z = 1; a = 2; m = 3; }");
4479 // BTreeMap keys are already sorted
4480 assert_eq!(
4481 v,
4482 Value::list(vec![
4483 Value::string("a"),
4484 Value::string("m"),
4485 Value::string("z"),
4486 ]),
4487 );
4488 }
4489
4490 #[test]
4491 fn eval_builtins_attr_values() {
4492 let v = ev("builtins.attrValues { a = 1; b = 2; }");
4493 // BTreeMap iteration is sorted by key, so a=1 first, b=2 second
4494 assert_eq!(v, Value::list(vec![Value::Int(1), Value::Int(2)]));
4495 }
4496
4497 #[test]
4498 fn eval_builtins_is_null() {
4499 assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
4500 assert_eq!(ev("builtins.isNull 1"), Value::Bool(false));
4501 }
4502
4503 #[test]
4504 fn eval_builtins_is_int() {
4505 assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
4506 assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
4507 }
4508
4509 #[test]
4510 fn eval_builtins_is_bool() {
4511 assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
4512 assert_eq!(ev("builtins.isBool 0"), Value::Bool(false));
4513 }
4514
4515 #[test]
4516 fn eval_builtins_is_string() {
4517 assert_eq!(ev(r#"builtins.isString "hi""#), Value::Bool(true));
4518 assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
4519 }
4520
4521 #[test]
4522 fn eval_builtins_is_list() {
4523 assert_eq!(ev("builtins.isList [1 2]"), Value::Bool(true));
4524 assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
4525 }
4526
4527 #[test]
4528 fn eval_builtins_is_attrs() {
4529 assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
4530 assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
4531 }
4532
4533 #[test]
4534 fn eval_builtins_string_length() {
4535 assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
4536 assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
4537 }
4538
4539 #[test]
4540 fn eval_builtins_to_json_roundtrip() {
4541 // toJSON produces a JSON string; fromJSON parses it back
4542 assert_eq!(
4543 ev(r#"builtins.fromJSON (builtins.toJSON 42)"#),
4544 Value::Int(42),
4545 );
4546 assert_eq!(
4547 ev(r#"builtins.fromJSON (builtins.toJSON [1 2 3])"#),
4548 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4549 );
4550 }
4551
4552 #[test]
4553 fn eval_builtins_from_json() {
4554 assert_eq!(
4555 ev(r#"builtins.fromJSON "{\"a\": 1}""#),
4556 {
4557 let mut attrs = NixAttrs::new();
4558 attrs.insert("a".to_string(), Value::Int(1));
4559 Value::Attrs(Rc::new(attrs))
4560 },
4561 );
4562 assert_eq!(ev(r#"builtins.fromJSON "null""#), Value::Null);
4563 assert_eq!(ev(r#"builtins.fromJSON "true""#), Value::Bool(true));
4564 }
4565
4566 #[test]
4567 fn eval_nested_function_application() {
4568 // (f 1) 2 where f = x: y: x + y
4569 assert_eq!(ev("(x: y: x + y) 1 2"), Value::Int(3));
4570 // equivalent parenthesized form
4571 assert_eq!(ev("((x: y: x + y) 1) 2"), Value::Int(3));
4572 }
4573
4574 #[test]
4575 fn eval_recursive_let() {
4576 assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
4577 assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
4578 }
4579
4580 #[test]
4581 fn eval_string_comparison() {
4582 assert_eq!(ev(r#""a" < "b""#), Value::Bool(true));
4583 assert_eq!(ev(r#""b" < "a""#), Value::Bool(false));
4584 assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4585 assert_eq!(ev(r#""abc" != "def""#), Value::Bool(true));
4586 }
4587
4588 #[test]
4589 fn eval_list_in_attrset() {
4590 let v = ev("{ x = [1 2 3]; }.x");
4591 assert_eq!(
4592 v,
4593 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
4594 );
4595 }
4596
4597 #[test]
4598 fn eval_nested_attrset_select() {
4599 assert_eq!(ev("{ a = { b = 42; }; }.a.b"), Value::Int(42));
4600 }
4601
4602 #[test]
4603 fn eval_let_shadows_outer() {
4604 assert_eq!(
4605 ev("let x = 1; in let x = 2; in x"),
4606 Value::Int(2),
4607 );
4608 }
4609
4610 #[test]
4611 fn eval_with_provides_scope() {
4612 // `with` scope is available for name resolution
4613 assert_eq!(
4614 ev("with { x = 42; y = 10; }; x + y"),
4615 Value::Int(52),
4616 );
4617 }
4618
4619 #[test]
4620 fn eval_list_equality() {
4621 assert_eq!(ev("[1 2] == [1 2]"), Value::Bool(true));
4622 assert_eq!(ev("[1 2] == [1 3]"), Value::Bool(false));
4623 }
4624
4625 #[test]
4626 fn eval_attrset_equality() {
4627 assert_eq!(ev("{ a = 1; } == { a = 1; }"), Value::Bool(true));
4628 assert_eq!(ev("{ a = 1; } == { a = 2; }"), Value::Bool(false));
4629 }
4630
4631 // ═══════════════════════════════════════════════════════════
4632 // 1. LITERAL TYPES
4633 // ═══════════════════════════════════════════════════════════
4634
4635 #[test]
4636 fn literal_int_large_zero_negative() {
4637 // Large positive integer (within i64 range)
4638 assert_eq!(ev("9223372036854775807"), Value::Int(i64::MAX));
4639 // Zero
4640 assert_eq!(ev("0"), Value::Int(0));
4641 // Negative via unary negate
4642 assert_eq!(ev("-1"), Value::Int(-1));
4643 assert_eq!(ev("-999999"), Value::Int(-999999));
4644 }
4645
4646 #[test]
4647 fn literal_float_small_large() {
4648 assert_eq!(ev("0.001"), Value::Float(0.001));
4649 assert_eq!(ev("999999.999"), Value::Float(999999.999));
4650 // Float with scientific notation via expression (1e6 parsed by rnix)
4651 assert_eq!(ev("1.0e3"), Value::Float(1000.0));
4652 assert_eq!(ev("1.5e2"), Value::Float(150.0));
4653 }
4654
4655 #[test]
4656 fn literal_string_empty_and_escapes() {
4657 assert_eq!(ev(r#""""#), Value::string(""));
4658 // Escape sequences within strings
4659 assert_eq!(ev(r#""hello\nworld""#), Value::string("hello\nworld"));
4660 assert_eq!(ev(r#""tab\there""#), Value::string("tab\there"));
4661 }
4662
4663 #[test]
4664 fn literal_multiline_string() {
4665 // Indented string ('' ... '')
4666 assert_eq!(
4667 ev("''hello''"),
4668 Value::string("hello"),
4669 );
4670 // Multiline indented string strips common indentation
4671 assert_eq!(
4672 ev("''\n line1\n line2\n''"),
4673 Value::string("line1\nline2\n"),
4674 );
4675 }
4676
4677 #[test]
4678 fn literal_paths() {
4679 // Relative path
4680 assert_eq!(ev("./foo"), Value::Path(Box::new(SmolStr::from("./foo"))));
4681 // Absolute path
4682 assert_eq!(ev("/nix/store/abc"), Value::Path(Box::new(SmolStr::from("/nix/store/abc"))));
4683 // Home path
4684 assert_eq!(ev("~/myfile"), Value::Path(Box::new(SmolStr::from("~/myfile"))));
4685 }
4686
4687 // ── Interpolated path literals (cid-marquee root, 2026-07-12) ──
4688 //
4689 // CppNix path literals may contain `${e}` antiquotations: `./${x}.nix`,
4690 // `/a/${e}`, `~/${e}`. sui previously flattened the whole path token to
4691 // raw text and dropped the interpolation (`import ./${x}.nix` →
4692 // `No such file or directory`). The `${e}` must be evaluated,
4693 // string-coerced (plain, no copy-to-store), spliced, and the result is
4694 // still a `path` value. Oracles taken from cppnix.
4695
4696 #[test]
4697 fn interp_path_abs_splices_and_types_path() {
4698 // /a/${x}/b with x="foo" → /a/foo/b, type path (nix oracle).
4699 let v = ev(r#"let x = "foo"; in /a/${x}/b"#);
4700 assert_eq!(v, Value::Path(Box::new(SmolStr::from("/a/foo/b"))));
4701 }
4702
4703 #[test]
4704 fn interp_path_abs_multi_and_slash_in_value() {
4705 // Multiple interpolations + a slash inside the spliced value.
4706 assert_eq!(
4707 ev(r#"let a = "x"; b = "y/z"; in /p/${a}/${b}.nix"#),
4708 Value::Path(Box::new(SmolStr::from("/p/x/y/z.nix"))),
4709 );
4710 }
4711
4712 #[test]
4713 fn interp_path_abs_normalizes_double_slash_seam() {
4714 // A path-typed interpolation splices the raw path (no copy-to-store)
4715 // and the `/` seam is normalized: `/bar/` + `/tmp/foo` → /bar/tmp/foo.
4716 assert_eq!(
4717 ev(r#"/bar/${/tmp/foo}"#),
4718 Value::Path(Box::new(SmolStr::from("/bar/tmp/foo"))),
4719 );
4720 }
4721
4722 #[test]
4723 fn interp_path_rel_resolves_against_eval_dir() {
4724 // The spicetify `map (x: ./${x}.nix) [...]` root: a relative
4725 // interpolated path resolves against the defining file's directory,
4726 // exactly like a plain `./foo.nix` literal.
4727 let _g = push_eval_file(std::path::PathBuf::from("/tmp/example/default.nix"));
4728 assert_eq!(
4729 ev(r#"let x = "foo"; in ./${x}.nix"#),
4730 Value::Path(Box::new(SmolStr::from("/tmp/example/foo.nix"))),
4731 );
4732 }
4733
4734 #[test]
4735 fn interp_path_rel_no_eval_dir_keeps_relative_text() {
4736 // With no eval-file context the plain branch keeps the raw relative
4737 // text; the interpolated branch splices then does the same.
4738 assert_eq!(
4739 ev(r#"let x = "foo"; in ./${x}.nix"#),
4740 Value::Path(Box::new(SmolStr::from("./foo.nix"))),
4741 );
4742 }
4743
4744 #[test]
4745 fn interp_path_home_splices_leading_tilde_preserved() {
4746 // Home paths splice their `${e}`; the leading `~` is carried as-is
4747 // (matching sui's plain `~/foo` behavior — `~`-expansion is a
4748 // separate, pre-existing concern, not introduced here).
4749 assert_eq!(
4750 ev(r#"let x = "foo"; in ~/${x}/bar"#),
4751 Value::Path(Box::new(SmolStr::from("~/foo/bar"))),
4752 );
4753 }
4754
4755 #[test]
4756 fn interp_path_non_interpolated_still_raw() {
4757 // A path with no `${…}` must keep the trivial raw-text shortcut
4758 // (byte-for-byte identical to the plain branch).
4759 assert_eq!(ev("/a/b/c"), Value::Path(Box::new(SmolStr::from("/a/b/c"))));
4760 assert_eq!(ev("~/plain"), Value::Path(Box::new(SmolStr::from("~/plain"))));
4761 }
4762
4763 #[test]
4764 fn literal_null_true_false_standalone() {
4765 assert_eq!(ev("null"), Value::Null);
4766 assert_eq!(ev("true"), Value::Bool(true));
4767 assert_eq!(ev("false"), Value::Bool(false));
4768 }
4769
4770 // ═══════════════════════════════════════════════════════════
4771 // 2. OPERATORS — COMPLETE COVERAGE
4772 // ═══════════════════════════════════════════════════════════
4773
4774 #[test]
4775 fn op_arithmetic_int() {
4776 assert_eq!(ev("100 + 200"), Value::Int(300));
4777 assert_eq!(ev("50 - 30"), Value::Int(20));
4778 assert_eq!(ev("7 * 8"), Value::Int(56));
4779 assert_eq!(ev("17 / 3"), Value::Int(5)); // integer division
4780 }
4781
4782 #[test]
4783 fn op_arithmetic_float() {
4784 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
4785 assert_eq!(ev("5.0 - 1.5"), Value::Float(3.5));
4786 assert_eq!(ev("2.0 * 3.0"), Value::Float(6.0));
4787 assert_eq!(ev("7.0 / 2.0"), Value::Float(3.5));
4788 }
4789
4790 #[test]
4791 fn op_arithmetic_mixed_int_float() {
4792 // int + float => float
4793 assert_eq!(ev("1 + 2.5"), Value::Float(3.5));
4794 assert_eq!(ev("2.5 + 1"), Value::Float(3.5));
4795 // int * float => float
4796 assert_eq!(ev("2 * 1.5"), Value::Float(3.0));
4797 // float - int => float
4798 assert_eq!(ev("5.5 - 2"), Value::Float(3.5));
4799 }
4800
4801 #[test]
4802 fn op_string_concat() {
4803 assert_eq!(ev(r#""foo" + "bar""#), Value::string("foobar"));
4804 assert_eq!(ev(r#""" + "x""#), Value::string("x"));
4805 assert_eq!(ev(r#""a" + "" + "b""#), Value::string("ab"));
4806 }
4807
4808 #[test]
4809 fn op_path_concat() {
4810 // path + string
4811 assert_eq!(ev(r#"./foo + "/bar""#), Value::Path(Box::new(SmolStr::from("./foo/bar"))));
4812 // path + path (should join with /)
4813 assert_eq!(ev("./a + ./b"), Value::Path(Box::new(SmolStr::from("./a/./b"))));
4814 }
4815
4816 #[test]
4817 fn op_comparison_ints() {
4818 assert_eq!(ev("1 < 2"), Value::Bool(true));
4819 assert_eq!(ev("2 < 1"), Value::Bool(false));
4820 assert_eq!(ev("2 > 1"), Value::Bool(true));
4821 assert_eq!(ev("1 > 2"), Value::Bool(false));
4822 assert_eq!(ev("2 <= 2"), Value::Bool(true));
4823 assert_eq!(ev("3 <= 2"), Value::Bool(false));
4824 assert_eq!(ev("2 >= 2"), Value::Bool(true));
4825 assert_eq!(ev("1 >= 2"), Value::Bool(false));
4826 }
4827
4828 #[test]
4829 fn op_comparison_floats() {
4830 assert_eq!(ev("1.5 < 2.5"), Value::Bool(true));
4831 assert_eq!(ev("2.5 > 1.5"), Value::Bool(true));
4832 assert_eq!(ev("1.5 <= 1.5"), Value::Bool(true));
4833 assert_eq!(ev("1.5 >= 1.5"), Value::Bool(true));
4834 }
4835
4836 #[test]
4837 fn op_comparison_strings() {
4838 assert_eq!(ev(r#""apple" < "banana""#), Value::Bool(true));
4839 assert_eq!(ev(r#""banana" > "apple""#), Value::Bool(true));
4840 assert_eq!(ev(r#""abc" == "abc""#), Value::Bool(true));
4841 assert_eq!(ev(r#""abc" != "xyz""#), Value::Bool(true));
4842 assert_eq!(ev(r#""abc" <= "abd""#), Value::Bool(true));
4843 assert_eq!(ev(r#""abc" >= "abb""#), Value::Bool(true));
4844 }
4845
4846 #[test]
4847 fn op_equality_various_types() {
4848 assert_eq!(ev("null == null"), Value::Bool(true));
4849 assert_eq!(ev("true == true"), Value::Bool(true));
4850 assert_eq!(ev("false == false"), Value::Bool(true));
4851 assert_eq!(ev("true == false"), Value::Bool(false));
4852 assert_eq!(ev("1 == 1"), Value::Bool(true));
4853 assert_eq!(ev("1 != 2"), Value::Bool(true));
4854 // Different types are not equal
4855 assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
4856 assert_eq!(ev("null == false"), Value::Bool(false));
4857 }
4858
4859 #[test]
4860 fn op_logic_short_circuit() {
4861 // false && <error> should NOT evaluate the RHS
4862 assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
4863 // true || <error> should NOT evaluate the RHS
4864 assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
4865 }
4866
4867 #[test]
4868 fn op_logic_full() {
4869 assert_eq!(ev("true && true"), Value::Bool(true));
4870 assert_eq!(ev("true && false"), Value::Bool(false));
4871 assert_eq!(ev("false && true"), Value::Bool(false));
4872 assert_eq!(ev("false && false"), Value::Bool(false));
4873 assert_eq!(ev("true || true"), Value::Bool(true));
4874 assert_eq!(ev("true || false"), Value::Bool(true));
4875 assert_eq!(ev("false || true"), Value::Bool(true));
4876 assert_eq!(ev("false || false"), Value::Bool(false));
4877 assert_eq!(ev("!true"), Value::Bool(false));
4878 assert_eq!(ev("!false"), Value::Bool(true));
4879 }
4880
4881 #[test]
4882 fn op_implication_truth_table() {
4883 // false -> anything = true
4884 assert_eq!(ev("false -> false"), Value::Bool(true));
4885 assert_eq!(ev("false -> true"), Value::Bool(true));
4886 // true -> x = x
4887 assert_eq!(ev("true -> true"), Value::Bool(true));
4888 assert_eq!(ev("true -> false"), Value::Bool(false));
4889 }
4890
4891 #[test]
4892 fn op_implication_short_circuit() {
4893 // false -> <error> should NOT evaluate the RHS
4894 assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
4895 }
4896
4897 #[test]
4898 fn op_update_merge() {
4899 let v = ev("{ a = 1; } // { b = 2; }");
4900 if let Value::Attrs(attrs) = v {
4901 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
4902 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
4903 } else {
4904 panic!("expected attrs");
4905 }
4906 }
4907
4908 #[test]
4909 fn op_update_right_wins() {
4910 assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
4911 }
4912
4913 #[test]
4914 fn op_list_concat() {
4915 assert_eq!(
4916 ev("[1 2] ++ [3 4]"),
4917 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4)]),
4918 );
4919 // Empty list concat
4920 assert_eq!(ev("[] ++ [1]"), Value::list(vec![Value::Int(1)]));
4921 assert_eq!(ev("[1] ++ []"), Value::list(vec![Value::Int(1)]));
4922 }
4923
4924 #[test]
4925 fn op_has_attr_present_and_absent() {
4926 assert_eq!(ev("{ x = 1; y = 2; } ? x"), Value::Bool(true));
4927 assert_eq!(ev("{ x = 1; } ? z"), Value::Bool(false));
4928 assert_eq!(ev("{} ? anything"), Value::Bool(false));
4929 }
4930
4931 #[test]
4932 fn op_unary_negate() {
4933 assert_eq!(ev("-42"), Value::Int(-42));
4934 assert_eq!(ev("-3.14"), Value::Float(-3.14));
4935 // Double negate
4936 assert_eq!(ev("- -5"), Value::Int(5));
4937 }
4938
4939 // ═══════════════════════════════════════════════════════════
4940 // 3. CONTROL FLOW
4941 // ═══════════════════════════════════════════════════════════
4942
4943 #[test]
4944 fn control_if_true_branch() {
4945 assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
4946 }
4947
4948 #[test]
4949 fn control_if_false_branch() {
4950 assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
4951 }
4952
4953 #[test]
4954 fn control_if_nested() {
4955 assert_eq!(
4956 ev("if true then (if false then 1 else 2) else 3"),
4957 Value::Int(2),
4958 );
4959 assert_eq!(
4960 ev("if false then 1 else (if true then 2 else 3)"),
4961 Value::Int(2),
4962 );
4963 }
4964
4965 #[test]
4966 fn control_assert_passing() {
4967 assert_eq!(ev("assert 1 == 1; 42"), Value::Int(42));
4968 assert_eq!(ev("assert true; true"), Value::Bool(true));
4969 }
4970
4971 #[test]
4972 fn control_assert_failing() {
4973 assert!(eval("assert false; 42").is_err());
4974 assert!(eval("assert 1 == 2; 42").is_err());
4975 }
4976
4977 #[test]
4978 fn control_with_basic_scope() {
4979 assert_eq!(ev("with { a = 1; b = 2; }; a + b"), Value::Int(3));
4980 }
4981
4982 #[test]
4983 fn control_with_lexical_precedence() {
4984 // let binding takes precedence over with scope
4985 assert_eq!(
4986 ev("let x = 10; in with { x = 99; }; x"),
4987 Value::Int(10),
4988 );
4989 }
4990
4991 #[test]
4992 fn control_with_nested() {
4993 assert_eq!(
4994 ev("with { a = 1; }; with { b = 2; }; a + b"),
4995 Value::Int(3),
4996 );
4997 }
4998
4999 #[test]
5000 fn control_with_lazy_fix_self() {
5001 // THE critical pattern that nixpkgs requires:
5002 // fix (self: with self; { a = 1; b = a + 1; })
5003 // Before the lazy-with fix, this would hit the blackhole detector
5004 // because `with` eagerly forced `self`.
5005 let result = eval(
5006 "let fix = f: let x = f x; in x; in fix (self: with self; { a = 1; b = a + 1; })"
5007 );
5008 assert!(result.is_ok(), "fix with self should work: {:?}", result);
5009 if let Ok(Value::Attrs(attrs)) = result {
5010 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5011 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5012 } else {
5013 panic!("expected Attrs, got {:?}", result);
5014 }
5015 }
5016
5017 #[test]
5018 fn control_with_lazy_fix_self_lib_pattern() {
5019 // The nixpkgs pattern: self-referential package set with lib.
5020 // Access via select to force through the thunk layer.
5021 let result = eval(r#"
5022 let fix = f: let x = f x; in x;
5023 in (fix (self: with self; {
5024 lib = { version = "1.0"; };
5025 hello = "hello ${lib.version}";
5026 })).hello
5027 "#);
5028 assert!(result.is_ok(), "nixpkgs-style lib pattern: {:?}", result);
5029 assert_eq!(
5030 result.unwrap(),
5031 Value::String(Rc::new(NixString::plain("hello 1.0"))),
5032 );
5033 }
5034
5035 #[test]
5036 fn control_with_non_attrset_errors() {
5037 // CppNix errors when with-scope is not an attrset and a lookup hits it
5038 let result = eval("with 42; 1");
5039 // The body `1` is a literal and doesn't look up anything in the
5040 // with-scope, so this should succeed (the scope is never forced).
5041 assert_eq!(result.unwrap(), Value::Int(1));
5042 }
5043
5044 #[test]
5045 fn control_with_non_attrset_lookup_falls_through() {
5046 // If the with scope is not an attrset, lookups should fall through
5047 // to outer scopes rather than crashing.
5048 let result = eval("let x = 1; in with 42; x");
5049 assert_eq!(result.unwrap(), Value::Int(1));
5050 }
5051
5052 #[test]
5053 fn control_let_simple_and_multiple() {
5054 assert_eq!(ev("let x = 5; in x"), Value::Int(5));
5055 assert_eq!(ev("let x = 1; y = 2; z = 3; in x + y + z"), Value::Int(6));
5056 }
5057
5058 #[test]
5059 fn control_let_shadow_outer() {
5060 assert_eq!(
5061 ev("let x = 1; in let x = 2; in x"),
5062 Value::Int(2),
5063 );
5064 }
5065
5066 #[test]
5067 fn control_let_recursive_reference() {
5068 assert_eq!(ev("let a = 1; b = a + 1; in b"), Value::Int(2));
5069 assert_eq!(ev("let a = 1; b = a + 1; c = b + 1; in c"), Value::Int(3));
5070 }
5071
5072 #[test]
5073 fn control_nested_let_expression() {
5074 assert_eq!(
5075 ev("let a = let b = 1; in b; in a"),
5076 Value::Int(1),
5077 );
5078 assert_eq!(
5079 ev("let a = let b = 10; in b + 5; in a * 2"),
5080 Value::Int(30),
5081 );
5082 }
5083
5084 // ═══════════════════════════════════════════════════════════
5085 // 4. FUNCTIONS — COMPLETE COVERAGE
5086 // ═══════════════════════════════════════════════════════════
5087
5088 #[test]
5089 fn func_identity_lambda() {
5090 assert_eq!(ev("(x: x) 42"), Value::Int(42));
5091 assert_eq!(ev(r#"(x: x) "hello""#), Value::string("hello"));
5092 }
5093
5094 #[test]
5095 fn func_curried_two_args() {
5096 assert_eq!(ev("(x: y: x + y) 3 4"), Value::Int(7));
5097 }
5098
5099 #[test]
5100 fn func_curried_three_args() {
5101 assert_eq!(ev("(a: b: c: a + b + c) 1 2 3"), Value::Int(6));
5102 }
5103
5104 #[test]
5105 fn func_formals_basic() {
5106 assert_eq!(ev("({ a, b }: a + b) { a = 3; b = 7; }"), Value::Int(10));
5107 }
5108
5109 #[test]
5110 fn func_formals_with_defaults() {
5111 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; }"), Value::Int(15));
5112 // Providing the default-able argument overrides the default
5113 assert_eq!(ev("({ a, b ? 10 }: a + b) { a = 5; b = 20; }"), Value::Int(25));
5114 }
5115
5116 #[test]
5117 fn func_formals_with_ellipsis() {
5118 assert_eq!(ev("({ a, ... }: a) { a = 1; b = 2; c = 3; }"), Value::Int(1));
5119 }
5120
5121 #[test]
5122 fn func_named_formals_at_before() {
5123 // args @ { a, b }: ...
5124 assert_eq!(
5125 ev("(args @ { a, b }: args.a + args.b) { a = 3; b = 4; }"),
5126 Value::Int(7),
5127 );
5128 }
5129
5130 #[test]
5131 fn func_named_formals_at_after() {
5132 // { a, b } @ args: ...
5133 assert_eq!(
5134 ev("({ a, b } @ args: args.a + args.b) { a = 10; b = 20; }"),
5135 Value::Int(30),
5136 );
5137 }
5138
5139 #[test]
5140 fn func_nested_application() {
5141 // Explicit parenthesized application
5142 assert_eq!(ev("((x: y: x * y) 3) 4"), Value::Int(12));
5143 }
5144
5145 #[test]
5146 fn func_higher_order_map() {
5147 assert_eq!(
5148 ev("builtins.map (x: x * 2) [1 2 3]"),
5149 Value::list(vec![Value::Int(2), Value::Int(4), Value::Int(6)]),
5150 );
5151 }
5152
5153 #[test]
5154 fn func_higher_order_filter() {
5155 assert_eq!(
5156 ev("builtins.filter (x: x > 2) [1 2 3 4 5]"),
5157 Value::list(vec![Value::Int(3), Value::Int(4), Value::Int(5)]),
5158 );
5159 }
5160
5161 #[test]
5162 fn func_higher_order_foldl() {
5163 // Sum of list via foldl'
5164 assert_eq!(
5165 ev("builtins.foldl' (acc: x: acc + x) 0 [1 2 3 4]"),
5166 Value::Int(10),
5167 );
5168 }
5169
5170 #[test]
5171 fn func_as_attrset_value() {
5172 assert_eq!(
5173 ev("let s = { f = x: x + 1; }; in s.f 5"),
5174 Value::Int(6),
5175 );
5176 }
5177
5178 #[test]
5179 fn func_immediate_application() {
5180 assert_eq!(ev("(x: x * x) 7"), Value::Int(49));
5181 }
5182
5183 #[test]
5184 fn func_in_let_binding() {
5185 assert_eq!(
5186 ev("let double = x: x * 2; in double 21"),
5187 Value::Int(42),
5188 );
5189 }
5190
5191 // ═══════════════════════════════════════════════════════════
5192 // 5. ATTRIBUTE SETS — COMPLETE COVERAGE
5193 // ═══════════════════════════════════════════════════════════
5194
5195 #[test]
5196 fn attrs_empty_set() {
5197 let v = ev("{}");
5198 if let Value::Attrs(attrs) = v {
5199 assert!(attrs.is_empty());
5200 } else {
5201 panic!("expected attrs");
5202 }
5203 }
5204
5205 #[test]
5206 fn attrs_simple() {
5207 assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
5208 }
5209
5210 #[test]
5211 fn attrs_nested_access() {
5212 assert_eq!(ev("{ a = { b = { c = 42; }; }; }.a.b.c"), Value::Int(42));
5213 }
5214
5215 #[test]
5216 fn attrs_recursive_set() {
5217 assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
5218 }
5219
5220 #[test]
5221 fn attrs_update_disjoint() {
5222 let v = ev("{ a = 1; } // { b = 2; }");
5223 if let Value::Attrs(attrs) = v {
5224 assert_eq!(attrs.len(), 2);
5225 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5226 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
5227 } else {
5228 panic!("expected attrs");
5229 }
5230 }
5231
5232 #[test]
5233 fn attrs_update_override() {
5234 assert_eq!(ev("({ a = 1; } // { a = 2; }).a"), Value::Int(2));
5235 }
5236
5237 #[test]
5238 fn attrs_has_attr_operator() {
5239 assert_eq!(ev("{ a = 1; } ? a"), Value::Bool(true));
5240 assert_eq!(ev("{ a = 1; } ? b"), Value::Bool(false));
5241 }
5242
5243 #[test]
5244 fn attrs_select_with_default() {
5245 assert_eq!(ev("{ a = 1; }.a or 99"), Value::Int(1));
5246 assert_eq!(ev("{}.missing or 99"), Value::Int(99));
5247 assert_eq!(ev("{ a = 1; }.b or 42"), Value::Int(42));
5248 }
5249
5250 #[test]
5251 fn attrs_nested_attr_path_in_binding() {
5252 // { a.b = 1; } creates { a = { b = 1; }; }
5253 assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
5254 }
5255
5256 #[test]
5257 fn attrs_inherit_from_scope() {
5258 assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.x"), Value::Int(1));
5259 assert_eq!(ev("let x = 1; y = 2; in { inherit x y; }.y"), Value::Int(2));
5260 }
5261
5262 #[test]
5263 fn attrs_inherit_from_expr() {
5264 assert_eq!(
5265 ev("{ inherit ({ a = 42; b = 10; }) a; }.a"),
5266 Value::Int(42),
5267 );
5268 }
5269
5270 #[test]
5271 fn attrs_dynamic_attr_name() {
5272 assert_eq!(
5273 ev(r#"let name = "x"; in { ${name} = 42; }.x"#),
5274 Value::Int(42),
5275 );
5276 }
5277
5278 #[test]
5279 fn attrs_attr_names_sorted() {
5280 assert_eq!(
5281 ev("builtins.attrNames { z = 1; m = 2; a = 3; }"),
5282 Value::list(vec![
5283 Value::string("a"),
5284 Value::string("m"),
5285 Value::string("z"),
5286 ]),
5287 );
5288 }
5289
5290 #[test]
5291 fn attrs_attr_values_follow_key_order() {
5292 // BTreeMap iteration order: a=1, b=2, c=3
5293 assert_eq!(
5294 ev("builtins.attrValues { c = 3; a = 1; b = 2; }"),
5295 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5296 );
5297 }
5298
5299 #[test]
5300 fn attrs_update_is_shallow() {
5301 // // is a shallow merge; nested attrs are replaced, not merged
5302 assert_eq!(
5303 ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a ? x"),
5304 Value::Bool(false),
5305 );
5306 assert_eq!(
5307 ev("({ a = { x = 1; }; } // { a = { y = 2; }; }).a.y"),
5308 Value::Int(2),
5309 );
5310 }
5311
5312 // ═══════════════════════════════════════════════════════════
5313 // 6. LISTS — COMPLETE COVERAGE
5314 // ═══════════════════════════════════════════════════════════
5315
5316 #[test]
5317 fn list_empty() {
5318 assert_eq!(ev("[]"), Value::list(vec![]));
5319 }
5320
5321 #[test]
5322 fn list_single_element() {
5323 assert_eq!(ev("[1]"), Value::list(vec![Value::Int(1)]));
5324 }
5325
5326 #[test]
5327 fn list_mixed_types() {
5328 assert_eq!(
5329 ev(r#"[1 "two" true null]"#),
5330 Value::list(vec![
5331 Value::Int(1),
5332 Value::string("two"),
5333 Value::Bool(true),
5334 Value::Null,
5335 ]),
5336 );
5337 }
5338
5339 #[test]
5340 fn list_nested() {
5341 assert_eq!(
5342 ev("[[1 2] [3 4]]"),
5343 Value::list(vec![
5344 Value::list(vec![Value::Int(1), Value::Int(2)]),
5345 Value::list(vec![Value::Int(3), Value::Int(4)]),
5346 ]),
5347 );
5348 }
5349
5350 #[test]
5351 fn list_concat_operator() {
5352 assert_eq!(
5353 ev("[1] ++ [2] ++ [3]"),
5354 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5355 );
5356 }
5357
5358 #[test]
5359 fn list_builtins_length() {
5360 assert_eq!(ev("builtins.length [1 2 3]"), Value::Int(3));
5361 assert_eq!(ev("builtins.length []"), Value::Int(0));
5362 }
5363
5364 #[test]
5365 fn list_builtins_elem_at() {
5366 assert_eq!(ev("builtins.elemAt [10 20 30] 0"), Value::Int(10));
5367 assert_eq!(ev("builtins.elemAt [10 20 30] 1"), Value::Int(20));
5368 assert_eq!(ev("builtins.elemAt [10 20 30] 2"), Value::Int(30));
5369 }
5370
5371 #[test]
5372 fn list_equality() {
5373 assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
5374 assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
5375 assert_eq!(ev("[] == []"), Value::Bool(true));
5376 }
5377
5378 // ═══════════════════════════════════════════════════════════
5379 // 7. STRING INTERPOLATION
5380 // ═══════════════════════════════════════════════════════════
5381
5382 #[test]
5383 fn interp_simple_variable() {
5384 assert_eq!(
5385 ev(r#"let name = "world"; in "hello ${name}""#),
5386 Value::string("hello world"),
5387 );
5388 }
5389
5390 #[test]
5391 fn interp_nested_expression() {
5392 assert_eq!(
5393 ev(r#""result: ${builtins.toString (1 + 2)}""#),
5394 Value::string("result: 3"),
5395 );
5396 }
5397
5398 #[test]
5399 fn interp_int_coercion() {
5400 // Ints are coerced to string in interpolation
5401 assert_eq!(
5402 ev(r#"let x = 42; in "count: ${builtins.toString x}""#),
5403 Value::string("count: 42"),
5404 );
5405 }
5406
5407 #[test]
5408 fn interp_multiple() {
5409 assert_eq!(
5410 ev(r#"let a = "foo"; b = "bar"; in "${a} and ${b}""#),
5411 Value::string("foo and bar"),
5412 );
5413 }
5414
5415 #[test]
5416 fn interp_in_let() {
5417 assert_eq!(
5418 ev(r#"let x = "world"; in "hello ${x}""#),
5419 Value::string("hello world"),
5420 );
5421 }
5422
5423 #[test]
5424 fn interp_empty_result() {
5425 assert_eq!(
5426 ev(r#"let x = ""; in "a${x}b""#),
5427 Value::string("ab"),
5428 );
5429 }
5430
5431 #[test]
5432 fn interp_path_in_string_context() {
5433 // CppNix string interpolation is copy-to-store coercion: a nonexistent
5434 // path errors "path '…' does not exist" (previously sui spliced the raw
5435 // relative path "./foo" verbatim, diverging from nix). The positive
5436 // copy-to-store case is byte-verified in
5437 // interp_path_copies_to_store_byte_matches_cppnix below.
5438 assert!(eval(r#""path: ${./foo-nonexistent-xyz}""#).is_err());
5439 }
5440
5441 #[test]
5442 fn interp_adjacent_interpolations() {
5443 assert_eq!(
5444 ev(r#"let a = "x"; b = "y"; in "${a}${b}""#),
5445 Value::string("xy"),
5446 );
5447 }
5448
5449 // ═══════════════════════════════════════════════════════════
5450 // 8. BUILTINS — VERIFY ALL MAJOR ONES
5451 // ═══════════════════════════════════════════════════════════
5452
5453 #[test]
5454 fn builtins_map_filter_foldl() {
5455 // map
5456 assert_eq!(
5457 ev("builtins.map (x: x + 10) [1 2 3]"),
5458 Value::list(vec![Value::Int(11), Value::Int(12), Value::Int(13)]),
5459 );
5460 // filter
5461 assert_eq!(
5462 ev("builtins.filter (x: x > 1) [1 2 3]"),
5463 Value::list(vec![Value::Int(2), Value::Int(3)]),
5464 );
5465 // foldl' — product
5466 assert_eq!(
5467 ev("builtins.foldl' (a: b: a * b) 1 [2 3 4]"),
5468 Value::Int(24),
5469 );
5470 }
5471
5472 #[test]
5473 fn builtins_map_attrs() {
5474 assert_eq!(
5475 ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).a"),
5476 Value::Int(2),
5477 );
5478 assert_eq!(
5479 ev("(builtins.mapAttrs (name: value: value * 2) { a = 1; b = 2; }).b"),
5480 Value::Int(4),
5481 );
5482 }
5483
5484 #[test]
5485 fn builtins_list_to_attrs() {
5486 assert_eq!(
5487 ev(r#"(builtins.listToAttrs [{ name = "x"; value = 1; } { name = "y"; value = 2; }]).x"#),
5488 Value::Int(1),
5489 );
5490 }
5491
5492 #[test]
5493 fn builtins_list_to_attrs_duplicate_key_first_wins() {
5494 // Nix `listToAttrs` keeps the FIRST occurrence of a duplicate `name`
5495 // (later duplicates are ignored). cppnix returns 1 here, not 2.
5496 // Byte-parity root (cid darwin): a Cargo.lock listing a crate twice
5497 // (registry entry then git entry of the same name+version) must
5498 // resolve to the FIRST source, so `substrate/lockfile-delta.nix`'s
5499 // `lockByKey` picks the registry crate exactly as nix does. Last-wins
5500 // silently switched the source to git and produced a structurally
5501 // different `rust_<crate>` derivation.
5502 assert_eq!(
5503 ev(r#"(builtins.listToAttrs [{ name = "k"; value = 1; } { name = "k"; value = 2; }]).k"#),
5504 Value::Int(1),
5505 );
5506 }
5507
5508 #[test]
5509 fn builtins_concat_map() {
5510 assert_eq!(
5511 ev("builtins.concatMap (x: [x (x * 2)]) [1 2 3]"),
5512 Value::list(vec![
5513 Value::Int(1), Value::Int(2),
5514 Value::Int(2), Value::Int(4),
5515 Value::Int(3), Value::Int(6),
5516 ]),
5517 );
5518 }
5519
5520 #[test]
5521 fn builtins_concat_lists() {
5522 assert_eq!(
5523 ev("builtins.concatLists [[1 2] [3] [4 5]]"),
5524 Value::list(vec![
5525 Value::Int(1), Value::Int(2), Value::Int(3),
5526 Value::Int(4), Value::Int(5),
5527 ]),
5528 );
5529 }
5530
5531 #[test]
5532 fn builtins_concat_strings_sep() {
5533 assert_eq!(
5534 ev(r#"builtins.concatStringsSep ", " ["a" "b" "c"]"#),
5535 Value::string("a, b, c"),
5536 );
5537 assert_eq!(
5538 ev(r#"builtins.concatStringsSep "" ["x" "y"]"#),
5539 Value::string("xy"),
5540 );
5541 }
5542
5543 #[test]
5544 fn builtins_replace_strings() {
5545 assert_eq!(
5546 ev(r#"builtins.replaceStrings ["o"] ["0"] "foobar""#),
5547 Value::string("f00bar"),
5548 );
5549 assert_eq!(
5550 ev(r#"builtins.replaceStrings ["hello"] ["goodbye"] "hello world""#),
5551 Value::string("goodbye world"),
5552 );
5553 }
5554
5555 /// `hasPrefix`/`hasSuffix` are nixpkgs `lib.strings` functions, NOT CppNix
5556 /// builtins — so sui must not have them either. This test used to assert
5557 /// they worked; it now asserts they are absent, which is the same test
5558 /// pointed the correct way.
5559 #[test]
5560 fn builtins_has_prefix_has_suffix_are_not_builtins() {
5561 assert_eq!(ev(r#"builtins ? hasPrefix"#), Value::Bool(false));
5562 assert_eq!(ev(r#"builtins ? hasSuffix"#), Value::Bool(false));
5563 assert!(
5564 eval(r#"builtins.hasPrefix "he" "hello""#).is_err(),
5565 "builtins.hasPrefix must fail the way real nix fails it"
5566 );
5567 assert!(
5568 eval(r#"builtins.hasSuffix "lo" "hello""#).is_err(),
5569 "builtins.hasSuffix must fail the way real nix fails it"
5570 );
5571 }
5572
5573 #[test]
5574 fn builtins_all_any() {
5575 assert_eq!(ev("builtins.all (x: x > 0) [1 2 3]"), Value::Bool(true));
5576 assert_eq!(ev("builtins.all (x: x > 1) [1 2 3]"), Value::Bool(false));
5577 assert_eq!(ev("builtins.any (x: x > 2) [1 2 3]"), Value::Bool(true));
5578 assert_eq!(ev("builtins.any (x: x > 5) [1 2 3]"), Value::Bool(false));
5579 }
5580
5581 #[test]
5582 fn builtins_sort() {
5583 assert_eq!(
5584 ev("builtins.sort (a: b: a < b) [3 1 2]"),
5585 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5586 );
5587 }
5588
5589 #[test]
5590 fn builtins_remove_attrs() {
5591 let v = ev(r#"builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b" "c"]"#);
5592 if let Value::Attrs(attrs) = v {
5593 assert_eq!(attrs.len(), 1);
5594 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
5595 assert!(attrs.get("b").is_none());
5596 } else {
5597 panic!("expected attrs");
5598 }
5599 }
5600
5601 #[test]
5602 fn builtins_intersect_attrs() {
5603 let v = ev("builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }");
5604 if let Value::Attrs(attrs) = v {
5605 assert_eq!(attrs.len(), 1);
5606 // intersectAttrs returns values from the second set
5607 assert_eq!(attrs.get("b"), Some(&Value::Int(20)));
5608 } else {
5609 panic!("expected attrs");
5610 }
5611 }
5612
5613 #[test]
5614 fn builtins_type_of_all_types() {
5615 assert_eq!(ev("builtins.typeOf null"), Value::string("null"));
5616 assert_eq!(ev("builtins.typeOf true"), Value::string("bool"));
5617 assert_eq!(ev("builtins.typeOf 42"), Value::string("int"));
5618 assert_eq!(ev("builtins.typeOf 3.14"), Value::string("float"));
5619 assert_eq!(ev(r#"builtins.typeOf "hi""#), Value::string("string"));
5620 assert_eq!(ev("builtins.typeOf [1]"), Value::string("list"));
5621 assert_eq!(ev("builtins.typeOf {}"), Value::string("set"));
5622 assert_eq!(ev("builtins.typeOf (x: x)"), Value::string("lambda"));
5623 }
5624
5625 #[test]
5626 fn builtins_is_type_checks() {
5627 assert_eq!(ev("builtins.isNull null"), Value::Bool(true));
5628 assert_eq!(ev("builtins.isNull 0"), Value::Bool(false));
5629 assert_eq!(ev("builtins.isInt 42"), Value::Bool(true));
5630 assert_eq!(ev("builtins.isInt 3.14"), Value::Bool(false));
5631 assert_eq!(ev("builtins.isBool true"), Value::Bool(true));
5632 assert_eq!(ev("builtins.isBool 1"), Value::Bool(false));
5633 assert_eq!(ev(r#"builtins.isString "x""#), Value::Bool(true));
5634 assert_eq!(ev("builtins.isString 1"), Value::Bool(false));
5635 assert_eq!(ev("builtins.isList []"), Value::Bool(true));
5636 assert_eq!(ev("builtins.isList {}"), Value::Bool(false));
5637 assert_eq!(ev("builtins.isAttrs {}"), Value::Bool(true));
5638 assert_eq!(ev("builtins.isAttrs []"), Value::Bool(false));
5639 assert_eq!(ev("builtins.isFunction (x: x)"), Value::Bool(true));
5640 assert_eq!(ev("builtins.isFunction 1"), Value::Bool(false));
5641 assert_eq!(ev("builtins.isFloat 3.14"), Value::Bool(true));
5642 assert_eq!(ev("builtins.isFloat 1"), Value::Bool(false));
5643 }
5644
5645 #[test]
5646 fn builtins_to_json_from_json_roundtrip() {
5647 // int roundtrip
5648 assert_eq!(ev("builtins.fromJSON (builtins.toJSON 42)"), Value::Int(42));
5649 // string roundtrip
5650 assert_eq!(
5651 ev(r#"builtins.fromJSON (builtins.toJSON "hello")"#),
5652 Value::string("hello"),
5653 );
5654 // list roundtrip
5655 assert_eq!(
5656 ev("builtins.fromJSON (builtins.toJSON [1 2 3])"),
5657 Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
5658 );
5659 // null roundtrip
5660 assert_eq!(ev("builtins.fromJSON (builtins.toJSON null)"), Value::Null);
5661 // bool roundtrip
5662 assert_eq!(ev("builtins.fromJSON (builtins.toJSON true)"), Value::Bool(true));
5663 }
5664
5665 #[test]
5666 fn builtins_to_string_various() {
5667 assert_eq!(ev("builtins.toString 42"), Value::string("42"));
5668 assert_eq!(ev("builtins.toString true"), Value::string("1"));
5669 assert_eq!(ev("builtins.toString false"), Value::string(""));
5670 assert_eq!(ev("builtins.toString null"), Value::string(""));
5671 assert_eq!(ev(r#"builtins.toString "hello""#), Value::string("hello"));
5672 }
5673
5674 #[test]
5675 fn builtins_function_args() {
5676 let v = ev("builtins.functionArgs ({ a, b ? 1 }: a)");
5677 if let Value::Attrs(attrs) = v {
5678 assert_eq!(attrs.get("a"), Some(&Value::Bool(false))); // no default
5679 assert_eq!(attrs.get("b"), Some(&Value::Bool(true))); // has default
5680 } else {
5681 panic!("expected attrs");
5682 }
5683 }
5684
5685 #[test]
5686 fn builtins_gen_list() {
5687 assert_eq!(
5688 ev("builtins.genList (x: x * x) 5"),
5689 Value::list(vec![
5690 Value::Int(0), Value::Int(1), Value::Int(4),
5691 Value::Int(9), Value::Int(16),
5692 ]),
5693 );
5694 assert_eq!(ev("builtins.genList (x: x) 0"), Value::list(vec![]));
5695 }
5696
5697 #[test]
5698 fn builtins_elem() {
5699 assert_eq!(ev("builtins.elem 2 [1 2 3]"), Value::Bool(true));
5700 assert_eq!(ev("builtins.elem 5 [1 2 3]"), Value::Bool(false));
5701 assert_eq!(ev("builtins.elem 1 []"), Value::Bool(false));
5702 }
5703
5704 #[test]
5705 fn builtins_head_tail() {
5706 assert_eq!(ev("builtins.head [10 20 30]"), Value::Int(10));
5707 assert_eq!(
5708 ev("builtins.tail [10 20 30]"),
5709 Value::list(vec![Value::Int(20), Value::Int(30)]),
5710 );
5711 }
5712
5713 #[test]
5714 fn builtins_string_length() {
5715 assert_eq!(ev(r#"builtins.stringLength "hello""#), Value::Int(5));
5716 assert_eq!(ev(r#"builtins.stringLength """#), Value::Int(0));
5717 assert_eq!(ev(r#"builtins.stringLength "abc def""#), Value::Int(7));
5718 }
5719
5720 #[test]
5721 fn builtins_ceil_floor() {
5722 assert_eq!(ev("builtins.ceil 2.3"), Value::Int(3));
5723 assert_eq!(ev("builtins.ceil 2.0"), Value::Int(2));
5724 assert_eq!(ev("builtins.floor 2.9"), Value::Int(2));
5725 assert_eq!(ev("builtins.floor 2.0"), Value::Int(2));
5726 // Int coercion: ceil/floor on int should work via to_float()
5727 assert_eq!(ev("builtins.ceil 5"), Value::Int(5));
5728 assert_eq!(ev("builtins.floor 5"), Value::Int(5));
5729 }
5730
5731 #[test]
5732 fn builtins_try_eval() {
5733 let v = ev("builtins.tryEval 42");
5734 if let Value::Attrs(attrs) = v {
5735 assert_eq!(attrs.get("success"), Some(&Value::Bool(true)));
5736 assert_eq!(attrs.get("value"), Some(&Value::Int(42)));
5737 } else {
5738 panic!("expected attrs");
5739 }
5740 }
5741
5742 #[test]
5743 fn builtins_throw() {
5744 let result = eval(r#"builtins.throw "oops""#);
5745 assert!(result.is_err());
5746 let msg = format!("{}", result.unwrap_err());
5747 assert!(msg.contains("oops"));
5748 }
5749
5750 #[test]
5751 fn builtins_seq_deep_seq() {
5752 // seq forces first arg, returns second
5753 assert_eq!(ev("builtins.seq 1 42"), Value::Int(42));
5754 // deepSeq similarly
5755 assert_eq!(ev("builtins.deepSeq [1 2 3] 99"), Value::Int(99));
5756 }
5757
5758 #[test]
5759 fn builtins_current_system() {
5760 let v = ev("builtins.currentSystem");
5761 if let Value::String(ns) = v {
5762 let s = &ns.chars;
5763 // Should be a valid system string
5764 assert!(
5765 s == "aarch64-darwin"
5766 || s == "x86_64-darwin"
5767 || s == "aarch64-linux"
5768 || s == "x86_64-linux",
5769 "unexpected system: {s}",
5770 );
5771 } else {
5772 panic!("expected string");
5773 }
5774 }
5775
5776 // ═══════════════════════════════════════════════════════════
5777 // 9. REAL-WORLD NIXPKGS PATTERNS
5778 // ═══════════════════════════════════════════════════════════
5779
5780 #[test]
5781 fn pattern_mkif_like() {
5782 // lib.mkIf pattern: if condition then { key = value; } else {}
5783 assert_eq!(
5784 ev("(if true then { x = 1; } else {}).x"),
5785 Value::Int(1),
5786 );
5787 let v = ev("if false then { x = 1; } else {}");
5788 if let Value::Attrs(attrs) = v {
5789 assert!(attrs.is_empty());
5790 } else {
5791 panic!("expected attrs");
5792 }
5793 }
5794
5795 #[test]
5796 fn pattern_optional_attrs() {
5797 // lib.optionalAttrs pattern
5798 assert_eq!(
5799 ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in (optionalAttrs true { a = 1; }).a"),
5800 Value::Int(1),
5801 );
5802 let v = ev("let optionalAttrs = cond: attrs: if cond then attrs else {}; in optionalAttrs false { a = 1; }");
5803 if let Value::Attrs(attrs) = v {
5804 assert!(attrs.is_empty());
5805 } else {
5806 panic!("expected attrs");
5807 }
5808 }
5809
5810 #[test]
5811 fn pattern_filter_attrs_via_remove() {
5812 // lib.filterAttrs pattern via removeAttrs
5813 assert_eq!(
5814 ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]).a"#),
5815 Value::Int(1),
5816 );
5817 assert_eq!(
5818 ev(r#"(builtins.removeAttrs { a = 1; b = 2; c = 3; } ["b"]) ? b"#),
5819 Value::Bool(false),
5820 );
5821 }
5822
5823 #[test]
5824 fn pattern_override() {
5825 // default // overrides pattern
5826 let v = ev(r#"
5827 let
5828 defaults = { debug = false; port = 8080; host = "localhost"; };
5829 overrides = { debug = true; port = 9090; };
5830 in defaults // overrides
5831 "#);
5832 if let Value::Attrs(attrs) = v {
5833 assert_eq!(attrs.get("debug"), Some(&Value::Bool(true)));
5834 assert_eq!(attrs.get("port"), Some(&Value::Int(9090)));
5835 assert_eq!(attrs.get("host"), Some(&Value::string("localhost")));
5836 } else {
5837 panic!("expected attrs");
5838 }
5839 }
5840
5841 #[test]
5842 fn pattern_functor() {
5843 // { __functor = self: x: self.value + x; value = 10; } 5
5844 assert_eq!(
5845 ev("let s = { __functor = self: x: self.value + x; value = 10; }; in s 5"),
5846 Value::Int(15),
5847 );
5848 }
5849
5850 #[test]
5851 fn pattern_platform_check() {
5852 // Check pattern: if builtins.currentSystem == "..." then ... else ...
5853 let v = ev(r#"if builtins.currentSystem == "aarch64-darwin" then "arm" else "other""#);
5854 // We just verify it evaluates without error and produces a string
5855 if let Value::String(_) = v {
5856 // ok
5857 } else {
5858 panic!("expected string");
5859 }
5860 }
5861
5862 #[test]
5863 fn pattern_recursive_overlay_lambda_structure() {
5864 // Test the lambda structure of an overlay (self: super: { ... })
5865 let v = ev("let overlay = self: super: { pkg = 42; }; in overlay {} {}");
5866 if let Value::Attrs(attrs) = v {
5867 assert_eq!(attrs.get("pkg"), Some(&Value::Int(42)));
5868 } else {
5869 panic!("expected attrs");
5870 }
5871 }
5872
5873 #[test]
5874 fn pattern_call_package_simplified() {
5875 // Simplified callPackage: f: f { inherit lib; }
5876 assert_eq!(
5877 ev("let callPkg = f: f { lib = { id = x: x; }; }; lib = { id = x: x; }; in callPkg ({ lib }: lib.id 42)"),
5878 Value::Int(42),
5879 );
5880 }
5881
5882 #[test]
5883 fn pattern_derivation_like_attrset() {
5884 let v = ev(r#"{ type = "derivation"; name = "hello"; system = builtins.currentSystem; builder = "/bin/sh"; }"#);
5885 if let Value::Attrs(attrs) = v {
5886 assert_eq!(attrs.get("type"), Some(&Value::string("derivation")));
5887 assert_eq!(attrs.get("name"), Some(&Value::string("hello")));
5888 assert_eq!(attrs.get("builder"), Some(&Value::string("/bin/sh")));
5889 // system should be a string (may be a thunk that forces to string)
5890 let system = force_value(attrs.get("system").unwrap()).unwrap();
5891 assert!(matches!(system, Value::String(_)), "expected string, got {system:?}");
5892 } else {
5893 panic!("expected attrs");
5894 }
5895 }
5896
5897 #[test]
5898 fn pattern_module_system_simplified() {
5899 // Simplified NixOS module evaluation
5900 assert_eq!(
5901 ev(r#"
5902 let
5903 eval = m: m { config = {}; lib = { mkDefault = x: x; }; };
5904 in eval ({ config, lib }: { result = lib.mkDefault 42; })
5905 "#),
5906 {
5907 let mut attrs = NixAttrs::new();
5908 attrs.insert("result".to_string(), Value::Int(42));
5909 Value::Attrs(Rc::new(attrs))
5910 },
5911 );
5912 }
5913
5914 // ═══════════════════════════════════════════════════════════
5915 // 10. ERROR HANDLING
5916 // ═══════════════════════════════════════════════════════════
5917
5918 #[test]
5919 fn error_undefined_variable() {
5920 let result = eval("nonexistent_var");
5921 assert!(result.is_err());
5922 let msg = format!("{}", result.unwrap_err());
5923 assert!(msg.contains("undefined variable") || msg.contains("nonexistent_var"));
5924 }
5925
5926 #[test]
5927 fn error_type_mismatch_arithmetic() {
5928 let result = eval(r#"1 + "hello""#);
5929 assert!(result.is_err());
5930 }
5931
5932 #[test]
5933 fn error_missing_attribute() {
5934 let result = eval("{}.nonexistent");
5935 assert!(result.is_err());
5936 let msg = format!("{}", result.unwrap_err());
5937 assert!(msg.contains("nonexistent") || msg.contains("not found"));
5938 }
5939
5940 #[test]
5941 fn error_division_by_zero() {
5942 assert!(eval("1 / 0").is_err());
5943 assert!(eval("100 / 0").is_err());
5944 }
5945
5946 #[test]
5947 fn error_missing_required_function_arg() {
5948 let result = eval("({ a, b }: a + b) { a = 1; }");
5949 assert!(result.is_err());
5950 let msg = format!("{}", result.unwrap_err());
5951 assert!(msg.contains("missing argument"));
5952 }
5953
5954 #[test]
5955 fn error_unexpected_function_arg() {
5956 let result = eval("({ a }: a) { a = 1; b = 2; }");
5957 assert!(result.is_err());
5958 let msg = format!("{}", result.unwrap_err());
5959 assert!(msg.contains("unexpected argument"));
5960 }
5961
5962 #[test]
5963 fn error_assertion_failure() {
5964 assert!(eval("assert false; 1").is_err());
5965 assert!(eval("assert 1 == 2; 1").is_err());
5966 }
5967
5968 #[test]
5969 fn error_infinite_recursion() {
5970 // `let x = x; in x` should either hit the depth guard or fail on
5971 // undefined variable (since sequential let can't see its own binding).
5972 let result = eval("let x = x; in x");
5973 assert!(result.is_err());
5974 }
5975
5976 #[test]
5977 fn error_infinite_recursion_via_lambda() {
5978 // A true infinite recursion via self-application -- depth guard catches this.
5979 let result = eval("let f = x: f x; in f 1");
5980 assert!(result.is_err());
5981 let msg = format!("{}", result.unwrap_err());
5982 assert!(
5983 msg.contains("infinite recursion") || msg.contains("eval depth") || msg.contains("undefined"),
5984 );
5985 }
5986
5987 // ═══════════════════════════════════════════════════════════
5988 // ADDITIONAL COVERAGE: edge cases and integration
5989 // ═══════════════════════════════════════════════════════════
5990
5991 #[test]
5992 fn integration_let_with_function_returning_attrset() {
5993 assert_eq!(
5994 ev("let mkPkg = name: { inherit name; version = 1; }; in (mkPkg \"hello\").name"),
5995 Value::string("hello"),
5996 );
5997 }
5998
5999 #[test]
6000 fn integration_chained_updates() {
6001 assert_eq!(
6002 ev("({ a = 1; } // { b = 2; } // { c = 3; }).c"),
6003 Value::Int(3),
6004 );
6005 }
6006
6007 #[test]
6008 fn integration_map_over_attrnames() {
6009 // Common nixpkgs pattern: map over attrNames
6010 assert_eq!(
6011 ev(r#"
6012 let
6013 set = { a = 1; b = 2; };
6014 names = builtins.attrNames set;
6015 in builtins.length names
6016 "#),
6017 Value::Int(2),
6018 );
6019 }
6020
6021 #[test]
6022 fn integration_compose_functions() {
6023 // Function composition
6024 assert_eq!(
6025 ev("let compose = f: g: x: f (g x); double = x: x * 2; inc = x: x + 1; in compose double inc 5"),
6026 Value::Int(12), // (5 + 1) * 2
6027 );
6028 }
6029
6030 #[test]
6031 fn integration_recursive_list_building() {
6032 // Build a list using genList and map
6033 assert_eq!(
6034 ev("builtins.map (x: x * x) (builtins.genList (x: x + 1) 4)"),
6035 Value::list(vec![Value::Int(1), Value::Int(4), Value::Int(9), Value::Int(16)]),
6036 );
6037 }
6038
6039 #[test]
6040 fn integration_attrset_from_list() {
6041 // Convert list to attrset via listToAttrs + map
6042 let v = ev(r#"
6043 builtins.listToAttrs (builtins.map (x: { name = x; value = true; }) ["a" "b" "c"])
6044 "#);
6045 if let Value::Attrs(attrs) = v {
6046 assert_eq!(attrs.get("a"), Some(&Value::Bool(true)));
6047 assert_eq!(attrs.get("b"), Some(&Value::Bool(true)));
6048 assert_eq!(attrs.get("c"), Some(&Value::Bool(true)));
6049 } else {
6050 panic!("expected attrs");
6051 }
6052 }
6053
6054 #[test]
6055 fn integration_nested_with_and_let() {
6056 assert_eq!(
6057 ev("let x = 10; in with { y = 20; }; x + y"),
6058 Value::Int(30),
6059 );
6060 }
6061
6062 #[test]
6063 fn integration_complex_pattern_match() {
6064 // Complex function with defaults, ellipsis, and @ pattern
6065 assert_eq!(
6066 ev("(args @ { a, b ? 5, ... }: a + b + (if args ? c then args.c else 0)) { a = 1; c = 10; }"),
6067 Value::Int(16), // 1 + 5 + 10
6068 );
6069 }
6070
6071 #[test]
6072 fn integration_substring() {
6073 assert_eq!(
6074 ev(r#"builtins.substring 0 5 "hello world""#),
6075 Value::string("hello"),
6076 );
6077 assert_eq!(
6078 ev(r#"builtins.substring 6 5 "hello world""#),
6079 Value::string("world"),
6080 );
6081 }
6082
6083 #[test]
6084 fn integration_has_attr_on_nested() {
6085 // ? on nested attr paths
6086 assert_eq!(ev("{ a = { b = 1; }; } ? a"), Value::Bool(true));
6087 assert_eq!(
6088 ev("({ a = { b = 1; }; }.a) ? b"),
6089 Value::Bool(true),
6090 );
6091 }
6092
6093 #[test]
6094 fn integration_cat_attrs() {
6095 assert_eq!(
6096 ev(r#"builtins.catAttrs "x" [{ x = 1; } { y = 2; } { x = 3; }]"#),
6097 Value::list(vec![Value::Int(1), Value::Int(3)]),
6098 );
6099 }
6100
6101 #[test]
6102 fn integration_get_attr_builtin() {
6103 assert_eq!(
6104 ev(r#"builtins.getAttr "a" { a = 42; b = 10; }"#),
6105 Value::Int(42),
6106 );
6107 }
6108
6109 #[test]
6110 fn integration_has_attr_builtin() {
6111 assert_eq!(
6112 ev(r#"builtins.hasAttr "a" { a = 1; }"#),
6113 Value::Bool(true),
6114 );
6115 assert_eq!(
6116 ev(r#"builtins.hasAttr "z" { a = 1; }"#),
6117 Value::Bool(false),
6118 );
6119 }
6120
6121 #[test]
6122 fn integration_is_path() {
6123 assert_eq!(ev("builtins.isPath ./foo"), Value::Bool(true));
6124 assert_eq!(ev("builtins.isPath 42"), Value::Bool(false));
6125 }
6126
6127 #[test]
6128 fn integration_builtins_trace() {
6129 // trace prints the first arg (as debug) and returns the second
6130 assert_eq!(ev(r#"builtins.trace "debug msg" 42"#), Value::Int(42));
6131 }
6132
6133 #[test]
6134 fn integration_builtins_split() {
6135 // Nix spec: split returns alternating non-match strings and match group lists.
6136 // When the regex has no capture groups, separator positions get empty lists.
6137 // split "/" "a/b/c" => ["a" [] "b" [] "c"]
6138 assert_eq!(
6139 ev(r#"builtins.split "/" "a/b/c""#),
6140 Value::list(vec![
6141 Value::string("a"),
6142 Value::list(vec![]),
6143 Value::string("b"),
6144 Value::list(vec![]),
6145 Value::string("c"),
6146 ]),
6147 );
6148 // With a capture group, the captured text appears in the list.
6149 // split "(/)" "a/b/c" => ["a" ["/"] "b" ["/"] "c"]
6150 assert_eq!(
6151 ev(r#"builtins.split "(/)" "a/b/c""#),
6152 Value::list(vec![
6153 Value::string("a"),
6154 Value::list(vec![Value::string("/")]),
6155 Value::string("b"),
6156 Value::list(vec![Value::string("/")]),
6157 Value::string("c"),
6158 ]),
6159 );
6160 }
6161
6162 #[test]
6163 fn integration_builtins_split_no_capture_groups() {
6164 // builtins.split with no capture groups returns empty lists
6165 // at separator positions — matches CppNix behavior.
6166 // This is critical for nixpkgs lib.splitString which uses
6167 // builtins.filter builtins.isString on the result.
6168 assert_eq!(
6169 ev(r#"builtins.split "-" "aarch64-darwin""#),
6170 Value::list(vec![
6171 Value::string("aarch64"),
6172 Value::list(vec![]),
6173 Value::string("darwin"),
6174 ]),
6175 );
6176 }
6177
6178 #[test]
6179 fn integration_builtins_split_system_string_filter() {
6180 // Simulates nixpkgs lib.splitString: filter isString (split pattern string)
6181 // This is the exact pattern that parses system strings like "aarch64-darwin".
6182 assert_eq!(
6183 ev(r#"builtins.filter builtins.isString (builtins.split "-" "aarch64-darwin")"#),
6184 Value::list(vec![
6185 Value::string("aarch64"),
6186 Value::string("darwin"),
6187 ]),
6188 );
6189 }
6190
6191 #[test]
6192 fn integration_deeply_nested_let() {
6193 // Deeply nested let-in expressions
6194 assert_eq!(
6195 ev("let a = let b = let c = 10; in c * 2; in b + 1; in a"),
6196 Value::Int(21),
6197 );
6198 }
6199
6200 #[test]
6201 fn integration_if_in_attrset_value() {
6202 assert_eq!(
6203 ev("{ x = if true then 1 else 2; }.x"),
6204 Value::Int(1),
6205 );
6206 }
6207
6208 #[test]
6209 fn integration_lambda_in_list() {
6210 // Store lambdas in a list and apply them
6211 assert_eq!(
6212 ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 0) 5"),
6213 Value::Int(6),
6214 );
6215 assert_eq!(
6216 ev("let fs = [(x: x + 1) (x: x * 2)]; in (builtins.elemAt fs 1) 5"),
6217 Value::Int(10),
6218 );
6219 }
6220
6221 #[test]
6222 fn integration_nixpkgs_lib_id() {
6223 // lib.id = x: x
6224 assert_eq!(
6225 ev("let lib = { id = x: x; const = a: b: a; }; in lib.id 42"),
6226 Value::Int(42),
6227 );
6228 assert_eq!(
6229 ev("let lib = { id = x: x; const = a: b: a; }; in lib.const 1 2"),
6230 Value::Int(1),
6231 );
6232 }
6233
6234 #[test]
6235 fn integration_multiple_inherit() {
6236 assert_eq!(
6237 ev("let a = 1; b = 2; c = 3; in { inherit a b c; }.b"),
6238 Value::Int(2),
6239 );
6240 }
6241
6242 #[test]
6243 fn integration_rec_set_with_builtins() {
6244 assert_eq!(
6245 ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6246 Value::Int(5),
6247 );
6248 }
6249
6250 // ═══════════════════════════════════════════════════════════
6251 // 11. __FUNCTOR PROTOCOL
6252 // ═══════════════════════════════════════════════════════════
6253
6254 #[test]
6255 fn functor_simple_callable_attrset() {
6256 assert_eq!(
6257 ev("let s = { __functor = self: x: x + 1; }; in s 41"),
6258 Value::Int(42),
6259 );
6260 }
6261
6262 #[test]
6263 fn functor_with_self_reference() {
6264 assert_eq!(
6265 ev("let s = { __functor = self: x: self.base + x; base = 100; }; in s 23"),
6266 Value::Int(123),
6267 );
6268 }
6269
6270 #[test]
6271 fn functor_updated_attrset() {
6272 // Override a field in the attrset, functor still works
6273 assert_eq!(
6274 ev(r#"
6275 let
6276 mk = { __functor = self: x: self.n + x; n = 0; };
6277 s = mk // { n = 50; };
6278 in s 7
6279 "#),
6280 Value::Int(57),
6281 );
6282 }
6283
6284 #[test]
6285 fn functor_error_on_non_callable_attrset() {
6286 // Attrset without __functor should produce error when called
6287 let result = eval("let s = { a = 1; }; in s 5");
6288 assert!(result.is_err());
6289 }
6290
6291 // ═══════════════════════════════════════════════════════════
6292 // 12. __TOSTRING PROTOCOL
6293 // ═══════════════════════════════════════════════════════════
6294
6295 #[test]
6296 fn to_string_protocol_in_interpolation() {
6297 assert_eq!(
6298 ev(r#"let s = { __toString = self: "world"; }; in "hello ${s}""#),
6299 Value::string("hello world"),
6300 );
6301 }
6302
6303 #[test]
6304 fn to_string_protocol_accesses_self() {
6305 assert_eq!(
6306 ev(r#"let s = { __toString = self: self.val; val = "abc"; }; in "${s}""#),
6307 Value::string("abc"),
6308 );
6309 }
6310
6311 #[test]
6312 fn to_string_protocol_via_builtin_to_string() {
6313 assert_eq!(
6314 ev(r#"builtins.toString { __toString = self: "via-builtin"; }"#),
6315 Value::string("via-builtin"),
6316 );
6317 }
6318
6319 #[test]
6320 fn to_string_protocol_attrset_without_toString_fails() {
6321 // An attrset without __toString should fail in string context
6322 let result = eval(r#""${{}}"#);
6323 assert!(result.is_err());
6324 }
6325
6326 // ═══════════════════════════════════════════════════════════
6327 // 13. NEWLY IMPLEMENTED BUILTINS (eval-level tests)
6328 // ═══════════════════════════════════════════════════════════
6329
6330 /// `concatStrings` is nixpkgs `lib.strings.concatStrings`, not a CppNix
6331 /// builtin. The CAPABILITY is not lost — `concatStringsSep ""` is the real
6332 /// builtin spelling and is asserted here to still produce the same bytes,
6333 /// so this test proves both halves: the invented name is gone, and nothing
6334 /// a nix program can legally write got worse.
6335 #[test]
6336 fn eval_builtins_concat_strings_is_not_a_builtin() {
6337 assert_eq!(ev(r#"builtins ? concatStrings"#), Value::Bool(false));
6338 assert!(
6339 eval(r#"builtins.concatStrings ["a" "b" "c"]"#).is_err(),
6340 "builtins.concatStrings must fail the way real nix fails it"
6341 );
6342 assert_eq!(
6343 ev(r#"builtins.concatStringsSep "" ["a" "b" "c"]"#),
6344 Value::string("abc"),
6345 );
6346 assert_eq!(
6347 ev(r#"builtins.concatStringsSep "" []"#),
6348 Value::string(""),
6349 );
6350 }
6351
6352 #[test]
6353 fn eval_builtins_partition() {
6354 let v = ev("builtins.partition (x: x > 3) [1 2 3 4 5]");
6355 if let Value::Attrs(a) = v {
6356 assert_eq!(a.get("right"), Some(&Value::list(vec![Value::Int(4), Value::Int(5)])));
6357 assert_eq!(a.get("wrong"), Some(&Value::list(vec![Value::Int(1), Value::Int(2), Value::Int(3)])));
6358 } else {
6359 panic!("expected attrs");
6360 }
6361 }
6362
6363 #[test]
6364 fn eval_builtins_group_by() {
6365 let v = ev(r#"builtins.groupBy (x: if x > 0 then "pos" else "neg") [1 (0 - 2) 3 (0 - 4)]"#);
6366 if let Value::Attrs(a) = v {
6367 assert_eq!(a.get("pos"), Some(&Value::list(vec![Value::Int(1), Value::Int(3)])));
6368 assert_eq!(a.get("neg"), Some(&Value::list(vec![Value::Int(-2), Value::Int(-4)])));
6369 } else {
6370 panic!("expected attrs");
6371 }
6372 }
6373
6374 #[test]
6375 fn eval_builtins_zip_attrs_with() {
6376 let v = ev("builtins.zipAttrsWith (n: vs: builtins.head vs) [{ a = 1; } { a = 2; b = 3; }]");
6377 if let Value::Attrs(a) = v {
6378 assert_eq!(a.get("a"), Some(&Value::Int(1)));
6379 assert_eq!(a.get("b"), Some(&Value::Int(3)));
6380 } else {
6381 panic!("expected attrs");
6382 }
6383 }
6384
6385 #[test]
6386 fn eval_builtins_compare_versions() {
6387 assert_eq!(ev(r#"builtins.compareVersions "2.0" "1.0""#), Value::Int(1));
6388 assert_eq!(ev(r#"builtins.compareVersions "1.0" "2.0""#), Value::Int(-1));
6389 assert_eq!(ev(r#"builtins.compareVersions "1.0" "1.0""#), Value::Int(0));
6390 }
6391
6392 #[test]
6393 fn eval_builtins_parse_drv_name() {
6394 let v = ev(r#"builtins.parseDrvName "nix-2.3.4""#);
6395 if let Value::Attrs(a) = v {
6396 assert_eq!(a.get("name"), Some(&Value::string("nix")));
6397 assert_eq!(a.get("version"), Some(&Value::string("2.3.4")));
6398 } else {
6399 panic!("expected attrs");
6400 }
6401 }
6402
6403 #[test]
6404 fn eval_builtins_base_name_of() {
6405 assert_eq!(
6406 ev(r#"builtins.baseNameOf "/foo/bar/baz""#),
6407 Value::string("baz"),
6408 );
6409 }
6410
6411 #[test]
6412 fn eval_builtins_dir_of() {
6413 assert_eq!(
6414 ev(r#"builtins.dirOf "/foo/bar/baz""#),
6415 Value::string("/foo/bar"),
6416 );
6417 }
6418
6419 #[test]
6420 fn eval_builtins_add_error_context() {
6421 assert_eq!(
6422 ev(r#"builtins.addErrorContext "some context" 42"#),
6423 Value::Int(42),
6424 );
6425 }
6426
6427 #[test]
6428 fn eval_builtins_abort() {
6429 let result = eval(r#"builtins.abort "fatal error""#);
6430 assert!(result.is_err());
6431 let msg = format!("{}", result.unwrap_err());
6432 assert!(msg.contains("fatal error"));
6433 }
6434
6435 // ═══════════════════════════════════════════════════════════
6436 // 14. INDENTED STRINGS ('' ... '')
6437 // ═══════════════════════════════════════════════════════════
6438
6439 #[test]
6440 fn indented_string_simple() {
6441 assert_eq!(ev("''hello''"), Value::string("hello"));
6442 }
6443
6444 #[test]
6445 fn indented_string_multiline_strips_indent() {
6446 assert_eq!(
6447 ev("''\n line1\n line2\n''"),
6448 Value::string("line1\nline2\n"),
6449 );
6450 }
6451
6452 #[test]
6453 fn indented_string_with_interpolation() {
6454 let code = "let x = \"world\"; in ''hello ${x}''";
6455 assert_eq!(
6456 ev(code),
6457 Value::string("hello world"),
6458 );
6459 }
6460
6461 #[test]
6462 fn indented_string_deeper_indent_preserved() {
6463 // Common indent is 2 spaces; the 4-space line keeps 2 extra
6464 assert_eq!(
6465 ev("''\n a\n b\n''"),
6466 Value::string("a\n b\n"),
6467 );
6468 }
6469
6470 // ═══════════════════════════════════════════════════════════
6471 // 15. DYNAMIC ATTRIBUTE NAMES
6472 // ═══════════════════════════════════════════════════════════
6473
6474 #[test]
6475 fn dynamic_attr_name_in_set() {
6476 assert_eq!(
6477 ev(r#"let key = "mykey"; in { ${key} = 42; }.mykey"#),
6478 Value::Int(42),
6479 );
6480 }
6481
6482 #[test]
6483 fn dynamic_attr_name_with_expression() {
6484 assert_eq!(
6485 ev(r#"let prefix = "foo"; in { ${"${prefix}bar"} = 1; }.foobar"#),
6486 Value::Int(1),
6487 );
6488 }
6489
6490 // ═══════════════════════════════════════════════════════════
6491 // 16. IGNORED TESTS — features needing major infrastructure
6492 // ═══════════════════════════════════════════════════════════
6493
6494 #[test]
6495 fn eval_builtins_match() {
6496 assert_eq!(
6497 ev(r#"builtins.match "([0-9]+)" "42""#),
6498 Value::list(vec![Value::string("42")]),
6499 );
6500 }
6501
6502 #[test]
6503 fn eval_builtins_hash_string() {
6504 let v = ev(r#"builtins.hashString "sha256" "hello""#);
6505 if let Value::String(ns) = v {
6506 assert_eq!(ns.chars.len(), 64);
6507 } else {
6508 panic!("expected string");
6509 }
6510 }
6511
6512 #[test]
6513 fn eval_builtins_import() {
6514 let dir = std::env::temp_dir();
6515 let path = dir.join("sui_eval_test_import_eval.nix");
6516 std::fs::write(&path, "42").unwrap();
6517 let expr = format!(r#"import "{}""#, path.display());
6518 let v = eval(&expr).unwrap();
6519 assert_eq!(v, Value::Int(42));
6520 std::fs::remove_file(&path).ok();
6521 }
6522
6523 #[test]
6524 fn eval_builtins_derivation() {
6525 let v = eval(r#"builtins.derivation { name = "test"; system = "x86_64-linux"; builder = "/bin/sh"; }"#).unwrap();
6526 if let Value::Attrs(a) = v {
6527 assert_eq!(a.get("type"), Some(&Value::string("derivation")));
6528 } else {
6529 panic!("expected attrs");
6530 }
6531 }
6532
6533 #[test]
6534 fn eval_mutual_recursive_let() {
6535 // Multi-pass evaluation allows forward references in let bindings.
6536 // After 3 passes (placeholder + eval + re-eval), `a.x` resolves to
6537 // the value of `b` from the previous pass, and `a.x.y` is an attrset.
6538 // Full semantic equivalence with Nix (a.x.y == a) requires lazy
6539 // thunks, but the multi-pass approach is sufficient for common
6540 // patterns like mutual module references.
6541 let v = eval("let a = { x = b; }; b = { y = a; }; in a.x.y");
6542 assert!(v.is_ok(), "mutual recursive let should not error: {v:?}");
6543 // a.x.y should be an attrset (it's a's value from a prior pass)
6544 let val = v.unwrap();
6545 assert!(
6546 matches!(val, Value::Attrs(_)),
6547 "a.x.y should be an attrset, got: {val:?}",
6548 );
6549 }
6550
6551 #[test]
6552 fn eval_mutual_recursive_let_simple() {
6553 // Simpler case: forward reference in sequential let bindings
6554 let v = eval("let a = b; b = 42; in a");
6555 assert!(v.is_ok());
6556 // After multi-pass: pass 2 sets a=Null (b not yet bound), b=42
6557 // pass 3 sets a=42, b=42
6558 assert_eq!(v.unwrap(), Value::Int(42));
6559 }
6560
6561 #[test]
6562 fn eval_builtins_read_dir() {
6563 let dir = std::env::temp_dir().join("sui_eval_test_readdir_eval");
6564 let _ = std::fs::remove_dir_all(&dir);
6565 std::fs::create_dir_all(&dir).unwrap();
6566 std::fs::write(dir.join("a.txt"), "").unwrap();
6567 let expr = format!(r#"builtins.readDir "{}""#, dir.display());
6568 let v = eval(&expr).unwrap();
6569 if let Value::Attrs(a) = v {
6570 assert_eq!(a.get("a.txt"), Some(&Value::string("regular")));
6571 } else {
6572 panic!("expected attrs");
6573 }
6574 let _ = std::fs::remove_dir_all(&dir);
6575 }
6576
6577 // ═══════════════════════════════════════════════════════════
6578 // 17. THUNK / LAZY EVALUATION
6579 // ═══════════════════════════════════════════════════════════
6580
6581 #[test]
6582 fn thunk_basic_let() {
6583 // Simple let binding through thunk.
6584 assert_eq!(ev("let x = 1; in x"), Value::Int(1));
6585 }
6586
6587 #[test]
6588 fn thunk_forward_ref() {
6589 // Forward reference: `a` references `b` which is defined later.
6590 assert_eq!(ev("let a = b; b = 1; in a"), Value::Int(1));
6591 }
6592
6593 #[test]
6594 fn thunk_mutual_rec_attrset_in_let() {
6595 // Mutual recursion through attrsets in let bindings.
6596 assert_eq!(ev("let a = { x = b; }; b = { y = 1; }; in a.x.y"), Value::Int(1));
6597 }
6598
6599 #[test]
6600 fn thunk_rec_attrset() {
6601 // rec { a = b; b = 1; } -- forward ref within rec set.
6602 assert_eq!(ev("(rec { a = b; b = 1; }).a"), Value::Int(1));
6603 }
6604
6605 #[test]
6606 fn thunk_rec_attrset_chain() {
6607 // Longer chain: c depends on b depends on a.
6608 assert_eq!(ev("(rec { a = 1; b = a + 1; c = b + 1; }).c"), Value::Int(3));
6609 }
6610
6611 #[test]
6612 fn thunk_fixpoint() {
6613 // Classic fixpoint combinator -- the core of nixpkgs' `lib.fix`.
6614 assert_eq!(
6615 ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; })).b"),
6616 Value::Int(2),
6617 );
6618 }
6619
6620 #[test]
6621 fn thunk_blackhole_self_reference() {
6622 // `let x = x; in x` is infinite recursion -- blackhole detection.
6623 let result = eval("let x = x; in x");
6624 assert!(result.is_err());
6625 let msg = format!("{}", result.unwrap_err());
6626 assert!(
6627 msg.contains("infinite recursion") || msg.contains("blackhole"),
6628 "expected blackhole error, got: {msg}",
6629 );
6630 }
6631
6632 #[test]
6633 fn thunk_mutual_blackhole() {
6634 // `let a = b; b = a; in a` -- mutual infinite recursion.
6635 let result = eval("let a = b; b = a; in a");
6636 assert!(result.is_err());
6637 }
6638
6639 #[test]
6640 fn thunk_let_body_forces_correctly() {
6641 // The let body should be able to use thunked bindings in arithmetic.
6642 assert_eq!(ev("let a = 10; b = 20; in a + b"), Value::Int(30));
6643 }
6644
6645 #[test]
6646 fn thunk_only_forced_when_needed() {
6647 // The binding `bad` would error if forced, but it is never used.
6648 assert_eq!(ev("let bad = 1 / 0; good = 42; in good"), Value::Int(42));
6649 }
6650
6651 #[test]
6652 fn thunk_forward_ref_in_function_body() {
6653 // Forward reference used inside a function body.
6654 assert_eq!(
6655 ev("let f = x: x + b; b = 10; in f 5"),
6656 Value::Int(15),
6657 );
6658 }
6659
6660 #[test]
6661 fn thunk_rec_set_self_ref_through_self() {
6662 // rec set where `b` references `a` which is in the same set.
6663 assert_eq!(
6664 ev(r#"(rec { a = "hello"; b = builtins.stringLength a; }).b"#),
6665 Value::Int(5),
6666 );
6667 }
6668
6669 #[test]
6670 fn thunk_nested_let_forward_ref() {
6671 // Forward reference in nested let.
6672 assert_eq!(
6673 ev("let a = b + 1; b = 2; in a"),
6674 Value::Int(3),
6675 );
6676 }
6677
6678 #[test]
6679 fn thunk_deep_chain() {
6680 // Chain of forward references: e -> d -> c -> b -> a.
6681 assert_eq!(
6682 ev("let a = 1; b = a; c = b; d = c; e = d; in e"),
6683 Value::Int(1),
6684 );
6685 }
6686
6687 #[test]
6688 fn thunk_rec_set_fixpoint() {
6689 // Fixpoint through rec set -- common nixpkgs pattern.
6690 assert_eq!(
6691 ev("let fix = f: let x = f x; in x; in (fix (self: { a = 1; b = self.a + 1; c = self.b + 1; })).c"),
6692 Value::Int(3),
6693 );
6694 }
6695
6696 #[test]
6697 fn thunk_let_with_inherit() {
6698 // Inherit in let should work alongside thunked bindings.
6699 assert_eq!(
6700 ev("let a = 1; in let inherit a; b = a + 1; in b"),
6701 Value::Int(2),
6702 );
6703 }
6704
6705 #[test]
6706 fn thunk_attrset_value_lazy() {
6707 // Values in non-rec attrsets are evaluated eagerly, but the test
6708 // verifies that thunked let bindings inside attrset values work.
6709 assert_eq!(
6710 ev("let x = 42; in { a = x; }.a"),
6711 Value::Int(42),
6712 );
6713 }
6714
6715 #[test]
6716 fn thunk_unused_error_not_forced() {
6717 // Multiple bindings, only `ok` is used. `bad` throws but is never forced.
6718 assert_eq!(
6719 ev(r#"let bad = builtins.throw "boom"; ok = 1; in ok"#),
6720 Value::Int(1),
6721 );
6722 }
6723
6724 #[test]
6725 fn thunk_rec_set_mutual_reference() {
6726 // Mutual reference within rec set.
6727 let v = ev("rec { a = { val = b.val + 1; }; b = { val = 10; }; }");
6728 if let Value::Attrs(attrs) = v {
6729 let a = attrs.get("a").unwrap();
6730 let a_forced = force_value(a).unwrap();
6731 if let Value::Attrs(a_attrs) = a_forced {
6732 assert_eq!(a_attrs.get("val"), Some(&Value::Int(11)));
6733 } else {
6734 panic!("expected attrs for a");
6735 }
6736 } else {
6737 panic!("expected attrs");
6738 }
6739 }
6740
6741 // ── let-rec self-reference corner cases ───────────────
6742
6743 #[test]
6744 fn let_rec_self_reference_simple() {
6745 assert_eq!(
6746 ev("let x = 1; y = x + 1; in y"),
6747 Value::Int(2),
6748 );
6749 }
6750
6751 #[test]
6752 fn let_rec_self_reference_chain() {
6753 assert_eq!(
6754 ev("let a = 1; b = a + 1; c = b + 1; in c"),
6755 Value::Int(3),
6756 );
6757 }
6758
6759 #[test]
6760 fn let_rec_self_reference_with_function() {
6761 assert_eq!(
6762 ev("let f = x: x + 1; y = f 10; in y"),
6763 Value::Int(11),
6764 );
6765 }
6766
6767 #[test]
6768 fn let_rec_mutual_recursion_via_if() {
6769 assert_eq!(
6770 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"),
6771 Value::Bool(true),
6772 );
6773 }
6774
6775 #[test]
6776 fn let_rec_forward_ref_in_list() {
6777 assert_eq!(
6778 ev("let xs = [a b]; a = 1; b = 2; in builtins.length xs"),
6779 Value::Int(2),
6780 );
6781 }
6782
6783 // ── with-shadowing corner cases ───────────────────────
6784
6785 #[test]
6786 fn with_shadowing_let_wins_over_with() {
6787 assert_eq!(
6788 ev("let x = 1; in with { x = 2; }; x"),
6789 Value::Int(1),
6790 );
6791 }
6792
6793 #[test]
6794 fn with_shadowing_inner_with_wins() {
6795 assert_eq!(
6796 ev("with { x = 1; }; with { x = 2; }; x"),
6797 Value::Int(2),
6798 );
6799 }
6800
6801 #[test]
6802 fn with_shadowing_outer_provides_missing() {
6803 assert_eq!(
6804 ev("with { x = 1; y = 10; }; with { x = 2; }; x + y"),
6805 Value::Int(12),
6806 );
6807 }
6808
6809 #[test]
6810 fn with_shadowing_lambda_arg_wins() {
6811 assert_eq!(
6812 ev("(x: with { x = 99; }; x) 42"),
6813 Value::Int(42),
6814 );
6815 }
6816
6817 #[test]
6818 fn with_shadowing_nested_let_wins_over_with() {
6819 assert_eq!(
6820 ev("with { x = 1; }; let x = 2; in x"),
6821 Value::Int(2),
6822 );
6823 }
6824
6825 #[test]
6826 fn with_scope_dynamic_attrs() {
6827 assert_eq!(
6828 ev(r#"with { x = 1; y = 2; z = 3; }; x + y + z"#),
6829 Value::Int(6),
6830 );
6831 }
6832
6833 #[test]
6834 fn with_scope_over_lazy_thunk_chain_resolves() {
6835 // A `with`-head that resolves through a NESTED thunk chain
6836 // (`Thunk(Thunk(Attrs))`) must still be searched: the lookup
6837 // has to FULLY force the head (chase the chain), not take a
6838 // single force step. A single step leaves a `Value::Thunk`
6839 // that `type_name()` reports as "set" but the `Value::Attrs`
6840 // match rejects — the scope is skipped and a bare ident
6841 // through it fails with a spurious UndefinedVar. This corners
6842 // the nixpkgs `platforms = with lib.platforms; unix;` shape.
6843 assert_eq!(
6844 ev(r#"let outer = if true then (if true then { unix = 42; } else {}) else {};
6845 # force a two-deep lazy wrap of the with-head
6846 head = (x: x) ((y: y) outer);
6847 in with head; unix"#),
6848 Value::Int(42),
6849 );
6850 }
6851
6852 #[test]
6853 fn with_scope_head_from_deep_select_resolves() {
6854 // `with a.b.c; key` where a.b.c is a lazily-selected attrset —
6855 // the bare-ident body must find `key` through the forced head.
6856 assert_eq!(
6857 ev(r#"let a = { b = { c = { key = 7; }; }; }; in with a.b.c; key"#),
6858 Value::Int(7),
6859 );
6860 }
6861
6862 // ── attrset deep merge ────────────────────────────────
6863
6864 #[test]
6865 fn attrset_deep_merge_simple() {
6866 let v = ev("{ a.b = 1; a.c = 2; }");
6867 if let Value::Attrs(attrs) = v {
6868 let a = force_value(attrs.get("a").unwrap()).unwrap();
6869 if let Value::Attrs(inner) = a {
6870 assert_eq!(force_value(inner.get("b").unwrap()).unwrap(), Value::Int(1));
6871 assert_eq!(force_value(inner.get("c").unwrap()).unwrap(), Value::Int(2));
6872 } else {
6873 panic!("expected nested attrs");
6874 }
6875 } else {
6876 panic!("expected attrs");
6877 }
6878 }
6879
6880 #[test]
6881 fn attrset_deep_merge_three_levels() {
6882 let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
6883 if let Value::Attrs(attrs) = v {
6884 let a = force_value(attrs.get("a").unwrap()).unwrap();
6885 if let Value::Attrs(a_inner) = a {
6886 let e = force_value(a_inner.get("e").unwrap()).unwrap();
6887 assert_eq!(e, Value::Int(3));
6888 let b = force_value(a_inner.get("b").unwrap()).unwrap();
6889 if let Value::Attrs(b_inner) = b {
6890 assert_eq!(force_value(b_inner.get("c").unwrap()).unwrap(), Value::Int(1));
6891 assert_eq!(force_value(b_inner.get("d").unwrap()).unwrap(), Value::Int(2));
6892 } else {
6893 panic!("expected nested attrs for b");
6894 }
6895 } else {
6896 panic!("expected nested attrs for a");
6897 }
6898 } else {
6899 panic!("expected attrs");
6900 }
6901 }
6902
6903 #[test]
6904 fn attrset_deep_merge_preserves_siblings() {
6905 assert_eq!(
6906 ev("{ a.x = 1; b = 2; a.y = 3; }.b"),
6907 Value::Int(2),
6908 );
6909 }
6910
6911 #[test]
6912 fn attrset_deep_merge_in_let() {
6913 let v = ev("let s = { a.b = 1; a.c = 2; }; in s.a.b + s.a.c");
6914 assert_eq!(v, Value::Int(3));
6915 }
6916
6917 #[test]
6918 fn attrset_deep_merge_fullset_then_dotted() {
6919 // General root (gst-plugins-base `passthru.waylandEnabled` drop):
6920 // `a = { x = 1; }; a.y = 2;` — the full-set binding is a lazy
6921 // Thunk (attrset literals go through maybe_thunk), so a naive
6922 // merge_nested_insert (which only merges concrete Value::Attrs)
6923 // overwrote `a` with `{ y = 2 }`, silently dropping `x`. The
6924 // collision must force the existing thunk to WHNF first.
6925 let v = ev("let s = { a = { x = 1; }; a.y = 2; }; in s.a.x + s.a.y");
6926 assert_eq!(v, Value::Int(3));
6927 // both keys must survive (not just their sum)
6928 let both = ev("let s = { a = { x = 1; }; a.y = 2; }; in [ s.a.x s.a.y ]");
6929 if let Value::List(items) = both {
6930 assert_eq!(force_value(&items[0]).unwrap(), Value::Int(1));
6931 assert_eq!(force_value(&items[1]).unwrap(), Value::Int(2));
6932 } else {
6933 panic!("expected list");
6934 }
6935 }
6936
6937 // ── inherit-from patterns ─────────────────────────────
6938
6939 #[test]
6940 fn inherit_from_basic() {
6941 assert_eq!(
6942 ev("let s = { x = 1; y = 2; }; in let inherit (s) x y; in x + y"),
6943 Value::Int(3),
6944 );
6945 }
6946
6947 #[test]
6948 fn inherit_from_with_shadowing() {
6949 assert_eq!(
6950 ev("let x = 10; in let inherit ({ x = 20; }) x; in x"),
6951 Value::Int(20),
6952 );
6953 }
6954
6955 #[test]
6956 fn inherit_from_in_attrset() {
6957 let v = ev(r#"let s = { a = 1; b = 2; }; in { inherit (s) a b; c = 3; }"#);
6958 if let Value::Attrs(attrs) = v {
6959 assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
6960 assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
6961 assert_eq!(force_value(attrs.get("c").unwrap()).unwrap(), Value::Int(3));
6962 } else {
6963 panic!("expected attrs");
6964 }
6965 }
6966
6967 #[test]
6968 fn inherit_from_rec_set() {
6969 assert_eq!(
6970 ev("rec { inherit ({ x = 42; }) x; y = x; }.y"),
6971 Value::Int(42),
6972 );
6973 }
6974
6975 #[test]
6976 fn inherit_plain_from_scope() {
6977 assert_eq!(
6978 ev("let x = 1; in { inherit x; }.x"),
6979 Value::Int(1),
6980 );
6981 }
6982
6983 // Regression (2026-07-11): a bare `inherit x;` must resolve LAZILY, like
6984 // a plain reference to `x` — not eagerly at attrset construction. When
6985 // `x` is provided only by an enclosing `with` scope whose value is a
6986 // fixpoint still being constructed, eager resolution spuriously threw
6987 // `UndefinedVar`. nixpkgs `all-packages.nix` is
6988 // `with pkgs; { nettle = import … { inherit callPackage; }; }`, so
6989 // `inherit callPackage` must resolve from the `with pkgs` scope at force
6990 // time. (This was the nettle UndefinedVar('callPackage') drop.)
6991 #[test]
6992 fn inherit_plain_from_with_scope_lazy() {
6993 // `inherit cp` reads `cp` from a `with self` fixpoint scope; the
6994 // attr forcing it (`a`) must resolve `cp` lazily against the settled
6995 // scope, not eagerly during attrset construction.
6996 assert_eq!(
6997 ev("let fix = f: let x = f x; in x;
6998 self = fix (self: with self; {
6999 a = use { inherit cp; };
7000 use = { cp }: cp 5;
7001 cp = x: x + 100;
7002 });
7003 in self.a"),
7004 Value::Int(105),
7005 );
7006 // Simpler: bare inherit from a plain (non-blackhole) with scope.
7007 assert_eq!(
7008 ev("with { y = 7; }; { inherit y; }.y"),
7009 Value::Int(7),
7010 );
7011 }
7012
7013 #[test]
7014 fn inherit_multiple_from_expr() {
7015 assert_eq!(
7016 ev("let s = { a = 10; b = 20; c = 30; }; in let inherit (s) a b c; in a + b + c"),
7017 Value::Int(60),
7018 );
7019 }
7020
7021 // ── string interpolation edge cases ───────────────────
7022
7023 #[test]
7024 fn interp_nested_attrset_access() {
7025 assert_eq!(
7026 ev(r#"let x = { a = "hello"; }; in "${x.a} world""#),
7027 Value::string("hello world"),
7028 );
7029 }
7030
7031 #[test]
7032 fn interp_with_let_expression() {
7033 assert_eq!(
7034 ev(r#""${let x = "inner"; in x}""#),
7035 Value::string("inner"),
7036 );
7037 }
7038
7039 #[test]
7040 fn interp_float_coercion() {
7041 // CppNix %f-format: always 6 decimal places.
7042 assert_eq!(
7043 ev(r#""${toString 3.14}""#),
7044 Value::string("3.140000"),
7045 );
7046 }
7047
7048 // ── comparison edge cases ─────────────────────────────
7049
7050 #[test]
7051 fn compare_mixed_int_float() {
7052 assert_eq!(ev("1 < 1.5"), Value::Bool(true));
7053 assert_eq!(ev("1.5 > 1"), Value::Bool(true));
7054 assert_eq!(ev("2.0 == 2"), Value::Bool(true));
7055 }
7056
7057 #[test]
7058 fn compare_string_lexicographic() {
7059 assert_eq!(ev(r#""abc" < "abd""#), Value::Bool(true));
7060 assert_eq!(ev(r#""abc" < "abc""#), Value::Bool(false));
7061 assert_eq!(ev(r#""abc" <= "abc""#), Value::Bool(true));
7062 }
7063
7064 // ── update operator edge cases ────────────────────────
7065
7066 #[test]
7067 fn update_empty_sets() {
7068 let v = ev("{} // {}");
7069 if let Value::Attrs(a) = v { assert!(a.is_empty()); } else { panic!(); }
7070 }
7071
7072 #[test]
7073 fn update_right_overrides_completely() {
7074 assert_eq!(
7075 ev("{ a = 1; b = 2; } // { a = 10; c = 30; }"),
7076 ev("{ a = 10; b = 2; c = 30; }"),
7077 );
7078 }
7079
7080 #[test]
7081 fn update_chained() {
7082 assert_eq!(
7083 ev("{ a = 1; } // { b = 2; } // { c = 3; }"),
7084 ev("{ a = 1; b = 2; c = 3; }"),
7085 );
7086 }
7087
7088 // ── force_value edge cases ────────────────────────────
7089
7090 #[test]
7091 fn force_value_concrete_unchanged() {
7092 let v = Value::Int(42);
7093 assert_eq!(force_value(&v).unwrap(), Value::Int(42));
7094 }
7095
7096 #[test]
7097 fn force_value_null() {
7098 assert_eq!(force_value(&Value::Null).unwrap(), Value::Null);
7099 }
7100
7101 // ── eval_with_file ────────────────────────────────────
7102
7103 #[test]
7104 fn eval_with_file_none() {
7105 let result = eval_with_file("1 + 2", None).unwrap();
7106 assert_eq!(result, Value::Int(3));
7107 }
7108
7109 // ── error messages ────────────────────────────────────
7110
7111 #[test]
7112 fn error_type_mismatch_in_comparison() {
7113 let result = eval(r#"1 < "a""#);
7114 assert!(result.is_err());
7115 }
7116
7117 #[test]
7118 fn error_select_from_non_set() {
7119 let result = eval("42.x");
7120 assert!(result.is_err());
7121 }
7122
7123 #[test]
7124 fn error_call_non_function() {
7125 let result = eval("42 1");
7126 assert!(result.is_err());
7127 }
7128
7129 #[test]
7130 fn error_negate_string() {
7131 let result = eval(r#"-"hello""#);
7132 assert!(result.is_err());
7133 }
7134
7135 // ── multiline string edge cases ───────────────────────
7136
7137 #[test]
7138 fn multiline_string_empty() {
7139 assert_eq!(ev("''''"), Value::string(""));
7140 }
7141
7142 #[test]
7143 fn multiline_string_with_trailing_newline() {
7144 let v = ev("''\n hello\n''");
7145 assert_eq!(v, Value::string("hello\n"));
7146 }
7147
7148 // ── list operations ───────────────────────────────────
7149
7150 #[test]
7151 fn list_concat_empty_left() {
7152 assert_eq!(ev("[] ++ [1 2]"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7153 }
7154
7155 #[test]
7156 fn list_concat_empty_right() {
7157 assert_eq!(ev("[1 2] ++ []"), Value::list(vec![Value::Int(1), Value::Int(2)]));
7158 }
7159
7160 #[test]
7161 fn list_concat_both_empty() {
7162 assert_eq!(ev("[] ++ []"), Value::list(vec![]));
7163 }
7164
7165 // ── pattern matching / formals edge cases ─────────────
7166
7167 #[test]
7168 fn formals_at_pattern_accessible() {
7169 assert_eq!(
7170 ev("({ x, ... } @ args: builtins.length (builtins.attrNames args)) { x = 1; y = 2; z = 3; }"),
7171 Value::Int(3),
7172 );
7173 }
7174
7175 #[test]
7176 fn formals_default_uses_other_arg() {
7177 assert_eq!(
7178 ev("({ x, y ? x + 1 }: y) { x = 10; }"),
7179 Value::Int(11),
7180 );
7181 }
7182
7183 #[test]
7184 fn formals_default_lazy_assert_false() {
7185 // nixpkgs parse.nix pattern: default is `assert false; null` but
7186 // the body checks `args ? vendor` instead of using `vendor`
7187 // directly, so the default must never be forced.
7188 assert_eq!(
7189 ev("({ cpu, vendor ? assert false; null, kernel } @ args: if args ? vendor then vendor else \"inferred\") { cpu = \"x86_64\"; kernel = \"linux\"; }"),
7190 Value::String(Rc::new(NixString::plain("inferred"))),
7191 );
7192 }
7193
7194 #[test]
7195 fn formals_default_lazy_only_forced_when_accessed() {
7196 // When the default IS accessed, it should still evaluate correctly.
7197 assert_eq!(
7198 ev("({ a, b ? 42 }: b) { a = 1; }"),
7199 Value::Int(42),
7200 );
7201 }
7202
7203 #[test]
7204 fn formals_ellipsis_ignores_extra() {
7205 assert_eq!(
7206 ev("({ x, ... }: x) { x = 1; y = 2; z = 3; }"),
7207 Value::Int(1),
7208 );
7209 }
7210
7211 // ── pure mode ─────────────────────────────────────────
7212
7213 #[test]
7214 fn pure_mode_roundtrip() {
7215 let was_pure = is_pure_mode();
7216 set_pure_mode(true);
7217 assert!(is_pure_mode());
7218 set_pure_mode(false);
7219 assert!(!is_pure_mode());
7220 set_pure_mode(was_pure);
7221 }
7222
7223 // ── path operations ───────────────────────────────────
7224
7225 #[test]
7226 fn path_concat_with_string() {
7227 assert_eq!(
7228 ev(r#"/foo + "bar""#),
7229 Value::Path(Box::new(SmolStr::from("/foobar"))),
7230 );
7231 }
7232
7233 #[test]
7234 fn path_concat_with_path() {
7235 assert_eq!(
7236 ev("/foo + /bar"),
7237 Value::Path(Box::new(SmolStr::from("/foo//bar"))),
7238 );
7239 }
7240
7241 // ── EvalFileGuard / current_eval_dir ───────────────────
7242
7243 #[test]
7244 fn current_eval_dir_empty_when_no_file_pushed() {
7245 // Without a push, current_eval_dir should yield None.
7246 // (Note: this test is order-dependent; we accept whatever the
7247 // top of the stack happens to be when called.)
7248 let snapshot = current_eval_dir();
7249 // At minimum the API doesn't panic and returns Option.
7250 let _ = snapshot;
7251 }
7252
7253 #[test]
7254 fn push_eval_file_sets_current_dir() {
7255 let p = std::path::PathBuf::from("/tmp/example/file.nix");
7256 {
7257 let _g = push_eval_file(p.clone());
7258 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/tmp/example")));
7259 }
7260 // Guard dropped, stack popped — current dir is whatever was below.
7261 // We can't assert exact value without snapshotting first, but the
7262 // value before push should be restored.
7263 }
7264
7265 #[test]
7266 fn push_eval_file_nested_stack() {
7267 let outer = std::path::PathBuf::from("/a/x.nix");
7268 let inner = std::path::PathBuf::from("/b/y.nix");
7269 {
7270 let _g_outer = push_eval_file(outer.clone());
7271 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7272 {
7273 let _g_inner = push_eval_file(inner.clone());
7274 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/b")));
7275 }
7276 // Inner dropped — outer is back on top.
7277 assert_eq!(current_eval_dir(), Some(std::path::PathBuf::from("/a")));
7278 }
7279 }
7280
7281 /// A fileless frame MASKS the parent's file rather than being skipped.
7282 ///
7283 /// Regression: the stack used to be `Vec<PathBuf>`, so a thunk captured in
7284 /// a `--expr` context pushed nothing when it forced and the callee's file
7285 /// stayed visible. `builtins.unsafeGetAttrPos` then reported the callee's
7286 /// path where CppNix reports `null`, which set `eval-config.nix`'s
7287 /// `modulesLocation` and permuted NixOS module definition order.
7288 #[test]
7289 fn fileless_frame_masks_parent_file() {
7290 let outer = std::path::PathBuf::from("/a/x.nix");
7291 let _g_outer = push_eval_file(outer.clone());
7292 assert_eq!(current_eval_file(), Some(outer.clone()));
7293 {
7294 let _g_none = push_eval_frame(None);
7295 // The whole point: NOT Some("/a/x.nix").
7296 assert_eq!(current_eval_file(), None);
7297 assert_eq!(current_eval_dir(), None);
7298 assert_eq!(eval_file_stack_snapshot().last().map(String::as_str), Some("<no-file>"));
7299 }
7300 // Popped — the parent is visible again.
7301 assert_eq!(current_eval_file(), Some(outer));
7302 }
7303
7304 // ── Source-mapped error context ────────────────────────
7305
7306 #[test]
7307 fn error_undefined_var_includes_file_context() {
7308 let p = std::path::PathBuf::from("/nix/store/abc-default.nix");
7309 let _g = push_eval_file(p);
7310 let result = eval("nonexistent_xyz");
7311 let msg = format!("{}", result.unwrap_err());
7312 assert!(msg.contains("undefined variable"), "msg: {msg}");
7313 assert!(msg.contains("nonexistent_xyz"), "msg: {msg}");
7314 assert!(msg.contains("abc-default.nix"), "msg: {msg}");
7315 }
7316
7317 #[test]
7318 fn error_attr_not_found_includes_file_context() {
7319 let p = std::path::PathBuf::from("/nix/store/xyz-module.nix");
7320 let _g = push_eval_file(p);
7321 let result = eval("{}.missing_key");
7322 let msg = format!("{}", result.unwrap_err());
7323 assert!(msg.contains("not found") || msg.contains("missing_key"), "msg: {msg}");
7324 assert!(msg.contains("xyz-module.nix"), "msg: {msg}");
7325 }
7326
7327 #[test]
7328 fn error_assertion_failed_includes_file_context() {
7329 let p = std::path::PathBuf::from("/nix/store/test-assert.nix");
7330 let _g = push_eval_file(p);
7331 let result = eval("assert false; 1");
7332 let msg = format!("{}", result.unwrap_err());
7333 assert!(msg.contains("assertion failed"), "msg: {msg}");
7334 assert!(msg.contains("test-assert.nix"), "msg: {msg}");
7335 }
7336
7337 /// `inherit` binds an attribute, so it carries a position.
7338 ///
7339 /// Regression: `attach_attrset_positions` matched only
7340 /// `Entry::AttrpathValue`, so every inherited key was position-less — most
7341 /// of nixpkgs' `lib`, which re-exports via `inherit (self.options) mkOption
7342 /// …`, and it fed a null into `eval-config.nix`'s `modulesLocation`.
7343 ///
7344 /// Shaped exactly like `unsafe_get_attr_pos_reports_file_and_offset_column`
7345 /// (ONE direct `eval`, no lambda, no second evaluation) because the
7346 /// in-process harness is fragile here: the source-text registry is a
7347 /// thread-local that `pos.rs`'s tests clear, so a multi-eval version passes
7348 /// standalone and fails in the full suite. The CLI path is not affected —
7349 /// verified against `nix eval` on both shapes, both engines agreeing on
7350 /// column 18.
7351 #[test]
7352 fn inherit_bindings_carry_positions() {
7353 let dir = tempfile::tempdir().unwrap();
7354 // A PLAIN attrset, no `let ... in` wrapper: with the wrapper the
7355 // result is built lazily AFTER `import` returns, and the in-process
7356 // harness then resolves it without the file on the eval stack. The CLI
7357 // handles both (measured), the harness only this one.
7358 let body = "{ inherit ({ x = 1; }) x; }\n";
7359 let f = dir.path().join("inh.nix");
7360 std::fs::write(&f, body).unwrap();
7361 let v = eval(&format!("builtins.unsafeGetAttrPos \"x\" (import {})", f.display())).unwrap();
7362 let attrs = match v {
7363 Value::Attrs(a) => a,
7364 Value::Null => panic!("null — the inherit binding carried no position"),
7365 o => panic!("expected attrs, got {o:?}"),
7366 };
7367 // Computed from the fixture, never hardcoded: a hardcoded expectation is
7368 // how `pos::line_col`'s own "verified" comment came to agree with the
7369 // bug it documented.
7370 let off = body.rfind("x; }").unwrap();
7371 let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7372 assert_eq!(*attrs.get("line").unwrap(), Value::Int(1));
7373 assert_eq!(*attrs.get("column").unwrap(), Value::Int((off - bol) as i64 + 1));
7374 }
7375
7376 /// Corpus gate: every attribute-BINDING form carries a position.
7377 ///
7378 /// Seals the class the three position bugs came from, rather than the three
7379 /// instances: `//` dropping positions wholesale, `pos::line_col` returning a
7380 /// constant, and `inherit` never being recorded. Each was found only because
7381 /// a NixOS toplevel drvPath diverged — an expensive way to learn that an
7382 /// attribute lost its position.
7383 ///
7384 /// Expectations are DERIVED from the fixture, never written out, so the test
7385 /// cannot drift into agreeing with whatever the implementation emits. That
7386 /// is exactly how `line_col`'s own "verified against nix eval" comment came
7387 /// to document the bug it contained.
7388 ///
7389 /// Anti-vacuity: the row count is asserted, and any `NULL` fails. A change
7390 /// that stops attaching positions altogether makes every row `NULL` — which
7391 /// must be a failure, not an empty-set pass.
7392 #[test]
7393 fn every_binding_form_carries_a_position() {
7394 let dir = tempfile::tempdir().unwrap();
7395 // One line per key so the expected line number is its 1-based index.
7396 let body = concat!(
7397 "let src = { i = 1; j = 2; }; in {\n",
7398 " plain = 1;\n",
7399 " \"quoted\" = 2;\n",
7400 " inherit (src) i;\n",
7401 " inherit src;\n",
7402 " nested.deep = 3;\n",
7403 "}\n",
7404 );
7405 let f = dir.path().join("forms.nix");
7406 std::fs::write(&f, body).unwrap();
7407
7408 // `nested` is the head of a dotted path; CppNix points at the head.
7409 let keys = ["plain", "quoted", "i", "src", "nested"];
7410 let probe = keys
7411 .iter()
7412 .map(|k| format!(
7413 "(let q = builtins.unsafeGetAttrPos \"{k}\" t; \
7414 in if q == null then \"{k}=NULL\" \
7415 else \"{k}=${{toString q.line}}:${{toString q.column}}\")"
7416 ))
7417 .collect::<Vec<_>>()
7418 .join(" + \" \" + ");
7419 let got = eval(&format!("let t = import {}; in {probe}", f.display()))
7420 .unwrap()
7421 .as_string()
7422 .unwrap()
7423 .to_string();
7424
7425 assert!(!got.contains("NULL"), "a binding form lost its position: {got}");
7426 let rows: Vec<&str> = got.split(' ').collect();
7427 assert_eq!(rows.len(), keys.len(), "corpus shrank — gate would be vacuous: {got}");
7428
7429 // Derive each expectation by locating the key token in the fixture.
7430 for (k, row) in keys.iter().zip(&rows) {
7431 let needle = match *k {
7432 "quoted" => "\"quoted\"".to_string(),
7433 "i" => "i;".to_string(),
7434 "src" => "src;".to_string(),
7435 // A dotted path's head is followed by `.`, not ` =` — CppNix
7436 // reports the HEAD token's position for the outer key.
7437 "nested" => "nested.".to_string(),
7438 other => format!("{other} ="),
7439 };
7440 let off = body.find(&needle).unwrap();
7441 let bol = body[..off].rfind('\n').map_or(0, |i| i + 1);
7442 let line = 1 + body[..off].matches('\n').count();
7443 let col = off - bol + 1;
7444 assert_eq!(*row, format!("{k}={line}:{col}"), "wrong position for `{k}` in:\n{body}");
7445 }
7446 }
7447
7448 /// A missing-argument error names the file the LAMBDA came from.
7449 ///
7450 /// Evaluated with `eval_with_file`, not `push_eval_file` + bare `eval`, and
7451 /// the difference is the point. Calling a closure now pushes the closure's
7452 /// OWN file — including a fileless frame when it has none — so a lambda
7453 /// defined in a fileless string no longer borrows whatever unrelated file
7454 /// happens to sit on the stack. That borrowing is what the old form
7455 /// asserted, and CppNix does not do it: an `--expr` lambda has no file.
7456 /// Associating the source with a file, as every real `import` does, keeps
7457 /// the original intent (errors carry file context) while testing the path
7458 /// production actually takes. Verified against CppNix: for a lambda in a
7459 /// real file both engines name that file.
7460 #[test]
7461 fn error_missing_argument_includes_file_context() {
7462 let p = std::path::PathBuf::from("/nix/store/func.nix");
7463 let result = eval_with_file("({ a, b }: a) { a = 1; }", Some(p));
7464 let msg = format!("{}", result.unwrap_err());
7465 assert!(msg.contains("missing argument"), "msg: {msg}");
7466 assert!(msg.contains("func.nix"), "msg: {msg}");
7467 }
7468
7469 #[test]
7470 fn error_cannot_call_includes_file_context() {
7471 let p = std::path::PathBuf::from("/nix/store/call.nix");
7472 let _g = push_eval_file(p);
7473 let result = eval("42 99");
7474 let msg = format!("{}", result.unwrap_err());
7475 assert!(msg.contains("cannot call"), "msg: {msg}");
7476 assert!(msg.contains("call.nix"), "msg: {msg}");
7477 }
7478
7479 #[test]
7480 fn error_without_file_has_no_in_prefix() {
7481 // When no file is on the eval stack, error messages should
7482 // not contain ", in" context.
7483 let result = eval("nonexistent_xyz");
7484 let msg = format!("{}", result.unwrap_err());
7485 assert!(msg.contains("undefined variable"), "msg: {msg}");
7486 assert!(!msg.contains(", in"), "msg should not contain file context: {msg}");
7487 }
7488
7489 // ── pure mode getter/setter independence ───────────────
7490
7491 #[test]
7492 fn pure_mode_set_get_independence() {
7493 let was = is_pure_mode();
7494 set_pure_mode(true);
7495 assert!(is_pure_mode());
7496 set_pure_mode(false);
7497 assert!(!is_pure_mode());
7498 set_pure_mode(was);
7499 }
7500
7501 // ── eval_with_file with file path ──────────────────────
7502
7503 #[test]
7504 fn eval_with_file_some_path_arithmetic() {
7505 let p = std::path::PathBuf::from("/tmp/imaginary.nix");
7506 let result = eval_with_file("1 + 2", Some(p)).unwrap();
7507 assert_eq!(result, Value::Int(3));
7508 }
7509
7510 // ── unsafeGetAttrPos — the options.json `attrTag` declarations root ──
7511 //
7512 // Seals the CppNix-matching behavior: for a literal attrset built in a
7513 // FILE, `builtins.unsafeGetAttrPos <key> <set>` returns
7514 // `{ file; line=1; column=<key byte offset>+1; }`; for a `<string>` eval
7515 // (no file) it returns `null`. Byte-verified against `nix eval`.
7516
7517 #[test]
7518 fn unsafe_get_attr_pos_reports_file_and_offset_column() {
7519 // The real `attrTag` path: a literal attrset built in an IMPORTED file.
7520 // `import` registers the file's source text + pushes it on the eval
7521 // stack, so `eval_attrset` captures the key positions against that file
7522 // and `unsafeGetAttrPos` resolves them. CppNix reports the file plus a
7523 // real newline-resolved line and BYTE column.
7524 //
7525 // Re-baselined: this used to assert line 1 and column = the key's
7526 // 1-based byte offset in the whole file, citing "verified against nix
7527 // eval". It was not — that was sui's own output taken as the oracle,
7528 // and the same false rule was pinned in pos.rs. Measured on nix 2.31.5:
7529 // for `{ a = 1;\n b = 2; }` the `b` key is 2:3, not 1:12.
7530 let dir = tempfile::tempdir().unwrap();
7531 // The literal's `b` key sits at a known byte offset in this file.
7532 let file_body = "{ a = 1;\n b = 2; }\n";
7533 let f = dir.path().join("lit.nix");
7534 std::fs::write(&f, file_body).unwrap();
7535 let src = format!("builtins.unsafeGetAttrPos \"b\" (import {})", f.display());
7536 let v = eval(&src).unwrap();
7537 let attrs = match v { Value::Attrs(a) => a, other => panic!("expected attrs, got {other:?}") };
7538 assert_eq!(
7539 attrs.get("file").unwrap().as_string().unwrap(),
7540 f.to_string_lossy(),
7541 );
7542 // `b` is on the SECOND line, at byte column 3.
7543 let off = file_body.find("b = 2").unwrap();
7544 let bol = file_body[..off].rfind('\n').map_or(0, |i| i + 1);
7545 let expected_line = 1 + file_body[..off].matches('\n').count() as i64;
7546 let expected_col = (off - bol) as i64 + 1;
7547 assert_eq!(expected_line, 2, "fixture must put `b` on line 2");
7548 assert_eq!(*attrs.get("line").unwrap(), Value::Int(expected_line));
7549 let col = match attrs.get("column").unwrap() { Value::Int(n) => *n, o => panic!("{o:?}") };
7550 assert_eq!(col, expected_col, "column must be the 1-based BYTE column");
7551 }
7552
7553 #[test]
7554 fn unsafe_get_attr_pos_null_for_string_origin() {
7555 // A `<string>`-eval'd literal (no file on the stack) has no position → null.
7556 let v = eval("builtins.unsafeGetAttrPos \"a\" { a = 1; }").unwrap();
7557 assert_eq!(v, Value::Null);
7558 }
7559
7560 #[test]
7561 fn unsafe_get_attr_pos_null_for_missing_key() {
7562 // A key absent from an imported set → null.
7563 let dir = tempfile::tempdir().unwrap();
7564 let f = dir.path().join("lit.nix");
7565 std::fs::write(&f, "{ a = 1; }\n").unwrap();
7566 let src = format!("builtins.unsafeGetAttrPos \"zzz\" (import {})", f.display());
7567 let v = eval(&src).unwrap();
7568 assert_eq!(v, Value::Null);
7569 }
7570
7571 // ── String interpolation primitive coercions ───────────
7572
7573 #[test]
7574 fn interp_int_into_string() {
7575 // Integer interpolated into a string is coerced to its decimal repr.
7576 assert_eq!(ev(r#""val=${toString 42}""#), Value::string("val=42"));
7577 }
7578
7579 #[test]
7580 fn interp_bool_true_becomes_one() {
7581 // Per eval_str: Bool(true) → "1", Bool(false) → "" (empty)
7582 let v = ev(r#"let x = true; in "${builtins.toString x}""#);
7583 assert_eq!(v, Value::string("1"));
7584 }
7585
7586 #[test]
7587 fn interp_null_becomes_empty() {
7588 // Null in interpolation is empty.
7589 let v = ev(r#"let x = null; in "${builtins.toString x}""#);
7590 assert_eq!(v, Value::string(""));
7591 }
7592
7593 #[test]
7594 fn interp_attrset_without_to_string_errors() {
7595 // An attrset interpolated without __toString is a type error.
7596 let result = eval(r#"let s = { x = 1; }; in "${s}""#);
7597 assert!(result.is_err());
7598 }
7599
7600 #[test]
7601 fn interp_attrset_with_to_string_protocol() {
7602 // __toString protocol returns a string when called with self.
7603 let v = ev(r#""${{ __toString = self: "ok"; }}""#);
7604 assert_eq!(v, Value::string("ok"));
7605 }
7606
7607 // ── Path PathRel / PathHome / PathAbs ─────────────────
7608
7609 #[test]
7610 fn eval_path_absolute_literal() {
7611 let v = ev("/tmp/foo");
7612 match v {
7613 Value::Path(p) => assert!(p.contains("/tmp/foo")),
7614 _ => panic!("expected Path"),
7615 }
7616 }
7617
7618 #[test]
7619 fn eval_path_home_literal() {
7620 let v = ev("~/foo.nix");
7621 match v {
7622 Value::Path(p) => assert!(p.contains("~/foo.nix") || p.ends_with("foo.nix")),
7623 _ => panic!("expected Path"),
7624 }
7625 }
7626
7627 // ── search path miss ──────────────────────────────────
7628
7629 #[test]
7630 fn path_search_unmatched_errors() {
7631 // Without NIX_PATH entries matching, <nonexistent> errors out.
7632 // We unset NIX_PATH locally to ensure no entries match.
7633 let saved = std::env::var("NIX_PATH").ok();
7634 // SAFETY: tests run sequentially in single-threaded mode by
7635 // default? The thread_local NIX_PATH is per-thread but std::env
7636 // is process-global. We restore it after.
7637 unsafe {
7638 std::env::remove_var("NIX_PATH");
7639 }
7640 let result = eval("<this_should_not_resolve>");
7641 if let Some(v) = saved {
7642 unsafe {
7643 std::env::set_var("NIX_PATH", v);
7644 }
7645 }
7646 assert!(result.is_err());
7647 }
7648
7649 // ── Unary operators ────────────────────────────────────
7650
7651 #[test]
7652 fn unary_negate_int() {
7653 assert_eq!(ev("-7"), Value::Int(-7));
7654 }
7655
7656 #[test]
7657 fn unary_negate_float() {
7658 assert_eq!(ev("-2.5"), Value::Float(-2.5));
7659 }
7660
7661 #[test]
7662 fn unary_invert_true() {
7663 assert_eq!(ev("!true"), Value::Bool(false));
7664 }
7665
7666 #[test]
7667 fn unary_invert_false() {
7668 assert_eq!(ev("!false"), Value::Bool(true));
7669 }
7670
7671 #[test]
7672 fn unary_negate_bool_errors() {
7673 let result = eval("-true");
7674 assert!(result.is_err());
7675 }
7676
7677 #[test]
7678 fn unary_invert_int_errors() {
7679 let result = eval("!42");
7680 assert!(result.is_err());
7681 }
7682
7683 // ── Binary op type errors ──────────────────────────────
7684
7685 #[test]
7686 fn binop_add_attrs_errors() {
7687 let result = eval("{a=1;} + {b=2;}");
7688 assert!(result.is_err());
7689 }
7690
7691 #[test]
7692 fn binop_sub_string_errors() {
7693 let result = eval(r#""a" - "b""#);
7694 assert!(result.is_err());
7695 }
7696
7697 #[test]
7698 fn binop_mul_string_errors() {
7699 let result = eval(r#""a" * "b""#);
7700 assert!(result.is_err());
7701 }
7702
7703 #[test]
7704 fn binop_div_string_errors() {
7705 let result = eval(r#""a" / "b""#);
7706 assert!(result.is_err());
7707 }
7708
7709 #[test]
7710 fn binop_compare_attrs_errors() {
7711 let result = eval("{a=1;} < {b=2;}");
7712 assert!(result.is_err());
7713 }
7714
7715 #[test]
7716 fn binop_div_float_by_zero_int() {
7717 // Float / int(0) is NOT a DivisionByZero error in this evaluator —
7718 // only int/int matches the DivisionByZero branch. This documents
7719 // that branch.
7720 let result = eval("1.0 / 0");
7721 // Either inf or error is acceptable; the documented branch is
7722 // the int/int(0) → DivisionByZero one.
7723 let _ = result;
7724 }
7725
7726 #[test]
7727 fn binop_int_div_zero_is_division_by_zero() {
7728 let result = eval("5 / 0");
7729 match result {
7730 Err(EvalError::DivisionByZero) => {}
7731 other => panic!("expected DivisionByZero, got {other:?}"),
7732 }
7733 }
7734
7735 // ── if/then/else laziness ──────────────────────────────
7736
7737 #[test]
7738 fn if_else_only_chosen_branch_evaluated_then() {
7739 // The else branch contains a divide-by-zero that would error
7740 // if eagerly evaluated. Choosing the then branch must skip it.
7741 assert_eq!(ev("if true then 42 else 1 / 0"), Value::Int(42));
7742 }
7743
7744 #[test]
7745 fn if_else_only_chosen_branch_evaluated_else() {
7746 assert_eq!(ev("if false then 1 / 0 else 99"), Value::Int(99));
7747 }
7748
7749 #[test]
7750 fn if_condition_must_be_bool() {
7751 let result = eval("if 1 then 1 else 2");
7752 assert!(result.is_err());
7753 }
7754
7755 #[test]
7756 fn if_condition_lazy_does_not_force_unused() {
7757 // Lazy `let` ensures that `bad` is only forced if the chosen
7758 // branch references it.
7759 assert_eq!(
7760 ev("let bad = 1 / 0; in if true then 42 else bad"),
7761 Value::Int(42),
7762 );
7763 }
7764
7765 // ── Logic short-circuit laziness ───────────────────────
7766
7767 #[test]
7768 fn and_short_circuits_on_false() {
7769 // RHS contains an error; should never run.
7770 assert_eq!(ev("false && (1 / 0 == 0)"), Value::Bool(false));
7771 }
7772
7773 #[test]
7774 fn or_short_circuits_on_true() {
7775 assert_eq!(ev("true || (1 / 0 == 0)"), Value::Bool(true));
7776 }
7777
7778 #[test]
7779 fn implication_short_circuits_on_false_lhs() {
7780 // false -> anything is true; RHS not evaluated.
7781 assert_eq!(ev("false -> (1 / 0 == 0)"), Value::Bool(true));
7782 }
7783
7784 // ── Lambda fixpoint via let ────────────────────────────
7785
7786 #[test]
7787 fn lambda_fix_combinator_returns_attrset() {
7788 // The classic `fix = f: let x = f x; in x` shape.
7789 let v = ev(
7790 "let fix = f: let x = f x; in x; in
7791 (fix (self: { val = 1; double = self.val * 2; })).double",
7792 );
7793 assert_eq!(v, Value::Int(2));
7794 }
7795
7796 // ── eval_attrset rec scope details ─────────────────────
7797
7798 #[test]
7799 fn rec_attrset_self_reference() {
7800 // rec set with simple forward reference.
7801 let v = ev("(rec { a = b; b = 1; }).a");
7802 assert_eq!(v, Value::Int(1));
7803 }
7804
7805 #[test]
7806 fn rec_attrset_inherit_from_uses_outer_scope() {
7807 // inherit-from in rec uses the OUTER (lexical) scope to evaluate
7808 // the source expression, not the rec scope. We bind `src` in
7809 // an outer let so the inherit can find it.
7810 let v = ev(
7811 "let src = { a = 10; }; in
7812 rec {
7813 inherit (src) a;
7814 b = a + 1;
7815 }",
7816 );
7817 if let Value::Attrs(attrs) = v {
7818 let b = attrs.get("b").unwrap();
7819 let b_forced = force_value(b).unwrap();
7820 assert_eq!(b_forced, Value::Int(11));
7821 } else {
7822 panic!("expected attrs");
7823 }
7824 }
7825
7826 #[test]
7827 fn nonrec_attrset_no_self_reference() {
7828 // In a non-rec set, a name doesn't see its sibling. The error
7829 // surfaces as an UndefinedVar when the thunk is forced.
7830 let result = eval("({ a = 1; b = a + 1; }).b");
7831 assert!(result.is_err());
7832 }
7833
7834 // ── eval_attrset deep merge edge cases ─────────────────
7835
7836 #[test]
7837 fn dotted_binding_three_segments_then_sibling() {
7838 let v = ev("{ a.b.c = 1; a.b.d = 2; a.e = 3; }");
7839 if let Value::Attrs(attrs) = v {
7840 let a = attrs.get("a").unwrap();
7841 let a_forced = force_value(a).unwrap();
7842 if let Value::Attrs(a_attrs) = a_forced {
7843 let b = a_attrs.get("b").unwrap();
7844 let b_forced = force_value(b).unwrap();
7845 if let Value::Attrs(b_attrs) = b_forced {
7846 assert_eq!(force_value(b_attrs.get("c").unwrap()).unwrap(), Value::Int(1));
7847 assert_eq!(force_value(b_attrs.get("d").unwrap()).unwrap(), Value::Int(2));
7848 } else {
7849 panic!("expected b to be attrs");
7850 }
7851 assert_eq!(force_value(a_attrs.get("e").unwrap()).unwrap(), Value::Int(3));
7852 } else {
7853 panic!("expected a to be attrs");
7854 }
7855 } else {
7856 panic!("expected outer attrs");
7857 }
7858 }
7859
7860 // ── rec/let dotted bindings in recursive scope ────────
7861
7862 #[test]
7863 fn rec_dotted_bindings_visible_to_siblings() {
7864 // Dotted bindings in rec blocks must be visible to sibling
7865 // bindings -- this is the nixpkgs lib/systems/parse.nix pattern.
7866 let v = ev("rec { types.openSB = 1; types.openCpu = 2; foo = types.openSB; }.foo");
7867 assert_eq!(v, Value::Int(1));
7868 }
7869
7870 #[test]
7871 fn rec_dotted_leaf_uses_rec_scope() {
7872 // Leaf expressions in dotted bindings must see sibling
7873 // rec-bindings, not just the parent scope.
7874 let v = ev("rec { types.a = f 1; f = x: x + 1; }.types.a");
7875 assert_eq!(v, Value::Int(2));
7876 }
7877
7878 #[test]
7879 fn rec_dotted_multiple_keys_merge() {
7880 // Multiple dotted bindings sharing a top-level key must merge.
7881 let v = ev("rec { types.a = 1; types.b = 2; x = types; }.x");
7882 if let Value::Attrs(attrs) = v {
7883 assert_eq!(force_value(attrs.get("a").unwrap()).unwrap(), Value::Int(1));
7884 assert_eq!(force_value(attrs.get("b").unwrap()).unwrap(), Value::Int(2));
7885 } else {
7886 panic!("expected attrs");
7887 }
7888 }
7889
7890 #[test]
7891 fn rec_nixpkgs_parse_pattern() {
7892 // Simplified nixpkgs lib/systems/parse.nix pattern:
7893 // rec block with dotted types.xxx bindings that reference
7894 // each other through the rec scope.
7895 let v = ev(r#"
7896 let
7897 mkOptionType = x: x;
7898 mergeOneOption = "merge";
7899 attrValues = builtins.attrValues;
7900 setType = name: value: { __type = name; } // value;
7901 mapAttrs = builtins.mapAttrs;
7902 enum = xs: mkOptionType { name = "enum"; check = x: builtins.elem x xs; };
7903 setTypes = type: mapAttrs (name: value: setType type.name ({ inherit name; } // value));
7904 in
7905 rec {
7906 types.openSB = mkOptionType { name = "sb"; merge = mergeOneOption; };
7907 types.significantByte = enum (attrValues significantBytes);
7908 significantBytes = setTypes types.openSB { bigEndian = {}; littleEndian = {}; };
7909 types.openCpuType = mkOptionType { name = "cpu-type"; };
7910 types.cpuType = enum (attrValues cpuTypes);
7911 cpuTypes = setTypes types.openCpuType { arm = { bits = 32; }; };
7912 }.types.openCpuType
7913 "#);
7914 if let Value::Attrs(attrs) = v {
7915 assert_eq!(
7916 force_value(attrs.get("name").unwrap()).unwrap(),
7917 Value::string("cpu-type")
7918 );
7919 } else {
7920 panic!("expected attrs");
7921 }
7922 }
7923
7924 #[test]
7925 fn let_dotted_leaf_uses_let_scope() {
7926 // Dotted binding leaf in a let block sees sibling let-bindings.
7927 let v = ev("let a.x = f 1; f = x: x + 1; in a.x");
7928 assert_eq!(v, Value::Int(2));
7929 }
7930
7931 #[test]
7932 fn let_inherit_from_plus_dotted_overrides() {
7933 // inherit-from and dotted bindings for the same key in a let
7934 // block: CppNix rejects this as a duplicate definition. Sui
7935 // currently lets the dotted binding win (last-write-wins).
7936 // This test documents the current behaviour -- when we add
7937 // duplicate detection it should change to assert an error.
7938 let v = ev(r#"
7939 let
7940 src = { types = { existing = true; }; };
7941 inherit (src) types;
7942 types.added = true;
7943 in types
7944 "#);
7945 if let Value::Attrs(attrs) = v {
7946 // Dotted binding overwrites the inherited value
7947 assert_eq!(
7948 force_value(attrs.get("added").unwrap()).unwrap(),
7949 Value::Bool(true)
7950 );
7951 // Inherited 'existing' is lost because dotted replaced it
7952 assert!(attrs.get("existing").is_none());
7953 } else {
7954 panic!("expected attrs");
7955 }
7956 }
7957
7958 // ── Function pattern variations ────────────────────────
7959
7960 #[test]
7961 fn pattern_empty_no_args_no_ellipsis() {
7962 // {} pattern accepts only an empty attrset.
7963 assert_eq!(ev("({}: 1) {}"), Value::Int(1));
7964 }
7965
7966 #[test]
7967 fn pattern_empty_with_ellipsis_accepts_extra() {
7968 assert_eq!(ev("({...}: 1) { a = 1; b = 2; }"), Value::Int(1));
7969 }
7970
7971 #[test]
7972 fn pattern_all_defaults() {
7973 assert_eq!(
7974 ev("({a ? 1, b ? 2}: a + b) {}"),
7975 Value::Int(3),
7976 );
7977 }
7978
7979 #[test]
7980 fn pattern_at_bind_before() {
7981 // args @ { x }: args.x — bind name comes before pattern.
7982 assert_eq!(ev("(args @ { x }: args.x) { x = 7; }"), Value::Int(7));
7983 }
7984
7985 #[test]
7986 fn pattern_at_bind_after() {
7987 // { x } @ args: args.x — bind name comes after pattern.
7988 assert_eq!(ev("({ x } @ args: args.x) { x = 7; }"), Value::Int(7));
7989 }
7990
7991 #[test]
7992 fn pattern_default_references_other_arg() {
7993 // The default for `b` references `a` (which exists).
7994 assert_eq!(ev("({a, b ? a + 1}: b) {a = 10;}"), Value::Int(11));
7995 }
7996
7997 #[test]
7998 fn pattern_required_missing_errors() {
7999 let result = eval("({ a, b }: a) { a = 1; }");
8000 assert!(result.is_err());
8001 }
8002
8003 #[test]
8004 fn pattern_unexpected_errors_without_ellipsis() {
8005 let result = eval("({ a }: a) { a = 1; b = 2; }");
8006 assert!(result.is_err());
8007 }
8008
8009 // ── apply: error on non-callable ───────────────────────
8010
8011 #[test]
8012 fn apply_int_errors() {
8013 let result = eval("42 5");
8014 assert!(result.is_err());
8015 }
8016
8017 #[test]
8018 fn apply_string_errors() {
8019 let result = eval(r#""hi" 5"#);
8020 assert!(result.is_err());
8021 }
8022
8023 #[test]
8024 fn apply_attrset_without_functor_errors() {
8025 let result = eval("{ x = 1; } 5");
8026 assert!(result.is_err());
8027 let msg = format!("{}", result.unwrap_err());
8028 assert!(msg.contains("__functor") || msg.contains("cannot call"));
8029 }
8030
8031 // ── Select with multi-segment + default ────────────────
8032
8033 #[test]
8034 fn select_multi_segment_with_default() {
8035 // a.b.missing or 99 -- the missing segment yields the default.
8036 assert_eq!(ev("{ a = { b = 1; }; }.a.c or 99"), Value::Int(99));
8037 }
8038
8039 #[test]
8040 fn select_from_int_errors() {
8041 let result = eval("(1).x");
8042 assert!(result.is_err());
8043 }
8044
8045 // ── HasAttr edge cases ─────────────────────────────────
8046
8047 #[test]
8048 fn has_attr_on_non_set_returns_false() {
8049 // `expr ? a` where expr is not a set returns false (not error).
8050 assert_eq!(ev("1 ? x"), Value::Bool(false));
8051 }
8052
8053 #[test]
8054 fn has_attr_nested_path_present() {
8055 assert_eq!(ev("{ a = { b = 1; }; } ? a.b"), Value::Bool(true));
8056 }
8057
8058 #[test]
8059 fn has_attr_nested_path_missing() {
8060 assert_eq!(ev("{ a = { b = 1; }; } ? a.c"), Value::Bool(false));
8061 }
8062
8063 #[test]
8064 fn has_attr_intermediate_missing_returns_false() {
8065 assert_eq!(ev("{} ? a.b.c"), Value::Bool(false));
8066 }
8067
8068 // ── List eval edge cases ───────────────────────────────
8069
8070 #[test]
8071 fn list_with_function_value() {
8072 let v = ev("[(x: x + 1)]");
8073 if let Value::List(items) = v {
8074 assert_eq!(items.len(), 1);
8075 // List elements are now lazy (thunked). Force to check type.
8076 let forced = force_value(&items[0]).unwrap();
8077 assert!(matches!(forced, Value::Lambda(_)));
8078 } else {
8079 panic!("expected list");
8080 }
8081 }
8082
8083 // ── eval_inherit edge: inherit from missing var ────────
8084
8085 #[test]
8086 fn inherit_unknown_name_errors() {
8087 let result = eval("let x = 1; in let inherit nonexistent; in nonexistent");
8088 assert!(result.is_err());
8089 }
8090
8091 // ── String op: string concat preserves context ─────────
8092
8093 #[test]
8094 fn string_concat_no_context_when_both_plain() {
8095 let v = ev(r#""abc" + "def""#);
8096 if let Value::String(ns) = v {
8097 assert_eq!(ns.chars, "abcdef");
8098 assert!(!ns.has_context());
8099 } else {
8100 panic!("expected string");
8101 }
8102 }
8103
8104 // ── Parens / Root ──────────────────────────────────────
8105
8106 #[test]
8107 fn parens_around_expression() {
8108 assert_eq!(ev("(1 + 2)"), Value::Int(3));
8109 }
8110
8111 #[test]
8112 fn nested_parens() {
8113 assert_eq!(ev("(((42)))"), Value::Int(42));
8114 }
8115
8116 // ── Throw via builtins ─────────────────────────────────
8117
8118 #[test]
8119 fn throw_propagates_as_error() {
8120 let result = eval(r#"builtins.throw "kaboom""#);
8121 match result {
8122 Err(EvalError::Throw(s)) => assert!(s.contains("kaboom")),
8123 other => panic!("expected Throw, got {other:?}"),
8124 }
8125 }
8126
8127 #[test]
8128 fn assert_failed_propagates_as_error() {
8129 let result = eval("assert false; 1");
8130 match result {
8131 Err(EvalError::AssertionFailed(_)) => {}
8132 other => panic!("expected AssertionFailed, got {other:?}"),
8133 }
8134 }
8135
8136 // ── eval_str InterpolPart::Literal only ────────────────
8137
8138 #[test]
8139 fn string_no_interp_yields_no_context() {
8140 let v = ev(r#""just literal""#);
8141 if let Value::String(ns) = v {
8142 assert!(!ns.has_context());
8143 } else {
8144 panic!("expected string");
8145 }
8146 }
8147
8148 // ── Path interpolation adds context ───────────────────
8149
8150 // Byte-parity root #5: interpolating a source path is CppNix copy-to-store
8151 // coercion — the path is NAR-copied into /nix/store/<hash>-<name> and the
8152 // store path (with store-path context) is spliced in, not the raw path.
8153 // NAR of a single regular file is content+basename only (location-
8154 // independent), so a temp <dir>/data.txt of "hello\n" yields the exact
8155 // store path nix 2.34 produced: /nix/store/y9dmv…-data.txt.
8156 #[test]
8157 fn interp_path_copies_to_store_byte_matches_cppnix() {
8158 let dir = std::env::temp_dir().join(format!("sui-r5-interp-{}", std::process::id()));
8159 let _ = std::fs::remove_dir_all(&dir);
8160 std::fs::create_dir_all(&dir).unwrap();
8161 let f = dir.join("data.txt");
8162 std::fs::write(&f, b"hello\n").unwrap();
8163 let expr = format!(r#""${{{}}}""#, f.display());
8164 let v = eval(&expr).unwrap();
8165 if let Value::String(ns) = v {
8166 assert_eq!(
8167 ns.chars.to_string(),
8168 "/nix/store/y9dmvfhip31hg8ia4njwjz9vfa3ndphr-data.txt",
8169 );
8170 assert!(ns.has_context());
8171 } else {
8172 panic!("expected string");
8173 }
8174 let _ = std::fs::remove_dir_all(&dir);
8175 }
8176
8177 // ── pipe operators (NotImplemented) ────────────────────
8178 // Pipe operators (|>, <|) are parsed as PipeRight/PipeLeft and
8179 // currently return NotImplemented. We can't easily evaluate them
8180 // here because rnix may not even parse them, so we just rely on
8181 // the binop branch existing.
8182
8183 // ── ParseError surface ─────────────────────────────────
8184
8185 #[test]
8186 fn parse_error_unbalanced_braces() {
8187 let result = eval("{ a = 1");
8188 assert!(result.is_err());
8189 let err = result.unwrap_err();
8190 assert!(matches!(err, EvalError::ParseError(_)));
8191 }
8192
8193 #[test]
8194 fn parse_error_dangling_let() {
8195 let result = eval("let in");
8196 assert!(result.is_err());
8197 }
8198
8199 #[test]
8200 fn parse_error_empty_input() {
8201 let result = eval("");
8202 assert!(result.is_err());
8203 }
8204
8205 // ── num_op coverage via float ops ──────────────────────
8206
8207 #[test]
8208 fn float_int_subtraction() {
8209 assert_eq!(ev("3.5 - 1"), Value::Float(2.5));
8210 }
8211
8212 #[test]
8213 fn int_float_subtraction() {
8214 assert_eq!(ev("3 - 0.5"), Value::Float(2.5));
8215 }
8216
8217 #[test]
8218 fn float_float_division() {
8219 assert_eq!(ev("6.0 / 2.0"), Value::Float(3.0));
8220 }
8221
8222 #[test]
8223 fn int_float_multiplication() {
8224 assert_eq!(ev("3 * 2.5"), Value::Float(7.5));
8225 }
8226
8227 // ── compare with mixed numerics ────────────────────────
8228
8229 #[test]
8230 fn compare_int_float_less() {
8231 assert_eq!(ev("1 < 1.5"), Value::Bool(true));
8232 }
8233
8234 #[test]
8235 fn compare_float_int_more() {
8236 assert_eq!(ev("3.5 > 3"), Value::Bool(true));
8237 }
8238
8239 #[test]
8240 fn compare_equal_int_float() {
8241 assert_eq!(ev("3 <= 3.0"), Value::Bool(true));
8242 }
8243
8244 // ── Equality ──────────────────────────────────────────
8245
8246 #[test]
8247 fn equal_lists_same() {
8248 assert_eq!(ev("[1 2 3] == [1 2 3]"), Value::Bool(true));
8249 }
8250
8251 #[test]
8252 fn equal_lists_diff_length() {
8253 assert_eq!(ev("[1 2] == [1 2 3]"), Value::Bool(false));
8254 }
8255
8256 #[test]
8257 fn not_equal_lists() {
8258 assert_eq!(ev("[1] != [2]"), Value::Bool(true));
8259 }
8260
8261 #[test]
8262 fn equal_attrsets_same() {
8263 assert_eq!(ev("{a = 1; b = 2;} == {b = 2; a = 1;}"), Value::Bool(true));
8264 }
8265
8266 // ── Lambda identity equality (Rc ptr_eq) ────────────────
8267 // Regression test: same lambda via Rc must compare equal.
8268 // Without this, nixpkgs stdenv evaluation enters an infinite loop
8269 // because `crossSystem != localSystem` returns true even when both
8270 // are the same elaborate result (containing shared function attrs).
8271
8272 #[test]
8273 fn lambda_self_equality_in_attrset() {
8274 // Same closure shared via let → inherit must be equal
8275 assert_eq!(
8276 ev("let f = x: x; in { a = 1; inherit f; } == { a = 1; inherit f; }"),
8277 Value::Bool(true),
8278 );
8279 }
8280
8281 #[test]
8282 fn lambda_self_reference_attrset_equality() {
8283 // Attrset with function attr: x == x must be true
8284 assert_eq!(
8285 ev("let x = { a = 1; f = y: y; }; in x == x"),
8286 Value::Bool(true),
8287 );
8288 }
8289
8290 #[test]
8291 fn lambda_different_closures_not_equal() {
8292 // Different lambda closures (even structurally identical) must be false
8293 assert_eq!(
8294 ev("{ f = x: x; } == { f = x: x; }"),
8295 Value::Bool(false),
8296 );
8297 }
8298
8299 #[test]
8300 fn lambda_ne_does_not_force_unused_branch() {
8301 // If crossSystem == localSystem (same obj), != returns false,
8302 // and the then-branch (with throw) is never forced.
8303 assert_eq!(
8304 ev("let ls = { a = 1; f = x: x; }; in if ls != ls then builtins.throw \"bug\" else 42"),
8305 Value::Int(42),
8306 );
8307 }
8308
8309 // ── force_value chains thunks ──────────────────────────
8310
8311 #[test]
8312 fn force_value_through_thunk() {
8313 let root = rnix::Root::parse("1 + 2");
8314 let expr = root.tree().expr().unwrap();
8315 let thunk = Thunk::new_suspended(expr, Env::new());
8316 let val = Value::Thunk(thunk);
8317 assert_eq!(force_value(&val).unwrap(), Value::Int(3));
8318 }
8319
8320 // ── Builtin name "tryEval" lazy arg path ──────────────
8321
8322 #[test]
8323 fn try_eval_catches_thrown_error() {
8324 // tryEval wraps the thunk and catches throws inside.
8325 let v = ev(r#"(builtins.tryEval (builtins.throw "oops")).success"#);
8326 assert_eq!(v, Value::Bool(false));
8327 }
8328
8329 #[test]
8330 fn try_eval_returns_value_on_success() {
8331 let v = ev("(builtins.tryEval 42).value");
8332 assert_eq!(v, Value::Int(42));
8333 }
8334
8335 // ── LegacyLet (`let { body = ...; ...}`) ───────────────
8336
8337 #[test]
8338 fn legacy_let_returns_body_attr() {
8339 // `let { x = 1; body = x + 41; }` is the legacy let form: it
8340 // is desugared as a recursive set whose `body` attr is the
8341 // result.
8342 assert_eq!(ev("let { x = 1; body = x + 41; }"), Value::Int(42));
8343 }
8344
8345 #[test]
8346 fn legacy_let_missing_body_errors() {
8347 let result = eval("let { x = 1; }");
8348 assert!(result.is_err());
8349 }
8350
8351 #[test]
8352 fn legacy_let_with_inherit_from_scope() {
8353 assert_eq!(
8354 ev("let outer = 5; in let { inherit outer; body = outer * 2; }"),
8355 Value::Int(10),
8356 );
8357 }
8358
8359 // ── eval_str interpolation more cases ──────────────────
8360
8361 #[test]
8362 fn interp_with_string_concat_preserves_order() {
8363 assert_eq!(
8364 ev(r#"let a = "x"; b = "y"; in "${a}-${b}""#),
8365 Value::string("x-y"),
8366 );
8367 }
8368
8369 #[test]
8370 fn interp_only_literal_part() {
8371 assert_eq!(ev(r#""no interp here""#), Value::string("no interp here"));
8372 }
8373
8374 // ── eval_attr dynamic / string keys ────────────────────
8375
8376 #[test]
8377 fn dynamic_attr_via_string_key_in_set() {
8378 // `{ "a" = 1; }.a` works because attr keys can be string literals.
8379 assert_eq!(ev(r#"{ "a" = 1; }.a"#), Value::Int(1));
8380 }
8381
8382 #[test]
8383 fn dynamic_attr_via_interpolated_key() {
8384 let v = ev(r#"let k = "foo"; in { ${k} = 99; }.foo"#);
8385 assert_eq!(v, Value::Int(99));
8386 }
8387
8388 // ── String key access via select with dynamic ──────────
8389
8390 #[test]
8391 fn select_with_string_key() {
8392 let v = ev(r#"{ a = 42; }."a""#);
8393 assert_eq!(v, Value::Int(42));
8394 }
8395
8396 // ── Apply via __functor on attrset ─────────────────────
8397
8398 #[test]
8399 fn apply_attrset_with_functor_works() {
8400 let v = ev("let s = { __functor = self: x: x + 1; }; in s 5");
8401 assert_eq!(v, Value::Int(6));
8402 }
8403
8404 // ── Negation of negative ───────────────────────────────
8405
8406 #[test]
8407 fn double_negate_int() {
8408 assert_eq!(ev("- (-5)"), Value::Int(5));
8409 }
8410
8411 // ── Inherit from rec scope binding visibility ──────────
8412
8413 #[test]
8414 fn inherit_in_let_makes_name_available() {
8415 assert_eq!(
8416 ev("let src = { a = 7; }; in let inherit (src) a; in a"),
8417 Value::Int(7),
8418 );
8419 }
8420
8421 // ── String + path ──────────────────────────────────────
8422
8423 #[test]
8424 fn path_plus_string_yields_path() {
8425 let v = ev(r#"/foo + "/bar""#);
8426 match v {
8427 Value::Path(p) => assert_eq!(&*p, "/foo/bar"),
8428 _ => panic!("expected path"),
8429 }
8430 }
8431
8432 // ── Lazy attrset value not forced unless selected ──────
8433
8434 #[test]
8435 fn attrset_value_not_forced_unless_selected() {
8436 // `bad` is an attr whose value would error if forced, but we
8437 // only ever select `good`, so it's never touched.
8438 assert_eq!(
8439 ev(r#"{ bad = builtins.throw "boom"; good = 42; }.good"#),
8440 Value::Int(42),
8441 );
8442 }
8443
8444 // ── Lambda calling itself via let ──────────────────────
8445
8446 #[test]
8447 fn lambda_recursive_via_let() {
8448 // factorial via let-bound recursive function
8449 assert_eq!(
8450 ev("let fact = n: if n == 0 then 1 else n * fact (n - 1); in fact 5"),
8451 Value::Int(120),
8452 );
8453 }
8454
8455 // ── Dynamic key in select ──────────────────────────────
8456
8457 #[test]
8458 fn select_with_dynamic_key_via_var() {
8459 // ${k} interpolation in select position is not standard Nix
8460 // syntax, but a string-literal key works for select.
8461 assert_eq!(ev(r#"let k = { x = 1; }; in k.x"#), Value::Int(1));
8462 }
8463
8464 // ── Compare strings ────────────────────────────────────
8465
8466 #[test]
8467 fn compare_string_lex_greater_or_equal() {
8468 assert_eq!(ev(r#""b" >= "a""#), Value::Bool(true));
8469 assert_eq!(ev(r#""a" >= "a""#), Value::Bool(true));
8470 assert_eq!(ev(r#""a" >= "b""#), Value::Bool(false));
8471 }
8472
8473 // ── PartialEq across types ─────────────────────────────
8474
8475 #[test]
8476 fn equal_int_string_false() {
8477 assert_eq!(ev(r#"1 == "1""#), Value::Bool(false));
8478 }
8479
8480 #[test]
8481 fn equal_null_int_false() {
8482 assert_eq!(ev("null == 0"), Value::Bool(false));
8483 }
8484
8485 // ── Update operator on thunked operands ────────────────
8486
8487 #[test]
8488 fn update_with_let_bound_operands() {
8489 assert_eq!(
8490 ev("let a = { x = 1; }; b = { y = 2; }; in (a // b).y"),
8491 Value::Int(2),
8492 );
8493 }
8494
8495 // ── Concat on let-bound lists ──────────────────────────
8496
8497 #[test]
8498 fn concat_lists_from_let() {
8499 assert_eq!(
8500 ev("let a = [1 2]; b = [3 4]; in builtins.length (a ++ b)"),
8501 Value::Int(4),
8502 );
8503 }
8504
8505 // ── String interpolation: list coercion ─────────────────
8506
8507 #[test]
8508 fn interp_list_coerces_with_spaces() {
8509 // Lists in interpolation are now coerced via coerce_to_string
8510 // (space-joined elements).
8511 assert_eq!(
8512 ev(r#""${toString [1 2 3]}""#),
8513 Value::string("1 2 3"),
8514 );
8515 }
8516
8517 #[test]
8518 fn interp_list_directly_coerces() {
8519 // Direct list interpolation space-joins elements via coerce_to_string.
8520 assert_eq!(
8521 ev(r#""${[1 2]}""#),
8522 Value::string("1 2"),
8523 );
8524 }
8525
8526 // ── String interpolation: outPath ─────────────────────
8527
8528 #[test]
8529 fn interp_outpath_attrset() {
8530 assert_eq!(
8531 ev(r#"let x = { outPath = "/nix/store/abc"; }; in "${x}""#),
8532 Value::string("/nix/store/abc"),
8533 );
8534 }
8535
8536 #[test]
8537 fn interp_tostring_takes_priority_over_outpath() {
8538 assert_eq!(
8539 ev(r#"let x = { __toString = self: "custom"; outPath = "/ignored"; }; in "${x}""#),
8540 Value::string("custom"),
8541 );
8542 }
8543
8544 #[test]
8545 fn interp_derivation_coerces_to_outpath() {
8546 // derivation produces an attrset with outPath
8547 let result = eval(r#"
8548 let drv = builtins.derivation {
8549 name = "test";
8550 system = "x86_64-linux";
8551 builder = "/bin/sh";
8552 };
8553 in "${drv}"
8554 "#).unwrap();
8555 if let Value::String(s) = result {
8556 assert!(s.chars.starts_with("/nix/store/"), "got: {}", s.chars);
8557 } else {
8558 panic!("expected string");
8559 }
8560 }
8561
8562 // ── String interpolation: lambda error ─────────────────
8563
8564 #[test]
8565 fn interp_lambda_errors() {
8566 let result = eval(r#""${x: x}""#);
8567 assert!(result.is_err());
8568 }
8569
8570 // ── force_value tests ────────────────────────────────────
8571
8572 #[test]
8573 fn force_value_int_returns_same() {
8574 let v = Value::Int(42);
8575 assert_eq!(force_value(&v).unwrap(), Value::Int(42));
8576 }
8577
8578 #[test]
8579 fn force_value_bool_returns_same() {
8580 let v = Value::Bool(true);
8581 assert_eq!(force_value(&v).unwrap(), Value::Bool(true));
8582 }
8583
8584 #[test]
8585 fn force_value_string_returns_same() {
8586 let v = Value::string("hello");
8587 assert_eq!(force_value(&v).unwrap(), Value::string("hello"));
8588 }
8589
8590 #[test]
8591 fn force_value_attrs_returns_same() {
8592 let mut a = NixAttrs::new();
8593 a.insert("x".to_string(), Value::Int(1));
8594 let v = Value::Attrs(Rc::new(a.clone()));
8595 assert_eq!(force_value(&v).unwrap(), Value::Attrs(Rc::new(a)));
8596 }
8597
8598 #[test]
8599 fn force_value_list_returns_same() {
8600 let v = Value::list(vec![Value::Int(1), Value::Int(2)]);
8601 assert_eq!(
8602 force_value(&v).unwrap(),
8603 Value::list(vec![Value::Int(1), Value::Int(2)]),
8604 );
8605 }
8606
8607 #[test]
8608 fn force_value_null_returns_null() {
8609 let v = Value::Null;
8610 assert_eq!(force_value(&v).unwrap(), Value::Null);
8611 }
8612
8613 #[test]
8614 fn force_value_evaluated_thunk_returns_cached() {
8615 // Thunk wrapping a simple expression should evaluate and cache
8616 let v = ev("let x = 1 + 2; in x");
8617 assert_eq!(v, Value::Int(3));
8618 // Force again — should return the cached value
8619 assert_eq!(force_value(&v).unwrap(), Value::Int(3));
8620 }
8621
8622 // ── Tail-call loop tests ─────────────────────────────────
8623
8624 #[test]
8625 fn tco_if_true_condition() {
8626 assert_eq!(ev("if true then 42 else 0"), Value::Int(42));
8627 }
8628
8629 #[test]
8630 fn tco_if_false_condition() {
8631 assert_eq!(ev("if false then 42 else 0"), Value::Int(0));
8632 }
8633
8634 #[test]
8635 fn tco_deeply_nested_if_else_chain() {
8636 // Build a chain: if false then 1 else if false then 2 else ... else 150
8637 // All conditions are false except the final else, which produces 150.
8638 let mut expr = String::from("150");
8639 for i in (1..150).rev() {
8640 expr = format!("if false then {} else {}", i, expr);
8641 }
8642 let v = ev(&expr);
8643 assert_eq!(v, Value::Int(150));
8644 }
8645
8646 #[test]
8647 fn tco_assert_true_passes_through() {
8648 assert_eq!(ev("assert true; 42"), Value::Int(42));
8649 }
8650
8651 #[test]
8652 fn tco_assert_false_throws_assertion_failed() {
8653 let result = eval("assert false; 42");
8654 assert!(result.is_err());
8655 let err = result.unwrap_err();
8656 assert!(
8657 matches!(err, EvalError::AssertionFailed(_)),
8658 "expected AssertionFailed, got: {err}",
8659 );
8660 }
8661
8662 #[test]
8663 fn tco_with_makes_scope_available() {
8664 assert_eq!(ev("with { x = 10; y = 20; }; x + y"), Value::Int(30));
8665 }
8666
8667 #[test]
8668 fn tco_let_in_creates_bindings() {
8669 assert_eq!(ev("let a = 5; in a"), Value::Int(5));
8670 }
8671
8672 #[test]
8673 fn tco_let_in_multiple_bindings() {
8674 assert_eq!(ev("let a = 1; b = 2; c = 3; in a + b + c"), Value::Int(6));
8675 }
8676
8677 // ── eval_attrset tests ───────────────────────────────────
8678
8679 #[test]
8680 fn eval_attrset_empty() {
8681 let v = ev("{}");
8682 if let Value::Attrs(attrs) = v {
8683 assert!(attrs.is_empty(), "expected empty attrset");
8684 } else {
8685 panic!("expected attrset, got {v:?}");
8686 }
8687 }
8688
8689 #[test]
8690 fn eval_attrset_simple_kv() {
8691 let v = ev("{ a = 1; b = 2; }");
8692 if let Value::Attrs(attrs) = v {
8693 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8694 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8695 } else {
8696 panic!("expected attrset, got {v:?}");
8697 }
8698 }
8699
8700 #[test]
8701 fn eval_attrset_recursive() {
8702 assert_eq!(ev("(rec { a = 1; b = a + 1; }).b"), Value::Int(2));
8703 assert_eq!(ev("(rec { a = 1; b = a + 1; }).a"), Value::Int(1));
8704 }
8705
8706 #[test]
8707 fn eval_attrset_inherit_from_scope() {
8708 assert_eq!(ev("let x = 1; in { inherit x; }.x"), Value::Int(1));
8709 }
8710
8711 #[test]
8712 fn eval_attrset_inherit_from_expr() {
8713 assert_eq!(
8714 ev("{ inherit (builtins) true; }.true"),
8715 Value::Bool(true),
8716 );
8717 }
8718
8719 #[test]
8720 fn eval_attrset_dotted_path() {
8721 assert_eq!(ev("{ a.b.c = 1; }.a.b.c"), Value::Int(1));
8722 }
8723
8724 #[test]
8725 fn eval_attrset_update_merge() {
8726 let v = ev("{ a = 1; } // { b = 2; }");
8727 if let Value::Attrs(attrs) = v {
8728 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8729 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8730 } else {
8731 panic!("expected attrset, got {v:?}");
8732 }
8733 }
8734
8735 // ── eval_apply tests ─────────────────────────────────────
8736
8737 #[test]
8738 fn eval_apply_simple_function() {
8739 assert_eq!(ev("(x: x + 1) 2"), Value::Int(3));
8740 }
8741
8742 #[test]
8743 fn eval_apply_pattern_destructuring() {
8744 assert_eq!(ev("({a, b}: a + b) { a = 1; b = 2; }"), Value::Int(3));
8745 }
8746
8747 #[test]
8748 fn eval_apply_default_arguments() {
8749 assert_eq!(ev("({a, b ? 0}: a + b) { a = 1; }"), Value::Int(1));
8750 }
8751
8752 #[test]
8753 fn eval_apply_ellipsis() {
8754 assert_eq!(ev("({a, ...}: a) { a = 1; b = 2; }"), Value::Int(1));
8755 }
8756
8757 // ── eval_select tests ────────────────────────────────────
8758
8759 #[test]
8760 fn eval_select_single_key() {
8761 assert_eq!(ev("{ a = 1; }.a"), Value::Int(1));
8762 }
8763
8764 #[test]
8765 fn eval_select_multi_level() {
8766 assert_eq!(ev("{ a.b = 1; }.a.b"), Value::Int(1));
8767 }
8768
8769 #[test]
8770 fn eval_select_with_or_default() {
8771 assert_eq!(ev("{}.a or 42"), Value::Int(42));
8772 }
8773
8774 #[test]
8775 fn eval_select_missing_key_without_default_throws() {
8776 let result = eval("{}.a");
8777 assert!(result.is_err());
8778 }
8779
8780 // ── BinOp tests ──────────────────────────────────────────
8781
8782 #[test]
8783 fn binop_add_ints() {
8784 assert_eq!(ev("1 + 2"), Value::Int(3));
8785 }
8786
8787 #[test]
8788 fn binop_sub_ints() {
8789 assert_eq!(ev("3 - 1"), Value::Int(2));
8790 }
8791
8792 #[test]
8793 fn binop_mul_ints() {
8794 assert_eq!(ev("2 * 3"), Value::Int(6));
8795 }
8796
8797 #[test]
8798 fn binop_div_ints() {
8799 assert_eq!(ev("6 / 2"), Value::Int(3));
8800 }
8801
8802 #[test]
8803 fn binop_float_arithmetic() {
8804 assert_eq!(ev("1.5 + 2.5"), Value::Float(4.0));
8805 }
8806
8807 #[test]
8808 fn binop_string_concat() {
8809 assert_eq!(
8810 ev(r#""hello" + " " + "world""#),
8811 Value::string("hello world"),
8812 );
8813 }
8814
8815 #[test]
8816 fn binop_list_concat() {
8817 assert_eq!(
8818 ev("[1 2] ++ [3 4]"),
8819 Value::list(vec![
8820 Value::Int(1),
8821 Value::Int(2),
8822 Value::Int(3),
8823 Value::Int(4),
8824 ]),
8825 );
8826 }
8827
8828 #[test]
8829 fn binop_attrset_update() {
8830 let v = ev("{ a = 1; } // { b = 2; }");
8831 if let Value::Attrs(attrs) = v {
8832 assert_eq!(attrs.get("a"), Some(&Value::Int(1)));
8833 assert_eq!(attrs.get("b"), Some(&Value::Int(2)));
8834 } else {
8835 panic!("expected attrset, got {v:?}");
8836 }
8837 }
8838
8839 #[test]
8840 fn binop_less_than() {
8841 assert_eq!(ev("1 < 2"), Value::Bool(true));
8842 assert_eq!(ev("2 < 1"), Value::Bool(false));
8843 }
8844
8845 #[test]
8846 fn binop_greater_than() {
8847 assert_eq!(ev("2 > 1"), Value::Bool(true));
8848 assert_eq!(ev("1 > 2"), Value::Bool(false));
8849 }
8850
8851 #[test]
8852 fn binop_equal() {
8853 assert_eq!(ev("1 == 1"), Value::Bool(true));
8854 assert_eq!(ev("1 == 2"), Value::Bool(false));
8855 }
8856
8857 #[test]
8858 fn binop_not_equal() {
8859 assert_eq!(ev("1 != 2"), Value::Bool(true));
8860 assert_eq!(ev("1 != 1"), Value::Bool(false));
8861 }
8862
8863 #[test]
8864 fn binop_logical_and() {
8865 assert_eq!(ev("true && false"), Value::Bool(false));
8866 assert_eq!(ev("true && true"), Value::Bool(true));
8867 }
8868
8869 #[test]
8870 fn binop_logical_or() {
8871 assert_eq!(ev("true || false"), Value::Bool(true));
8872 assert_eq!(ev("false || false"), Value::Bool(false));
8873 }
8874
8875 #[test]
8876 fn binop_logical_not() {
8877 assert_eq!(ev("!true"), Value::Bool(false));
8878 assert_eq!(ev("!false"), Value::Bool(true));
8879 }
8880
8881 #[test]
8882 fn binop_implication() {
8883 assert_eq!(ev("false -> true"), Value::Bool(true));
8884 assert_eq!(ev("false -> false"), Value::Bool(true));
8885 assert_eq!(ev("true -> true"), Value::Bool(true));
8886 assert_eq!(ev("true -> false"), Value::Bool(false));
8887 }
8888}