pleme-doc-gen 0.1.54

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 Ruby AST + pretty-printer (config-DSL subset).
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Covers the
//! manifest subset of Ruby used in `.gemspec`, `Gemfile`, `Rakefile`,
//! and similar config DSLs:
//!
//!   Object.method do |param|
//!     receiver.attr = value         # Stmt::Assign
//!     receiver.method args, args    # Stmt::MethodCall
//!   end
//!
//! Strings use SINGLE-QUOTED form (the gem convention) so the only
//! escape concern is `\` and `'`. Symbols / arrays / hashes are typed
//! shapes that compose without manual quoting or trailing commas.
//!
//! NOT a general Ruby AST — no control flow, no method definitions, no
//! interpolation. If a future consumer needs those, extend the AST
//! by adding enum variants, never by punching format!() through.

use std::fmt::Write;

/// A typed Ruby expression — the RHS shape in config DSLs.
#[derive(Debug, Clone)]
pub enum Expr {
    /// `'string with single-quote-safe escaping'`
    String(String),
    /// `:symbol`
    Symbol(String),
    /// `[a, b, c]`
    Array(Vec<Expr>),
    /// `{ key: value, key: value }`
    Hash(Vec<(String, Expr)>),
    /// Bare token: an integer literal, a constant name, a glob expression.
    /// Used for things like `Dir['lib/**/*.rb']` — the caller is responsible
    /// for ensuring the text is valid Ruby. Use SPARINGLY; prefer building
    /// the value through a typed variant when possible.
    Raw(String),
}

impl Expr {
    pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
    pub fn sym(v: impl Into<String>) -> Self { Self::Symbol(v.into()) }
    pub fn arr(items: impl IntoIterator<Item = Expr>) -> Self {
        Self::Array(items.into_iter().collect())
    }
    pub fn raw(v: impl Into<String>) -> Self { Self::Raw(v.into()) }

    fn render(&self) -> String {
        match self {
            Self::String(s) => render_single_quoted(s),
            Self::Symbol(s) => {
                let mut out = String::from(":");
                out.push_str(s);
                out
            }
            Self::Array(items) => {
                let mut out = String::from("[");
                for (i, v) in items.iter().enumerate() {
                    if i > 0 { out.push_str(", "); }
                    out.push_str(&v.render());
                }
                out.push(']');
                out
            }
            Self::Hash(pairs) => {
                let mut out = String::from("{ ");
                for (i, (k, v)) in pairs.iter().enumerate() {
                    if i > 0 { out.push_str(", "); }
                    out.push_str(k);
                    out.push_str(": ");
                    out.push_str(&v.render());
                }
                out.push_str(" }");
                out
            }
            Self::Raw(s) => s.clone(),
        }
    }
}

/// Ruby's single-quoted string literal — escape only `\` and `'`.
/// Newlines, tabs, etc. pass through verbatim (single-quoted strings
/// don't interpret escape sequences except for `\\` and `\'`).
fn render_single_quoted(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(r"\'"),
            c => out.push(c),
        }
    }
    out.push('\'');
    out
}

/// A typed Ruby statement inside a block body.
#[derive(Debug, Clone)]
pub enum Stmt {
    /// `receiver.attr = value`
    Assign { receiver: String, attr: String, value: Expr },
    /// `receiver.method arg1, arg2` (parenthesisless call — Ruby idiom).
    MethodCall { receiver: String, method: String, args: Vec<Expr> },
    /// Blank line between stanzas inside the body.
    Blank,
    /// `# comment` line.
    Comment(String),
}

/// A typed `<object> do |<param>| ... end` block — the canonical
/// shape for `.gemspec` files (`Gem::Specification.new do |spec| … end`)
/// and `Gemfile` blocks.
#[derive(Debug, Clone)]
pub struct Block {
    pub object: String,
    pub param: String,
    pub body: Vec<Stmt>,
}

impl Block {
    pub fn new(object: impl Into<String>, param: impl Into<String>) -> Self {
        Self { object: object.into(), param: param.into(), body: Vec::new() }
    }
    pub fn push(&mut self, stmt: Stmt) -> &mut Self {
        self.body.push(stmt);
        self
    }
    pub fn render(&self) -> String {
        let mut out = String::new();
        let _ = writeln!(out, "{} do |{}|", self.object, self.param);
        for stmt in &self.body {
            match stmt {
                Stmt::Assign { receiver, attr, value } => {
                    let _ = writeln!(out, "  {receiver}.{attr} = {}", value.render());
                }
                Stmt::MethodCall { receiver, method, args } => {
                    let arg_text: Vec<String> = args.iter().map(Expr::render).collect();
                    let _ = writeln!(out, "  {receiver}.{method} {}", arg_text.join(", "));
                }
                Stmt::Blank => out.push('\n'),
                Stmt::Comment(c) => {
                    let _ = writeln!(out, "  # {c}");
                }
            }
        }
        out.push_str("end\n");
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn single_quoted_escapes_quote_and_backslash() {
        assert_eq!(render_single_quoted(r"it's \fine"), r"'it\'s \\fine'");
    }
    #[test]
    fn symbol_renders_with_colon() {
        assert_eq!(Expr::sym("ok").render(), ":ok");
    }
    #[test]
    fn array_of_strings_renders_inline() {
        let e = Expr::arr([Expr::s("a"), Expr::s("b")]);
        assert_eq!(e.render(), "['a', 'b']");
    }
    #[test]
    fn block_renders_assign_and_method_call() {
        let mut b = Block::new("Gem::Specification.new", "spec");
        b.push(Stmt::Assign {
            receiver: "spec".into(), attr: "name".into(),
            value: Expr::s("demo"),
        });
        b.push(Stmt::MethodCall {
            receiver: "spec".into(), method: "add_dependency".into(),
            args: vec![Expr::s("rake"), Expr::s(">= 13")],
        });
        let out = b.render();
        assert!(out.starts_with("Gem::Specification.new do |spec|\n"));
        assert!(out.contains("  spec.name = 'demo'\n"));
        assert!(out.contains("  spec.add_dependency 'rake', '>= 13'\n"));
        assert!(out.ends_with("end\n"));
    }
}