Skip to main content

ishou_render/
nix_ast.rs

1//! Typed Nix AST + printer.
2//!
3//! Every Nix-emitting renderer in ishou (`fleet_fonts`, `stylix_fonts`,
4//! `nix` for the Nord palette) builds a `NixExpr` tree and calls
5//! [`print`] — string concatenation of Nix syntax is forbidden because
6//! it's how silent malformed output ships (missing semicolons, unbalanced
7//! braces, broken interpolation). The AST guarantees structural validity
8//! by construction: every value is in a slot the printer knows how to
9//! emit.
10//!
11//! Scope: the small Nix dialect ishou needs to emit — string literals,
12//! attribute sets, lists, lambdas with parameter patterns, raw Nix
13//! variable references (e.g. `pkgs.X.Y`), and `null`. The full Nix
14//! language is intentionally out of scope; ishou never authors
15//! `with … in`, `let … in`, or function application beyond the
16//! top-level wrapper.
17//!
18//! This module mirrors the shape of `iac-forge::nix::NixValue` but
19//! adds lambda + comment + raw-ident support. Once a third consumer
20//! (beyond iac-forge and ishou) needs a Nix AST, the PRIME DIRECTIVE
21//! lift target is `pleme-io/nix-ast` as its own crate.
22
23use std::fmt::Write;
24
25/// A node in the Nix expression tree.
26#[derive(Debug, Clone)]
27pub enum NixExpr {
28    /// `"…"` — string literal. The printer escapes `"`, `\`, `${`,
29    /// and newlines as Nix requires.
30    Str(String),
31    /// `true` / `false`.
32    Bool(bool),
33    /// `null`.
34    Null,
35    /// `123` — integer literal.
36    Int(i64),
37    /// A bare identifier or dotted reference, e.g. `pkgs.iosevka` or
38    /// `pkgs.nerd-fonts.jetbrains-mono`. The printer emits the string
39    /// verbatim — it's the author's responsibility to ensure it's a
40    /// valid Nix identifier expression. (Used because typed nesting
41    /// of every `pkgs.<attr>.<sub>` would buy nothing.)
42    Raw(String),
43    /// `[ a b c … ]` — list literal.
44    List(Vec<NixExpr>),
45    /// `{ key = value; … }` — attribute set. Vec (not BTreeMap) so the
46    /// renderer's iteration order is preserved end-to-end; in design
47    /// systems this matters for legibility of the rendered output.
48    /// Each entry is `(key, value, optional inline_comment)`.
49    AttrSet(Vec<AttrEntry>),
50    /// `{ <params> }: <body>` — single-parameter-pattern lambda.
51    /// `params` is a list of parameter names; the printer emits
52    /// `{ p1, p2, … }: body`.
53    Lambda {
54        params: Vec<String>,
55        body: Box<NixExpr>,
56    },
57}
58
59/// One key/value pair in an attribute set, with optional
60/// preceding-line comment block. Comments document intent without
61/// leaking into the structural shape.
62#[derive(Debug, Clone)]
63pub struct AttrEntry {
64    pub key: String,
65    pub value: NixExpr,
66    /// Comment lines emitted ABOVE the entry. Each string becomes
67    /// one `# …` line. Empty vec = no comment.
68    pub comment: Vec<String>,
69    /// Force a blank line above this entry even when no comment is
70    /// present — used by renderers like the Nord palette to visually
71    /// group sibling attribute sets.
72    pub blank_above: bool,
73}
74
75impl AttrEntry {
76    pub fn new(key: impl Into<String>, value: NixExpr) -> Self {
77        Self {
78            key: key.into(),
79            value,
80            comment: Vec::new(),
81            blank_above: false,
82        }
83    }
84    pub fn with_comment(mut self, lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
85        self.comment = lines.into_iter().map(Into::into).collect();
86        self
87    }
88    pub fn with_blank_above(mut self) -> Self {
89        self.blank_above = true;
90        self
91    }
92}
93
94/// A complete `.nix` file — a header comment block plus a top-level
95/// expression. Renderers build one of these and call
96/// [`NixFile::render`].
97#[derive(Debug, Clone)]
98pub struct NixFile {
99    /// Lines emitted at the very top as `# …` comments. Used for
100    /// provenance ("Generated by …", "DO NOT EDIT", architecture
101    /// pointer, etc.).
102    pub header: Vec<String>,
103    /// The top-level expression. For ishou's renderers this is
104    /// almost always a `Lambda` wrapping an `AttrSet`.
105    pub expr: NixExpr,
106}
107
108impl NixFile {
109    pub fn new(header: impl IntoIterator<Item = impl Into<String>>, expr: NixExpr) -> Self {
110        Self {
111            header: header.into_iter().map(Into::into).collect(),
112            expr,
113        }
114    }
115
116    /// Render the file to a Nix-syntax string. Trailing newline
117    /// included — every renderer's previous output had one.
118    #[must_use]
119    pub fn render(&self) -> String {
120        let mut out = String::new();
121        for line in &self.header {
122            if line.is_empty() {
123                out.push_str("#\n");
124            } else {
125                let _ = writeln!(out, "# {line}");
126            }
127        }
128        print_expr(&mut out, &self.expr, 0);
129        if !out.ends_with('\n') {
130            out.push('\n');
131        }
132        out
133    }
134}
135
136// ── Builder helpers — terser than building NixExpr literals by hand ──
137
138#[must_use]
139pub fn str_(s: impl Into<String>) -> NixExpr {
140    NixExpr::Str(s.into())
141}
142
143#[must_use]
144pub fn raw(s: impl Into<String>) -> NixExpr {
145    NixExpr::Raw(s.into())
146}
147
148#[must_use]
149pub fn attrset(entries: Vec<AttrEntry>) -> NixExpr {
150    NixExpr::AttrSet(entries)
151}
152
153#[must_use]
154pub fn list(items: Vec<NixExpr>) -> NixExpr {
155    NixExpr::List(items)
156}
157
158#[must_use]
159pub fn lambda(params: Vec<&str>, body: NixExpr) -> NixExpr {
160    NixExpr::Lambda {
161        params: params.into_iter().map(String::from).collect(),
162        body: Box::new(body),
163    }
164}
165
166// ── Printer ──────────────────────────────────────────────────────
167
168fn indent_str(level: usize) -> String {
169    "  ".repeat(level)
170}
171
172fn print_expr(out: &mut String, expr: &NixExpr, level: usize) {
173    match expr {
174        NixExpr::Str(s) => print_string(out, s),
175        NixExpr::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
176        NixExpr::Null => out.push_str("null"),
177        NixExpr::Int(i) => {
178            let _ = write!(out, "{i}");
179        }
180        NixExpr::Raw(s) => out.push_str(s),
181        NixExpr::List(items) => print_list(out, items, level),
182        NixExpr::AttrSet(entries) => print_attrset(out, entries, level),
183        NixExpr::Lambda { params, body } => {
184            // `{ p1, p2 }: <body>`
185            out.push_str("{ ");
186            out.push_str(&params.join(", "));
187            out.push_str(" }:\n");
188            print_expr(out, body, level);
189        }
190    }
191}
192
193fn print_string(out: &mut String, s: &str) {
194    out.push('"');
195    for c in s.chars() {
196        match c {
197            '"' => out.push_str("\\\""),
198            '\\' => out.push_str("\\\\"),
199            '\n' => out.push_str("\\n"),
200            '\r' => out.push_str("\\r"),
201            '\t' => out.push_str("\\t"),
202            // `${` triggers Nix interpolation; escape with backslash.
203            c if c == '$' => out.push_str("\\$"),
204            c => out.push(c),
205        }
206    }
207    out.push('"');
208}
209
210fn print_list(out: &mut String, items: &[NixExpr], level: usize) {
211    if items.is_empty() {
212        out.push_str("[]");
213        return;
214    }
215    out.push_str("[\n");
216    let inner = level + 1;
217    for item in items {
218        out.push_str(&indent_str(inner));
219        print_expr(out, item, inner);
220        out.push('\n');
221    }
222    out.push_str(&indent_str(level));
223    out.push(']');
224}
225
226fn print_attrset(out: &mut String, entries: &[AttrEntry], level: usize) {
227    if entries.is_empty() {
228        out.push_str("{}");
229        return;
230    }
231    out.push_str("{\n");
232    let inner = level + 1;
233    for (i, entry) in entries.iter().enumerate() {
234        if i > 0 && (entry.blank_above || !entry.comment.is_empty()) {
235            out.push('\n');
236        }
237        for line in &entry.comment {
238            let _ = writeln!(out, "{}# {line}", indent_str(inner));
239        }
240        out.push_str(&indent_str(inner));
241        out.push_str(&entry.key);
242        out.push_str(" = ");
243        print_expr(out, &entry.value, inner);
244        out.push_str(";\n");
245    }
246    out.push_str(&indent_str(level));
247    out.push('}');
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn renders_null_bool_int_raw_str() {
256        let f = NixFile::new(
257            Vec::<String>::new(),
258            attrset(vec![
259                AttrEntry::new("n", NixExpr::Null),
260                AttrEntry::new("t", NixExpr::Bool(true)),
261                AttrEntry::new("i", NixExpr::Int(42)),
262                AttrEntry::new("r", raw("pkgs.iosevka")),
263                AttrEntry::new("s", str_("hello")),
264            ]),
265        );
266        let out = f.render();
267        assert!(out.contains("n = null;"));
268        assert!(out.contains("t = true;"));
269        assert!(out.contains("i = 42;"));
270        assert!(out.contains("r = pkgs.iosevka;"));
271        assert!(out.contains("s = \"hello\";"));
272    }
273
274    #[test]
275    fn renders_nested_attrset_and_list() {
276        let f = NixFile::new(
277            Vec::<String>::new(),
278            attrset(vec![
279                AttrEntry::new(
280                    "inner",
281                    attrset(vec![
282                        AttrEntry::new("a", NixExpr::Int(1)),
283                        AttrEntry::new("b", str_("two")),
284                    ]),
285                ),
286                AttrEntry::new("items", list(vec![str_("x"), str_("y"), str_("z")])),
287            ]),
288        );
289        let out = f.render();
290        assert!(out.contains("inner = {"));
291        assert!(out.contains("items = ["));
292        assert!(out.contains("\"x\""));
293    }
294
295    #[test]
296    fn renders_lambda_wrapper() {
297        let f = NixFile::new(
298            Vec::<String>::new(),
299            lambda(
300                vec!["pkgs"],
301                attrset(vec![AttrEntry::new("primary", str_("JetBrains"))]),
302            ),
303        );
304        let out = f.render();
305        assert!(out.starts_with("{ pkgs }:\n"), "got:\n{out}");
306        assert!(out.contains("primary = \"JetBrains\";"));
307    }
308
309    #[test]
310    fn header_comments_render_before_body() {
311        let f = NixFile::new(["Generated by test", "DO NOT EDIT"], NixExpr::Null);
312        let out = f.render();
313        let lines: Vec<&str> = out.lines().collect();
314        assert_eq!(lines[0], "# Generated by test");
315        assert_eq!(lines[1], "# DO NOT EDIT");
316        assert_eq!(lines[2], "null");
317    }
318
319    #[test]
320    fn entry_comments_render_above_their_entry() {
321        let f = NixFile::new(
322            Vec::<String>::new(),
323            attrset(vec![
324                AttrEntry::new("a", NixExpr::Int(1))
325                    .with_comment(["the first key", "very important"]),
326                AttrEntry::new("b", NixExpr::Int(2)),
327            ]),
328        );
329        let out = f.render();
330        let a_idx = out.find("a = 1;").unwrap();
331        let comment_idx = out.find("# the first key").unwrap();
332        assert!(comment_idx < a_idx);
333    }
334
335    #[test]
336    fn string_escapes_quote_and_backslash_and_interp() {
337        let f = NixFile::new(
338            Vec::<String>::new(),
339            attrset(vec![AttrEntry::new(
340                "a",
341                str_("has \"quotes\" and \\backslash\\ and ${var}"),
342            )]),
343        );
344        let out = f.render();
345        assert!(out.contains("\\\""));
346        assert!(out.contains("\\\\"));
347        assert!(out.contains("\\$"));
348    }
349
350    #[test]
351    fn empty_list_and_set_print_inline() {
352        let f = NixFile::new(
353            Vec::<String>::new(),
354            attrset(vec![
355                AttrEntry::new("l", list(vec![])),
356                AttrEntry::new("s", attrset(vec![])),
357            ]),
358        );
359        let out = f.render();
360        assert!(out.contains("l = [];"));
361        assert!(out.contains("s = {};"));
362    }
363}