use std::fmt::Write;
#[derive(Debug, Clone)]
pub enum NixExpr {
Str(String),
Bool(bool),
Null,
Int(i64),
Raw(String),
List(Vec<NixExpr>),
AttrSet(Vec<AttrEntry>),
Lambda { params: Vec<String>, body: Box<NixExpr> },
}
#[derive(Debug, Clone)]
pub struct AttrEntry {
pub key: String,
pub value: NixExpr,
pub comment: Vec<String>,
pub blank_above: bool,
}
impl AttrEntry {
pub fn new(key: impl Into<String>, value: NixExpr) -> Self {
Self {
key: key.into(),
value,
comment: Vec::new(),
blank_above: false,
}
}
pub fn with_comment(mut self, lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.comment = lines.into_iter().map(Into::into).collect();
self
}
pub fn with_blank_above(mut self) -> Self {
self.blank_above = true;
self
}
}
#[derive(Debug, Clone)]
pub struct NixFile {
pub header: Vec<String>,
pub expr: NixExpr,
}
impl NixFile {
pub fn new(header: impl IntoIterator<Item = impl Into<String>>, expr: NixExpr) -> Self {
Self {
header: header.into_iter().map(Into::into).collect(),
expr,
}
}
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
for line in &self.header {
if line.is_empty() {
out.push_str("#\n");
} else {
let _ = writeln!(out, "# {line}");
}
}
print_expr(&mut out, &self.expr, 0);
if !out.ends_with('\n') {
out.push('\n');
}
out
}
}
#[must_use]
pub fn str_(s: impl Into<String>) -> NixExpr { NixExpr::Str(s.into()) }
#[must_use]
pub fn raw(s: impl Into<String>) -> NixExpr { NixExpr::Raw(s.into()) }
#[must_use]
pub fn attrset(entries: Vec<AttrEntry>) -> NixExpr { NixExpr::AttrSet(entries) }
#[must_use]
pub fn list(items: Vec<NixExpr>) -> NixExpr { NixExpr::List(items) }
#[must_use]
pub fn lambda(params: Vec<&str>, body: NixExpr) -> NixExpr {
NixExpr::Lambda {
params: params.into_iter().map(String::from).collect(),
body: Box::new(body),
}
}
fn indent_str(level: usize) -> String { " ".repeat(level) }
fn print_expr(out: &mut String, expr: &NixExpr, level: usize) {
match expr {
NixExpr::Str(s) => print_string(out, s),
NixExpr::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
NixExpr::Null => out.push_str("null"),
NixExpr::Int(i) => {
let _ = write!(out, "{i}");
}
NixExpr::Raw(s) => out.push_str(s),
NixExpr::List(items) => print_list(out, items, level),
NixExpr::AttrSet(entries) => print_attrset(out, entries, level),
NixExpr::Lambda { params, body } => {
out.push_str("{ ");
out.push_str(¶ms.join(", "));
out.push_str(" }:\n");
print_expr(out, body, level);
}
}
}
fn print_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c == '$' => out.push_str("\\$"),
c => out.push(c),
}
}
out.push('"');
}
fn print_list(out: &mut String, items: &[NixExpr], level: usize) {
if items.is_empty() {
out.push_str("[]");
return;
}
out.push_str("[\n");
let inner = level + 1;
for item in items {
out.push_str(&indent_str(inner));
print_expr(out, item, inner);
out.push('\n');
}
out.push_str(&indent_str(level));
out.push(']');
}
fn print_attrset(out: &mut String, entries: &[AttrEntry], level: usize) {
if entries.is_empty() {
out.push_str("{}");
return;
}
out.push_str("{\n");
let inner = level + 1;
for (i, entry) in entries.iter().enumerate() {
if i > 0 && (entry.blank_above || !entry.comment.is_empty()) {
out.push('\n');
}
for line in &entry.comment {
let _ = writeln!(out, "{}# {line}", indent_str(inner));
}
out.push_str(&indent_str(inner));
out.push_str(&entry.key);
out.push_str(" = ");
print_expr(out, &entry.value, inner);
out.push_str(";\n");
}
out.push_str(&indent_str(level));
out.push('}');
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_null_bool_int_raw_str() {
let f = NixFile::new(Vec::<String>::new(), attrset(vec![
AttrEntry::new("n", NixExpr::Null),
AttrEntry::new("t", NixExpr::Bool(true)),
AttrEntry::new("i", NixExpr::Int(42)),
AttrEntry::new("r", raw("pkgs.iosevka")),
AttrEntry::new("s", str_("hello")),
]));
let out = f.render();
assert!(out.contains("n = null;"));
assert!(out.contains("t = true;"));
assert!(out.contains("i = 42;"));
assert!(out.contains("r = pkgs.iosevka;"));
assert!(out.contains("s = \"hello\";"));
}
#[test]
fn renders_nested_attrset_and_list() {
let f = NixFile::new(Vec::<String>::new(), attrset(vec![
AttrEntry::new("inner", attrset(vec![
AttrEntry::new("a", NixExpr::Int(1)),
AttrEntry::new("b", str_("two")),
])),
AttrEntry::new("items", list(vec![str_("x"), str_("y"), str_("z")])),
]));
let out = f.render();
assert!(out.contains("inner = {"));
assert!(out.contains("items = ["));
assert!(out.contains("\"x\""));
}
#[test]
fn renders_lambda_wrapper() {
let f = NixFile::new(Vec::<String>::new(), lambda(vec!["pkgs"], attrset(vec![
AttrEntry::new("primary", str_("JetBrains")),
])));
let out = f.render();
assert!(out.starts_with("{ pkgs }:\n"), "got:\n{out}");
assert!(out.contains("primary = \"JetBrains\";"));
}
#[test]
fn header_comments_render_before_body() {
let f = NixFile::new(
["Generated by test", "DO NOT EDIT"],
NixExpr::Null,
);
let out = f.render();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "# Generated by test");
assert_eq!(lines[1], "# DO NOT EDIT");
assert_eq!(lines[2], "null");
}
#[test]
fn entry_comments_render_above_their_entry() {
let f = NixFile::new(Vec::<String>::new(), attrset(vec![
AttrEntry::new("a", NixExpr::Int(1))
.with_comment(["the first key", "very important"]),
AttrEntry::new("b", NixExpr::Int(2)),
]));
let out = f.render();
let a_idx = out.find("a = 1;").unwrap();
let comment_idx = out.find("# the first key").unwrap();
assert!(comment_idx < a_idx);
}
#[test]
fn string_escapes_quote_and_backslash_and_interp() {
let f = NixFile::new(Vec::<String>::new(), attrset(vec![
AttrEntry::new("a", str_("has \"quotes\" and \\backslash\\ and ${var}")),
]));
let out = f.render();
assert!(out.contains("\\\""));
assert!(out.contains("\\\\"));
assert!(out.contains("\\$"));
}
#[test]
fn empty_list_and_set_print_inline() {
let f = NixFile::new(Vec::<String>::new(), attrset(vec![
AttrEntry::new("l", list(vec![])),
AttrEntry::new("s", attrset(vec![])),
]));
let out = f.render();
assert!(out.contains("l = [];"));
assert!(out.contains("s = {};"));
}
}