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 iff it has no interpolation.
299 ast::Attr::Str(s) => literal_str_text(s).map(|t| AttrKey::Static(sui_intern::intern(&t))),
300 // `${e}` folds iff `e` is a pure string literal — AFTER stripping
301 // parens. `${("a")}` and `${''a''}` both fold in nix; a fold that
302 // only looks for a bare `Expr::Str` misses them and demotes a
303 // mergeable static key to dynamic, which silently changes the answer.
304 ast::Attr::Dynamic(dy) => {
305 let inner = dy.expr()?;
306 let stripped = strip_parens(&inner);
307 match &stripped {
308 ast::Expr::Str(s) => literal_str_text(s)
309 .map(|t| AttrKey::Static(sui_intern::intern(&t)))
310 .or(Some(AttrKey::Dynamic(inner.clone()))),
311 _ => Some(AttrKey::Dynamic(inner.clone())),
312 }
313 }
314 }
315}
316
317/// The text of a string node iff it is a pure literal (no `${…}` parts).
318fn literal_str_text(s: &ast::Str) -> Option<String> {
319 // `normalized_parts`, not `parts`: it applies `''` indentation stripping and
320 // escape processing, so the folded key is the string nix would compare —
321 // `{ "a\tb" = 1; }` must fold to a TAB, not to a backslash and a `t`.
322 let mut out = String::new();
323 for part in s.normalized_parts() {
324 match part {
325 ast::InterpolPart::Literal(text) => out.push_str(&text),
326 ast::InterpolPart::Interpolation(_) => return None,
327 }
328 }
329 Some(out)
330}
331
332/// The normalized plans for one parsed source tree.
333///
334/// Keyed by the binding-group node's `text_range().start()`, exactly as
335/// `sui_resolve::ResolveTable` keys idents. An unrecorded offset means the
336/// group needs no normalization — no duplicate static key and no dotted path —
337/// so consumers keep their existing path for it.
338#[derive(Clone, Debug, Default)]
339pub struct NormalizeTable {
340 by_offset: rustc_hash::FxHashMap<u32, GroupPlan>,
341}
342
343impl NormalizeTable {
344 /// An empty table — every lookup returns `None`.
345 #[must_use]
346 pub fn new() -> Self {
347 Self::default()
348 }
349
350 /// The plan recorded for the group node starting at `text_offset`, if any.
351 #[must_use]
352 pub fn get(&self, text_offset: u32) -> Option<&GroupPlan> {
353 self.by_offset.get(&text_offset)
354 }
355
356 /// Number of recorded groups. Small by construction — see the module docs.
357 #[must_use]
358 pub fn len(&self) -> usize {
359 self.by_offset.len()
360 }
361
362 /// True when no group needed normalization.
363 #[must_use]
364 pub fn is_empty(&self) -> bool {
365 self.by_offset.is_empty()
366 }
367
368 /// Iterate the recorded `(text_offset, plan)` pairs. Consumers merge these
369 /// into their own per-`(source_id, offset)` table, exactly as
370 /// `sui-resolve`'s consumers do.
371 pub fn iter(&self) -> impl Iterator<Item = (u32, &GroupPlan)> {
372 self.by_offset.iter().map(|(o, p)| (*o, p))
373 }
374
375 fn insert(&mut self, offset: u32, plan: GroupPlan) {
376 self.by_offset.insert(offset, plan);
377 }
378}
379
380// ── The splice ───────────────────────────────────────────────────────────
381
382/// Byte offset where a node starts.
383fn offset_of(node: &rowan::SyntaxNode<rnix::NixLanguage>) -> u32 {
384 u32::from(node.text_range().start())
385}
386
387/// Insert `value` at `path` into `group` — CppNix's `ExprAttrs::addAttr`.
388///
389/// Descends `path`, creating IMPLICIT non-recursive groups for intermediate
390/// components (that is what makes `{ a.b = 1; }` and `{ a = { b = 1; }; }`
391/// produce identical trees). At the leaf:
392///
393/// * absent -> insert
394/// * both are `Group` -> **SPLICE**: recursively add the incoming group's
395/// members into the EXISTING one, so the existing
396/// node's `recursive` survives and the incoming
397/// group's is discarded
398/// * anything else -> reject, naming the dotted path
399///
400/// `trail` carries the components consumed so far, purely so the error can name
401/// the full path — nix reports `attribute 'a.b' already defined`, not `'b'`.
402fn add_attr(
403 group: &mut GroupPlan,
404 path: &[AttrKey],
405 value: Binding,
406 pos: u32,
407 trail: &mut Vec<String>,
408) -> Result<(), NormalizeError> {
409 let Some((head, rest)) = path.split_first() else {
410 // An empty attrpath is not constructible from the grammar; treat it as
411 // "nothing to add" rather than panicking on a slice index — the exact
412 // shape that made the bytecode compiler crash on `{ a.b = 1; a.b = 2; }`.
413 return Ok(());
414 };
415
416 let sym = match head {
417 AttrKey::Static(s) => *s,
418 AttrKey::Dynamic(key) => {
419 // A dynamic component never participates in a parse-time merge and
420 // never rejects at parse. It is deferred wholesale: nix checks it
421 // when the attrset is FORCED, and not at all if it never is —
422 // `let s = { "${"a"}" = 1; a = 2; }; in 42` is `42`, no error.
423 //
424 // Only a TRAILING dynamic component can bind a value here. An
425 // intermediate one (`a.${k}.b = 1`) mints a fresh implicit group
426 // every time and so can never collide, which is why a reported
427 // duplicate path is always all-static.
428 if rest.is_empty() {
429 group.dynamics.push(DynamicBinding {
430 key: key.clone(),
431 value,
432 });
433 }
434 return Ok(());
435 }
436 };
437
438 trail.push(show_attr_component(&sui_intern::resolve(sym)));
439
440 if rest.is_empty() {
441 match group.index_of(sym) {
442 None => {
443 group.statics.push(StaticBinding {
444 name: sym,
445 binding: value,
446 pos,
447 });
448 }
449 Some(idx) => {
450 let first = group.statics[idx].pos;
451 // ★ THE SPLICE. Both sides must be syntactic groups; the
452 // EXISTING one is the survivor, so its `recursive` governs and
453 // the incoming one's is dropped on the floor — which is exactly
454 // why a later `rec`'s internal references break.
455 match (&mut group.statics[idx].binding, value) {
456 (Binding::Group(existing), Binding::Group(incoming)) => {
457 // The incoming group's `inherit (e)` clauses move into
458 // the existing group, so every `InheritFrom` index in
459 // its members must be REBASED onto the existing
460 // group's arena. Missing this silently points a
461 // spliced inherit at the wrong source expression.
462 let base = existing.inherit_froms.len();
463 existing.inherit_froms.extend(incoming.inherit_froms);
464 let incoming_statics: Vec<StaticBinding> = incoming
465 .statics
466 .into_iter()
467 .map(|mut m| {
468 rebase_inherit_from(&mut m.binding, base);
469 m
470 })
471 .collect();
472 for member in incoming_statics {
473 let mut sub_trail = trail.clone();
474 add_attr(
475 existing,
476 &[AttrKey::Static(member.name)],
477 member.binding,
478 member.pos,
479 &mut sub_trail,
480 )?;
481 }
482 existing.dynamics.extend(incoming.dynamics);
483 }
484 _ => {
485 return Err(NormalizeError::DuplicateAttr {
486 path: trail.join("."),
487 first,
488 second: pos,
489 });
490 }
491 }
492 }
493 }
494 } else {
495 // Intermediate component: get-or-create an implicit NON-recursive group.
496 let idx = match group.index_of(sym) {
497 Some(idx) => idx,
498 None => {
499 group.statics.push(StaticBinding {
500 name: sym,
501 binding: Binding::Group(GroupPlan::default()),
502 pos,
503 });
504 group.statics.len() - 1
505 }
506 };
507 let first = group.statics[idx].pos;
508 let Binding::Group(sub) = &mut group.statics[idx].binding else {
509 // The path descends THROUGH a non-mergeable binding, e.g.
510 // `{ a = 1; a.b = 2; }`. nix names the SHALLOWEST level where a
511 // side is not a literal — which is the trail as it stands now.
512 return Err(NormalizeError::DuplicateAttr {
513 path: trail.join("."),
514 first,
515 second: pos,
516 });
517 };
518 add_attr(sub, rest, value, pos, trail)?;
519 }
520 Ok(())
521}
522
523/// Shift every `InheritFrom` index in `binding` (and, recursively, in any
524/// nested group) by `base`. Used when a group is spliced into another and its
525/// `inherit_froms` arena is appended to the survivor's.
526fn rebase_inherit_from(binding: &mut Binding, base: usize) {
527 match binding {
528 Binding::InheritFrom { from } => *from += base,
529 Binding::Group(sub) => {
530 for m in &mut sub.statics {
531 rebase_inherit_from(&mut m.binding, base);
532 }
533 }
534 Binding::Leaf(_) | Binding::Inherit => {}
535 }
536}
537
538/// Lower one value expression to a [`Binding`].
539///
540/// A syntactic attrset literal becomes a `Group` — recursively normalized —
541/// so that merging is uniformly "both sides are Groups". Everything else is a
542/// `Leaf` and can never merge. This is the single place the syntax-not-value
543/// rule is decided.
544fn lower_value(expr: &ast::Expr) -> Result<Binding, NormalizeError> {
545 match as_attrset_literal(expr) {
546 Some(set) => Ok(Binding::Group(plan_for_entries(&set, set.rec_token().is_some())?)),
547 None => Ok(Binding::Leaf(expr.clone())),
548 }
549}
550
551/// Build the plan for one `HasEntry` node's entries, in source order.
552fn plan_for_entries<N: HasEntry>(node: &N, recursive: bool) -> Result<GroupPlan, NormalizeError> {
553 let mut plan = GroupPlan {
554 recursive,
555 ..GroupPlan::default()
556 };
557
558 for entry in node.entries() {
559 match entry {
560 ast::Entry::AttrpathValue(av) => {
561 let (Some(attrpath), Some(value)) = (av.attrpath(), av.value()) else {
562 continue;
563 };
564 let mut path = Vec::new();
565 for attr in attrpath.attrs() {
566 let Some(key) = fold_attr(&attr) else { continue };
567 path.push(key);
568 }
569 let pos = offset_of(av.syntax());
570 let binding = lower_value(&value)?;
571 let mut trail = Vec::new();
572 add_attr(&mut plan, &path, binding, pos, &mut trail)?;
573 }
574 ast::Entry::Inherit(inh) => {
575 // Register the clause's source ONCE; every name it binds
576 // refers to it by index. Cloning the expr per name would
577 // evaluate the source N times.
578 let from_idx = inh.from().and_then(|f| f.expr()).map(|e| {
579 plan.inherit_froms.push(e);
580 plan.inherit_froms.len() - 1
581 });
582 for attr in inh.attrs() {
583 let Some(AttrKey::Static(sym)) = fold_attr(&attr) else {
584 // A dynamic key in `inherit` is rejected by nix at
585 // parse with its OWN message, which belongs to a
586 // different layer than duplicate detection.
587 continue;
588 };
589 let pos = offset_of(attr.syntax());
590 let mut trail = Vec::new();
591 let binding = match from_idx {
592 Some(from) => Binding::InheritFrom { from },
593 None => Binding::Inherit,
594 };
595 add_attr(&mut plan, &[AttrKey::Static(sym)], binding, pos, &mut trail)?;
596 }
597 }
598 }
599 }
600 Ok(plan)
601}
602
603/// Normalize every binding group in a parsed tree.
604///
605/// Returns the plans for groups that actually need one. A group with no
606/// duplicate static key and no dotted path is NOT recorded — consumers keep
607/// their existing path for it, which is what bounds this pass's blast radius.
608///
609/// # Errors
610///
611/// Returns the first [`NormalizeError`] in source order, matching nix's
612/// parse-time rejection.
613pub fn normalize(root: &ast::Root) -> Result<NormalizeTable, NormalizeError> {
614 let mut table = NormalizeTable::new();
615 let Some(expr) = root.expr() else {
616 return Ok(table);
617 };
618 walk(&expr, &mut table)?;
619 Ok(table)
620}
621
622/// Does this group need a plan at all? True iff some entry has a dotted path
623/// or two entries share a folded static key.
624fn needs_plan<N: HasEntry>(node: &N) -> bool {
625 let mut seen: Vec<Symbol> = Vec::new();
626 for entry in node.entries() {
627 match entry {
628 ast::Entry::AttrpathValue(av) => {
629 let Some(attrpath) = av.attrpath() else { continue };
630 let attrs: Vec<_> = attrpath.attrs().collect();
631 if attrs.len() > 1 {
632 return true;
633 }
634 if let Some(AttrKey::Static(s)) = attrs.first().and_then(fold_attr) {
635 if seen.contains(&s) {
636 return true;
637 }
638 seen.push(s);
639 }
640 }
641 ast::Entry::Inherit(inh) => {
642 for attr in inh.attrs() {
643 if let Some(AttrKey::Static(s)) = fold_attr(&attr) {
644 if seen.contains(&s) {
645 return true;
646 }
647 seen.push(s);
648 }
649 }
650 }
651 }
652 }
653 false
654}
655
656/// Recursively visit every expression, recording plans for binding groups.
657fn walk(expr: &ast::Expr, table: &mut NormalizeTable) -> Result<(), NormalizeError> {
658 if let ast::Expr::AttrSet(set) = expr {
659 if needs_plan(set) {
660 let plan = plan_for_entries(set, set.rec_token().is_some())?;
661 table.insert(offset_of(set.syntax()), plan);
662 }
663 } else if let ast::Expr::LetIn(letin) = expr {
664 if needs_plan(letin) {
665 // A `let` binds recursively by construction.
666 let plan = plan_for_entries(letin, true)?;
667 table.insert(offset_of(letin.syntax()), plan);
668 }
669 }
670
671 for child in expr.syntax().children() {
672 if let Some(child_expr) = ast::Expr::cast(child) {
673 walk(&child_expr, table)?;
674 }
675 }
676 Ok(())
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 fn parse(src: &str) -> ast::Root {
684 let parse = rnix::Root::parse(src);
685 assert!(
686 parse.errors().is_empty(),
687 "{src}: rnix parse errors {:?}",
688 parse.errors()
689 );
690 parse.tree()
691 }
692
693 fn plan(src: &str) -> Result<NormalizeTable, NormalizeError> {
694 normalize(&parse(src))
695 }
696
697 /// Render the FIRST recorded group as a canonical shape string, so tests
698 /// read as the tree they assert. `rec` is shown because rec-ness is the
699 /// half of this rule that a value-level merge cannot express.
700 fn shape(src: &str) -> String {
701 let table = plan(src).unwrap_or_else(|e| panic!("{src}: rejected: {e}"));
702 let mut offsets: Vec<u32> = table.by_offset.keys().copied().collect();
703 offsets.sort_unstable();
704 let first = offsets
705 .first()
706 .unwrap_or_else(|| panic!("{src}: no group was recorded"));
707 render(table.get(*first).expect("recorded"))
708 }
709
710 fn render(plan: &GroupPlan) -> String {
711 let mut parts: Vec<String> = plan
712 .statics
713 .iter()
714 .map(|b| {
715 let name = sui_intern::resolve(b.name);
716 match &b.binding {
717 Binding::Leaf(_) => name.to_string(),
718 Binding::Inherit | Binding::InheritFrom { .. } => format!("inherit {name}"),
719 Binding::Group(sub) => format!("{name} = {}", render(sub)),
720 }
721 })
722 .collect();
723 for d in &plan.dynamics {
724 let _ = &d.key;
725 parts.push("${…}".to_string());
726 }
727 let body = parts.join("; ");
728 if plan.recursive {
729 format!("rec {{ {body} }}")
730 } else {
731 format!("{{ {body} }}")
732 }
733 }
734
735 fn err(src: &str) -> NormalizeError {
736 plan(src).expect_err(&format!("{src}: expected a rejection"))
737 }
738
739 // ── the MERGE rows ───────────────────────────────────────────────────
740
741 #[test]
742 fn dotted_paths_merge() {
743 assert_eq!(shape("{ a.b = 1; a.c = 2; }"), "{ a = { b; c } }");
744 assert_eq!(shape("{ a.b.c = 1; a.b.d = 2; }"), "{ a = { b = { c; d } } }");
745 }
746
747 /// ★ The literal and dotted forms produce the SAME tree — that is the whole
748 /// reason a duplicate-key check cannot be "reject repeated keys".
749 #[test]
750 fn attrset_literals_merge_like_dotted_paths() {
751 let dotted = shape("{ a.b = 1; a.c = 2; }");
752 let literal = shape("{ a = {b=1;}; a = {c=2;}; }");
753 let mixed = shape("{ a = {b=1;}; a.c = 2; }");
754 assert_eq!(dotted, literal, "literal form must merge like the dotted one");
755 assert_eq!(dotted, mixed, "mixed form must merge like the dotted one");
756 }
757
758 /// ★ rec-ness comes from the FIRST definition, and a later `rec` is
759 /// DISCARDED. This is the row a value-level merge structurally cannot
760 /// express, and it is why `{ a = {b=1;}; a = rec {c=2; d=c;}; }` is a nix
761 /// parse error: the incoming block's own internal reference is destroyed.
762 #[test]
763 fn recness_is_taken_from_the_first_definition() {
764 assert_eq!(
765 shape("{ a = rec {b=1;}; a = {c=2;}; }"),
766 "{ a = rec { b; c } }",
767 "the FIRST definition's rec must survive"
768 );
769 assert_eq!(
770 shape("{ a = {b=1;}; a = rec {c=2;}; }"),
771 "{ a = { b; c } }",
772 "a LATER rec must be discarded, not OR-ed in"
773 );
774 }
775
776 /// A dotted member splices INSIDE an existing rec.
777 #[test]
778 fn a_dotted_member_lands_inside_the_rec() {
779 assert_eq!(shape("{ a = rec {b=1;}; a.d = 3; }"), "{ a = rec { b; d } }");
780 }
781
782 /// ★ Parens are transparent RECURSIVELY. CppNix's grammar discards them;
783 /// rnix keeps NODE_PAREN, so a bare `matches!(e, Expr::AttrSet(_))` misses
784 /// these and turns a legal merge into a rejection.
785 #[test]
786 fn parentheses_are_transparent() {
787 assert_eq!(shape("{ a = ({b=1;}); a = {c=2;}; }"), "{ a = { b; c } }");
788 assert_eq!(shape("{ a = ((({b=1;}))); a = {c=2;}; }"), "{ a = { b; c } }");
789 assert_eq!(
790 shape("{ a = ((rec {b=1;})); a = {c=2;}; }"),
791 "{ a = rec { b; c } }",
792 "a rec inside parens must still be seen, WITH its rec-ness"
793 );
794 }
795
796 /// `let` obeys the identical rule — the class of bug this crate exists for
797 /// is at its worst here, because sui silently DROPS keys.
798 #[test]
799 fn let_bindings_merge_by_the_same_rule() {
800 assert_eq!(shape("let a = {b=1;}; a = {c=2;}; in a"), "rec { a = { b; c } }");
801 assert_eq!(shape("let a.b = 1; a.c = 2; in a"), "rec { a = { b; c } }");
802 }
803
804 // ── the REJECT rows ──────────────────────────────────────────────────
805
806 #[test]
807 fn a_plain_duplicate_is_rejected() {
808 assert!(matches!(
809 err("{ a = 1; a = 2; }"),
810 NormalizeError::DuplicateAttr { ref path, .. } if path == "a"
811 ));
812 }
813
814 /// ★ The check is RECURSIVE and the message names the FULL dotted path —
815 /// `a.b`, not `b`.
816 #[test]
817 fn a_nested_conflict_names_the_full_path() {
818 let NormalizeError::DuplicateAttr { path, .. } = err("{ a = {b=1;}; a = {b=2;}; }") else {
819 panic!("expected DuplicateAttr");
820 };
821 assert_eq!(path, "a.b");
822
823 let NormalizeError::DuplicateAttr { path, .. } = err("{ a.b.c = 1; a.b.c = 2; }") else {
824 panic!("expected DuplicateAttr");
825 };
826 assert_eq!(path, "a.b.c");
827 }
828
829 /// ★ Decided from SYNTAX, never the value. Every wrapper except parens is
830 /// opaque, even when it plainly evaluates to an attrset.
831 #[test]
832 fn non_literal_wrappers_are_opaque() {
833 for src in [
834 "{ a = if true then {b=1;} else {}; a = {c=2;}; }",
835 "{ a = let x = 1; in {b=x;}; a = {c=2;}; }",
836 "{ a = with {}; {b=1;}; a = {c=2;}; }",
837 "{ a = assert true; {b=1;}; a = {c=2;}; }",
838 "{ a = {b=1;} // {z=9;}; a = {c=2;}; }",
839 ] {
840 assert!(
841 plan(src).is_err(),
842 "{src}: the value is an attrset but the SYNTAX is not — must reject"
843 );
844 }
845 }
846
847 /// A path descending THROUGH a non-mergeable binding rejects at the
848 /// shallowest level where a side is not a literal.
849 #[test]
850 fn descending_through_a_leaf_is_rejected() {
851 let NormalizeError::DuplicateAttr { path, .. } = err("{ a = 1; a.b = 2; }") else {
852 panic!("expected DuplicateAttr");
853 };
854 assert_eq!(path, "a");
855 }
856
857 /// An inherited binding never merges, in either order.
858 #[test]
859 fn inherit_never_merges() {
860 assert!(plan("let x = 1; in { inherit x; x = 2; }").is_err());
861 assert!(plan("let x = 1; in { x = 2; inherit x; }").is_err());
862 assert!(plan("{ inherit (s) a; a = 1; }").is_err());
863 }
864
865 /// ★ One `inherit (e) a b c;` registers its source EXACTLY ONCE, and all
866 /// three names index it. A source cloned per name is evaluated per name —
867 /// observable through `builtins.trace`, and through any impure source.
868 #[test]
869 fn an_inherit_from_clause_shares_one_source() {
870 let table = plan("{ inherit (src) a b c; d.e = 1; }").expect("ok");
871 let mut offs: Vec<u32> = table.by_offset.keys().copied().collect();
872 offs.sort_unstable();
873 let g = table.get(offs[0]).expect("recorded");
874 assert_eq!(
875 g.inherit_froms.len(),
876 1,
877 "one clause must yield one arena entry, got {}",
878 g.inherit_froms.len()
879 );
880 let idxs: Vec<usize> = g
881 .statics
882 .iter()
883 .filter_map(|b| match b.binding {
884 Binding::InheritFrom { from } => Some(from),
885 _ => None,
886 })
887 .collect();
888 assert_eq!(idxs, vec![0, 0, 0], "all three names must share index 0");
889 }
890
891 /// And when a group carrying an `inherit (e)` is SPLICED into another that
892 /// already has one, the incoming indices must be REBASED onto the
893 /// survivor's arena. Without the rebase a spliced inherit silently points
894 /// at the wrong source expression — a wrong value, not an error.
895 #[test]
896 fn a_spliced_inherit_from_is_rebased_onto_the_survivor() {
897 let table = plan("{ a = { inherit (p) x; }; a = { inherit (q) y; }; }").expect("ok");
898 let mut offs: Vec<u32> = table.by_offset.keys().copied().collect();
899 offs.sort_unstable();
900 let outer = table.get(offs[0]).expect("recorded");
901 let Binding::Group(merged) = &outer.statics[0].binding else {
902 panic!("expected a merged group");
903 };
904 assert_eq!(merged.inherit_froms.len(), 2, "both clauses must survive");
905 let idxs: Vec<usize> = merged
906 .statics
907 .iter()
908 .filter_map(|b| match b.binding {
909 Binding::InheritFrom { from } => Some(from),
910 _ => None,
911 })
912 .collect();
913 assert_eq!(
914 idxs,
915 vec![0, 1],
916 "the spliced clause must point at its OWN source, not the survivor's"
917 );
918 }
919
920 // ── the constant-fold boundary ───────────────────────────────────────
921
922 /// ★ Static/dynamic is a fold on NODE KIND, not quoted-vs-unquoted.
923 /// Getting this wrong in the permissive direction turns a
924 /// currently-CORRECT sui answer into a throw.
925 #[test]
926 fn static_dynamic_split_is_a_constant_fold() {
927 // Folds to static -> participates in the merge, and can reject.
928 assert!(plan(r#"{ "a" = 1; a = 2; }"#).is_err(), "a quoted key IS the same key");
929 assert!(
930 plan(r#"{ ${"a"} = 1; a = 2; }"#).is_err(),
931 "a bare interpolation of a pure string literal folds to a static key"
932 );
933 assert_eq!(
934 shape(r#"{ ${"a"}.b = 1; a.c = 2; }"#),
935 "{ a = { b; c } }",
936 "a folded bare interpolation must MERGE with the plain ident"
937 );
938
939 // Stays dynamic -> deferred to force time, never a parse rejection.
940 assert!(
941 plan(r#"{ "${"a"}" = 1; a = 2; }"#).is_ok(),
942 "an INTERPOLATED string key stays dynamic and must not reject at parse"
943 );
944 assert!(
945 plan(r#"{ ${"a"+""} = 1; a = 2; }"#).is_ok(),
946 "a non-Str dynamic key stays dynamic"
947 );
948 }
949
950 /// A quoted dotted string is ONE key, not a path.
951 #[test]
952 fn a_quoted_dotted_string_is_a_single_key() {
953 assert!(
954 plan(r#"{ "a.b" = 1; a.b = 2; }"#).is_ok(),
955 r#""a.b" and a.b are DIFFERENT keys"#
956 );
957 assert!(plan(r#"{ "a.b" = 1; "a.b" = 2; }"#).is_err());
958 }
959
960 // ── error rendering ──────────────────────────────────────────────────
961
962 /// `showAttrPath` quotes iff the component is not an identifier or IS one
963 /// of exactly nine reserved words. Note `or`/`true`/`false`/`null` are NOT
964 /// reserved in this position — measured, not assumed.
965 #[test]
966 fn attr_path_components_quote_like_cppnix() {
967 for bare in ["a", "a1", "_a", "a-b", "a'b", "or", "true", "false", "null"] {
968 assert_eq!(show_attr_component(bare), bare, "{bare} must render bare");
969 }
970 for (raw, want) in [
971 ("1a", "\"1a\""),
972 ("a b", "\"a b\""),
973 ("a.b", "\"a.b\""),
974 ("", "\"\""),
975 ("if", "\"if\""),
976 ("rec", "\"rec\""),
977 ("inherit", "\"inherit\""),
978 ] {
979 assert_eq!(show_attr_component(raw), want, "{raw} must render quoted");
980 }
981 }
982
983 // ── the table stays small ────────────────────────────────────────────
984
985 /// ★ ANTI-VACUITY + the parity argument in one row. A group with no
986 /// duplicate and no dotted path must NOT be recorded, or this pass would
987 /// take over every attrset in the fleet instead of only the broken ones.
988 /// The second half is the anti-vacuity check: a group that DOES need a
989 /// plan must actually be recorded, or "records nothing" would pass
990 /// trivially.
991 #[test]
992 fn only_groups_that_need_normalizing_are_recorded() {
993 for clean in [
994 "{ a = 1; b = 2; }",
995 "{ }",
996 "let a = 1; b = 2; in a",
997 "rec { a = 1; b = a; }",
998 "{ a = { b = 1; }; }",
999 ] {
1000 assert!(
1001 plan(clean).expect("clean").is_empty(),
1002 "{clean}: nothing to normalize, so nothing may be recorded"
1003 );
1004 }
1005 for dirty in ["{ a.b = 1; }", "{ a.b = 1; a.c = 2; }", "let a.b = 1; in a"] {
1006 assert!(
1007 !plan(dirty).expect("dirty").is_empty(),
1008 "{dirty}: needs a plan, so one must be recorded"
1009 );
1010 }
1011 }
1012}