pleme-doc-gen 0.1.45

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 Elixir AST + pretty-printer (Mix.Project DSL subset).
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Covers the
//! mix.exs shape:
//!
//!   defmodule Demo.MixProject do
//!     use Mix.Project
//!
//!     def project, do: [
//!       app: :demo,
//!       version: "0.1.0",
//!       deps: deps()
//!     ]
//!
//!     defp deps, do: [
//!       {:cowboy, "~> 2.10"},
//!     ]
//!   end
//!
//! NOT a general Elixir AST — no pattern matching, no pipes, no
//! protocols. Just the manifest-DSL subset.

use std::fmt::Write;

#[derive(Debug, Clone)]
pub enum Expr {
    /// `"text"` — double-quoted.
    String(String),
    Int(i64),
    Bool(bool),
    /// `:atom`
    Atom(String),
    /// Bare identifier — variable or module reference.
    Ident(String),
    /// `[a, b, c]` — list.
    List(Vec<Expr>),
    /// `[key: val, key: val]` — keyword list (Elixir's main config shape).
    KeywordList(Vec<(String, Expr)>),
    /// `{a, b, c}` — tuple. The dep-spec shape `{:cowboy, "~> 2.10"}`
    /// is built as Tuple([Atom("cowboy"), String("~> 2.10")]).
    Tuple(Vec<Expr>),
    /// `func(args)` — function call.
    Call { 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 b(v: bool) -> Self { Self::Bool(v) }
    pub fn atom(v: impl Into<String>) -> Self { Self::Atom(v.into()) }
    pub fn ident(v: impl Into<String>) -> Self { Self::Ident(v.into()) }
    pub fn list(items: impl IntoIterator<Item = Expr>) -> Self {
        Self::List(items.into_iter().collect())
    }
    pub fn kwlist(pairs: impl IntoIterator<Item = (String, Expr)>) -> Self {
        Self::KeywordList(pairs.into_iter().collect())
    }
    pub fn tuple(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 { 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_elixir_string(s),
            Self::Int(n) => n.to_string(),
            Self::Bool(true) => "true".into(),
            Self::Bool(false) => "false".into(),
            Self::Atom(a) => { let mut s = String::from(":"); s.push_str(a); s }
            Self::Ident(s) => s.clone(),
            Self::List(items) if items.is_empty() => "[]".into(),
            Self::List(items) => {
                let parts: Vec<String> = items.iter().map(|v| v.render(indent + 1)).collect();
                let inline = {
                    let mut s = String::from("["); s.push_str(&parts.join(", ")); s.push(']'); s
                };
                if inline.len() <= 80 && !inline.contains('\n') {
                    inline
                } else {
                    let mut out = String::from("[\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}]");
                    out
                }
            }
            Self::KeywordList(items) if items.is_empty() => "[]".into(),
            Self::KeywordList(items) => {
                // Always multi-line for project/deps clarity.
                let mut out = String::from("[\n");
                for (i, (k, v)) in items.iter().enumerate() {
                    let _ = write!(out, "{pad_inner}{k}: {}", v.render(indent + 1));
                    if i + 1 < items.len() { out.push(','); }
                    out.push('\n');
                }
                let _ = write!(out, "{pad_outer}]");
                out
            }
            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('}');
                s
            }
            Self::Call { name, args } => {
                let parts: Vec<String> = args.iter().map(|a| a.render(indent + 1)).collect();
                let mut s = String::from(name);
                s.push('(');
                s.push_str(&parts.join(", "));
                s.push(')');
                s
            }
        }
    }
}

fn render_elixir_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("\\\""),
            '#' => out.push_str(r"\#"), // defang `#{…}` interpolation
            '\n' => out.push_str("\\n"),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

#[derive(Debug, Clone)]
pub enum Stmt {
    /// `use ModuleName`
    Use(String),
    /// `def <name>, do: <body>` — one-line public function.
    Def { name: String, body: Expr },
    /// `defp <name>, do: <body>` — private function.
    Defp { name: String, body: Expr },
    Blank,
    Comment(String),
}

#[derive(Debug, Clone)]
pub struct Module {
    pub name: String,
    pub body: Vec<Stmt>,
}

impl Module {
    pub fn new(name: impl Into<String>) -> Self {
        Self { name: name.into(), body: Vec::new() }
    }
    pub fn push(&mut self, s: Stmt) -> &mut Self { self.body.push(s); self }
    pub fn render(&self) -> String {
        let mut out = String::new();
        let _ = writeln!(out, "defmodule {} do", self.name);
        let pad = "  ";
        for s in &self.body {
            match s {
                Stmt::Use(m) => { let _ = writeln!(out, "{pad}use {m}"); }
                Stmt::Def { name, body } => {
                    let _ = writeln!(out, "{pad}def {name}, do: {}", body.render(1));
                }
                Stmt::Defp { name, body } => {
                    let _ = writeln!(out, "{pad}defp {name}, do: {}", body.render(1));
                }
                Stmt::Blank => out.push('\n'),
                Stmt::Comment(c) => { let _ = writeln!(out, "{pad}# {c}"); }
            }
        }
        out.push_str("end\n");
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn atom_renders_with_colon_prefix() {
        assert_eq!(Expr::atom("ok").render(0), ":ok");
    }
    #[test]
    fn tuple_renders_with_braces() {
        let e = Expr::tuple(vec![Expr::atom("cowboy"), Expr::s("~> 2.10")]);
        assert_eq!(e.render(0), "{:cowboy, \"~> 2.10\"}");
    }
    #[test]
    fn keyword_list_indents_per_pair() {
        let e = Expr::kwlist(vec![
            ("app".into(), Expr::atom("demo")),
            ("version".into(), Expr::s("0.1.0")),
        ]);
        let out = e.render(0);
        assert!(out.starts_with("[\n"));
        assert!(out.contains("  app: :demo,"));
        assert!(out.contains("  version: \"0.1.0\""));
        assert!(out.ends_with(']'));
    }
    #[test]
    fn module_with_use_and_def() {
        let mut m = Module::new("Demo");
        m.push(Stmt::Use("Mix.Project".into()));
        m.push(Stmt::Blank);
        m.push(Stmt::Def { name: "project".into(), body: Expr::list([]) });
        let out = m.render();
        assert!(out.starts_with("defmodule Demo do\n"));
        assert!(out.contains("  use Mix.Project\n"));
        assert!(out.contains("  def project, do: []\n"));
        assert!(out.ends_with("end\n"));
    }
    #[test]
    fn string_defangs_hash_interpolation() {
        assert_eq!(render_elixir_string("a#{b}"), r#""a\#{b}""#);
    }
}