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 { params: Vec<String>, body: Box<NixExpr> },
54}
55
56/// One key/value pair in an attribute set, with optional
57/// preceding-line comment block. Comments document intent without
58/// leaking into the structural shape.
59#[derive(Debug, Clone)]
60pub struct AttrEntry {
61    pub key: String,
62    pub value: NixExpr,
63    /// Comment lines emitted ABOVE the entry. Each string becomes
64    /// one `# …` line. Empty vec = no comment.
65    pub comment: Vec<String>,
66    /// Force a blank line above this entry even when no comment is
67    /// present — used by renderers like the Nord palette to visually
68    /// group sibling attribute sets.
69    pub blank_above: bool,
70}
71
72impl AttrEntry {
73    pub fn new(key: impl Into<String>, value: NixExpr) -> Self {
74        Self {
75            key: key.into(),
76            value,
77            comment: Vec::new(),
78            blank_above: false,
79        }
80    }
81    pub fn with_comment(mut self, lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
82        self.comment = lines.into_iter().map(Into::into).collect();
83        self
84    }
85    pub fn with_blank_above(mut self) -> Self {
86        self.blank_above = true;
87        self
88    }
89}
90
91/// A complete `.nix` file — a header comment block plus a top-level
92/// expression. Renderers build one of these and call
93/// [`NixFile::render`].
94#[derive(Debug, Clone)]
95pub struct NixFile {
96    /// Lines emitted at the very top as `# …` comments. Used for
97    /// provenance ("Generated by …", "DO NOT EDIT", architecture
98    /// pointer, etc.).
99    pub header: Vec<String>,
100    /// The top-level expression. For ishou's renderers this is
101    /// almost always a `Lambda` wrapping an `AttrSet`.
102    pub expr: NixExpr,
103}
104
105impl NixFile {
106    pub fn new(header: impl IntoIterator<Item = impl Into<String>>, expr: NixExpr) -> Self {
107        Self {
108            header: header.into_iter().map(Into::into).collect(),
109            expr,
110        }
111    }
112
113    /// Render the file to a Nix-syntax string. Trailing newline
114    /// included — every renderer's previous output had one.
115    #[must_use]
116    pub fn render(&self) -> String {
117        let mut out = String::new();
118        for line in &self.header {
119            if line.is_empty() {
120                out.push_str("#\n");
121            } else {
122                let _ = writeln!(out, "# {line}");
123            }
124        }
125        print_expr(&mut out, &self.expr, 0);
126        if !out.ends_with('\n') {
127            out.push('\n');
128        }
129        out
130    }
131}
132
133// ── Builder helpers — terser than building NixExpr literals by hand ──
134
135#[must_use]
136pub fn str_(s: impl Into<String>) -> NixExpr { NixExpr::Str(s.into()) }
137
138#[must_use]
139pub fn raw(s: impl Into<String>) -> NixExpr { NixExpr::Raw(s.into()) }
140
141#[must_use]
142pub fn attrset(entries: Vec<AttrEntry>) -> NixExpr { NixExpr::AttrSet(entries) }
143
144#[must_use]
145pub fn list(items: Vec<NixExpr>) -> NixExpr { NixExpr::List(items) }
146
147#[must_use]
148pub fn lambda(params: Vec<&str>, body: NixExpr) -> NixExpr {
149    NixExpr::Lambda {
150        params: params.into_iter().map(String::from).collect(),
151        body: Box::new(body),
152    }
153}
154
155// ── Printer ──────────────────────────────────────────────────────
156
157fn indent_str(level: usize) -> String { "  ".repeat(level) }
158
159fn print_expr(out: &mut String, expr: &NixExpr, level: usize) {
160    match expr {
161        NixExpr::Str(s) => print_string(out, s),
162        NixExpr::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
163        NixExpr::Null => out.push_str("null"),
164        NixExpr::Int(i) => {
165            let _ = write!(out, "{i}");
166        }
167        NixExpr::Raw(s) => out.push_str(s),
168        NixExpr::List(items) => print_list(out, items, level),
169        NixExpr::AttrSet(entries) => print_attrset(out, entries, level),
170        NixExpr::Lambda { params, body } => {
171            // `{ p1, p2 }: <body>`
172            out.push_str("{ ");
173            out.push_str(&params.join(", "));
174            out.push_str(" }:\n");
175            print_expr(out, body, level);
176        }
177    }
178}
179
180fn print_string(out: &mut String, s: &str) {
181    out.push('"');
182    for c in s.chars() {
183        match c {
184            '"' => out.push_str("\\\""),
185            '\\' => out.push_str("\\\\"),
186            '\n' => out.push_str("\\n"),
187            '\r' => out.push_str("\\r"),
188            '\t' => out.push_str("\\t"),
189            // `${` triggers Nix interpolation; escape with backslash.
190            c if c == '$' => out.push_str("\\$"),
191            c => out.push(c),
192        }
193    }
194    out.push('"');
195}
196
197fn print_list(out: &mut String, items: &[NixExpr], level: usize) {
198    if items.is_empty() {
199        out.push_str("[]");
200        return;
201    }
202    out.push_str("[\n");
203    let inner = level + 1;
204    for item in items {
205        out.push_str(&indent_str(inner));
206        print_expr(out, item, inner);
207        out.push('\n');
208    }
209    out.push_str(&indent_str(level));
210    out.push(']');
211}
212
213fn print_attrset(out: &mut String, entries: &[AttrEntry], level: usize) {
214    if entries.is_empty() {
215        out.push_str("{}");
216        return;
217    }
218    out.push_str("{\n");
219    let inner = level + 1;
220    for (i, entry) in entries.iter().enumerate() {
221        if i > 0 && (entry.blank_above || !entry.comment.is_empty()) {
222            out.push('\n');
223        }
224        for line in &entry.comment {
225            let _ = writeln!(out, "{}# {line}", indent_str(inner));
226        }
227        out.push_str(&indent_str(inner));
228        out.push_str(&entry.key);
229        out.push_str(" = ");
230        print_expr(out, &entry.value, inner);
231        out.push_str(";\n");
232    }
233    out.push_str(&indent_str(level));
234    out.push('}');
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn renders_null_bool_int_raw_str() {
243        let f = NixFile::new(Vec::<String>::new(), attrset(vec![
244            AttrEntry::new("n", NixExpr::Null),
245            AttrEntry::new("t", NixExpr::Bool(true)),
246            AttrEntry::new("i", NixExpr::Int(42)),
247            AttrEntry::new("r", raw("pkgs.iosevka")),
248            AttrEntry::new("s", str_("hello")),
249        ]));
250        let out = f.render();
251        assert!(out.contains("n = null;"));
252        assert!(out.contains("t = true;"));
253        assert!(out.contains("i = 42;"));
254        assert!(out.contains("r = pkgs.iosevka;"));
255        assert!(out.contains("s = \"hello\";"));
256    }
257
258    #[test]
259    fn renders_nested_attrset_and_list() {
260        let f = NixFile::new(Vec::<String>::new(), attrset(vec![
261            AttrEntry::new("inner", attrset(vec![
262                AttrEntry::new("a", NixExpr::Int(1)),
263                AttrEntry::new("b", str_("two")),
264            ])),
265            AttrEntry::new("items", list(vec![str_("x"), str_("y"), str_("z")])),
266        ]));
267        let out = f.render();
268        assert!(out.contains("inner = {"));
269        assert!(out.contains("items = ["));
270        assert!(out.contains("\"x\""));
271    }
272
273    #[test]
274    fn renders_lambda_wrapper() {
275        let f = NixFile::new(Vec::<String>::new(), lambda(vec!["pkgs"], attrset(vec![
276            AttrEntry::new("primary", str_("JetBrains")),
277        ])));
278        let out = f.render();
279        assert!(out.starts_with("{ pkgs }:\n"), "got:\n{out}");
280        assert!(out.contains("primary = \"JetBrains\";"));
281    }
282
283    #[test]
284    fn header_comments_render_before_body() {
285        let f = NixFile::new(
286            ["Generated by test", "DO NOT EDIT"],
287            NixExpr::Null,
288        );
289        let out = f.render();
290        let lines: Vec<&str> = out.lines().collect();
291        assert_eq!(lines[0], "# Generated by test");
292        assert_eq!(lines[1], "# DO NOT EDIT");
293        assert_eq!(lines[2], "null");
294    }
295
296    #[test]
297    fn entry_comments_render_above_their_entry() {
298        let f = NixFile::new(Vec::<String>::new(), attrset(vec![
299            AttrEntry::new("a", NixExpr::Int(1))
300                .with_comment(["the first key", "very important"]),
301            AttrEntry::new("b", NixExpr::Int(2)),
302        ]));
303        let out = f.render();
304        let a_idx = out.find("a = 1;").unwrap();
305        let comment_idx = out.find("# the first key").unwrap();
306        assert!(comment_idx < a_idx);
307    }
308
309    #[test]
310    fn string_escapes_quote_and_backslash_and_interp() {
311        let f = NixFile::new(Vec::<String>::new(), attrset(vec![
312            AttrEntry::new("a", str_("has \"quotes\" and \\backslash\\ and ${var}")),
313        ]));
314        let out = f.render();
315        assert!(out.contains("\\\""));
316        assert!(out.contains("\\\\"));
317        assert!(out.contains("\\$"));
318    }
319
320    #[test]
321    fn empty_list_and_set_print_inline() {
322        let f = NixFile::new(Vec::<String>::new(), attrset(vec![
323            AttrEntry::new("l", list(vec![])),
324            AttrEntry::new("s", attrset(vec![])),
325        ]));
326        let out = f.render();
327        assert!(out.contains("l = [];"));
328        assert!(out.contains("s = {};"));
329    }
330}