ishou-render 0.1.6

ishou — target-specific renderers for the pleme-io design token set
Documentation
//! Typed Nix AST + printer.
//!
//! Every Nix-emitting renderer in ishou (`fleet_fonts`, `stylix_fonts`,
//! `nix` for the Nord palette) builds a `NixExpr` tree and calls
//! [`print`] — string concatenation of Nix syntax is forbidden because
//! it's how silent malformed output ships (missing semicolons, unbalanced
//! braces, broken interpolation). The AST guarantees structural validity
//! by construction: every value is in a slot the printer knows how to
//! emit.
//!
//! Scope: the small Nix dialect ishou needs to emit — string literals,
//! attribute sets, lists, lambdas with parameter patterns, raw Nix
//! variable references (e.g. `pkgs.X.Y`), and `null`. The full Nix
//! language is intentionally out of scope; ishou never authors
//! `with … in`, `let … in`, or function application beyond the
//! top-level wrapper.
//!
//! This module mirrors the shape of `iac-forge::nix::NixValue` but
//! adds lambda + comment + raw-ident support. Once a third consumer
//! (beyond iac-forge and ishou) needs a Nix AST, the PRIME DIRECTIVE
//! lift target is `pleme-io/nix-ast` as its own crate.

use std::fmt::Write;

/// A node in the Nix expression tree.
#[derive(Debug, Clone)]
pub enum NixExpr {
    /// `"…"` — string literal. The printer escapes `"`, `\`, `${`,
    /// and newlines as Nix requires.
    Str(String),
    /// `true` / `false`.
    Bool(bool),
    /// `null`.
    Null,
    /// `123` — integer literal.
    Int(i64),
    /// A bare identifier or dotted reference, e.g. `pkgs.iosevka` or
    /// `pkgs.nerd-fonts.jetbrains-mono`. The printer emits the string
    /// verbatim — it's the author's responsibility to ensure it's a
    /// valid Nix identifier expression. (Used because typed nesting
    /// of every `pkgs.<attr>.<sub>` would buy nothing.)
    Raw(String),
    /// `[ a b c … ]` — list literal.
    List(Vec<NixExpr>),
    /// `{ key = value; … }` — attribute set. Vec (not BTreeMap) so the
    /// renderer's iteration order is preserved end-to-end; in design
    /// systems this matters for legibility of the rendered output.
    /// Each entry is `(key, value, optional inline_comment)`.
    AttrSet(Vec<AttrEntry>),
    /// `{ <params> }: <body>` — single-parameter-pattern lambda.
    /// `params` is a list of parameter names; the printer emits
    /// `{ p1, p2, … }: body`.
    Lambda { params: Vec<String>, body: Box<NixExpr> },
}

/// One key/value pair in an attribute set, with optional
/// preceding-line comment block. Comments document intent without
/// leaking into the structural shape.
#[derive(Debug, Clone)]
pub struct AttrEntry {
    pub key: String,
    pub value: NixExpr,
    /// Comment lines emitted ABOVE the entry. Each string becomes
    /// one `# …` line. Empty vec = no comment.
    pub comment: Vec<String>,
    /// Force a blank line above this entry even when no comment is
    /// present — used by renderers like the Nord palette to visually
    /// group sibling attribute sets.
    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
    }
}

/// A complete `.nix` file — a header comment block plus a top-level
/// expression. Renderers build one of these and call
/// [`NixFile::render`].
#[derive(Debug, Clone)]
pub struct NixFile {
    /// Lines emitted at the very top as `# …` comments. Used for
    /// provenance ("Generated by …", "DO NOT EDIT", architecture
    /// pointer, etc.).
    pub header: Vec<String>,
    /// The top-level expression. For ishou's renderers this is
    /// almost always a `Lambda` wrapping an `AttrSet`.
    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,
        }
    }

    /// Render the file to a Nix-syntax string. Trailing newline
    /// included — every renderer's previous output had one.
    #[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
    }
}

// ── Builder helpers — terser than building NixExpr literals by hand ──

#[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),
    }
}

// ── Printer ──────────────────────────────────────────────────────

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 } => {
            // `{ p1, p2 }: <body>`
            out.push_str("{ ");
            out.push_str(&params.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"),
            // `${` triggers Nix interpolation; escape with backslash.
            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 = {};"));
    }
}