Expand description
Parse-time attrset-binding normalizer — CppNix’s ExprAttrs::addAttr.
§The defect this exists to remove
sui decides duplicate-key merge-vs-overwrite at EVAL time, by forcing the colliding value to WHNF and asking “is it an attrset?”. Real nix decides it at PARSE time, from SYNTAX. Because sui asks a different question at a different time, all three engines give SILENT WRONG ANSWERS on legal nix. Measured against nix 2.31.5 on 2026-08-18:
let a = {b=1;}; a = {c=2;}; in a nix {b=1;c=2;} sui {c=2;}
rec { o = {e=1;}; o.x = 2; } nix {o={e=1;x=2;};} sui {o={x=2;};}
let b=1; in { a={x=2;}; a=rec{b=99;c=b;}; }
nix c=1 sui c=99Every row exits 0. No error anywhere.
§The rule, measured
Merge iff both sides are syntactic attrset literals ({…} / rec {…},
including the implicit ones a dotted path creates), recursively. Reject
otherwise. Decided from SYNTAX, never from the runtime value — so
let x = {b=1;}; in { s = x; s = {c=2;}; } is a parse error even though the
value of x is an attrset.
§★ It is a destructive SPLICE, not predicate-plus-union
nix splices the second side’s bindings INTO THE FIRST-DECLARED NODE. The
first node’s rec governs and a later rec is DISCARDED; the second side’s
bindings are RE-SCOPED into the first node’s scope:
{ a = rec {b=1;}; a = {c = b+1;}; }.a.c -> 2 non-rec side binds to the FIRST's rec scope
{ a = {b=1;}; a = rec {c=2; d=c;}; } -> parse error: undefined variable 'c'
a standalone-valid rec block's INTERNAL
reference is destroyed by the merge
{ a = rec {b=1; c=b+1;}; a.d = 3; } -> `{ a = rec { b=1; c=(b+1); d=3; }; }`
the DOTTED member lands INSIDE the recNo value-level merge can express re-scoping. That is why this is a structural pass and not a fix inside any engine’s collection loop.
§Why a side-table rather than a rewritten tree
rnix’s CST is lossless and IMMUTABLE — the tree cannot be spliced. So this
pass emits a NormalizeTable: a plan per binding-group node, which each
engine consumes INSTEAD OF its own for entry in set.entries() loop. The
shape is deliberately modelled on sui-resolve’s ResolveTable, including
its most useful property:
An absent entry means “nothing to normalize” — no collision, no dotted path — so every engine’s existing fast path is untouched for the overwhelming majority of attrsets, and the table stays small. That is the parity-by-construction argument: this pass can only change the groups it records, and it records only the groups that today are wrong.
§Placement
This crate must be reachable by all three engines, so it sits at
sui-resolve’s level and depends on sui-intern + rnix + rowan only.
It must NOT depend on sui-eval: sui-bytecode carries sui-eval as a
path-only dev-dep to break a publish cycle, and a real edge here would close
it. Hence the error type is self-contained — no EvalError — and each
engine maps NormalizeError into its own error on the way out.
§Status
STAGE 0: UNWIRED. Nothing consumes this yet, by design — the rule is built and proven against the oracle before it can change any engine’s behaviour. Wiring is stages 1–4.
Structs§
- Dynamic
Binding - A dynamic-keyed binding, kept out of the static map.
- Group
Plan - The normalized plan for one binding group — an attrset, a
let, arec, or a legacylet { … }. - Normalize
Table - The normalized plans for one parsed source tree.
- Static
Binding - One static binding, with the source position of its FIRST definition.
Enums§
- AttrKey
- One component of an attribute path, after constant-folding.
- Binding
- A binding after normalization.
- Normalize
Error - A parse-time rejection. Carries what is needed to render nix’s message.
Functions§
- as_
attrset_ literal - True iff
expris a syntactic attrset literal, seeing through PARENTHESES. - fold_
attr - fold_
attr_ name - Constant-fold an attribute-path component, per the rule on
AttrKey. The TEXT-level half offold_attr: the static name an attribute folds to, orNoneif it is genuinely dynamic. - normalize
- Normalize every binding group in a parsed tree.
- plan_
for_ group - The plan for ONE binding group, computed on demand.
- show_
attr_ component - Render one path component the way CppNix’s
showAttrPathdoes. - strip_
parens - Strip
(…)recursively. CppNix’s grammar discards parens outright, so anything that asks “what KIND of expression is this?” must strip them first or it will answer about the wrapper.