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` / `inherit (e) x`. Never mergeable in either direction, so
117    /// it is its own arm rather than a `Leaf`: an inherited binding colliding
118    /// with anything is a reject, in both orders.
119    Inherit {
120        /// `Some(e)` for `inherit (e) x`; `None` for plain `inherit x`.
121        from: Option<ast::Expr>,
122    },
123}
124
125/// A dynamic-keyed binding, kept out of the static map.
126#[derive(Clone, Debug)]
127pub struct DynamicBinding {
128    /// The key expression, evaluated at force time.
129    pub key: ast::Expr,
130    /// The bound value.
131    pub value: ast::Expr,
132}
133
134/// One static binding, with the source position of its FIRST definition.
135///
136/// The position is carried because nix's duplicate message names two of them —
137/// `attribute 'a.b' already defined at «string»:1:3` for the first, and the
138/// error's own location for the second.
139#[derive(Clone, Debug)]
140pub struct StaticBinding {
141    /// The folded attribute name.
142    pub name: Symbol,
143    /// What is bound.
144    pub binding: Binding,
145    /// Byte offset where this name was FIRST defined.
146    pub pos: u32,
147}
148
149/// The normalized plan for one binding group — an attrset, a `let`, a `rec`,
150/// or a legacy `let { … }`.
151#[derive(Clone, Debug, Default)]
152pub struct GroupPlan {
153    /// Effective recursiveness. **The FIRST definition's, never an OR of the
154    /// two** — a later `rec` is discarded, which is what makes
155    /// `{ a = {b=1;}; a = rec {c=2; d=c;}; }` a parse error in nix.
156    pub recursive: bool,
157    /// Static bindings after the splice, in nix's insertion order.
158    pub statics: Vec<StaticBinding>,
159    /// Dynamic-keyed bindings. Collisions among these — and against
160    /// `statics` — are an EVAL-time error, and only when forced.
161    pub dynamics: Vec<DynamicBinding>,
162}
163
164impl GroupPlan {
165    fn index_of(&self, sym: Symbol) -> Option<usize> {
166        self.statics.iter().position(|b| b.name == sym)
167    }
168}
169
170/// A parse-time rejection. Carries what is needed to render nix's message.
171///
172/// nix has FIVE distinct reject messages, not one; this models the two that
173/// duplicate-attribute normalization produces. The other three (dynamic
174/// attributes in `let`/`inherit`, and the `${e}` force-time collision) belong
175/// to their own layers.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub enum NormalizeError {
178    /// `attribute '<dotted-path>' already defined at <pos>`
179    DuplicateAttr {
180        /// The full dotted path, already quoted per `showAttrPath` rules.
181        path: String,
182        /// Byte offset of the FIRST definition.
183        first: u32,
184        /// Byte offset of the offending one.
185        second: u32,
186    },
187    /// `duplicate formal function argument '<name>'`
188    DuplicateFormal {
189        /// The repeated formal's name.
190        name: String,
191        /// Byte offset to report.
192        at: u32,
193    },
194}
195
196impl std::fmt::Display for NormalizeError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            Self::DuplicateAttr { path, .. } => {
200                write!(f, "attribute '{path}' already defined")
201            }
202            Self::DuplicateFormal { name, .. } => {
203                write!(f, "duplicate formal function argument '{name}'")
204            }
205        }
206    }
207}
208
209impl std::error::Error for NormalizeError {}
210
211/// Render one path component the way CppNix's `showAttrPath` does.
212///
213/// A component is emitted bare iff it matches `[A-Za-z_][A-Za-z0-9_'-]*` AND is
214/// not one of exactly NINE reserved words. Everything else is quoted, with `"`
215/// and newline escaped. Measured bare: `a`, `a1`, `_a`, `a-b`, `a'b`, and
216/// notably `or`, `true`, `false`, `null` — which are NOT reserved in this
217/// position. Measured quoted: `"1a"`, `"a b"`, `"a.b"`, `""`, and the nine.
218#[must_use]
219pub fn show_attr_component(name: &str) -> String {
220    const RESERVED: [&str; 9] = [
221        "if", "then", "else", "assert", "with", "let", "in", "rec", "inherit",
222    ];
223    let mut chars = name.chars();
224    let bare = match chars.next() {
225        Some(c) if c.is_ascii_alphabetic() || c == '_' => chars
226            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\'' | '-')),
227        _ => false,
228    } && !RESERVED.contains(&name);
229
230    if bare {
231        name.to_string()
232    } else {
233        format!(
234            "\"{}\"",
235            name.replace('\\', "\\\\")
236                .replace('"', "\\\"")
237                .replace('\n', "\\n")
238        )
239    }
240}
241
242/// True iff `expr` is a syntactic attrset literal, seeing through PARENTHESES.
243///
244/// ★ Parens are the trap. CppNix's grammar discards them (`'(' expr ')' { $$ =
245/// $2; }`) so its `dynamic_cast<ExprAttrs *>` sees straight through; rnix keeps
246/// `NODE_PAREN` as a real CST node, so a bare `matches!(e, Expr::AttrSet(_))`
247/// misses `((({b=1;})))` — which nix merges. Parens are transparent
248/// RECURSIVELY, including around `rec`.
249///
250/// Every OTHER wrapper is opaque, verified: `let`, `with`, `assert`, `if`, and
251/// `//` all make the value non-mergeable even when it evaluates to an attrset.
252#[must_use]
253pub fn as_attrset_literal(expr: &ast::Expr) -> Option<ast::AttrSet> {
254    match expr {
255        ast::Expr::AttrSet(set) => Some(set.clone()),
256        ast::Expr::Paren(p) => p.expr().as_ref().and_then(as_attrset_literal),
257        _ => None,
258    }
259}
260
261/// Strip `(…)` recursively. CppNix's grammar discards parens outright, so
262/// anything that asks "what KIND of expression is this?" must strip them first
263/// or it will answer about the wrapper.
264#[must_use]
265pub fn strip_parens(expr: &ast::Expr) -> ast::Expr {
266    match expr {
267        ast::Expr::Paren(p) => p.expr().as_ref().map_or_else(|| expr.clone(), strip_parens),
268        other => other.clone(),
269    }
270}
271
272/// Constant-fold an attribute-path component, per the rule on [`AttrKey`].
273#[must_use]
274pub fn fold_attr(attr: &ast::Attr) -> Option<AttrKey> {
275    match attr {
276        ast::Attr::Ident(ident) => Some(AttrKey::Static(sui_intern::intern(
277            &ident.syntax().text().to_string(),
278        ))),
279        // A quoted key folds iff it has no interpolation.
280        ast::Attr::Str(s) => literal_str_text(s).map(|t| AttrKey::Static(sui_intern::intern(&t))),
281        // `${e}` folds iff `e` is a pure string literal — AFTER stripping
282        // parens. `${("a")}` and `${''a''}` both fold in nix; a fold that
283        // only looks for a bare `Expr::Str` misses them and demotes a
284        // mergeable static key to dynamic, which silently changes the answer.
285        ast::Attr::Dynamic(dy) => {
286            let inner = dy.expr()?;
287            let stripped = strip_parens(&inner);
288            match &stripped {
289                ast::Expr::Str(s) => literal_str_text(s)
290                    .map(|t| AttrKey::Static(sui_intern::intern(&t)))
291                    .or(Some(AttrKey::Dynamic(inner.clone()))),
292                _ => Some(AttrKey::Dynamic(inner.clone())),
293            }
294        }
295    }
296}
297
298/// The text of a string node iff it is a pure literal (no `${…}` parts).
299fn literal_str_text(s: &ast::Str) -> Option<String> {
300    // `normalized_parts`, not `parts`: it applies `''` indentation stripping and
301    // escape processing, so the folded key is the string nix would compare —
302    // `{ "a\tb" = 1; }` must fold to a TAB, not to a backslash and a `t`.
303    let mut out = String::new();
304    for part in s.normalized_parts() {
305        match part {
306            ast::InterpolPart::Literal(text) => out.push_str(&text),
307            ast::InterpolPart::Interpolation(_) => return None,
308        }
309    }
310    Some(out)
311}
312
313/// The normalized plans for one parsed source tree.
314///
315/// Keyed by the binding-group node's `text_range().start()`, exactly as
316/// `sui_resolve::ResolveTable` keys idents. An unrecorded offset means the
317/// group needs no normalization — no duplicate static key and no dotted path —
318/// so consumers keep their existing path for it.
319#[derive(Clone, Debug, Default)]
320pub struct NormalizeTable {
321    by_offset: rustc_hash::FxHashMap<u32, GroupPlan>,
322}
323
324impl NormalizeTable {
325    /// An empty table — every lookup returns `None`.
326    #[must_use]
327    pub fn new() -> Self {
328        Self::default()
329    }
330
331    /// The plan recorded for the group node starting at `text_offset`, if any.
332    #[must_use]
333    pub fn get(&self, text_offset: u32) -> Option<&GroupPlan> {
334        self.by_offset.get(&text_offset)
335    }
336
337    /// Number of recorded groups. Small by construction — see the module docs.
338    #[must_use]
339    pub fn len(&self) -> usize {
340        self.by_offset.len()
341    }
342
343    /// True when no group needed normalization.
344    #[must_use]
345    pub fn is_empty(&self) -> bool {
346        self.by_offset.is_empty()
347    }
348
349    fn insert(&mut self, offset: u32, plan: GroupPlan) {
350        self.by_offset.insert(offset, plan);
351    }
352}
353
354// ── The splice ───────────────────────────────────────────────────────────
355
356/// Byte offset where a node starts.
357fn offset_of(node: &rowan::SyntaxNode<rnix::NixLanguage>) -> u32 {
358    u32::from(node.text_range().start())
359}
360
361/// Insert `value` at `path` into `group` — CppNix's `ExprAttrs::addAttr`.
362///
363/// Descends `path`, creating IMPLICIT non-recursive groups for intermediate
364/// components (that is what makes `{ a.b = 1; }` and `{ a = { b = 1; }; }`
365/// produce identical trees). At the leaf:
366///
367/// * absent            -> insert
368/// * both are `Group`  -> **SPLICE**: recursively add the incoming group's
369///                        members into the EXISTING one, so the existing
370///                        node's `recursive` survives and the incoming
371///                        group's is discarded
372/// * anything else     -> reject, naming the dotted path
373///
374/// `trail` carries the components consumed so far, purely so the error can name
375/// the full path — nix reports `attribute 'a.b' already defined`, not `'b'`.
376fn add_attr(
377    group: &mut GroupPlan,
378    path: &[AttrKey],
379    value: Binding,
380    pos: u32,
381    trail: &mut Vec<String>,
382) -> Result<(), NormalizeError> {
383    let Some((head, rest)) = path.split_first() else {
384        // An empty attrpath is not constructible from the grammar; treat it as
385        // "nothing to add" rather than panicking on a slice index — the exact
386        // shape that made the bytecode compiler crash on `{ a.b = 1; a.b = 2; }`.
387        return Ok(());
388    };
389
390    let sym = match head {
391        AttrKey::Static(s) => *s,
392        AttrKey::Dynamic(key) => {
393            // A dynamic component never participates in a parse-time merge, and
394            // never rejects at parse. It is deferred wholesale: nix checks it
395            // when the attrset is FORCED, and not at all if it never is.
396            if let Binding::Leaf(expr) | Binding::Inherit { from: Some(expr) } = &value {
397                group.dynamics.push(DynamicBinding {
398                    key: key.clone(),
399                    value: expr.clone(),
400                });
401            }
402            return Ok(());
403        }
404    };
405
406    trail.push(show_attr_component(&sui_intern::resolve(sym)));
407
408    if rest.is_empty() {
409        match group.index_of(sym) {
410            None => {
411                group.statics.push(StaticBinding {
412                    name: sym,
413                    binding: value,
414                    pos,
415                });
416            }
417            Some(idx) => {
418                let first = group.statics[idx].pos;
419                // ★ THE SPLICE. Both sides must be syntactic groups; the
420                // EXISTING one is the survivor, so its `recursive` governs and
421                // the incoming one's is dropped on the floor — which is exactly
422                // why a later `rec`'s internal references break.
423                match (&mut group.statics[idx].binding, value) {
424                    (Binding::Group(existing), Binding::Group(incoming)) => {
425                        for member in incoming.statics {
426                            let mut sub_trail = trail.clone();
427                            add_attr(
428                                existing,
429                                &[AttrKey::Static(member.name)],
430                                member.binding,
431                                member.pos,
432                                &mut sub_trail,
433                            )?;
434                        }
435                        existing.dynamics.extend(incoming.dynamics);
436                    }
437                    _ => {
438                        return Err(NormalizeError::DuplicateAttr {
439                            path: trail.join("."),
440                            first,
441                            second: pos,
442                        });
443                    }
444                }
445            }
446        }
447    } else {
448        // Intermediate component: get-or-create an implicit NON-recursive group.
449        let idx = match group.index_of(sym) {
450            Some(idx) => idx,
451            None => {
452                group.statics.push(StaticBinding {
453                    name: sym,
454                    binding: Binding::Group(GroupPlan::default()),
455                    pos,
456                });
457                group.statics.len() - 1
458            }
459        };
460        let first = group.statics[idx].pos;
461        let Binding::Group(sub) = &mut group.statics[idx].binding else {
462            // The path descends THROUGH a non-mergeable binding, e.g.
463            // `{ a = 1; a.b = 2; }`. nix names the SHALLOWEST level where a
464            // side is not a literal — which is the trail as it stands now.
465            return Err(NormalizeError::DuplicateAttr {
466                path: trail.join("."),
467                first,
468                second: pos,
469            });
470        };
471        add_attr(sub, rest, value, pos, trail)?;
472    }
473    Ok(())
474}
475
476/// Lower one value expression to a [`Binding`].
477///
478/// A syntactic attrset literal becomes a `Group` — recursively normalized —
479/// so that merging is uniformly "both sides are Groups". Everything else is a
480/// `Leaf` and can never merge. This is the single place the syntax-not-value
481/// rule is decided.
482fn lower_value(expr: &ast::Expr) -> Result<Binding, NormalizeError> {
483    match as_attrset_literal(expr) {
484        Some(set) => Ok(Binding::Group(plan_for_entries(&set, set.rec_token().is_some())?)),
485        None => Ok(Binding::Leaf(expr.clone())),
486    }
487}
488
489/// Build the plan for one `HasEntry` node's entries, in source order.
490fn plan_for_entries<N: HasEntry>(node: &N, recursive: bool) -> Result<GroupPlan, NormalizeError> {
491    let mut plan = GroupPlan {
492        recursive,
493        ..GroupPlan::default()
494    };
495
496    for entry in node.entries() {
497        match entry {
498            ast::Entry::AttrpathValue(av) => {
499                let (Some(attrpath), Some(value)) = (av.attrpath(), av.value()) else {
500                    continue;
501                };
502                let mut path = Vec::new();
503                for attr in attrpath.attrs() {
504                    let Some(key) = fold_attr(&attr) else { continue };
505                    path.push(key);
506                }
507                let pos = offset_of(av.syntax());
508                let binding = lower_value(&value)?;
509                let mut trail = Vec::new();
510                add_attr(&mut plan, &path, binding, pos, &mut trail)?;
511            }
512            ast::Entry::Inherit(inh) => {
513                let from = inh.from().and_then(|f| f.expr());
514                for attr in inh.attrs() {
515                    let Some(AttrKey::Static(sym)) = fold_attr(&attr) else {
516                        // A dynamic key in `inherit` is rejected by nix at
517                        // parse with its OWN message, which belongs to a
518                        // different layer than duplicate detection.
519                        continue;
520                    };
521                    let pos = offset_of(attr.syntax());
522                    let mut trail = Vec::new();
523                    add_attr(
524                        &mut plan,
525                        &[AttrKey::Static(sym)],
526                        Binding::Inherit {
527                            from: from.clone(),
528                        },
529                        pos,
530                        &mut trail,
531                    )?;
532                }
533            }
534        }
535    }
536    Ok(plan)
537}
538
539/// Normalize every binding group in a parsed tree.
540///
541/// Returns the plans for groups that actually need one. A group with no
542/// duplicate static key and no dotted path is NOT recorded — consumers keep
543/// their existing path for it, which is what bounds this pass's blast radius.
544///
545/// # Errors
546///
547/// Returns the first [`NormalizeError`] in source order, matching nix's
548/// parse-time rejection.
549pub fn normalize(root: &ast::Root) -> Result<NormalizeTable, NormalizeError> {
550    let mut table = NormalizeTable::new();
551    let Some(expr) = root.expr() else {
552        return Ok(table);
553    };
554    walk(&expr, &mut table)?;
555    Ok(table)
556}
557
558/// Does this group need a plan at all? True iff some entry has a dotted path
559/// or two entries share a folded static key.
560fn needs_plan<N: HasEntry>(node: &N) -> bool {
561    let mut seen: Vec<Symbol> = Vec::new();
562    for entry in node.entries() {
563        match entry {
564            ast::Entry::AttrpathValue(av) => {
565                let Some(attrpath) = av.attrpath() else { continue };
566                let attrs: Vec<_> = attrpath.attrs().collect();
567                if attrs.len() > 1 {
568                    return true;
569                }
570                if let Some(AttrKey::Static(s)) = attrs.first().and_then(fold_attr) {
571                    if seen.contains(&s) {
572                        return true;
573                    }
574                    seen.push(s);
575                }
576            }
577            ast::Entry::Inherit(inh) => {
578                for attr in inh.attrs() {
579                    if let Some(AttrKey::Static(s)) = fold_attr(&attr) {
580                        if seen.contains(&s) {
581                            return true;
582                        }
583                        seen.push(s);
584                    }
585                }
586            }
587        }
588    }
589    false
590}
591
592/// Recursively visit every expression, recording plans for binding groups.
593fn walk(expr: &ast::Expr, table: &mut NormalizeTable) -> Result<(), NormalizeError> {
594    if let ast::Expr::AttrSet(set) = expr {
595        if needs_plan(set) {
596            let plan = plan_for_entries(set, set.rec_token().is_some())?;
597            table.insert(offset_of(set.syntax()), plan);
598        }
599    } else if let ast::Expr::LetIn(letin) = expr {
600        if needs_plan(letin) {
601            // A `let` binds recursively by construction.
602            let plan = plan_for_entries(letin, true)?;
603            table.insert(offset_of(letin.syntax()), plan);
604        }
605    }
606
607    for child in expr.syntax().children() {
608        if let Some(child_expr) = ast::Expr::cast(child) {
609            walk(&child_expr, table)?;
610        }
611    }
612    Ok(())
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    fn parse(src: &str) -> ast::Root {
620        let parse = rnix::Root::parse(src);
621        assert!(
622            parse.errors().is_empty(),
623            "{src}: rnix parse errors {:?}",
624            parse.errors()
625        );
626        parse.tree()
627    }
628
629    fn plan(src: &str) -> Result<NormalizeTable, NormalizeError> {
630        normalize(&parse(src))
631    }
632
633    /// Render the FIRST recorded group as a canonical shape string, so tests
634    /// read as the tree they assert. `rec` is shown because rec-ness is the
635    /// half of this rule that a value-level merge cannot express.
636    fn shape(src: &str) -> String {
637        let table = plan(src).unwrap_or_else(|e| panic!("{src}: rejected: {e}"));
638        let mut offsets: Vec<u32> = table.by_offset.keys().copied().collect();
639        offsets.sort_unstable();
640        let first = offsets
641            .first()
642            .unwrap_or_else(|| panic!("{src}: no group was recorded"));
643        render(table.get(*first).expect("recorded"))
644    }
645
646    fn render(plan: &GroupPlan) -> String {
647        let mut parts: Vec<String> = plan
648            .statics
649            .iter()
650            .map(|b| {
651                let name = sui_intern::resolve(b.name);
652                match &b.binding {
653                    Binding::Leaf(_) => name.to_string(),
654                    Binding::Inherit { .. } => format!("inherit {name}"),
655                    Binding::Group(sub) => format!("{name} = {}", render(sub)),
656                }
657            })
658            .collect();
659        for d in &plan.dynamics {
660            let _ = &d.key;
661            parts.push("${…}".to_string());
662        }
663        let body = parts.join("; ");
664        if plan.recursive {
665            format!("rec {{ {body} }}")
666        } else {
667            format!("{{ {body} }}")
668        }
669    }
670
671    fn err(src: &str) -> NormalizeError {
672        plan(src).expect_err(&format!("{src}: expected a rejection"))
673    }
674
675    // ── the MERGE rows ───────────────────────────────────────────────────
676
677    #[test]
678    fn dotted_paths_merge() {
679        assert_eq!(shape("{ a.b = 1; a.c = 2; }"), "{ a = { b; c } }");
680        assert_eq!(shape("{ a.b.c = 1; a.b.d = 2; }"), "{ a = { b = { c; d } } }");
681    }
682
683    /// ★ The literal and dotted forms produce the SAME tree — that is the whole
684    /// reason a duplicate-key check cannot be "reject repeated keys".
685    #[test]
686    fn attrset_literals_merge_like_dotted_paths() {
687        let dotted = shape("{ a.b = 1; a.c = 2; }");
688        let literal = shape("{ a = {b=1;}; a = {c=2;}; }");
689        let mixed = shape("{ a = {b=1;}; a.c = 2; }");
690        assert_eq!(dotted, literal, "literal form must merge like the dotted one");
691        assert_eq!(dotted, mixed, "mixed form must merge like the dotted one");
692    }
693
694    /// ★ rec-ness comes from the FIRST definition, and a later `rec` is
695    /// DISCARDED. This is the row a value-level merge structurally cannot
696    /// express, and it is why `{ a = {b=1;}; a = rec {c=2; d=c;}; }` is a nix
697    /// parse error: the incoming block's own internal reference is destroyed.
698    #[test]
699    fn recness_is_taken_from_the_first_definition() {
700        assert_eq!(
701            shape("{ a = rec {b=1;}; a = {c=2;}; }"),
702            "{ a = rec { b; c } }",
703            "the FIRST definition's rec must survive"
704        );
705        assert_eq!(
706            shape("{ a = {b=1;}; a = rec {c=2;}; }"),
707            "{ a = { b; c } }",
708            "a LATER rec must be discarded, not OR-ed in"
709        );
710    }
711
712    /// A dotted member splices INSIDE an existing rec.
713    #[test]
714    fn a_dotted_member_lands_inside_the_rec() {
715        assert_eq!(shape("{ a = rec {b=1;}; a.d = 3; }"), "{ a = rec { b; d } }");
716    }
717
718    /// ★ Parens are transparent RECURSIVELY. CppNix's grammar discards them;
719    /// rnix keeps NODE_PAREN, so a bare `matches!(e, Expr::AttrSet(_))` misses
720    /// these and turns a legal merge into a rejection.
721    #[test]
722    fn parentheses_are_transparent() {
723        assert_eq!(shape("{ a = ({b=1;}); a = {c=2;}; }"), "{ a = { b; c } }");
724        assert_eq!(shape("{ a = ((({b=1;}))); a = {c=2;}; }"), "{ a = { b; c } }");
725        assert_eq!(
726            shape("{ a = ((rec {b=1;})); a = {c=2;}; }"),
727            "{ a = rec { b; c } }",
728            "a rec inside parens must still be seen, WITH its rec-ness"
729        );
730    }
731
732    /// `let` obeys the identical rule — the class of bug this crate exists for
733    /// is at its worst here, because sui silently DROPS keys.
734    #[test]
735    fn let_bindings_merge_by_the_same_rule() {
736        assert_eq!(shape("let a = {b=1;}; a = {c=2;}; in a"), "rec { a = { b; c } }");
737        assert_eq!(shape("let a.b = 1; a.c = 2; in a"), "rec { a = { b; c } }");
738    }
739
740    // ── the REJECT rows ──────────────────────────────────────────────────
741
742    #[test]
743    fn a_plain_duplicate_is_rejected() {
744        assert!(matches!(
745            err("{ a = 1; a = 2; }"),
746            NormalizeError::DuplicateAttr { ref path, .. } if path == "a"
747        ));
748    }
749
750    /// ★ The check is RECURSIVE and the message names the FULL dotted path —
751    /// `a.b`, not `b`.
752    #[test]
753    fn a_nested_conflict_names_the_full_path() {
754        let NormalizeError::DuplicateAttr { path, .. } = err("{ a = {b=1;}; a = {b=2;}; }") else {
755            panic!("expected DuplicateAttr");
756        };
757        assert_eq!(path, "a.b");
758
759        let NormalizeError::DuplicateAttr { path, .. } = err("{ a.b.c = 1; a.b.c = 2; }") else {
760            panic!("expected DuplicateAttr");
761        };
762        assert_eq!(path, "a.b.c");
763    }
764
765    /// ★ Decided from SYNTAX, never the value. Every wrapper except parens is
766    /// opaque, even when it plainly evaluates to an attrset.
767    #[test]
768    fn non_literal_wrappers_are_opaque() {
769        for src in [
770            "{ a = if true then {b=1;} else {}; a = {c=2;}; }",
771            "{ a = let x = 1; in {b=x;}; a = {c=2;}; }",
772            "{ a = with {}; {b=1;}; a = {c=2;}; }",
773            "{ a = assert true; {b=1;}; a = {c=2;}; }",
774            "{ a = {b=1;} // {z=9;}; a = {c=2;}; }",
775        ] {
776            assert!(
777                plan(src).is_err(),
778                "{src}: the value is an attrset but the SYNTAX is not — must reject"
779            );
780        }
781    }
782
783    /// A path descending THROUGH a non-mergeable binding rejects at the
784    /// shallowest level where a side is not a literal.
785    #[test]
786    fn descending_through_a_leaf_is_rejected() {
787        let NormalizeError::DuplicateAttr { path, .. } = err("{ a = 1; a.b = 2; }") else {
788            panic!("expected DuplicateAttr");
789        };
790        assert_eq!(path, "a");
791    }
792
793    /// An inherited binding never merges, in either order.
794    #[test]
795    fn inherit_never_merges() {
796        assert!(plan("let x = 1; in { inherit x; x = 2; }").is_err());
797        assert!(plan("let x = 1; in { x = 2; inherit x; }").is_err());
798        assert!(plan("{ inherit (s) a; a = 1; }").is_err());
799    }
800
801    // ── the constant-fold boundary ───────────────────────────────────────
802
803    /// ★ Static/dynamic is a fold on NODE KIND, not quoted-vs-unquoted.
804    /// Getting this wrong in the permissive direction turns a
805    /// currently-CORRECT sui answer into a throw.
806    #[test]
807    fn static_dynamic_split_is_a_constant_fold() {
808        // Folds to static -> participates in the merge, and can reject.
809        assert!(plan(r#"{ "a" = 1; a = 2; }"#).is_err(), "a quoted key IS the same key");
810        assert!(
811            plan(r#"{ ${"a"} = 1; a = 2; }"#).is_err(),
812            "a bare interpolation of a pure string literal folds to a static key"
813        );
814        assert_eq!(
815            shape(r#"{ ${"a"}.b = 1; a.c = 2; }"#),
816            "{ a = { b; c } }",
817            "a folded bare interpolation must MERGE with the plain ident"
818        );
819
820        // Stays dynamic -> deferred to force time, never a parse rejection.
821        assert!(
822            plan(r#"{ "${"a"}" = 1; a = 2; }"#).is_ok(),
823            "an INTERPOLATED string key stays dynamic and must not reject at parse"
824        );
825        assert!(
826            plan(r#"{ ${"a"+""} = 1; a = 2; }"#).is_ok(),
827            "a non-Str dynamic key stays dynamic"
828        );
829    }
830
831    /// A quoted dotted string is ONE key, not a path.
832    #[test]
833    fn a_quoted_dotted_string_is_a_single_key() {
834        assert!(
835            plan(r#"{ "a.b" = 1; a.b = 2; }"#).is_ok(),
836            r#""a.b" and a.b are DIFFERENT keys"#
837        );
838        assert!(plan(r#"{ "a.b" = 1; "a.b" = 2; }"#).is_err());
839    }
840
841    // ── error rendering ──────────────────────────────────────────────────
842
843    /// `showAttrPath` quotes iff the component is not an identifier or IS one
844    /// of exactly nine reserved words. Note `or`/`true`/`false`/`null` are NOT
845    /// reserved in this position — measured, not assumed.
846    #[test]
847    fn attr_path_components_quote_like_cppnix() {
848        for bare in ["a", "a1", "_a", "a-b", "a'b", "or", "true", "false", "null"] {
849            assert_eq!(show_attr_component(bare), bare, "{bare} must render bare");
850        }
851        for (raw, want) in [
852            ("1a", "\"1a\""),
853            ("a b", "\"a b\""),
854            ("a.b", "\"a.b\""),
855            ("", "\"\""),
856            ("if", "\"if\""),
857            ("rec", "\"rec\""),
858            ("inherit", "\"inherit\""),
859        ] {
860            assert_eq!(show_attr_component(raw), want, "{raw} must render quoted");
861        }
862    }
863
864    // ── the table stays small ────────────────────────────────────────────
865
866    /// ★ ANTI-VACUITY + the parity argument in one row. A group with no
867    /// duplicate and no dotted path must NOT be recorded, or this pass would
868    /// take over every attrset in the fleet instead of only the broken ones.
869    /// The second half is the anti-vacuity check: a group that DOES need a
870    /// plan must actually be recorded, or "records nothing" would pass
871    /// trivially.
872    #[test]
873    fn only_groups_that_need_normalizing_are_recorded() {
874        for clean in [
875            "{ a = 1; b = 2; }",
876            "{ }",
877            "let a = 1; b = 2; in a",
878            "rec { a = 1; b = a; }",
879            "{ a = { b = 1; }; }",
880        ] {
881            assert!(
882                plan(clean).expect("clean").is_empty(),
883                "{clean}: nothing to normalize, so nothing may be recorded"
884            );
885        }
886        for dirty in ["{ a.b = 1; }", "{ a.b = 1; a.c = 2; }", "let a.b = 1; in a"] {
887            assert!(
888                !plan(dirty).expect("dirty").is_empty(),
889                "{dirty}: needs a plan, so one must be recorded"
890            );
891        }
892    }
893}