pleme-doc-gen 0.1.52

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 s-expression AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Used by the
//! 3 s-expr ecosystems (ocaml-dune / clojure-deps / racket-info)
//! plus any future Lisp-shape consumer.

use std::fmt::Write;

#[derive(Debug, Clone)]
pub enum SExp {
    /// Bare symbol — never quoted.
    Symbol(String),
    /// String literal — always quoted + escaped.
    String(String),
    /// Keyword form `:keyword` (Clojure / EDN).
    Keyword(String),
    /// Integer literal.
    Int(i64),
    /// Boolean literal (true / false / nil per dialect).
    Bool(bool),
    /// Parenthesized list of nested forms.
    List(Vec<SExp>),
    /// EDN-style map `{ :k v :k v }`.
    Map(Vec<(SExp, SExp)>),
    /// EDN-style vector `[a b c]`.
    Vector(Vec<SExp>),
}

impl SExp {
    pub fn sym(s: impl Into<String>) -> Self { Self::Symbol(s.into()) }
    pub fn str(s: impl Into<String>) -> Self { Self::String(s.into()) }
    pub fn kw(s: impl Into<String>) -> Self { Self::Keyword(s.into()) }
    pub fn int(n: i64) -> Self { Self::Int(n) }
    pub fn b(v: bool) -> Self { Self::Bool(v) }
    pub fn list(items: impl IntoIterator<Item = SExp>) -> Self {
        Self::List(items.into_iter().collect())
    }
    pub fn vec_(items: impl IntoIterator<Item = SExp>) -> Self {
        Self::Vector(items.into_iter().collect())
    }
    pub fn map_(pairs: impl IntoIterator<Item = (SExp, SExp)>) -> Self {
        Self::Map(pairs.into_iter().collect())
    }
}

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

/// Render with multi-line layout for top-level lists; inline for
/// nested simple lists (≤ 4 atoms).
fn render_at(e: &SExp, indent: usize, out: &mut String) {
    let pad = "  ".repeat(indent);
    match e {
        SExp::Symbol(s) => out.push_str(s),
        SExp::String(s) => out.push_str(&escape_string(s)),
        SExp::Keyword(k) => {
            out.push(':');
            out.push_str(k);
        }
        SExp::Int(n) => { let _ = write!(out, "{n}"); }
        SExp::Bool(true) => out.push_str("true"),
        SExp::Bool(false) => out.push_str("false"),
        SExp::List(items) if items.is_empty() => out.push_str("()"),
        SExp::List(items) if is_inline_simple(items) => {
            out.push('(');
            for (i, item) in items.iter().enumerate() {
                if i > 0 { out.push(' '); }
                render_at(item, 0, out);
            }
            out.push(')');
        }
        SExp::List(items) => {
            out.push('(');
            // First atom on same line as `(`, then break.
            if let Some(first) = items.first() {
                render_at(first, 0, out);
            }
            for item in items.iter().skip(1) {
                out.push('\n');
                let _ = write!(out, "{pad}  ");
                render_at(item, indent + 1, out);
            }
            out.push(')');
        }
        SExp::Vector(items) if items.is_empty() => out.push_str("[]"),
        SExp::Vector(items) => {
            out.push('[');
            for (i, item) in items.iter().enumerate() {
                if i > 0 { out.push(' '); }
                render_at(item, indent, out);
            }
            out.push(']');
        }
        SExp::Map(pairs) if pairs.is_empty() => out.push_str("{}"),
        SExp::Map(pairs) => {
            out.push('{');
            for (i, (k, v)) in pairs.iter().enumerate() {
                if i > 0 {
                    out.push('\n');
                    let _ = write!(out, "{pad} ");
                }
                render_at(k, indent + 1, out);
                out.push(' ');
                render_at(v, indent + 1, out);
            }
            out.push('}');
        }
    }
}

fn is_inline_simple(items: &[SExp]) -> bool {
    items.len() <= 4
        && items.iter().all(|i| matches!(i,
            SExp::Symbol(_) | SExp::String(_) | SExp::Keyword(_) | SExp::Int(_) | SExp::Bool(_)
        ))
}

/// Render a top-level form list with each form on its own line.
pub fn render_forms(forms: &[SExp]) -> String {
    let mut out = String::new();
    for f in forms {
        render_at(f, 0, &mut out);
        out.push('\n');
    }
    out
}

/// Typed wrapper around a Vec<SExp> so the unified `ast::Render` trait
/// has a target type (the free function `render_forms` predates the
/// trait + remains the implementation backend).
#[derive(Debug, Clone, Default)]
pub struct Forms(pub Vec<SExp>);

impl Forms {
    pub fn new() -> Self { Self::default() }
    pub fn push(&mut self, form: SExp) -> &mut Self { self.0.push(form); self }
    pub fn from(items: impl IntoIterator<Item = SExp>) -> Self {
        Self(items.into_iter().collect())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn keyword_renders_with_colon() {
        let mut out = String::new();
        render_at(&SExp::kw("name"), 0, &mut out);
        assert_eq!(out, ":name");
    }
    #[test]
    fn string_escapes_quote() {
        assert_eq!(escape_string(r#"a"b"#), r#""a\"b""#);
    }
    #[test]
    fn inline_simple_list() {
        let e = SExp::list([SExp::sym("name"), SExp::sym("widget")]);
        let mut out = String::new();
        render_at(&e, 0, &mut out);
        assert_eq!(out, "(name widget)");
    }
    #[test]
    fn multi_line_complex_list() {
        let e = SExp::list([
            SExp::sym("package"),
            SExp::list([SExp::sym("name"), SExp::sym("widget")]),
            SExp::list([SExp::sym("version"), SExp::sym("1.0")]),
        ]);
        let mut out = String::new();
        render_at(&e, 0, &mut out);
        assert!(out.starts_with("(package"));
        assert!(out.contains("(name widget)"));
    }
}