Skip to main content

iac_forge/
nix.rs

1//! Sexpr ↔ Nix bridge.
2//!
3//! Every [`SExpr`][crate::sexpr::SExpr] value can be expressed as a
4//! [`NixValue`] (which renders to a valid Nix expression literal) and
5//! vice versa. This makes Nix a first-class authoring surface for IR
6//! values: a flake can write `{ name = "vpc"; cidr = "10.0.0.0/8"; }`,
7//! convert it to SExpr, hash or transform it, then convert back.
8//!
9//! # Value correspondence
10//!
11//! | SExpr                         | NixValue                          |
12//! |-------------------------------|-----------------------------------|
13//! | `Integer(i)`                  | `Int(i)`                          |
14//! | `Float(f)`                    | `Float(f)`                        |
15//! | `Bool(b)`                     | `Bool(b)`                         |
16//! | `Nil`                         | `Null`                            |
17//! | `String(s)`                   | `Str(s)`                          |
18//! | `Symbol(s)` (no colon prefix) | `Ident(s)`                        |
19//! | `Symbol(":field")`            | *(only valid in a struct-form)*   |
20//! | `List(items)` (struct-form)   | `AttrSet(BTreeMap)`               |
21//! | `List(items)` (plain)         | `List(Vec)`                       |
22//!
23//! The hairy case is telling a **struct-form** (an attribute-set pattern)
24//! apart from a **plain list** at the SExpr layer: they're both `SExpr::List`.
25//! We use the same heuristic as [`crate::sexpr_diff`]: if the head is a
26//! non-colon symbol and every tail item is a 2-element list starting with
27//! `:keyword`, it's a struct-form; otherwise it's a plain list.
28//!
29//! # Round-trip
30//!
31//! The round-trip law:
32//!
33//! ```text
34//! NixValue::from_sexpr(&NixValue::from_sexpr_round_trip(x).to_sexpr_root())? ≈ x
35//! ```
36//!
37//! Note "≈" not "==": Nix can't distinguish `(foo)` (a tuple-tag list with
38//! no args) from `foo` (a bare symbol) cleanly — both round-trip through
39//! identifier+null. The lossless layer is sexpr itself; Nix is the
40//! authoring convenience on top.
41
42use std::collections::BTreeMap;
43
44use crate::sexpr::SExpr;
45
46/// A minimal Nix value representation — enough to round-trip any SExpr
47/// value through a Nix expression literal.
48#[derive(Debug, Clone, PartialEq)]
49pub enum NixValue {
50    Int(i64),
51    Float(f64),
52    Bool(bool),
53    Str(String),
54    Null,
55    /// Bare identifier. In Nix expressions this is a variable reference;
56    /// we use it to encode sexpr symbols.
57    Ident(String),
58    List(Vec<NixValue>),
59    /// Attribute set. BTreeMap for deterministic iteration order.
60    AttrSet(BTreeMap<String, NixValue>),
61}
62
63impl NixValue {
64    /// Render to a Nix expression literal.
65    #[must_use]
66    pub fn to_nix_expr(&self) -> String {
67        let mut out = String::new();
68        self.write_into(&mut out, 0);
69        out
70    }
71
72    fn write_into(&self, out: &mut String, indent: usize) {
73        match self {
74            Self::Int(i) => out.push_str(&i.to_string()),
75            Self::Float(f) => {
76                let s = f.to_string();
77                if s.contains('.') || s.contains('e') || s.contains('E') {
78                    out.push_str(&s);
79                } else {
80                    out.push_str(&s);
81                    out.push_str(".0");
82                }
83            }
84            Self::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
85            Self::Null => out.push_str("null"),
86            Self::Str(s) => {
87                out.push('"');
88                for c in s.chars() {
89                    match c {
90                        '"' => out.push_str("\\\""),
91                        '\\' => out.push_str("\\\\"),
92                        '\n' => out.push_str("\\n"),
93                        '\t' => out.push_str("\\t"),
94                        '$' => {
95                            // Guard against accidental string interpolation.
96                            out.push_str("\\$");
97                        }
98                        c => out.push(c),
99                    }
100                }
101                out.push('"');
102            }
103            Self::Ident(name) => out.push_str(name),
104            Self::List(items) => {
105                out.push('[');
106                for (i, item) in items.iter().enumerate() {
107                    if i > 0 {
108                        out.push(' ');
109                    }
110                    item.write_into(out, indent);
111                }
112                out.push(']');
113            }
114            Self::AttrSet(entries) => {
115                if entries.is_empty() {
116                    out.push_str("{}");
117                    return;
118                }
119                out.push_str("{ ");
120                for (i, (k, v)) in entries.iter().enumerate() {
121                    if i > 0 {
122                        out.push(' ');
123                    }
124                    // Quote keys only when they contain non-identifier chars.
125                    if is_simple_ident(k) {
126                        out.push_str(k);
127                    } else {
128                        out.push('"');
129                        out.push_str(&k.replace('"', "\\\""));
130                        out.push('"');
131                    }
132                    out.push_str(" = ");
133                    v.write_into(out, indent);
134                    out.push(';');
135                }
136                out.push_str(" }");
137            }
138        }
139    }
140}
141
142fn is_simple_ident(s: &str) -> bool {
143    let mut chars = s.chars();
144    match chars.next() {
145        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
146        _ => return false,
147    }
148    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
149}
150
151// ── Sexpr → NixValue ────────────────────────────────────────────────
152
153impl NixValue {
154    /// Convert a canonical [`SExpr`] value into a [`NixValue`].
155    ///
156    /// Struct-form sexprs `(head (:field val) ...)` become attribute
157    /// sets `{ head = "head"; field = val; ... }` with an explicit
158    /// `head` key preserving the tag. Plain lists become Nix lists.
159    /// Bare symbols become `Ident(name)`.
160    #[must_use]
161    pub fn from_sexpr(s: &SExpr) -> Self {
162        match s {
163            SExpr::Integer(i) => Self::Int(*i),
164            SExpr::Float(f) => Self::Float(*f),
165            SExpr::Bool(b) => Self::Bool(*b),
166            SExpr::Nil => Self::Null,
167            SExpr::String(s) => Self::Str(s.clone()),
168            SExpr::Symbol(name) => Self::Ident(name.clone()),
169            SExpr::List(items) => {
170                if let Some(attrset) = struct_form_to_attrset(items) {
171                    attrset
172                } else {
173                    Self::List(items.iter().map(Self::from_sexpr).collect())
174                }
175            }
176        }
177    }
178
179    /// Convert back to an [`SExpr`]. The round-trip is lossless for
180    /// values originally produced by [`Self::from_sexpr`].
181    ///
182    /// Disambiguation rules:
183    /// - AttrSet with a `head` key → struct-form `(head (:k v) ...)`
184    /// - AttrSet without a `head` key → still struct-form with synthetic
185    ///   head `attrs`
186    /// - List → `(list ...)` if it looks like a `Vec<T>` (first item is
187    ///   the symbol `list`), else a tuple-tag list with the first element
188    ///   as the head.
189    #[must_use]
190    pub fn to_sexpr(&self) -> SExpr {
191        match self {
192            Self::Int(i) => SExpr::Integer(*i),
193            Self::Float(f) => SExpr::Float(*f),
194            Self::Bool(b) => SExpr::Bool(*b),
195            Self::Null => SExpr::Nil,
196            Self::Str(s) => SExpr::String(s.clone()),
197            Self::Ident(name) => SExpr::Symbol(name.clone()),
198            Self::List(items) => SExpr::List(items.iter().map(Self::to_sexpr).collect()),
199            Self::AttrSet(entries) => {
200                let head = entries
201                    .get("head")
202                    .and_then(|v| match v {
203                        Self::Str(s) | Self::Ident(s) => Some(s.clone()),
204                        _ => None,
205                    })
206                    .unwrap_or_else(|| "attrs".to_string());
207                let mut out = vec![SExpr::Symbol(head.clone())];
208                for (k, v) in entries {
209                    if k == "head" {
210                        continue;
211                    }
212                    out.push(SExpr::List(vec![
213                        SExpr::Symbol(format!(":{k}")),
214                        v.to_sexpr(),
215                    ]));
216                }
217                SExpr::List(out)
218            }
219        }
220    }
221}
222
223/// If `items` looks like a struct-form `(head (:field val) ...)`,
224/// convert to an AttrSet preserving the head as a `head` key.
225/// Otherwise return None.
226fn struct_form_to_attrset(items: &[SExpr]) -> Option<NixValue> {
227    let (head, rest) = items.split_first()?;
228    let head_name = match head {
229        SExpr::Symbol(s) if !s.starts_with(':') => s.clone(),
230        _ => return None,
231    };
232    if rest.is_empty() {
233        return None;
234    }
235    let mut entries: BTreeMap<String, NixValue> = BTreeMap::new();
236    for item in rest {
237        let pair = match item {
238            SExpr::List(pair) => pair,
239            _ => return None,
240        };
241        if pair.len() != 2 {
242            return None;
243        }
244        let key = match &pair[0] {
245            SExpr::Symbol(k) if k.starts_with(':') => k[1..].to_string(),
246            _ => return None,
247        };
248        entries.insert(key, NixValue::from_sexpr(&pair[1]));
249    }
250    entries.insert("head".into(), NixValue::Str(head_name));
251    Some(NixValue::AttrSet(entries))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::ir::IacType;
258    use crate::sexpr::ToSExpr;
259
260    // ── Scalars ───────────────────────────────────────────────
261
262    #[test]
263    fn int_roundtrip() {
264        let s = SExpr::Integer(42);
265        assert_eq!(NixValue::from_sexpr(&s).to_sexpr(), s);
266    }
267
268    #[test]
269    fn bool_roundtrip() {
270        assert_eq!(
271            NixValue::from_sexpr(&SExpr::Bool(true)).to_sexpr(),
272            SExpr::Bool(true)
273        );
274    }
275
276    #[test]
277    fn nil_roundtrip() {
278        assert_eq!(NixValue::from_sexpr(&SExpr::Nil).to_sexpr(), SExpr::Nil);
279    }
280
281    #[test]
282    fn string_roundtrip() {
283        let s = SExpr::String("hello world".into());
284        assert_eq!(NixValue::from_sexpr(&s).to_sexpr(), s);
285    }
286
287    #[test]
288    fn symbol_roundtrip() {
289        let s = SExpr::Symbol("string".into());
290        assert_eq!(NixValue::from_sexpr(&s).to_sexpr(), s);
291    }
292
293    // ── Struct-form ↔ AttrSet ────────────────────────────────
294
295    #[test]
296    fn struct_form_becomes_attrset() {
297        let s = SExpr::parse("(object (:name \"X\") (:size 3))").unwrap();
298        let nix = NixValue::from_sexpr(&s);
299        let matches = matches!(nix, NixValue::AttrSet(_));
300        assert!(matches, "struct-form should convert to AttrSet");
301    }
302
303    #[test]
304    fn attrset_roundtrips_through_struct_form() {
305        let s = SExpr::parse("(object (:name \"X\") (:size 3))").unwrap();
306        let nix = NixValue::from_sexpr(&s);
307        let back = nix.to_sexpr();
308        // Round-trip via Nix preserves the canonical text form.
309        assert_eq!(back, s);
310    }
311
312    #[test]
313    fn iac_type_list_roundtrips_through_nix() {
314        let ty = IacType::List(Box::new(IacType::String));
315        let s = ty.to_sexpr();
316        let nix = NixValue::from_sexpr(&s);
317        let back = nix.to_sexpr();
318        // IacType::List is a tuple-tag, not a struct-form — it
319        // survives as a plain list with the tag as first element.
320        assert_eq!(back, s);
321    }
322
323    #[test]
324    fn iac_attribute_roundtrips_through_nix_semantically() {
325        // Nix attribute-sets are intrinsically alphabetically-keyed —
326        // { a = 1; b = 2; } and { b = 2; a = 1; } are equal values
327        // in Nix. The SExpr layer allows declaration-order fields, so
328        // the round-trip Sexpr → Nix → Sexpr canonicalizes field order.
329        // This is SEMANTIC equivalence, not BYTE equivalence: parsing
330        // both sides via FromSExpr must produce identical IacAttribute
331        // values even though the canonical text (and thus hash) differs.
332        use crate::ir::IacAttribute;
333        use crate::sexpr::FromSExpr;
334        use crate::testing::TestAttributeBuilder;
335
336        let attr = TestAttributeBuilder::new("x", IacType::String)
337            .required()
338            .build();
339        let s = attr.to_sexpr();
340        let nix = NixValue::from_sexpr(&s);
341        let back = nix.to_sexpr();
342
343        let parsed = IacAttribute::from_sexpr(&back).expect("parse round-tripped sexpr");
344        assert_eq!(parsed, attr, "semantic round-trip must preserve value");
345    }
346
347    #[test]
348    fn nix_roundtrip_sorts_fields_alphabetically() {
349        // Document the canonicalization behavior explicitly: fields
350        // emerge from the Nix round-trip in alphabetical order
351        // because BTreeMap sorts.
352        let s = SExpr::parse("(x (:z 1) (:a 2))").unwrap();
353        let back = NixValue::from_sexpr(&s).to_sexpr();
354        let back_text = back.emit();
355        let a_pos = back_text.find(":a").unwrap();
356        let z_pos = back_text.find(":z").unwrap();
357        assert!(a_pos < z_pos, "a should come before z: {back_text}");
358    }
359
360    // ── Nix expression rendering ─────────────────────────────
361
362    #[test]
363    fn int_renders_as_decimal() {
364        assert_eq!(NixValue::Int(42).to_nix_expr(), "42");
365    }
366
367    #[test]
368    fn float_renders_with_decimal_point() {
369        assert_eq!(NixValue::Float(1.0).to_nix_expr(), "1.0");
370        assert_eq!(NixValue::Float(2.75).to_nix_expr(), "2.75");
371    }
372
373    #[test]
374    fn bool_renders_as_nix_literal() {
375        assert_eq!(NixValue::Bool(true).to_nix_expr(), "true");
376        assert_eq!(NixValue::Bool(false).to_nix_expr(), "false");
377    }
378
379    #[test]
380    fn null_renders() {
381        assert_eq!(NixValue::Null.to_nix_expr(), "null");
382    }
383
384    #[test]
385    fn string_escapes_dollar_sign() {
386        // Nix interpolates ${…}; a literal $ must be escaped or it
387        // would be (harmlessly, but confusingly) treated as a regular
388        // char. We escape defensively.
389        assert_eq!(NixValue::Str("$HOME".into()).to_nix_expr(), "\"\\$HOME\"",);
390    }
391
392    #[test]
393    fn string_escapes_quotes() {
394        assert_eq!(NixValue::Str("a\"b".into()).to_nix_expr(), "\"a\\\"b\"",);
395    }
396
397    #[test]
398    fn empty_list_renders() {
399        assert_eq!(NixValue::List(vec![]).to_nix_expr(), "[]");
400    }
401
402    #[test]
403    fn list_renders_space_separated() {
404        assert_eq!(
405            NixValue::List(vec![NixValue::Int(1), NixValue::Int(2), NixValue::Int(3),])
406                .to_nix_expr(),
407            "[1 2 3]",
408        );
409    }
410
411    #[test]
412    fn empty_attrset_renders() {
413        assert_eq!(NixValue::AttrSet(BTreeMap::new()).to_nix_expr(), "{}",);
414    }
415
416    #[test]
417    fn attrset_renders_semicolon_separated() {
418        let mut map = BTreeMap::new();
419        map.insert("a".to_string(), NixValue::Int(1));
420        map.insert("b".to_string(), NixValue::Int(2));
421        assert_eq!(NixValue::AttrSet(map).to_nix_expr(), "{ a = 1; b = 2; }",);
422    }
423
424    #[test]
425    fn attrset_quotes_non_ident_keys() {
426        let mut map = BTreeMap::new();
427        map.insert("weird key".to_string(), NixValue::Int(1));
428        let rendered = NixValue::AttrSet(map).to_nix_expr();
429        assert!(rendered.contains("\"weird key\""));
430    }
431
432    // ── Determinism ──────────────────────────────────────────
433
434    #[test]
435    fn attrset_iteration_is_deterministic() {
436        // BTreeMap guarantees sorted order, so rendering is stable.
437        let mut map = BTreeMap::new();
438        map.insert("z".to_string(), NixValue::Int(1));
439        map.insert("a".to_string(), NixValue::Int(2));
440        map.insert("m".to_string(), NixValue::Int(3));
441        let rendered = NixValue::AttrSet(map).to_nix_expr();
442        // Keys must appear in alphabetical order.
443        let a_pos = rendered.find("a = ").unwrap();
444        let m_pos = rendered.find("m = ").unwrap();
445        let z_pos = rendered.find("z = ").unwrap();
446        assert!(a_pos < m_pos);
447        assert!(m_pos < z_pos);
448    }
449
450    #[test]
451    fn iac_type_renders_as_nix() {
452        let ty = IacType::List(Box::new(IacType::Integer));
453        let nix = NixValue::from_sexpr(&ty.to_sexpr());
454        // IacType::List is a tuple-tag (list-with-head), so becomes
455        // a Nix list of ident + symbol.
456        let expr = nix.to_nix_expr();
457        assert!(expr.contains("list"));
458        assert!(expr.contains("integer"));
459    }
460
461    #[test]
462    fn iac_attribute_renders_as_attrset() {
463        use crate::testing::TestAttributeBuilder;
464        let attr = TestAttributeBuilder::new("x", IacType::String)
465            .required()
466            .build();
467        let nix = NixValue::from_sexpr(&attr.to_sexpr());
468        let expr = nix.to_nix_expr();
469        // Struct-form → AttrSet.
470        assert!(expr.starts_with("{ "));
471        assert!(expr.ends_with(" }"));
472        assert!(expr.contains("head = \"attribute\""));
473        assert!(expr.contains("required = true"));
474    }
475}