pleme-doc-gen 0.1.41

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 Scala AST + pretty-printer (SBT-DSL subset).
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Covers the
//! `build.sbt` shape:
//!
//!   name := "demo"
//!   version := "0.1.0"
//!   scalaVersion := "3.4.0"
//!   libraryDependencies ++= Seq(
//!     "org" %% "lib" % "ver",
//!   )
//!
//! SBT's operator-assignment is its own grammar (`:=`, `+=`, `++=`)
//! and the dependency-coord infix (`%`, `%%`, `%%%`) doesn't appear
//! in Kotlin/Swift. Hence a dedicated typed family.

use std::fmt::Write;

#[derive(Debug, Clone)]
pub enum Expr {
    /// `"text"` — double-quoted with Scala escape rules.
    String(String),
    /// Bare identifier or type name: `Seq`, `Some`.
    Ident(String),
    /// `name(arg, arg)` — function call. Always inline.
    Call { name: String, args: Vec<Expr> },
    /// `a op b op c` — infix-operator chain (left-associative).
    /// Used for SBT dep coords: `"org" %% "lib" % "ver"`.
    Infix { op: String, parts: Vec<Expr> },
}

impl Expr {
    pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
    pub fn ident(v: impl Into<String>) -> Self { Self::Ident(v.into()) }
    pub fn call(name: impl Into<String>, args: impl IntoIterator<Item = Expr>) -> Self {
        Self::Call { name: name.into(), args: args.into_iter().collect() }
    }
    pub fn infix(op: impl Into<String>, parts: impl IntoIterator<Item = Expr>) -> Self {
        Self::Infix { op: op.into(), parts: parts.into_iter().collect() }
    }

    fn render(&self) -> String {
        match self {
            Self::String(s) => render_scala_string(s),
            Self::Ident(s) => s.clone(),
            Self::Call { name, args } => {
                let mut out = String::from(name);
                out.push('(');
                let parts: Vec<String> = args.iter().map(Expr::render).collect();
                out.push_str(&parts.join(", "));
                out.push(')');
                out
            }
            Self::Infix { op, parts } => {
                let rendered: Vec<String> = parts.iter().map(Expr::render).collect();
                let sep = {
                    let mut s = String::from(" ");
                    s.push_str(op);
                    s.push(' ');
                    s
                };
                rendered.join(&sep)
            }
        }
    }
}

/// Scala double-quoted string escape — `"`, `\`, `$` (interp trigger),
/// tab, newline, CR.
fn render_scala_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("\\$"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

/// SBT-style statements. The operator-assignment is part of the typed
/// shape — callers pick Set / Append, not the literal `:=` / `++=`.
#[derive(Debug, Clone)]
pub enum Stmt {
    /// `key := value`
    Set { key: String, value: Expr },
    /// `key += value`
    Add { key: String, value: Expr },
    /// `key ++= value`
    Append { key: String, value: Expr },
    /// Blank-line separator.
    Blank,
    /// `// comment`
    Comment(String),
}

/// A typed SBT/Scala build file.
#[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 } => {
                    write_op(&mut out, key, ":=", value);
                }
                Stmt::Add { key, value } => {
                    write_op(&mut out, key, "+=", value);
                }
                Stmt::Append { key, value } => {
                    write_op(&mut out, key, "++=", value);
                }
                Stmt::Blank => out.push('\n'),
                Stmt::Comment(c) => {
                    let _ = writeln!(out, "// {c}");
                }
            }
        }
        out
    }
}

/// `key op value` with smart layout: a Seq(...) RHS with > 1 element
/// breaks onto multiple lines indented 2 spaces.
fn write_op(out: &mut String, key: &str, op: &str, value: &Expr) {
    let val_str = value.render();
    // Multi-line layout for `Seq(...)` calls that contain ≥ 2 args.
    let break_multi = matches!(value,
        Expr::Call { name, args } if name == "Seq" && args.len() >= 2
    );
    if break_multi {
        if let Expr::Call { name, args } = value {
            let _ = writeln!(out, "{key} {op} {name}(");
            for (i, a) in args.iter().enumerate() {
                let _ = write!(out, "  {}", a.render());
                if i + 1 < args.len() { out.push(','); }
                out.push('\n');
            }
            let _ = writeln!(out, ")");
            return;
        }
    }
    let _ = writeln!(out, "{key} {op} {val_str}");
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn string_escapes_quote_and_dollar() {
        assert_eq!(render_scala_string(r#"a"$b"#), r#""a\"\$b""#);
    }
    #[test]
    fn infix_renders_with_spaced_operator() {
        let e = Expr::infix("%%", vec![Expr::s("org"), Expr::s("lib")]);
        assert_eq!(e.render(), r#""org" %% "lib""#);
    }
    #[test]
    fn chained_infix_left_associative() {
        let e = Expr::infix(
            "%",
            vec![
                Expr::infix("%%", vec![Expr::s("org"), Expr::s("lib")]),
                Expr::s("ver"),
            ],
        );
        // Render: `"org" %% "lib" % "ver"` — flat because outer separator
        // uses " % ", inner stays flat from its own render.
        let out = e.render();
        assert!(out.contains(r#""org" %% "lib""#));
        assert!(out.ends_with(r#" % "ver""#));
    }
    #[test]
    fn set_renders_with_colon_equals() {
        let mut f = File::new();
        f.push(Stmt::Set { key: "name".into(), value: Expr::s("demo") });
        assert_eq!(f.render(), "name := \"demo\"\n");
    }
    #[test]
    fn append_seq_multiline_when_two_or_more() {
        let mut f = File::new();
        f.push(Stmt::Append {
            key: "libraryDependencies".into(),
            value: Expr::call("Seq", vec![
                Expr::infix("%", vec![
                    Expr::infix("%%", vec![Expr::s("a"), Expr::s("b")]),
                    Expr::s("1"),
                ]),
                Expr::infix("%", vec![
                    Expr::infix("%%", vec![Expr::s("c"), Expr::s("d")]),
                    Expr::s("2"),
                ]),
            ]),
        });
        let out = f.render();
        assert!(out.starts_with("libraryDependencies ++= Seq(\n"));
        assert!(out.contains("  \"a\" %% \"b\" % \"1\","));
        assert!(out.contains("  \"c\" %% \"d\" % \"2\""));
        assert!(out.ends_with(")\n"));
    }
}