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 { params: Vec<String>, body: Box<NixExpr> },
54}
55
56#[derive(Debug, Clone)]
60pub struct AttrEntry {
61 pub key: String,
62 pub value: NixExpr,
63 pub comment: Vec<String>,
66 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#[derive(Debug, Clone)]
95pub struct NixFile {
96 pub header: Vec<String>,
100 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 #[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#[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
155fn 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 out.push_str("{ ");
173 out.push_str(¶ms.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 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}