pleme-doc-gen 0.1.40

Rust replacement for the M0 Python _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions. Walks every action.yml + emits substrate's patterns-full.nix + per-action README.md + root catalog. Per the NO-SHELL prime directive.
//! Typed Zig AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Covers two
//! related Zig shapes:
//!
//! 1. `build.zig.zon` — Zig Object Notation anonymous struct literal:
//!    .{
//!        .name = .foo,
//!        .version = "0.1.0",
//!        .dependencies = .{},
//!        .paths = .{ "build.zig", "src" },
//!    }
//!
//! 2. `build.zig` — imperative Zig with const decls + `b.method(.{…})`
//!    call expressions and anonymous-struct args.
//!
//! Distinct from kotlin/swift because Zig uses `.identifier` for
//! enum-cases AND prefixes struct-literals with `.{…}`. The leading dot
//! is part of the typed Expr variant.

use std::fmt::Write;

#[derive(Debug, Clone)]
pub enum Expr {
    /// `"text"` — double-quoted Zig string.
    String(String),
    Int(i64),
    /// `bareIdent` — variable name or type, no leading dot.
    Ident(String),
    /// `.member` — leading-dot enum/tag literal, NOT a struct literal.
    Dot(String),
    /// `.{ .k = v, .k = v }` — anonymous struct literal with named fields.
    Struct(Vec<(String, Expr)>),
    /// `.{ a, b, c }` — anonymous-tuple struct literal (positional only).
    Tuple(Vec<Expr>),
    /// `foo(args)` or `foo.bar(args)` — function call.
    Call { receiver: Option<Box<Expr>>, name: String, args: Vec<Expr> },
}

impl Expr {
    pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
    pub fn i(v: i64) -> Self { Self::Int(v) }
    pub fn ident(v: impl Into<String>) -> Self { Self::Ident(v.into()) }
    pub fn dot(member: impl Into<String>) -> Self { Self::Dot(member.into()) }
    pub fn strct(fields: Vec<(String, Expr)>) -> Self { Self::Struct(fields) }
    pub fn tup(items: impl IntoIterator<Item = Expr>) -> Self {
        Self::Tuple(items.into_iter().collect())
    }
    pub fn call(name: impl Into<String>, args: impl IntoIterator<Item = Expr>) -> Self {
        Self::Call { receiver: None, name: name.into(), args: args.into_iter().collect() }
    }
    pub fn method(receiver: Expr, name: impl Into<String>, args: impl IntoIterator<Item = Expr>) -> Self {
        Self::Call {
            receiver: Some(Box::new(receiver)),
            name: name.into(),
            args: args.into_iter().collect(),
        }
    }

    fn render(&self, indent: usize) -> String {
        let pad_outer = "    ".repeat(indent);
        let pad_inner = "    ".repeat(indent + 1);
        match self {
            Self::String(s) => render_zig_string(s),
            Self::Int(n) => n.to_string(),
            Self::Ident(s) => s.clone(),
            Self::Dot(s) => {
                let mut o = String::from("."); o.push_str(s); o
            }
            Self::Struct(fields) if fields.is_empty() => ".{}".into(),
            Self::Struct(fields) => {
                // .{ … } with each field on its own line.
                let mut out = String::from(".{\n");
                for (i, (k, v)) in fields.iter().enumerate() {
                    let _ = write!(out, "{pad_inner}.{k} = {}", v.render(indent + 1));
                    if i + 1 < fields.len() { out.push(','); }
                    out.push('\n');
                }
                let _ = write!(out, "{pad_outer}}}");
                out
            }
            Self::Tuple(items) if items.is_empty() => ".{}".into(),
            Self::Tuple(items) => {
                let parts: Vec<String> = items.iter().map(|v| v.render(indent + 1)).collect();
                let mut s = String::from(".{ ");
                s.push_str(&parts.join(", "));
                s.push_str(" }");
                s
            }
            Self::Call { receiver, name, args } => {
                let mut out = String::new();
                if let Some(r) = receiver { out.push_str(&r.render(indent)); out.push('.'); }
                out.push_str(name);
                out.push('(');
                let parts: Vec<String> = args.iter().map(|a| a.render(indent + 1)).collect();
                // If any arg is multi-line, break the call too.
                if parts.iter().any(|p| p.contains('\n')) {
                    out.push('\n');
                    for (i, p) in parts.iter().enumerate() {
                        let _ = write!(out, "{pad_inner}{p}");
                        if i + 1 < parts.len() { out.push(','); }
                        out.push('\n');
                    }
                    let _ = write!(out, "{pad_outer})");
                } else {
                    out.push_str(&parts.join(", "));
                    out.push(')');
                }
                out
            }
        }
    }
}

