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 Lua AST + pretty-printer (config-literal subset).
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Covers the
//! Lua-table-literal grammar used in rockspecs and similar Lua-config
//! files:
//!
//!   package = "demo"
//!   description = {
//!     summary = "…",
//!     license = "MIT",
//!   }
//!   dependencies = { "lua >= 5.1" }
//!   build = { type = "builtin", modules = {} }
//!
//! NOT a general Lua AST — no control flow, no functions, no
//! metatables. Pure declarative table-literal grammar.

use std::fmt::Write;

#[derive(Debug, Clone)]
pub enum Expr {
    /// `"text"` — double-quoted with Lua's escape rules.
    String(String),
    /// `42` — integer.
    Int(i64),
    /// `true` / `false`.
    Bool(bool),
    /// `{ k1 = v, k2 = v }` — associative table (preserves insertion order).
    Table(Vec<(String, Expr)>),
    /// `{ a, b, c }` — array part (positional elements only).
    Array(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 table() -> Self { Self::Table(Vec::new()) }
    pub fn arr(items: impl IntoIterator<Item = Expr>) -> Self {
        Self::Array(items.into_iter().collect())
    }
    /// Insert into a Table, preserving order; replaces existing key.
    pub fn insert(&mut self, k: impl Into<String>, v: Expr) -> &mut Self {
        if let Self::Table(items) = self {
            let key = k.into();
            if let Some(idx) = items.iter().position(|(ek, _)| ek == &key) {
                items[idx].1 = v;
            } else {
                items.push((key, v));
            }
        }
        self
    }

    fn render(&self, indent: usize) -> String {
        let pad_outer = "  ".repeat(indent);
        let pad_inner = "  ".repeat(indent + 1);
        match self {
            Self::String(s) => render_lua_string(s),
            Self::Int(n) => n.to_string(),
            Self::Bool(true) => "true".into(),
            Self::Bool(false) => "false".into(),
            Self::Table(items) if items.is_empty() => "{}".into(),
            Self::Table(items) => {
                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::Array(items) if items.is_empty() => "{}".into(),
            Self::Array(items) => {
                // Inline form for short arrays.
                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_str(" }");
                    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
                }
            }
        }
    }
}

fn render_lua_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("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Top-level Lua statement — `key = value` assignment (the only shape
/// rockspec uses at the file level).
#[derive(Debug, Clone)]
pub enum Stmt {
    Set { key: String, value: Expr },
    Blank,
    Comment(String),
}

#[derive(Debug, Clone, Default)]
pub struct File { 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();
        for s in &self.stmts {
            match s {
                Stmt::Set { key, value } => {
                    let _ = writeln!(out, "{key} = {}", value.render(0));
                }
                Stmt::Blank => out.push('\n'),
                Stmt::Comment(c) => { let _ = writeln!(out, "-- {c}"); }
            }
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn empty_table_inline() {
        assert_eq!(Expr::table().render(0), "{}");
    }
    #[test]
    fn array_short_inline() {
        let e = Expr::arr([Expr::s("a"), Expr::s("b")]);
        assert_eq!(e.render(0), "{ \"a\", \"b\" }");
    }
    #[test]
    fn table_multi_line_with_comma_per_line() {
        let mut t = Expr::table();
        t.insert("name", Expr::s("demo"));
        t.insert("version", Expr::s("1"));
        let out = t.render(0);
        assert!(out.contains("name = \"demo\","));
        assert!(out.contains("version = \"1\""));
    }
    #[test]
    fn file_renders_set_lines() {
        let mut f = File::new();
        f.push(Stmt::Set { key: "package".into(), value: Expr::s("demo") });
        assert_eq!(f.render(), "package = \"demo\"\n");
    }
}