use rnix::ast::{self, HasEntry};
use rowan::ast::AstNode;
use sui_intern::Symbol;
#[derive(Clone, Debug)]
pub enum AttrKey {
Static(Symbol),
Dynamic(ast::Expr),
}
#[derive(Clone, Debug)]
pub enum Binding {
Leaf(ast::Expr),
Group(GroupPlan),
Inherit {
from: Option<ast::Expr>,
},
}
#[derive(Clone, Debug)]
pub struct DynamicBinding {
pub key: ast::Expr,
pub value: ast::Expr,
}
#[derive(Clone, Debug)]
pub struct StaticBinding {
pub name: Symbol,
pub binding: Binding,
pub pos: u32,
}
#[derive(Clone, Debug, Default)]
pub struct GroupPlan {
pub recursive: bool,
pub statics: Vec<StaticBinding>,
pub dynamics: Vec<DynamicBinding>,
}
impl GroupPlan {
fn index_of(&self, sym: Symbol) -> Option<usize> {
self.statics.iter().position(|b| b.name == sym)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NormalizeError {
DuplicateAttr {
path: String,
first: u32,
second: u32,
},
DuplicateFormal {
name: String,
at: u32,
},
}
impl std::fmt::Display for NormalizeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateAttr { path, .. } => {
write!(f, "attribute '{path}' already defined")
}
Self::DuplicateFormal { name, .. } => {
write!(f, "duplicate formal function argument '{name}'")
}
}
}
}
impl std::error::Error for NormalizeError {}
#[must_use]
pub fn show_attr_component(name: &str) -> String {
const RESERVED: [&str; 9] = [
"if", "then", "else", "assert", "with", "let", "in", "rec", "inherit",
];
let mut chars = name.chars();
let bare = match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => chars
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\'' | '-')),
_ => false,
} && !RESERVED.contains(&name);
if bare {
name.to_string()
} else {
format!(
"\"{}\"",
name.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
)
}
}
#[must_use]
pub fn as_attrset_literal(expr: &ast::Expr) -> Option<ast::AttrSet> {
match expr {
ast::Expr::AttrSet(set) => Some(set.clone()),
ast::Expr::Paren(p) => p.expr().as_ref().and_then(as_attrset_literal),
_ => None,
}
}
#[must_use]
pub fn strip_parens(expr: &ast::Expr) -> ast::Expr {
match expr {
ast::Expr::Paren(p) => p.expr().as_ref().map_or_else(|| expr.clone(), strip_parens),
other => other.clone(),
}
}
#[must_use]
pub fn fold_attr(attr: &ast::Attr) -> Option<AttrKey> {
match attr {
ast::Attr::Ident(ident) => Some(AttrKey::Static(sui_intern::intern(
&ident.syntax().text().to_string(),
))),
ast::Attr::Str(s) => literal_str_text(s).map(|t| AttrKey::Static(sui_intern::intern(&t))),
ast::Attr::Dynamic(dy) => {
let inner = dy.expr()?;
let stripped = strip_parens(&inner);
match &stripped {
ast::Expr::Str(s) => literal_str_text(s)
.map(|t| AttrKey::Static(sui_intern::intern(&t)))
.or(Some(AttrKey::Dynamic(inner.clone()))),
_ => Some(AttrKey::Dynamic(inner.clone())),
}
}
}
}
fn literal_str_text(s: &ast::Str) -> Option<String> {
let mut out = String::new();
for part in s.normalized_parts() {
match part {
ast::InterpolPart::Literal(text) => out.push_str(&text),
ast::InterpolPart::Interpolation(_) => return None,
}
}
Some(out)
}
#[derive(Clone, Debug, Default)]
pub struct NormalizeTable {
by_offset: rustc_hash::FxHashMap<u32, GroupPlan>,
}
impl NormalizeTable {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, text_offset: u32) -> Option<&GroupPlan> {
self.by_offset.get(&text_offset)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_offset.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_offset.is_empty()
}
fn insert(&mut self, offset: u32, plan: GroupPlan) {
self.by_offset.insert(offset, plan);
}
}
fn offset_of(node: &rowan::SyntaxNode<rnix::NixLanguage>) -> u32 {
u32::from(node.text_range().start())
}
fn add_attr(
group: &mut GroupPlan,
path: &[AttrKey],
value: Binding,
pos: u32,
trail: &mut Vec<String>,
) -> Result<(), NormalizeError> {
let Some((head, rest)) = path.split_first() else {
return Ok(());
};
let sym = match head {
AttrKey::Static(s) => *s,
AttrKey::Dynamic(key) => {
if let Binding::Leaf(expr) | Binding::Inherit { from: Some(expr) } = &value {
group.dynamics.push(DynamicBinding {
key: key.clone(),
value: expr.clone(),
});
}
return Ok(());
}
};
trail.push(show_attr_component(&sui_intern::resolve(sym)));
if rest.is_empty() {
match group.index_of(sym) {
None => {
group.statics.push(StaticBinding {
name: sym,
binding: value,
pos,
});
}
Some(idx) => {
let first = group.statics[idx].pos;
match (&mut group.statics[idx].binding, value) {
(Binding::Group(existing), Binding::Group(incoming)) => {
for member in incoming.statics {
let mut sub_trail = trail.clone();
add_attr(
existing,
&[AttrKey::Static(member.name)],
member.binding,
member.pos,
&mut sub_trail,
)?;
}
existing.dynamics.extend(incoming.dynamics);
}
_ => {
return Err(NormalizeError::DuplicateAttr {
path: trail.join("."),
first,
second: pos,
});
}
}
}
}
} else {
let idx = match group.index_of(sym) {
Some(idx) => idx,
None => {
group.statics.push(StaticBinding {
name: sym,
binding: Binding::Group(GroupPlan::default()),
pos,
});
group.statics.len() - 1
}
};
let first = group.statics[idx].pos;
let Binding::Group(sub) = &mut group.statics[idx].binding else {
return Err(NormalizeError::DuplicateAttr {
path: trail.join("."),
first,
second: pos,
});
};
add_attr(sub, rest, value, pos, trail)?;
}
Ok(())
}
fn lower_value(expr: &ast::Expr) -> Result<Binding, NormalizeError> {
match as_attrset_literal(expr) {
Some(set) => Ok(Binding::Group(plan_for_entries(&set, set.rec_token().is_some())?)),
None => Ok(Binding::Leaf(expr.clone())),
}
}
fn plan_for_entries<N: HasEntry>(node: &N, recursive: bool) -> Result<GroupPlan, NormalizeError> {
let mut plan = GroupPlan {
recursive,
..GroupPlan::default()
};
for entry in node.entries() {
match entry {
ast::Entry::AttrpathValue(av) => {
let (Some(attrpath), Some(value)) = (av.attrpath(), av.value()) else {
continue;
};
let mut path = Vec::new();
for attr in attrpath.attrs() {
let Some(key) = fold_attr(&attr) else { continue };
path.push(key);
}
let pos = offset_of(av.syntax());
let binding = lower_value(&value)?;
let mut trail = Vec::new();
add_attr(&mut plan, &path, binding, pos, &mut trail)?;
}
ast::Entry::Inherit(inh) => {
let from = inh.from().and_then(|f| f.expr());
for attr in inh.attrs() {
let Some(AttrKey::Static(sym)) = fold_attr(&attr) else {
continue;
};
let pos = offset_of(attr.syntax());
let mut trail = Vec::new();
add_attr(
&mut plan,
&[AttrKey::Static(sym)],
Binding::Inherit {
from: from.clone(),
},
pos,
&mut trail,
)?;
}
}
}
}
Ok(plan)
}
pub fn normalize(root: &ast::Root) -> Result<NormalizeTable, NormalizeError> {
let mut table = NormalizeTable::new();
let Some(expr) = root.expr() else {
return Ok(table);
};
walk(&expr, &mut table)?;
Ok(table)
}
fn needs_plan<N: HasEntry>(node: &N) -> bool {
let mut seen: Vec<Symbol> = Vec::new();
for entry in node.entries() {
match entry {
ast::Entry::AttrpathValue(av) => {
let Some(attrpath) = av.attrpath() else { continue };
let attrs: Vec<_> = attrpath.attrs().collect();
if attrs.len() > 1 {
return true;
}
if let Some(AttrKey::Static(s)) = attrs.first().and_then(fold_attr) {
if seen.contains(&s) {
return true;
}
seen.push(s);
}
}
ast::Entry::Inherit(inh) => {
for attr in inh.attrs() {
if let Some(AttrKey::Static(s)) = fold_attr(&attr) {
if seen.contains(&s) {
return true;
}
seen.push(s);
}
}
}
}
}
false
}
fn walk(expr: &ast::Expr, table: &mut NormalizeTable) -> Result<(), NormalizeError> {
if let ast::Expr::AttrSet(set) = expr {
if needs_plan(set) {
let plan = plan_for_entries(set, set.rec_token().is_some())?;
table.insert(offset_of(set.syntax()), plan);
}
} else if let ast::Expr::LetIn(letin) = expr {
if needs_plan(letin) {
let plan = plan_for_entries(letin, true)?;
table.insert(offset_of(letin.syntax()), plan);
}
}
for child in expr.syntax().children() {
if let Some(child_expr) = ast::Expr::cast(child) {
walk(&child_expr, table)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &str) -> ast::Root {
let parse = rnix::Root::parse(src);
assert!(
parse.errors().is_empty(),
"{src}: rnix parse errors {:?}",
parse.errors()
);
parse.tree()
}
fn plan(src: &str) -> Result<NormalizeTable, NormalizeError> {
normalize(&parse(src))
}
fn shape(src: &str) -> String {
let table = plan(src).unwrap_or_else(|e| panic!("{src}: rejected: {e}"));
let mut offsets: Vec<u32> = table.by_offset.keys().copied().collect();
offsets.sort_unstable();
let first = offsets
.first()
.unwrap_or_else(|| panic!("{src}: no group was recorded"));
render(table.get(*first).expect("recorded"))
}
fn render(plan: &GroupPlan) -> String {
let mut parts: Vec<String> = plan
.statics
.iter()
.map(|b| {
let name = sui_intern::resolve(b.name);
match &b.binding {
Binding::Leaf(_) => name.to_string(),
Binding::Inherit { .. } => format!("inherit {name}"),
Binding::Group(sub) => format!("{name} = {}", render(sub)),
}
})
.collect();
for d in &plan.dynamics {
let _ = &d.key;
parts.push("${…}".to_string());
}
let body = parts.join("; ");
if plan.recursive {
format!("rec {{ {body} }}")
} else {
format!("{{ {body} }}")
}
}
fn err(src: &str) -> NormalizeError {
plan(src).expect_err(&format!("{src}: expected a rejection"))
}
#[test]
fn dotted_paths_merge() {
assert_eq!(shape("{ a.b = 1; a.c = 2; }"), "{ a = { b; c } }");
assert_eq!(shape("{ a.b.c = 1; a.b.d = 2; }"), "{ a = { b = { c; d } } }");
}
#[test]
fn attrset_literals_merge_like_dotted_paths() {
let dotted = shape("{ a.b = 1; a.c = 2; }");
let literal = shape("{ a = {b=1;}; a = {c=2;}; }");
let mixed = shape("{ a = {b=1;}; a.c = 2; }");
assert_eq!(dotted, literal, "literal form must merge like the dotted one");
assert_eq!(dotted, mixed, "mixed form must merge like the dotted one");
}
#[test]
fn recness_is_taken_from_the_first_definition() {
assert_eq!(
shape("{ a = rec {b=1;}; a = {c=2;}; }"),
"{ a = rec { b; c } }",
"the FIRST definition's rec must survive"
);
assert_eq!(
shape("{ a = {b=1;}; a = rec {c=2;}; }"),
"{ a = { b; c } }",
"a LATER rec must be discarded, not OR-ed in"
);
}
#[test]
fn a_dotted_member_lands_inside_the_rec() {
assert_eq!(shape("{ a = rec {b=1;}; a.d = 3; }"), "{ a = rec { b; d } }");
}
#[test]
fn parentheses_are_transparent() {
assert_eq!(shape("{ a = ({b=1;}); a = {c=2;}; }"), "{ a = { b; c } }");
assert_eq!(shape("{ a = ((({b=1;}))); a = {c=2;}; }"), "{ a = { b; c } }");
assert_eq!(
shape("{ a = ((rec {b=1;})); a = {c=2;}; }"),
"{ a = rec { b; c } }",
"a rec inside parens must still be seen, WITH its rec-ness"
);
}
#[test]
fn let_bindings_merge_by_the_same_rule() {
assert_eq!(shape("let a = {b=1;}; a = {c=2;}; in a"), "rec { a = { b; c } }");
assert_eq!(shape("let a.b = 1; a.c = 2; in a"), "rec { a = { b; c } }");
}
#[test]
fn a_plain_duplicate_is_rejected() {
assert!(matches!(
err("{ a = 1; a = 2; }"),
NormalizeError::DuplicateAttr { ref path, .. } if path == "a"
));
}
#[test]
fn a_nested_conflict_names_the_full_path() {
let NormalizeError::DuplicateAttr { path, .. } = err("{ a = {b=1;}; a = {b=2;}; }") else {
panic!("expected DuplicateAttr");
};
assert_eq!(path, "a.b");
let NormalizeError::DuplicateAttr { path, .. } = err("{ a.b.c = 1; a.b.c = 2; }") else {
panic!("expected DuplicateAttr");
};
assert_eq!(path, "a.b.c");
}
#[test]
fn non_literal_wrappers_are_opaque() {
for src in [
"{ a = if true then {b=1;} else {}; a = {c=2;}; }",
"{ a = let x = 1; in {b=x;}; a = {c=2;}; }",
"{ a = with {}; {b=1;}; a = {c=2;}; }",
"{ a = assert true; {b=1;}; a = {c=2;}; }",
"{ a = {b=1;} // {z=9;}; a = {c=2;}; }",
] {
assert!(
plan(src).is_err(),
"{src}: the value is an attrset but the SYNTAX is not — must reject"
);
}
}
#[test]
fn descending_through_a_leaf_is_rejected() {
let NormalizeError::DuplicateAttr { path, .. } = err("{ a = 1; a.b = 2; }") else {
panic!("expected DuplicateAttr");
};
assert_eq!(path, "a");
}
#[test]
fn inherit_never_merges() {
assert!(plan("let x = 1; in { inherit x; x = 2; }").is_err());
assert!(plan("let x = 1; in { x = 2; inherit x; }").is_err());
assert!(plan("{ inherit (s) a; a = 1; }").is_err());
}
#[test]
fn static_dynamic_split_is_a_constant_fold() {
assert!(plan(r#"{ "a" = 1; a = 2; }"#).is_err(), "a quoted key IS the same key");
assert!(
plan(r#"{ ${"a"} = 1; a = 2; }"#).is_err(),
"a bare interpolation of a pure string literal folds to a static key"
);
assert_eq!(
shape(r#"{ ${"a"}.b = 1; a.c = 2; }"#),
"{ a = { b; c } }",
"a folded bare interpolation must MERGE with the plain ident"
);
assert!(
plan(r#"{ "${"a"}" = 1; a = 2; }"#).is_ok(),
"an INTERPOLATED string key stays dynamic and must not reject at parse"
);
assert!(
plan(r#"{ ${"a"+""} = 1; a = 2; }"#).is_ok(),
"a non-Str dynamic key stays dynamic"
);
}
#[test]
fn a_quoted_dotted_string_is_a_single_key() {
assert!(
plan(r#"{ "a.b" = 1; a.b = 2; }"#).is_ok(),
r#""a.b" and a.b are DIFFERENT keys"#
);
assert!(plan(r#"{ "a.b" = 1; "a.b" = 2; }"#).is_err());
}
#[test]
fn attr_path_components_quote_like_cppnix() {
for bare in ["a", "a1", "_a", "a-b", "a'b", "or", "true", "false", "null"] {
assert_eq!(show_attr_component(bare), bare, "{bare} must render bare");
}
for (raw, want) in [
("1a", "\"1a\""),
("a b", "\"a b\""),
("a.b", "\"a.b\""),
("", "\"\""),
("if", "\"if\""),
("rec", "\"rec\""),
("inherit", "\"inherit\""),
] {
assert_eq!(show_attr_component(raw), want, "{raw} must render quoted");
}
}
#[test]
fn only_groups_that_need_normalizing_are_recorded() {
for clean in [
"{ a = 1; b = 2; }",
"{ }",
"let a = 1; b = 2; in a",
"rec { a = 1; b = a; }",
"{ a = { b = 1; }; }",
] {
assert!(
plan(clean).expect("clean").is_empty(),
"{clean}: nothing to normalize, so nothing may be recorded"
);
}
for dirty in ["{ a.b = 1; }", "{ a.b = 1; a.c = 2; }", "let a.b = 1; in a"] {
assert!(
!plan(dirty).expect("dirty").is_empty(),
"{dirty}: needs a plan, so one must be recorded"
);
}
}
}