fn render_zig_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '\\' => out.push_str(r"\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str(r"\n"),
            '\r' => out.push_str(r"\r"),
            '\t' => out.push_str(r"\t"),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

#[derive(Debug, Clone)]
pub enum Stmt {
    /// `const <name> = <value>;`
    Const { name: String, value: Expr },
    /// Bare expression statement followed by `;`.
    Expr(Expr),
    /// `pub fn <name>(<params>) <return> { … }`
    Fn { sig: String, body: Vec<Stmt> },
    Blank,
    Comment(String),
}

#[derive(Debug, Clone, Default)]
pub struct File {
    /// Each top-level `@import("...")` shows up as a Const stmt with
    /// value Call(@import, [String]); callers can also push raw text
    /// for foreign-shape escape hatches via Stmt::Comment.
    pub stmts: Vec<Stmt>,
}

impl File {
    pub fn new() -> Self { Self::default() }
    pub fn push(&mut self, s: Stmt) -> &mut Self { self.stmts.push(s); self }
    pub fn render(&self) -> String {
        let mut out = String::new();
        render_at(&self.stmts, 0, &mut out);
        out
    }

    /// Render this AST as a standalone ZON document — no enclosing
    /// const-decl, just the root anonymous-struct expression with a
    /// trailing newline. Used by `build.zig.zon`.
    pub fn render_zon(root: &Expr) -> String {
        let mut s = root.render(0);
        s.push('\n');
        s
    }
}

fn render_at(stmts: &[Stmt], depth: usize, out: &mut String) {
    let pad = "    ".repeat(depth);
    for s in stmts {
        match s {
            Stmt::Const { name, value } => {
                let _ = writeln!(out, "{pad}const {name} = {};", value.render(depth));
            }
            Stmt::Expr(e) => { let _ = writeln!(out, "{pad}{};", e.render(depth)); }
            Stmt::Fn { sig, body } => {
                let _ = writeln!(out, "{pad}{sig} {{");
                render_at(body, depth + 1, out);
                let _ = writeln!(out, "{pad}}}");
            }
            Stmt::Blank => out.push('\n'),
            Stmt::Comment(c) => { let _ = writeln!(out, "{pad}// {c}"); }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn dot_member_renders_with_leading_dot() {
        assert_eq!(Expr::dot("v13").render(0), ".v13");
    }
    #[test]
    fn struct_literal_indents_fields() {
        let e = Expr::strct(vec![
            ("name".into(), Expr::dot("foo")),
            ("version".into(), Expr::s("1.0")),
        ]);
        let out = e.render(0);
        assert!(out.starts_with(".{\n"));
        assert!(out.contains("    .name = .foo,"));
        assert!(out.contains("    .version = \"1.0\""));
        assert!(out.ends_with('}'));
    }
    #[test]
    fn tuple_inline_when_short() {
        let e = Expr::tup([Expr::s("a"), Expr::s("b")]);
        assert_eq!(e.render(0), ".{ \"a\", \"b\" }");
    }
    #[test]
    fn method_call_with_struct_arg_breaks_multiline() {
        let e = Expr::method(Expr::ident("b"), "addExecutable", vec![
            Expr::strct(vec![("name".into(), Expr::s("demo"))]),
        ]);
        let out = e.render(0);
        assert!(out.contains("b.addExecutable("));
        assert!(out.contains(".name = \"demo\""));
    }
}