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