sui_normalize/lib.rs
1//! Parse-time attrset-binding normalizer — CppNix's `ExprAttrs::addAttr`.
2//!
3//! # The defect this exists to remove
4//!
5//! sui decides duplicate-key **merge-vs-overwrite at EVAL time**, by forcing the
6//! colliding value to WHNF and asking *"is it an attrset?"*. Real nix decides it
7//! at **PARSE time, from SYNTAX**. Because sui asks a different question at a
8//! different time, all three engines give SILENT WRONG ANSWERS on legal nix.
9//! Measured against nix 2.31.5 on 2026-08-18:
10//!
11//! ```text
12//! let a = {b=1;}; a = {c=2;}; in a nix {b=1;c=2;} sui {c=2;}
13//! rec { o = {e=1;}; o.x = 2; } nix {o={e=1;x=2;};} sui {o={x=2;};}
14//! let b=1; in { a={x=2;}; a=rec{b=99;c=b;}; }
15//! nix c=1 sui c=99
16//! ```
17//!
18//! Every row exits 0. No error anywhere.
19//!
20//! # The rule, measured
21//!
22//! Merge iff **both sides are syntactic attrset literals** (`{…}` / `rec {…}`,
23//! including the implicit ones a dotted path creates), **recursively**. Reject
24//! otherwise. Decided from SYNTAX, never from the runtime value — so
25//! `let x = {b=1;}; in { s = x; s = {c=2;}; }` is a parse error even though the
26//! value of `x` *is* an attrset.
27//!
28//! # ★ It is a destructive SPLICE, not predicate-plus-union
29//!
30//! nix splices the second side's bindings INTO THE FIRST-DECLARED NODE. The
31//! first node's `rec` governs and a later `rec` is DISCARDED; the second side's
32//! bindings are RE-SCOPED into the first node's scope:
33//!
34//! ```text
35//! { a = rec {b=1;}; a = {c = b+1;}; }.a.c -> 2 non-rec side binds to the FIRST's rec scope
36//! { a = {b=1;}; a = rec {c=2; d=c;}; } -> parse error: undefined variable 'c'
37//! a standalone-valid rec block's INTERNAL
38//! reference is destroyed by the merge
39//! { a = rec {b=1; c=b+1;}; a.d = 3; } -> `{ a = rec { b=1; c=(b+1); d=3; }; }`
40//! the DOTTED member lands INSIDE the rec
41//! ```
42//!
43//! **No value-level merge can express re-scoping.** That is why this is a
44//! structural pass and not a fix inside any engine's collection loop.
45//!
46//! # Why a side-table rather than a rewritten tree
47//!
48//! rnix's CST is lossless and IMMUTABLE — the tree cannot be spliced. So this
49//! pass emits a [`NormalizeTable`]: a plan per binding-group node, which each
50//! engine consumes INSTEAD OF its own `for entry in set.entries()` loop. The
51//! shape is deliberately modelled on `sui-resolve`'s `ResolveTable`, including
52//! its most useful property:
53//!
54//! **An absent entry means "nothing to normalize"** — no collision, no dotted
55//! path — so every engine's existing fast path is untouched for the
56//! overwhelming majority of attrsets, and the table stays small. That is the
57//! parity-by-construction argument: this pass can only change the groups it
58//! records, and it records only the groups that today are wrong.
59//!
60//! # Placement
61//!
62//! This crate must be reachable by all three engines, so it sits at
63//! `sui-resolve`'s level and depends on `sui-intern` + `rnix` + `rowan` only.
64//! It must NOT depend on `sui-eval`: `sui-bytecode` carries `sui-eval` as a
65//! path-only dev-dep to break a publish cycle, and a real edge here would close
66//! it. Hence the error type is self-contained — no `EvalError` — and each
67//! engine maps [`NormalizeError`] into its own error on the way out.
68//!
69//! # Status
70//!
71//! **STAGE 0: UNWIRED.** Nothing consumes this yet, by design — the rule is
72//! built and proven against the oracle before it can change any engine's
73//! behaviour. Wiring is stages 1–4.
74
75use rnix::ast::{self, HasEntry};
76use rowan::ast::AstNode;
77use sui_intern::Symbol;
78
79/// One component of an attribute path, after constant-folding.
80///
81/// The static/dynamic split is a **constant-fold on node kind**, not
82/// "quoted vs unquoted" — which is the trap that makes intuition wrong here:
83///
84/// ```text
85/// a Ident -> STATIC
86/// "a" Str, no interpolation
87/// -> STATIC (`{ "a" = 1; a = 2; }` is a duplicate)
88/// ${"a"} Dynamic wrapping a pure Str
89/// -> STATIC (`{ ${"a"} = 1; a = 2; }` is a duplicate)
90/// "${"a"}" Str WITH interpolation
91/// -> DYNAMIC (parses fine; errors only when FORCED)
92/// ${"a"+""} Dynamic wrapping a non-Str
93/// -> DYNAMIC
94/// ```
95///
96/// Getting this wrong in the permissive direction turns a currently-correct
97/// answer into a throw: `{ a = {p=1;}; ${"a"} = {q=2;}; }` merges in nix, and
98/// sui already agrees with it today.
99#[derive(Clone, Debug)]
100pub enum AttrKey {
101 /// Folded to a compile-time-known name.
102 Static(Symbol),
103 /// Genuinely dynamic — resolved, and checked for collisions, only when the
104 /// enclosing attrset is FORCED. Never participates in a parse-time merge.
105 Dynamic(ast::Expr),
106}
107
108/// A binding after normalization.
109#[derive(Clone, Debug)]
110pub enum Binding {
111 /// A value that is NOT a syntactic attrset literal, so it can never merge.
112 Leaf(ast::Expr),
113 /// A syntactic attrset literal, or an implicit one created by a dotted
114 /// path. Mergeable — and merging splices into THIS node.
115 ///
116 /// `Rc` because a consumer clones a sub-plan every time it builds the
117 /// nested group lazily; owned, that is a DEEP copy of the whole subtree on
118 /// each evaluation. During construction the `Rc` is uniquely owned, so
119 /// `Rc::make_mut` below is a no-op rather than a copy.
120 Group(std::rc::Rc<GroupPlan>),
121 /// `inherit x` — resolve `x` in the group's ENCLOSING scope, never its own
122 /// rec scope. That is what makes an inherited binding shadow rather than
123 /// self-reference, and why it can never merge.
124 Inherit,
125 /// `inherit (e) x` — force `GroupPlan::inherit_froms[from]`, then select.
126 ///
127 /// ★ An INDEX, not a cloned expression. One `inherit (e) a b c;` clause
128 /// must evaluate `e` AT MOST ONCE and share the result across all three
129 /// names; cloning the expr per name evaluates it three times, which is
130 /// observable through `builtins.trace` and through any impure source. The
131 /// index is per-GROUP because the splice moves a clause between groups.
132 InheritFrom {
133 /// Index into the owning [`GroupPlan::inherit_froms`].
134 from: usize,
135 },
136}
137
138/// A dynamic-keyed binding, kept out of the static map.
139#[derive(Clone, Debug)]
140pub struct DynamicBinding {
141 /// The key expression, evaluated at force time in the owning group's scope.
142 pub key: ast::Expr,
143 /// The bound value. A [`Binding`], not a bare expression: a dynamic key
144 /// can bind an attrset literal (`{ ${k} = {a=1;}; }`), which is already
145 /// lowered to a `Group` by the time it arrives here.
146 pub value: Binding,
147}
148
149/// One static binding, with the source position of its FIRST definition.
150///
151/// The position is carried because nix's duplicate message names two of them —
152/// `attribute 'a.b' already defined at «string»:1:3` for the first, and the
153/// error's own location for the second.
154#[derive(Clone, Debug)]
155pub struct StaticBinding {
156 /// The folded attribute name.
157 pub name: Symbol,
158 /// What is bound.
159 pub binding: Binding,
160 /// Byte offset where this name was FIRST defined.
161 pub pos: u32,
162}
163
164/// The normalized plan for one binding group — an attrset, a `let`, a `rec`,
165/// or a legacy `let { … }`.
166#[derive(Clone, Debug, Default)]
167pub struct GroupPlan {
168 /// Effective recursiveness. **The FIRST definition's, never an OR of the
169 /// two** — a later `rec` is discarded, which is what makes
170 /// `{ a = {b=1;}; a = rec {c=2; d=c;}; }` a parse error in nix.
171 pub recursive: bool,
172 /// Static bindings after the splice, in nix's insertion order.
173 pub statics: Vec<StaticBinding>,
174 /// Dynamic-keyed bindings. Collisions among these — and against
175 /// `statics` — are an EVAL-time error, and only when forced.
176 pub dynamics: Vec<DynamicBinding>,
177 /// One entry per `inherit (e) …;` clause that landed on this group,
178 /// INCLUDING clauses spliced in from a later side. Referenced by index
179 /// from [`Binding::InheritFrom`] so a clause's source is evaluated once
180 /// and shared across its names.
181 ///
182 /// Evaluated in the group's OWN scope, which is rec-visible when the group
183 /// is recursive — measured: `rec { b = {x=99;}; inherit (b) x; }` is
184 /// `x = 99`, so the source sees the group it is being bound into.
185 pub inherit_froms: Vec<ast::Expr>,
186}
187
188impl GroupPlan {
189 fn index_of(&self, sym: Symbol) -> Option<usize> {
190 self.statics.iter().position(|b| b.name == sym)
191 }
192}
193
194/// A parse-time rejection. Carries what is needed to render nix's message.
195///
196/// nix has FIVE distinct reject messages, not one; this models the two that
197/// duplicate-attribute normalization produces. The other three (dynamic
198/// attributes in `let`/`inherit`, and the `${e}` force-time collision) belong
199/// to their own layers.
200#[derive(Clone, Debug, PartialEq, Eq)]
201pub enum NormalizeError {
202 /// `attribute '<dotted-path>' already defined at <pos>`
203 DuplicateAttr {
204 /// The full dotted path, already quoted per `showAttrPath` rules.
205 path: String,
206 /// Byte offset of the FIRST definition.
207 first: u32,
208 /// Byte offset of the offending one.
209 second: u32,
210 },
211 /// `duplicate formal function argument '<name>'`
212 DuplicateFormal {
213 /// The repeated formal's name.
214 name: String,
215 /// Byte offset to report.
216 at: u32,
217 },
218}
219
220impl std::fmt::Display for NormalizeError {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 match self {
223 Self::DuplicateAttr { path, .. } => {
224 write!(f, "attribute '{path}' already defined")
225 }
226 Self::DuplicateFormal { name, .. } => {
227 write!(f, "duplicate formal function argument '{name}'")
228 }
229 }
230 }
231}
232
233impl std::error::Error for NormalizeError {}
234
235/// Render one path component the way CppNix's `showAttrPath` does.
236///
237/// A component is emitted bare iff it matches `[A-Za-z_][A-Za-z0-9_'-]*` AND is
238/// not one of exactly NINE reserved words. Everything else is quoted, with `"`
239/// and newline escaped. Measured bare: `a`, `a1`, `_a`, `a-b`, `a'b`, and
240/// notably `or`, `true`, `false`, `null` — which are NOT reserved in this
241/// position. Measured quoted: `"1a"`, `"a b"`, `"a.b"`, `""`, and the nine.
242#[must_use]
243pub fn show_attr_component(name: &str) -> String {
244 const RESERVED: [&str; 9] = [
245 "if", "then", "else", "assert", "with", "let", "in", "rec", "inherit",
246 ];
247 let mut chars = name.chars();
248 let bare = match chars.next() {
249 Some(c) if c.is_ascii_alphabetic() || c == '_' => chars
250 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\'' | '-')),
251 _ => false,
252 } && !RESERVED.contains(&name);
253
254 if bare {
255 name.to_string()
256 } else {
257 format!(
258 "\"{}\"",
259 name.replace('\\', "\\\\")
260 .replace('"', "\\\"")
261 .replace('\n', "\\n")
262 )
263 }
264}
265
266/// True iff `expr` is a syntactic attrset literal, seeing through PARENTHESES.
267///
268/// ★ Parens are the trap. CppNix's grammar discards them (`'(' expr ')' { $$ =
269/// $2; }`) so its `dynamic_cast<ExprAttrs *>` sees straight through; rnix keeps
270/// `NODE_PAREN` as a real CST node, so a bare `matches!(e, Expr::AttrSet(_))`
271/// misses `((({b=1;})))` — which nix merges. Parens are transparent
272/// RECURSIVELY, including around `rec`.
273///
274/// Every OTHER wrapper is opaque, verified: `let`, `with`, `assert`, `if`, and
275/// `//` all make the value non-mergeable even when it evaluates to an attrset.
276#[must_use]
277pub fn as_attrset_literal(expr: &ast::Expr) -> Option<ast::AttrSet> {
278 match expr {
279 ast::Expr::AttrSet(set) => Some(set.clone()),
280 ast::Expr::Paren(p) => p.expr().as_ref().and_then(as_attrset_literal),
281 _ => None,
282 }
283}
284
285/// Strip `(…)` recursively. CppNix's grammar discards parens outright, so
286/// anything that asks "what KIND of expression is this?" must strip them first
287/// or it will answer about the wrapper.
288#[must_use]
289pub fn strip_parens(expr: &ast::Expr) -> ast::Expr {
290 match expr {
291 ast::Expr::Paren(p) => p.expr().as_ref().map_or_else(|| expr.clone(), strip_parens),
292 other => other.clone(),
293 }
294}
295
296/// Constant-fold an attribute-path component, per the rule on [`AttrKey`].
297/// The TEXT-level half of [`fold_attr`]: the static name an attribute folds
298/// to, or `None` if it is genuinely dynamic.
299///
300/// Split out because [`needs_plan`] runs on every attribute of every attrset in
301/// every parsed file just to decide whether a plan is needed, and interning
302/// there costs a `String` allocation plus a hash into the intern table per
303/// attribute — measured as the dominant parse-time cost of this pass. This
304/// borrows the token's text instead.
305///
306/// `fold_attr` is defined in terms of this, so the fold rule still has exactly
307/// ONE implementation.
308#[must_use]
309pub fn fold_attr_name(attr: &ast::Attr) -> Option<String> {
310 match attr {
311 // `ident_token()` borrows; `syntax().text().to_string()` allocates.
312 ast::Attr::Ident(ident) => Some(
313 ident
314 .ident_token()
315 .map_or_else(|| ident.syntax().text().to_string(), |t| t.text().to_string()),
316 ),
317 ast::Attr::Str(s) => literal_str_text(s),
318 ast::Attr::Dynamic(dy) => match dy.expr().as_ref().map(strip_parens) {
319 Some(ast::Expr::Str(ref s)) => literal_str_text(s),
320 _ => None,
321 },
322 }
323}
324
325#[must_use]
326pub fn fold_attr(attr: &ast::Attr) -> Option<AttrKey> {
327 match attr {
328 ast::Attr::Ident(ident) => Some(AttrKey::Static(sui_intern::intern(
329 &ident.syntax().text().to_string(),
330 ))),
331 // A quoted key folds to STATIC iff it has no interpolation; with
332 // interpolation it is DYNAMIC.
333 //
334 // ★ It must never yield `None`. The caller skips a `None` component,
335 // which DROPS it from the attrpath — so `a."${"b"}" = 1; a."${"c"}" = 2;`
336 // collapsed to two bindings of plain `a` and was rejected as a
337 // duplicate, on input nix ACCEPTS. A false reject is the dangerous
338 // direction: it refuses working code. Caught by scanning the fleet,
339 // where it hit two of sui's own corpus fixtures.
340 ast::Attr::Str(s) => Some(literal_str_text(s).map_or_else(
341 || AttrKey::Dynamic(ast::Expr::Str(s.clone())),
342 |t| AttrKey::Static(sui_intern::intern(&t)),
343 )),
344 // `${e}` folds iff `e` is a pure string literal — AFTER stripping
345 // parens. `${("a")}` and `${''a''}` both fold in nix; a fold that
346 // only looks for a bare `Expr::Str` misses them and demotes a
347 // mergeable static key to dynamic, which silently changes the answer.
348 ast::Attr::Dynamic(dy) => {
349 let inner = dy.expr()?;
350 let stripped = strip_parens(&inner);
351 match &stripped {
352 ast::Expr::Str(s) => literal_str_text(s)
353 .map(|t| AttrKey::Static(sui_intern::intern(&t)))
354 .or(Some(AttrKey::Dynamic(inner.clone()))),
355 _ => Some(AttrKey::Dynamic(inner.clone())),
356 }
357 }
358 }
359}
360
361/// The text of a string node iff it is a pure literal (no `${…}` parts).
362fn literal_str_text(s: &ast::Str) -> Option<String> {
363 // `normalized_parts`, not `parts`: it applies `''` indentation stripping and
364 // escape processing, so the folded key is the string nix would compare —
365 // `{ "a\tb" = 1; }` must fold to a TAB, not to a backslash and a `t`.
366 let mut out = String::new();
367 for part in s.normalized_parts() {
368 match part {
369 ast::InterpolPart::Literal(text) => out.push_str(&text),
370 ast::InterpolPart::Interpolation(_) => return None,
371 }
372 }
373 Some(out)
374}
375
376/// The normalized plans for one parsed source tree.
377///
378/// Keyed by the binding-group node's `text_range().start()`, exactly as
379/// `sui_resolve::ResolveTable` keys idents. An unrecorded offset means the
380/// group needs no normalization — no duplicate static key and no dotted path —
381/// so consumers keep their existing path for it.
382#[derive(Clone, Debug, Default)]
383pub struct NormalizeTable {
384 by_offset: rustc_hash::FxHashMap<u32, GroupPlan>,
385}
386
387impl NormalizeTable {
388 /// An empty table — every lookup returns `None`.
389 #[must_use]
390 pub fn new() -> Self {
391 Self::default()
392 }
393
394 /// The plan recorded for the group node starting at `text_offset`, if any.
395 #[must_use]
396 pub fn get(&self, text_offset: u32) -> Option<&GroupPlan> {
397 self.by_offset.get(&text_offset)
398 }
399
400 /// Number of recorded groups. Small by construction — see the module docs.
401 #[must_use]
402 pub fn len(&self) -> usize {
403 self.by_offset.len()
404 }
405
406 /// True when no group needed normalization.
407 #[must_use]
408 pub fn is_empty(&self) -> bool {
409 self.by_offset.is_empty()
410 }
411
412 /// Iterate the recorded `(text_offset, plan)` pairs. Consumers merge these
413 /// into their own per-`(source_id, offset)` table, exactly as
414 /// `sui-resolve`'s consumers do.
415 pub fn iter(&self) -> impl Iterator<Item = (u32, &GroupPlan)> {
416 self.by_offset.iter().map(|(o, p)| (*o, p))
417 }
418
419 fn insert(&mut self, offset: u32, plan: GroupPlan) {
420 self.by_offset.insert(offset, plan);
421 }
422}
423
424// ── The splice ───────────────────────────────────────────────────────────
425
426/// Byte offset where a node starts.
427fn offset_of(node: &rowan::SyntaxNode<rnix::NixLanguage>) -> u32 {
428 u32::from(node.text_range().start())
429}
430
431/// Insert `value` at `path` into `group` — CppNix's `ExprAttrs::addAttr`.
432///
433/// Descends `path`, creating IMPLICIT non-recursive groups for intermediate
434/// components (that is what makes `{ a.b = 1; }` and `{ a = { b = 1; }; }`
435/// produce identical trees). At the leaf:
436///
437/// * absent -> insert
438/// * both are `Group` -> **SPLICE**: recursively add the incoming group's
439/// members into the EXISTING one, so the existing
440/// node's `recursive` survives and the incoming
441/// group's is discarded
442/// * anything else -> reject, naming the dotted path
443///
444/// `trail` carries the components consumed so far, purely so the error can name
445/// the full path — nix reports `attribute 'a.b' already defined`, not `'b'`.
446fn add_attr(
447 group: &mut GroupPlan,
448 path: &[AttrKey],
449 value: Binding,
450 pos: u32,
451 trail: &mut Vec<String>,
452) -> Result<(), NormalizeError> {
453 let Some((head, rest)) = path.split_first() else {
454 // An empty attrpath is not constructible from the grammar; treat it as
455 // "nothing to add" rather than panicking on a slice index — the exact
456 // shape that made the bytecode compiler crash on `{ a.b = 1; a.b = 2; }`.
457 return Ok(());
458 };
459
460 let sym = match head {
461 AttrKey::Static(s) => *s,
462 AttrKey::Dynamic(key) => {
463 // A dynamic component never participates in a parse-time merge and
464 // never rejects at parse. It is deferred wholesale: nix checks it
465 // when the attrset is FORCED, and not at all if it never is —
466 // `let s = { "${"a"}" = 1; a = 2; }; in 42` is `42`, no error.
467 //
468 // An INTERMEDIATE dynamic component (`a.${k}.b = 1`) mints a
469 // fresh implicit group and the REST OF THE PATH GOES INSIDE IT.
470 //
471 // ★ Returning here without recording anything silently DROPPED
472 // the binding: `let k = "z"; in { a.${k}.b = 1; }` built
473 // `{ a = { }; }` where nix builds `{ a = { z = { b = 1; }; }; }`.
474 // That is the exact failure class this crate exists to remove,
475 // reintroduced by its own fix — and the pre-existing path had it
476 // RIGHT. Caught by `sui-ir`'s eval differential, which is the
477 // only gate that compares the two engines on this shape.
478 //
479 // A fresh group per occurrence is also why an intermediate
480 // dynamic can never collide, and therefore why a reported
481 // duplicate path is always all-static.
482 if rest.is_empty() {
483 group.dynamics.push(DynamicBinding {
484 key: key.clone(),
485 value,
486 });
487 } else {
488 let mut sub = GroupPlan::default();
489 add_attr(&mut sub, rest, value, pos, trail)?;
490 group.dynamics.push(DynamicBinding {
491 key: key.clone(),
492 value: Binding::Group(std::rc::Rc::new(sub)),
493 });
494 }
495 return Ok(());
496 }
497 };
498
499 trail.push(show_attr_component(&sui_intern::resolve(sym)));
500
501 if rest.is_empty() {
502 match group.index_of(sym) {
503 None => {
504 group.statics.push(StaticBinding {
505 name: sym,
506 binding: value,
507 pos,
508 });
509 }
510 Some(idx) => {
511 let first = group.statics[idx].pos;
512 // ★ THE SPLICE. Both sides must be syntactic groups; the
513 // EXISTING one is the survivor, so its `recursive` governs and
514 // the incoming one's is dropped on the floor — which is exactly
515 // why a later `rec`'s internal references break.
516 match (&mut group.statics[idx].binding, value) {
517 (Binding::Group(existing_rc), Binding::Group(incoming_rc)) => {
518 let existing = std::rc::Rc::make_mut(existing_rc);
519 let incoming = std::rc::Rc::try_unwrap(incoming_rc)
520 .unwrap_or_else(|rc| (*rc).clone());
521 // The incoming group's `inherit (e)` clauses move into
522 // the existing group, so every `InheritFrom` index in
523 // its members must be REBASED onto the existing
524 // group's arena. Missing this silently points a
525 // spliced inherit at the wrong source expression.
526 let base = existing.inherit_froms.len();
527 existing.inherit_froms.extend(incoming.inherit_froms);
528 let incoming_statics: Vec<StaticBinding> = incoming
529 .statics
530 .into_iter()
531 .map(|mut m| {
532 rebase_inherit_from(&mut m.binding, base);
533 m
534 })
535 .collect();
536 for member in incoming_statics {
537 let mut sub_trail = trail.clone();
538 add_attr(
539 existing,
540 &[AttrKey::Static(member.name)],
541 member.binding,
542 member.pos,
543 &mut sub_trail,
544 )?;
545 }
546 existing.dynamics.extend(incoming.dynamics);
547 }
548 _ => {
549 return Err(NormalizeError::DuplicateAttr {
550 path: trail.join("."),
551 first,
552 second: pos,
553 });
554 }
555 }
556 }
557 }
558 } else {
559 // Intermediate component: get-or-create an implicit NON-recursive group.
560 let idx = match group.index_of(sym) {
561 Some(idx) => idx,
562 None => {
563 group.statics.push(StaticBinding {
564 name: sym,
565 binding: Binding::Group(std::rc::Rc::new(GroupPlan::default())),
566 pos,
567 });
568 group.statics.len() - 1
569 }
570 };
571 let first = group.statics[idx].pos;
572 let Binding::Group(sub_rc) = &mut group.statics[idx].binding else {
573 // The path descends THROUGH a non-mergeable binding, e.g.
574 // `{ a = 1; a.b = 2; }`. nix names the SHALLOWEST level where a
575 // side is not a literal — which is the trail as it stands now.
576 return Err(NormalizeError::DuplicateAttr {
577 path: trail.join("."),
578 first,
579 second: pos,
580 });
581 };
582 add_attr(std::rc::Rc::make_mut(sub_rc), rest, value, pos, trail)?;
583 }
584 Ok(())
585}
586
587/// Shift every `InheritFrom` index in `binding` (and, recursively, in any
588/// nested group) by `base`. Used when a group is spliced into another and its
589/// `inherit_froms` arena is appended to the survivor's.
590fn rebase_inherit_from(binding: &mut Binding, base: usize) {
591 match binding {
592 Binding::InheritFrom { from } => *from += base,
593 Binding::Group(sub) => {
594 for m in &mut std::rc::Rc::make_mut(sub).statics {
595 rebase_inherit_from(&mut m.binding, base);
596 }
597 }
598 Binding::Leaf(_) | Binding::Inherit => {}
599 }
600}
601
602/// Lower one value expression to a [`Binding`].
603///
604/// A syntactic attrset literal becomes a `Group` — recursively normalized —
605/// so that merging is uniformly "both sides are Groups". Everything else is a
606/// `Leaf` and can never merge. This is the single place the syntax-not-value
607/// rule is decided.
608fn lower_value(expr: &ast::Expr) -> Result<Binding, NormalizeError> {
609 match as_attrset_literal(expr) {
610 Some(set) => Ok(Binding::Group(std::rc::Rc::new(plan_for_entries(
611 &set,
612 set.rec_token().is_some(),
613 )?))),
614 None => Ok(Binding::Leaf(expr.clone())),
615 }
616}
617
618/// Build the plan for one `HasEntry` node's entries, in source order.
619fn plan_for_entries<N: HasEntry>(node: &N, recursive: bool) -> Result<GroupPlan, NormalizeError> {
620 let mut plan = GroupPlan {
621 recursive,
622 ..GroupPlan::default()
623 };
624
625 for entry in node.entries() {
626 match entry {
627 ast::Entry::AttrpathValue(av) => {
628 let (Some(attrpath), Some(value)) = (av.attrpath(), av.value()) else {
629 continue;
630 };
631 let mut path = Vec::new();
632 for attr in attrpath.attrs() {
633 let Some(key) = fold_attr(&attr) else { continue };
634 path.push(key);
635 }
636 let pos = offset_of(av.syntax());
637 let binding = lower_value(&value)?;
638 let mut trail = Vec::new();
639 add_attr(&mut plan, &path, binding, pos, &mut trail)?;
640 }
641 ast::Entry::Inherit(inh) => {
642 // Register the clause's source ONCE; every name it binds
643 // refers to it by index. Cloning the expr per name would
644 // evaluate the source N times.
645 let from_idx = inh.from().and_then(|f| f.expr()).map(|e| {
646 plan.inherit_froms.push(e);
647 plan.inherit_froms.len() - 1
648 });
649 for attr in inh.attrs() {
650 let Some(AttrKey::Static(sym)) = fold_attr(&attr) else {
651 // A dynamic key in `inherit` is rejected by nix at
652 // parse with its OWN message, which belongs to a
653 // different layer than duplicate detection.
654 continue;
655 };
656 let pos = offset_of(attr.syntax());
657 let mut trail = Vec::new();
658 let binding = match from_idx {
659 Some(from) => Binding::InheritFrom { from },
660 None => Binding::Inherit,
661 };
662 add_attr(&mut plan, &[AttrKey::Static(sym)], binding, pos, &mut trail)?;
663 }
664 }
665 }
666 }
667 Ok(plan)
668}
669
670/// Normalize every binding group in a parsed tree.
671///
672/// Returns the plans for groups that actually need one. A group with no
673/// duplicate static key and no dotted path is NOT recorded — consumers keep
674/// their existing path for it, which is what bounds this pass's blast radius.
675///
676/// # Errors
677///
678/// Returns the first [`NormalizeError`] in source order, matching nix's
679/// parse-time rejection.
680pub fn normalize(root: &ast::Root) -> Result<NormalizeTable, NormalizeError> {
681 let mut table = NormalizeTable::new();
682 record_plans(root, &mut table)?;
683 Ok(table)
684}
685
686/// Does this group need a plan at all? True iff some entry has a dotted path
687/// or two entries share a folded static key.
688fn needs_plan<N: HasEntry>(node: &N) -> bool {
689 // Interns and compares SYMBOLS (u32), not text. Measured: swapping this for
690 // borrowed text to "avoid the intern" made a real nixpkgs eval SLOWER —
691 // interning buys cheap integer comparison in `seen`, and the replacement
692 // paid a `String` allocation per attribute plus O(n) string compares. The
693 // intern is the optimization, not the cost.
694 //
695 // The cheap discriminator still runs first: a dotted path returns `true`
696 // with no allocation at all, and most attrsets exit there or with `seen`
697 // never growing past a few entries.
698 let mut seen: Vec<Symbol> = Vec::new();
699 for entry in node.entries() {
700 match entry {
701 ast::Entry::AttrpathValue(av) => {
702 let Some(attrpath) = av.attrpath() else { continue };
703 // Cheapest discriminator first: a dotted path always needs a
704 // plan, and answering that costs no allocation at all.
705 let mut attrs = attrpath.attrs();
706 let Some(first) = attrs.next() else { continue };
707 if attrs.next().is_some() {
708 return true;
709 }
710 if let Some(AttrKey::Static(sym)) = fold_attr(&first) {
711 if seen.contains(&sym) {
712 return true;
713 }
714 seen.push(sym);
715 }
716 }
717 ast::Entry::Inherit(inh) => {
718 for attr in inh.attrs() {
719 if let Some(AttrKey::Static(sym)) = fold_attr(&attr) {
720 if seen.contains(&sym) {
721 return true;
722 }
723 seen.push(sym);
724 }
725 }
726 }
727 }
728 }
729 false
730}
731
732/// The plan for ONE binding group, computed on demand.
733///
734/// `None` means the group needs no normalization — no duplicate static key and
735/// no dotted path — so the caller keeps its existing construction path.
736///
737/// ★ THIS IS THE PREFERRED ENTRY POINT. [`normalize`] walks a whole file and
738/// plans every binder in it, including the ones laziness never evaluates;
739/// measured on a real nixpkgs eval that is a ~4% wall-clock tax for work that
740/// is mostly discarded. Planning a group when it is first EVALUATED moves the
741/// cost onto the groups that actually matter, and the result is identical: a
742/// group's plan depends only on its own entries.
743///
744/// # Errors
745///
746/// Returns the group's [`NormalizeError`] if its bindings are a duplicate nix
747/// rejects.
748pub fn plan_for_group<N: HasEntry>(
749 node: &N,
750 recursive: bool,
751) -> Result<Option<GroupPlan>, NormalizeError> {
752 if !needs_plan(node) {
753 return Ok(None);
754 }
755 plan_for_entries(node, recursive).map(Some)
756}
757
758/// The plan for a group, **whether or not it needs one** — the total sibling
759/// of [`plan_for_group`].
760///
761/// The two exist because their consumers want opposite things and only one of
762/// them can be right for both.
763///
764/// [`plan_for_group`] is GATED, and that gate is what bounds a consumer's blast
765/// radius: a `None` says "no duplicate static key, no dotted path", i.e.
766/// exactly the groups whose existing construction path is already correct, so
767/// adopting it can only change the answers that were wrong. The tree-walker
768/// and `eval_ir` both want that — they keep their entry loops for the other
769/// 67% of the fleet.
770///
771/// A consumer that intends to RETIRE its entry loops wants this one instead. A
772/// gated planner cannot retire anything, because the un-planned groups still
773/// need the code path that would be deleted; and keeping both forever is two
774/// implementations of one rule, which is how the two halves drift. The bytecode
775/// compiler is that consumer: its five entry buckets are the defect (a key
776/// reaching two buckets is emitted twice), so it routes EVERY group through the
777/// plan and deletes them.
778///
779/// # Errors
780///
781/// Same as [`plan_for_group`] — a group nix itself rejects.
782pub fn plan_for_group_total<N: HasEntry>(
783 node: &N,
784 recursive: bool,
785) -> Result<GroupPlan, NormalizeError> {
786 plan_for_entries(node, recursive)
787}
788
789/// Record a plan for every binding group in the tree.
790///
791/// ★ ITERATES `descendants()`, NOT a recursion over `ast::Expr` children.
792///
793/// The first cut recursed with
794/// `for child in expr.syntax().children() { if let Some(e) = ast::Expr::cast(child) { … } }`,
795/// which looks total and is not: an attrset's children are `AttrpathValue` and
796/// `Inherit` nodes, and NEITHER casts to `ast::Expr`. So the walk stopped at
797/// the first attrset and never descended into a binding's VALUE. Only a binder
798/// that was itself an expression-child of the root ever got a plan; everything
799/// nested silently kept the old, wrong construction path:
800///
801/// ```text
802/// { outer = { a = rec { b = c + 1; d = 2; }; a.c = d + 3; }; }
803/// nix {"outer":{"a":{"b":6,"c":5,"d":2}}}
804/// sui Error: Eval(UndefinedVar("'c'"))
805/// ```
806///
807/// Every test written for the walker's adoption used a TOP-LEVEL binder, which
808/// is exactly why none of them caught it — and almost all real nix nests. The
809/// lesson is in the shape of the bug, not the fix: a traversal that filters by
810/// node type while recursing can be silently non-total, because the filter
811/// hides the nodes it refuses to descend through.
812///
813/// `descendants()` visits every node in the tree exactly once, so totality is
814/// structural rather than argued.
815fn record_plans(root: &ast::Root, table: &mut NormalizeTable) -> Result<(), NormalizeError> {
816 for node in root.syntax().descendants() {
817 // Dispatch on `kind()` — ONE enum compare — before attempting any
818 // cast. `descendants()` visits every node in the file and the
819 // overwhelming majority are not binders, so three speculative `cast`
820 // calls per node is three kind-checks where one will do.
821 if !matches!(
822 node.kind(),
823 rnix::SyntaxKind::NODE_ATTR_SET
824 | rnix::SyntaxKind::NODE_LET_IN
825 | rnix::SyntaxKind::NODE_LEGACY_LET
826 ) {
827 continue;
828 }
829 if let Some(set) = ast::AttrSet::cast(node.clone()) {
830 if needs_plan(&set) {
831 let plan = plan_for_entries(&set, set.rec_token().is_some())?;
832 table.insert(offset_of(set.syntax()), plan);
833 }
834 } else if let Some(letin) = ast::LetIn::cast(node.clone()) {
835 // A `let` binds recursively by construction.
836 if needs_plan(&letin) {
837 let plan = plan_for_entries(&letin, true)?;
838 table.insert(offset_of(letin.syntax()), plan);
839 }
840 } else if let Some(ll) = ast::LegacyLet::cast(node) {
841 // `let { … body = …; }` — the deprecated form. Also recursive, and
842 // also subject to the merge rule; the tree-walker silently
843 // DISCARDED every multi-segment attrpath here.
844 if needs_plan(&ll) {
845 let plan = plan_for_entries(&ll, true)?;
846 table.insert(offset_of(ll.syntax()), plan);
847 }
848 }
849 }
850 Ok(())
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 fn parse(src: &str) -> ast::Root {
858 let parse = rnix::Root::parse(src);
859 assert!(
860 parse.errors().is_empty(),
861 "{src}: rnix parse errors {:?}",
862 parse.errors()
863 );
864 parse.tree()
865 }
866
867 fn plan(src: &str) -> Result<NormalizeTable, NormalizeError> {
868 normalize(&parse(src))
869 }
870
871 /// Render the FIRST recorded group as a canonical shape string, so tests
872 /// read as the tree they assert. `rec` is shown because rec-ness is the
873 /// half of this rule that a value-level merge cannot express.
874 fn shape(src: &str) -> String {
875 let table = plan(src).unwrap_or_else(|e| panic!("{src}: rejected: {e}"));
876 let mut offsets: Vec<u32> = table.by_offset.keys().copied().collect();
877 offsets.sort_unstable();
878 let first = offsets
879 .first()
880 .unwrap_or_else(|| panic!("{src}: no group was recorded"));
881 render(table.get(*first).expect("recorded"))
882 }
883
884 fn render(plan: &GroupPlan) -> String {
885 let mut parts: Vec<String> = plan
886 .statics
887 .iter()
888 .map(|b| {
889 let name = sui_intern::resolve(b.name);
890 match &b.binding {
891 Binding::Leaf(_) => name.to_string(),
892 Binding::Inherit | Binding::InheritFrom { .. } => format!("inherit {name}"),
893 Binding::Group(sub) => format!("{name} = {}", render(sub)),
894 }
895 })
896 .collect();
897 for d in &plan.dynamics {
898 let _ = &d.key;
899 parts.push("${…}".to_string());
900 }
901 let body = parts.join("; ");
902 if plan.recursive {
903 format!("rec {{ {body} }}")
904 } else {
905 format!("{{ {body} }}")
906 }
907 }
908
909 fn err(src: &str) -> NormalizeError {
910 plan(src).expect_err(&format!("{src}: expected a rejection"))
911 }
912
913 // ── the MERGE rows ───────────────────────────────────────────────────
914
915 #[test]
916 fn dotted_paths_merge() {
917 assert_eq!(shape("{ a.b = 1; a.c = 2; }"), "{ a = { b; c } }");
918 assert_eq!(shape("{ a.b.c = 1; a.b.d = 2; }"), "{ a = { b = { c; d } } }");
919 }
920
921 /// ★ The literal and dotted forms produce the SAME tree — that is the whole
922 /// reason a duplicate-key check cannot be "reject repeated keys".
923 #[test]
924 fn attrset_literals_merge_like_dotted_paths() {
925 let dotted = shape("{ a.b = 1; a.c = 2; }");
926 let literal = shape("{ a = {b=1;}; a = {c=2;}; }");
927 let mixed = shape("{ a = {b=1;}; a.c = 2; }");
928 assert_eq!(dotted, literal, "literal form must merge like the dotted one");
929 assert_eq!(dotted, mixed, "mixed form must merge like the dotted one");
930 }
931
932 /// ★ rec-ness comes from the FIRST definition, and a later `rec` is
933 /// DISCARDED. This is the row a value-level merge structurally cannot
934 /// express, and it is why `{ a = {b=1;}; a = rec {c=2; d=c;}; }` is a nix
935 /// parse error: the incoming block's own internal reference is destroyed.
936 #[test]
937 fn recness_is_taken_from_the_first_definition() {
938 assert_eq!(
939 shape("{ a = rec {b=1;}; a = {c=2;}; }"),
940 "{ a = rec { b; c } }",
941 "the FIRST definition's rec must survive"
942 );
943 assert_eq!(
944 shape("{ a = {b=1;}; a = rec {c=2;}; }"),
945 "{ a = { b; c } }",
946 "a LATER rec must be discarded, not OR-ed in"
947 );
948 }
949
950 /// A dotted member splices INSIDE an existing rec.
951 #[test]
952 fn a_dotted_member_lands_inside_the_rec() {
953 assert_eq!(shape("{ a = rec {b=1;}; a.d = 3; }"), "{ a = rec { b; d } }");
954 }
955
956 /// ★ Parens are transparent RECURSIVELY. CppNix's grammar discards them;
957 /// rnix keeps NODE_PAREN, so a bare `matches!(e, Expr::AttrSet(_))` misses
958 /// these and turns a legal merge into a rejection.
959 #[test]
960 fn parentheses_are_transparent() {
961 assert_eq!(shape("{ a = ({b=1;}); a = {c=2;}; }"), "{ a = { b; c } }");
962 assert_eq!(shape("{ a = ((({b=1;}))); a = {c=2;}; }"), "{ a = { b; c } }");
963 assert_eq!(
964 shape("{ a = ((rec {b=1;})); a = {c=2;}; }"),
965 "{ a = rec { b; c } }",
966 "a rec inside parens must still be seen, WITH its rec-ness"
967 );
968 }
969
970 /// `let` obeys the identical rule — the class of bug this crate exists for
971 /// is at its worst here, because sui silently DROPS keys.
972 #[test]
973 fn let_bindings_merge_by_the_same_rule() {
974 assert_eq!(shape("let a = {b=1;}; a = {c=2;}; in a"), "rec { a = { b; c } }");
975 assert_eq!(shape("let a.b = 1; a.c = 2; in a"), "rec { a = { b; c } }");
976 }
977
978 // ── the REJECT rows ──────────────────────────────────────────────────
979
980 #[test]
981 fn a_plain_duplicate_is_rejected() {
982 assert!(matches!(
983 err("{ a = 1; a = 2; }"),
984 NormalizeError::DuplicateAttr { ref path, .. } if path == "a"
985 ));
986 }
987
988 /// ★ The check is RECURSIVE and the message names the FULL dotted path —
989 /// `a.b`, not `b`.
990 #[test]
991 fn a_nested_conflict_names_the_full_path() {
992 let NormalizeError::DuplicateAttr { path, .. } = err("{ a = {b=1;}; a = {b=2;}; }") else {
993 panic!("expected DuplicateAttr");
994 };
995 assert_eq!(path, "a.b");
996
997 let NormalizeError::DuplicateAttr { path, .. } = err("{ a.b.c = 1; a.b.c = 2; }") else {
998 panic!("expected DuplicateAttr");
999 };
1000 assert_eq!(path, "a.b.c");
1001 }
1002
1003 /// ★ Decided from SYNTAX, never the value. Every wrapper except parens is
1004 /// opaque, even when it plainly evaluates to an attrset.
1005 #[test]
1006 fn non_literal_wrappers_are_opaque() {
1007 for src in [
1008 "{ a = if true then {b=1;} else {}; a = {c=2;}; }",
1009 "{ a = let x = 1; in {b=x;}; a = {c=2;}; }",
1010 "{ a = with {}; {b=1;}; a = {c=2;}; }",
1011 "{ a = assert true; {b=1;}; a = {c=2;}; }",
1012 "{ a = {b=1;} // {z=9;}; a = {c=2;}; }",
1013 ] {
1014 assert!(
1015 plan(src).is_err(),
1016 "{src}: the value is an attrset but the SYNTAX is not — must reject"
1017 );
1018 }
1019 }
1020
1021 /// A path descending THROUGH a non-mergeable binding rejects at the
1022 /// shallowest level where a side is not a literal.
1023 #[test]
1024 fn descending_through_a_leaf_is_rejected() {
1025 let NormalizeError::DuplicateAttr { path, .. } = err("{ a = 1; a.b = 2; }") else {
1026 panic!("expected DuplicateAttr");
1027 };
1028 assert_eq!(path, "a");
1029 }
1030
1031 /// An inherited binding never merges, in either order.
1032 #[test]
1033 fn inherit_never_merges() {
1034 assert!(plan("let x = 1; in { inherit x; x = 2; }").is_err());
1035 assert!(plan("let x = 1; in { x = 2; inherit x; }").is_err());
1036 assert!(plan("{ inherit (s) a; a = 1; }").is_err());
1037 }
1038
1039 /// ★ One `inherit (e) a b c;` registers its source EXACTLY ONCE, and all
1040 /// three names index it. A source cloned per name is evaluated per name —
1041 /// observable through `builtins.trace`, and through any impure source.
1042 #[test]
1043 fn an_inherit_from_clause_shares_one_source() {
1044 let table = plan("{ inherit (src) a b c; d.e = 1; }").expect("ok");
1045 let mut offs: Vec<u32> = table.by_offset.keys().copied().collect();
1046 offs.sort_unstable();
1047 let g = table.get(offs[0]).expect("recorded");
1048 assert_eq!(
1049 g.inherit_froms.len(),
1050 1,
1051 "one clause must yield one arena entry, got {}",
1052 g.inherit_froms.len()
1053 );
1054 let idxs: Vec<usize> = g
1055 .statics
1056 .iter()
1057 .filter_map(|b| match b.binding {
1058 Binding::InheritFrom { from } => Some(from),
1059 _ => None,
1060 })
1061 .collect();
1062 assert_eq!(idxs, vec![0, 0, 0], "all three names must share index 0");
1063 }
1064
1065 /// And when a group carrying an `inherit (e)` is SPLICED into another that
1066 /// already has one, the incoming indices must be REBASED onto the
1067 /// survivor's arena. Without the rebase a spliced inherit silently points
1068 /// at the wrong source expression — a wrong value, not an error.
1069 #[test]
1070 fn a_spliced_inherit_from_is_rebased_onto_the_survivor() {
1071 let table = plan("{ a = { inherit (p) x; }; a = { inherit (q) y; }; }").expect("ok");
1072 let mut offs: Vec<u32> = table.by_offset.keys().copied().collect();
1073 offs.sort_unstable();
1074 let outer = table.get(offs[0]).expect("recorded");
1075 let Binding::Group(merged) = &outer.statics[0].binding else {
1076 panic!("expected a merged group");
1077 };
1078 assert_eq!(merged.inherit_froms.len(), 2, "both clauses must survive");
1079 let idxs: Vec<usize> = merged
1080 .statics
1081 .iter()
1082 .filter_map(|b| match b.binding {
1083 Binding::InheritFrom { from } => Some(from),
1084 _ => None,
1085 })
1086 .collect();
1087 assert_eq!(
1088 idxs,
1089 vec![0, 1],
1090 "the spliced clause must point at its OWN source, not the survivor's"
1091 );
1092 }
1093
1094 // ── the constant-fold boundary ───────────────────────────────────────
1095
1096 /// ★ Static/dynamic is a fold on NODE KIND, not quoted-vs-unquoted.
1097 /// Getting this wrong in the permissive direction turns a
1098 /// currently-CORRECT sui answer into a throw.
1099 #[test]
1100 fn static_dynamic_split_is_a_constant_fold() {
1101 // Folds to static -> participates in the merge, and can reject.
1102 assert!(plan(r#"{ "a" = 1; a = 2; }"#).is_err(), "a quoted key IS the same key");
1103 assert!(
1104 plan(r#"{ ${"a"} = 1; a = 2; }"#).is_err(),
1105 "a bare interpolation of a pure string literal folds to a static key"
1106 );
1107 assert_eq!(
1108 shape(r#"{ ${"a"}.b = 1; a.c = 2; }"#),
1109 "{ a = { b; c } }",
1110 "a folded bare interpolation must MERGE with the plain ident"
1111 );
1112
1113 // Stays dynamic -> deferred to force time, never a parse rejection.
1114 assert!(
1115 plan(r#"{ "${"a"}" = 1; a = 2; }"#).is_ok(),
1116 "an INTERPOLATED string key stays dynamic and must not reject at parse"
1117 );
1118 assert!(
1119 plan(r#"{ ${"a"+""} = 1; a = 2; }"#).is_ok(),
1120 "a non-Str dynamic key stays dynamic"
1121 );
1122 }
1123
1124 /// ★ An interpolated key must stay in the PATH, not vanish from it.
1125 ///
1126 /// `fold_attr` returning `None` made the caller skip the component, so
1127 /// `a."${"b"}" = 1; a."${"c"}" = 2;` collapsed to two bindings of plain
1128 /// `a` and was rejected as a duplicate — on input nix ACCEPTS. A false
1129 /// reject is the dangerous direction: it refuses working code. Both of
1130 /// these are real sui corpus fixtures that a fleet scan caught.
1131 #[test]
1132 fn an_interpolated_key_stays_in_the_path() {
1133 for src in [
1134 r#"{ a."${"b"}" = true; a."${"c"}" = false; }"#,
1135 r#"{ set3.a = 1; set3."${"b" + ""}" = 2; }"#,
1136 // The trailing-dynamic case at depth.
1137 r#"{ x.y."${"z"}" = 1; x.y."${"w"}" = 2; }"#,
1138 ] {
1139 assert!(
1140 plan(src).is_ok(),
1141 "{src}: nix accepts this; rejecting it refuses working code"
1142 );
1143 }
1144 }
1145
1146 /// And the component must be preserved as DYNAMIC, not silently folded to
1147 /// some static name — otherwise two different interpolations would
1148 /// collide with each other.
1149 #[test]
1150 fn two_different_interpolated_keys_do_not_collide() {
1151 let table = plan(r#"{ a."${"b"}" = 1; a."${"c"}" = 2; }"#).expect("ok");
1152 let mut offs: Vec<u32> = table.by_offset.keys().copied().collect();
1153 offs.sort_unstable();
1154 let outer = table.get(offs[0]).expect("recorded");
1155 let Binding::Group(inner) = &outer.statics[0].binding else {
1156 panic!("expected `a` to be an implicit group");
1157 };
1158 assert_eq!(
1159 inner.dynamics.len(),
1160 2,
1161 "both interpolated keys must survive as dynamics, got {}",
1162 inner.dynamics.len()
1163 );
1164 }
1165
1166 /// ★ An INTERMEDIATE dynamic component must keep the rest of its path.
1167 ///
1168 /// `let k = "z"; in { a.${k}.b = 1; }` is `{ a = { z = { b = 1; }; }; }`
1169 /// in nix. An earlier cut returned without recording anything for an
1170 /// intermediate dynamic, silently building `{ a = { }; }` — the very
1171 /// failure class this crate removes, reintroduced by its own fix, on a
1172 /// shape the PRE-EXISTING walker got right.
1173 #[test]
1174 fn an_intermediate_dynamic_keeps_the_rest_of_its_path() {
1175 let table = plan(r#"{ a.${k}.b = 1; }"#).expect("ok");
1176 let mut offs: Vec<u32> = table.by_offset.keys().copied().collect();
1177 offs.sort_unstable();
1178 let outer = table.get(offs[0]).expect("recorded");
1179 let Binding::Group(a) = &outer.statics[0].binding else {
1180 panic!("expected `a` to be an implicit group");
1181 };
1182 assert_eq!(a.dynamics.len(), 1, "the dynamic component must be recorded");
1183 let Binding::Group(inner) = &a.dynamics[0].value else {
1184 panic!("an intermediate dynamic must carry a GROUP, not a leaf — \
1185 otherwise the `.b` tail is dropped");
1186 };
1187 assert_eq!(
1188 inner.statics.len(),
1189 1,
1190 "the `.b` tail must live inside the dynamic's group"
1191 );
1192 }
1193
1194 /// A quoted dotted string is ONE key, not a path.
1195 #[test]
1196 fn a_quoted_dotted_string_is_a_single_key() {
1197 assert!(
1198 plan(r#"{ "a.b" = 1; a.b = 2; }"#).is_ok(),
1199 r#""a.b" and a.b are DIFFERENT keys"#
1200 );
1201 assert!(plan(r#"{ "a.b" = 1; "a.b" = 2; }"#).is_err());
1202 }
1203
1204 // ── error rendering ──────────────────────────────────────────────────
1205
1206 /// `showAttrPath` quotes iff the component is not an identifier or IS one
1207 /// of exactly nine reserved words. Note `or`/`true`/`false`/`null` are NOT
1208 /// reserved in this position — measured, not assumed.
1209 #[test]
1210 fn attr_path_components_quote_like_cppnix() {
1211 for bare in ["a", "a1", "_a", "a-b", "a'b", "or", "true", "false", "null"] {
1212 assert_eq!(show_attr_component(bare), bare, "{bare} must render bare");
1213 }
1214 for (raw, want) in [
1215 ("1a", "\"1a\""),
1216 ("a b", "\"a b\""),
1217 ("a.b", "\"a.b\""),
1218 ("", "\"\""),
1219 ("if", "\"if\""),
1220 ("rec", "\"rec\""),
1221 ("inherit", "\"inherit\""),
1222 ] {
1223 assert_eq!(show_attr_component(raw), want, "{raw} must render quoted");
1224 }
1225 }
1226
1227 // ── the table stays small ────────────────────────────────────────────
1228
1229 /// ★ ANTI-VACUITY + the parity argument in one row. A group with no
1230 /// duplicate and no dotted path must NOT be recorded, or this pass would
1231 /// take over every attrset in the fleet instead of only the broken ones.
1232 /// The second half is the anti-vacuity check: a group that DOES need a
1233 /// plan must actually be recorded, or "records nothing" would pass
1234 /// trivially.
1235 #[test]
1236 fn only_groups_that_need_normalizing_are_recorded() {
1237 for clean in [
1238 "{ a = 1; b = 2; }",
1239 "{ }",
1240 "let a = 1; b = 2; in a",
1241 "rec { a = 1; b = a; }",
1242 "{ a = { b = 1; }; }",
1243 ] {
1244 assert!(
1245 plan(clean).expect("clean").is_empty(),
1246 "{clean}: nothing to normalize, so nothing may be recorded"
1247 );
1248 }
1249 for dirty in ["{ a.b = 1; }", "{ a.b = 1; a.c = 2; }", "let a.b = 1; in a"] {
1250 assert!(
1251 !plan(dirty).expect("dirty").is_empty(),
1252 "{dirty}: needs a plan, so one must be recorded"
1253 );
1254 }
1255 }
1256}