Skip to main content

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