1use std::fmt::Write;
24
25#[derive(Debug, Clone)]
27pub enum NixExpr {
28 Str(String),
31 Bool(bool),
33 Null,
35 Int(i64),
37 Raw(String),
43 List(Vec<NixExpr>),
45 AttrSet(Vec<AttrEntry>),
50 Lambda {
54 params: Vec<String>,
55 body: Box<NixExpr>,
56 },
57}
58
59#[derive(Debug, Clone)]
63pub struct AttrEntry {
64 pub key: String,
65 pub value: NixExpr,
66 pub comment: Vec<String>,
69 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#[derive(Debug, Clone)]
98pub struct NixFile {
99 pub header: Vec<String>,
103 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 #[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#[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
166fn 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 out.push_str("{ ");
186 out.push_str(¶ms.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 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}