pleme-doc-gen 0.1.53

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 Swift AST + pretty-printer (Package.swift DSL subset).
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Covers the
//! manifest subset of Swift used in `Package.swift`:
//!
//!   // swift-tools-version:5.9
//!   import PackageDescription
//!
//!   let package = Package(
//!       name: "demo",
//!       platforms: [.macOS(.v13)],
//!       products: [.library(name: "demo", targets: ["demo"])],
//!       dependencies: [.package(url: "…", from: "1.0.0")],
//!       targets: [.target(name: "demo")]
//!   )
//!
//! NOT a general Swift AST — no struct/class definitions, no control
//! flow, no closures. Extend by adding variants when a new manifest
//! shape needs it, never by punching format!() through.

use std::fmt::Write;

/// Typed Swift expression — the RHS shape in Package.swift.
#[derive(Debug, Clone)]
pub enum Expr {
    /// `"value"` — double-quoted with Swift's escape rules.
    String(String),
    /// `42` — integer literal.
    Int(i64),
    /// Bare identifier or type name: `Package`, `PackageDescription`.
    Ident(String),
    /// `.member` — leading-dot enum-case reference (no args), like `.v13`.
    DotMember(String),
    /// `[a, b, c]` — array literal.
    Array(Vec<Expr>),
    /// Function call. `head` can be a bare type (`"Package"`) or a
    /// leading-dot member (`".executable"`). Caller spells the dot.
    /// Each arg is (optional-label, value); label = `None` is positional.
    Call { head: String, args: Vec<(Option<String>, 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 ident(v: impl Into<String>) -> Self { Self::Ident(v.into()) }
    pub fn dot(member: impl Into<String>) -> Self { Self::DotMember(member.into()) }
    pub fn arr(items: impl IntoIterator<Item = Expr>) -> Self {
        Self::Array(items.into_iter().collect())
    }
    pub fn call(head: impl Into<String>, args: Vec<(Option<String>, Expr)>) -> Self {
        Self::Call { head: head.into(), args }
    }
    /// Helper: a positional arg.
    pub fn pos(e: Expr) -> (Option<String>, Expr) { (None, e) }
    /// Helper: a labelled arg.
    pub fn lbl(label: &str, e: Expr) -> (Option<String>, Expr) {
        (Some(label.to_string()), e)
    }

    fn render(&self, indent: usize) -> String {
        match self {
            Self::String(s) => render_swift_string(s),
            Self::Int(n) => n.to_string(),
            Self::Ident(s) => s.clone(),
            Self::DotMember(s) => {
                let mut out = String::from(".");
                out.push_str(s);
                out
            }
            Self::Array(items) if items.is_empty() => "[]".to_string(),
            Self::Array(items) => {
                let inline: Vec<String> = items.iter().map(|v| v.render(indent + 1)).collect();
                let joined = inline.join(", ");
                let oneline = {
                    let mut a = String::from("[");
                    a.push_str(&joined);
                    a.push(']');
                    a
                };
                // Inline form when it fits; otherwise multi-line.
                if oneline.len() <= 80 && !oneline.contains('\n') {
                    oneline
                } else {
                    let pad_outer = "    ".repeat(indent);
                    let pad_inner = "    ".repeat(indent + 1);
                    let mut out = String::from("[\n");
                    for (i, v) in items.iter().enumerate() {
                        let _ = write!(out, "{pad_inner}{}", v.render(indent + 1));
                        if i + 1 < items.len() { out.push(','); }
                        out.push('\n');
                    }
                    let _ = write!(out, "{pad_outer}]");
                    out
                }
            }
            Self::Call { head, args } if args.is_empty() => {
                let mut out = String::from(head);
                out.push_str("()");
                out
            }
            Self::Call { head, args } => {
                // Inline form for compact calls.
                let inline_parts: Vec<String> = args.iter().map(|(lbl, v)| {
                    let mut p = String::new();
                    if let Some(l) = lbl {
                        p.push_str(l);
                        p.push_str(": ");
                    }
                    p.push_str(&v.render(indent + 1));
                    p
                }).collect();
                let oneline = {
                    let mut a = String::from(head);
                    a.push('(');
                    a.push_str(&inline_parts.join(", "));
                    a.push(')');
                    a
                };
                if oneline.len() <= 80 && !oneline.contains('\n') {
                    oneline
                } else {
                    let pad_outer = "    ".repeat(indent);
                    let pad_inner = "    ".repeat(indent + 1);
                    let mut out = String::from(head);
                    out.push_str("(\n");
                    for (i, part) in inline_parts.iter().enumerate() {
                        let _ = write!(out, "{pad_inner}{part}");
                        if i + 1 < inline_parts.len() { out.push(','); }
                        out.push('\n');
                    }
                    let _ = write!(out, "{pad_outer})");
                    out
                }
            }
        }
    }
}

/// Swift double-quoted string escape — `"`, `\`, `\(` (interpolation
/// trigger), tab/newline/CR.
fn render_swift_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 statement: `let <name> = <expr>` is the only shape
/// Package.swift uses for the package binding.
#[derive(Debug, Clone)]
pub enum Stmt {
    Let { name: String, value: Expr },
    Blank,
    Comment(String),
}

/// A Package.swift file. The tools-version directive + import lines
/// are part of the typed shape, never hand-formatted.
#[derive(Debug, Clone, Default)]
pub struct File {
    pub tools_version: Option<String>,
    pub imports: Vec<String>,
    pub stmts: Vec<Stmt>,
}

impl File {
    pub fn new() -> Self { Self::default() }
    pub fn tools(&mut self, v: impl Into<String>) -> &mut Self {
        self.tools_version = Some(v.into()); self
    }
    pub fn import(&mut self, m: impl Into<String>) -> &mut Self {
        self.imports.push(m.into()); self
    }
    pub fn push(&mut self, s: Stmt) -> &mut Self { self.stmts.push(s); self }

    pub fn render(&self) -> String {
        let mut out = String::new();
        if let Some(v) = &self.tools_version {
            let _ = writeln!(out, "// swift-tools-version:{v}");
        }
        for m in &self.imports {
            let _ = writeln!(out, "import {m}");
        }
        if (self.tools_version.is_some() || !self.imports.is_empty()) && !self.stmts.is_empty() {
            out.push('\n');
        }
        for s in &self.stmts {
            match s {
                Stmt::Let { name, value } => {
                    let _ = writeln!(out, "let {name} = {}", value.render(0));
                }
                Stmt::Blank => out.push('\n'),
                Stmt::Comment(c) => {
                    let _ = writeln!(out, "// {c}");
                }
            }
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn string_escapes_quote_and_backslash() {
        assert_eq!(render_swift_string(r#"a"b\c"#), r#""a\"b\\c""#);
    }
    #[test]
    fn dot_member_renders_with_leading_dot() {
        assert_eq!(Expr::dot("v13").render(0), ".v13");
    }
    #[test]
    fn leading_dot_call_keeps_dot_in_head() {
        let e = Expr::call(".macOS", vec![Expr::pos(Expr::dot("v13"))]);
        assert_eq!(e.render(0), ".macOS(.v13)");
    }
    #[test]
    fn short_call_renders_inline() {
        let e = Expr::call("Foo", vec![Expr::lbl("name", Expr::s("x"))]);
        assert_eq!(e.render(0), "Foo(name: \"x\")");
    }
    #[test]
    fn long_call_breaks_to_multiline() {
        let e = Expr::call("Package", vec![
            Expr::lbl("name", Expr::s("very-long-name-that-pushes-us-past-the-eighty-char-budget")),
            Expr::lbl("platforms", Expr::arr([Expr::call(".macOS", vec![Expr::pos(Expr::dot("v13"))])])),
        ]);
        let out = e.render(0);
        assert!(out.starts_with("Package(\n"), "should break to multi-line");
        assert!(out.ends_with(")"));
        assert!(out.contains("    name:"), "each arg should be 4-space indented");
    }
    #[test]
    fn file_emits_tools_version_and_import() {
        let mut f = File::new();
        f.tools("5.9").import("PackageDescription");
        f.push(Stmt::Let {
            name: "package".into(),
            value: Expr::call("Package", vec![Expr::lbl("name", Expr::s("demo"))]),
        });
        let out = f.render();
        assert!(out.starts_with("// swift-tools-version:5.9\n"));
        assert!(out.contains("\nimport PackageDescription\n"));
        assert!(out.contains("\nlet package = Package(name: \"demo\")\n"));
    }
}