pleme-doc-gen 0.1.45

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 YAML AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Used by the
//! 5 YAML ecosystems (helm / crystal / dart / conda / racket-helper).

use std::fmt::Write;

/// Ordered YAML value. Preserves insertion order (YAML maps are
/// stable in practice though spec-wise unordered).
#[derive(Debug, Clone)]
pub enum Value {
    String(String),
    Int(i64),
    Bool(bool),
    Null,
    Array(Vec<Value>),
    Map(Vec<(String, Value)>),
    /// YAML literal block scalar — renders as `|\n  line1\n  line2`.
    /// Use for multi-line content (shell scripts, embedded code) where
    /// the YAML idiom is `|` rather than escaped double-quoted scalars.
    BlockScalar(String),
}

impl Value {
    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 arr(vs: impl IntoIterator<Item = Value>) -> Self {
        Self::Array(vs.into_iter().collect())
    }
    pub fn map() -> Self { Self::Map(Vec::new()) }
    /// Construct a typed block scalar (YAML `|` literal). Preserves
    /// newlines verbatim under the appropriate indent.
    pub fn block(v: impl Into<String>) -> Self { Self::BlockScalar(v.into()) }

    pub fn insert(&mut self, k: impl Into<String>, v: Value) -> &mut Self {
        if let Self::Map(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
    }
}

/// Render a scalar value (used at leaf positions). YAML strings may
/// often be unquoted but we quote everything that needs escaping or
/// looks like a YAML reserved literal (true/false/null/numbers).
fn render_scalar(s: &str) -> String {
    // Quote when contains : # & * [ ] { } | > ' " or starts with `- ` or
    // matches yaml keywords / could be parsed as number.
    let needs_quote = s.is_empty()
        || s.starts_with(|c: char| c.is_ascii_whitespace())
        || s.ends_with(|c: char| c.is_ascii_whitespace())
        || s.chars().any(|c| matches!(c, ':' | '#' | '&' | '*' | '[' | ']' | '{' | '}' | '|' | '>' | '\'' | '"'))
        || matches!(s, "true" | "false" | "null" | "yes" | "no")
        || s.parse::<f64>().is_ok();
    if needs_quote {
        let escaped: String = s
            .chars()
            .flat_map(|c| match c {
                '"' => "\\\"".chars().collect::<Vec<_>>().into_iter(),
                '\\' => "\\\\".chars().collect::<Vec<_>>().into_iter(),
                c => vec![c].into_iter(),
            })
            .collect();
        let mut out = String::from("\"");
        out.push_str(&escaped);
        out.push('"');
        out
    } else {
        s.to_string()
    }
}

/// Render a YAML value at a given block-style indent depth.
fn render_at(v: &Value, indent: usize, out: &mut String) {
    match v {
        Value::String(s) => out.push_str(&render_scalar(s)),
        Value::Int(n) => { let _ = write!(out, "{n}"); }
        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Value::Null => out.push_str("null"),
        Value::Array(items) if items.is_empty() => out.push_str("[]"),
        Value::Array(items) => {
            for v in items {
                out.push('\n');
                push_indent(out, indent);
                out.push_str("- ");
                match v {
                    // Map-in-array: first entry inline on the `- ` line,
                    // remaining entries indented at depth+1. Canonical
                    // YAML block-style; no leading "- \n  k: v".
                    Value::Map(inner) if !inner.is_empty() => {
                        for (i, (k, mv)) in inner.iter().enumerate() {
                            if i > 0 {
                                out.push('\n');
                                push_indent(out, indent + 1);
                            }
                            out.push_str(&render_scalar(k));
                            out.push(':');
                            match mv {
                                Value::Map(im) if !im.is_empty() => {
                                    render_at(mv, indent + 2, out);
                                }
                                Value::Array(ia) if !ia.is_empty() => {
                                    render_at(mv, indent + 2, out);
                                }
                                _ => {
                                    out.push(' ');
                                    render_at(mv, indent + 1, out);
                                }
                            }
                        }
                    }
                    Value::Array(_) => render_at(v, indent + 1, out),
                    _ => render_at(v, indent, out),
                }
            }
        }
        Value::Map(items) if items.is_empty() => out.push_str("{}"),
        Value::Map(items) => {
            for (k, v) in items {
                out.push('\n');
                push_indent(out, indent);
                out.push_str(&render_scalar(k));
                out.push(':');
                match v {
                    Value::Map(inner) if !inner.is_empty() => render_at(v, indent + 1, out),
                    Value::Array(inner) if !inner.is_empty() => render_at(v, indent + 1, out),
                    Value::BlockScalar(_) => render_at(v, indent + 1, out),
                    _ => {
                        out.push(' ');
                        render_at(v, indent, out);
                    }
                }
            }
        }
        Value::BlockScalar(s) => {
            out.push_str(" |");
            for line in s.lines() {
                out.push('\n');
                push_indent(out, indent);
                out.push_str(line);
            }
        }
    }
}

fn push_indent(out: &mut String, depth: usize) {
    for _ in 0..depth { out.push_str("  "); }
}

pub fn render(v: &Value) -> String {
    let mut out = String::new();
    match v {
        Value::Map(_) | Value::Array(_) => {
            // Render top-level map without leading newline
            render_at(v, 0, &mut out);
            if out.starts_with('\n') {
                out.remove(0);
            }
        }
        _ => render_at(v, 0, &mut out),
    }
    if !out.ends_with('\n') { out.push('\n'); }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scalar_with_colon_gets_quoted() {
        assert!(render_scalar("foo:bar").starts_with('"'));
        assert!(!render_scalar("plain").starts_with('"'));
    }
    #[test]
    fn map_renders_with_indent() {
        let mut m = Value::map();
        m.insert("name", Value::s("demo"));
        m.insert("version", Value::s("0.1.0"));
        let out = render(&m);
        assert!(out.contains("name: demo"));
        assert!(out.contains("version: "));
    }
    #[test]
    fn nested_map_indented() {
        let mut outer = Value::map();
        let mut inner = Value::map();
        inner.insert("type", Value::s("git"));
        outer.insert("repository", inner);
        let s = render(&outer);
        assert!(s.contains("repository:"));
        assert!(s.contains("  type: git"));
    }

    #[test]
    fn array_of_maps_inlines_first_key_on_dash_line() {
        // The canonical block-style YAML for a list of maps puts
        // the first key on the `- ` line:
        //   - name: a
        //     ecosystem: x
        //   - name: b
        //     ecosystem: y
        // NOT:
        //   -
        //     name: a
        //     ecosystem: x
        let mut r1 = Value::map();
        r1.insert("name", Value::s("a"));
        r1.insert("ecosystem", Value::s("x"));
        let mut r2 = Value::map();
        r2.insert("name", Value::s("b"));
        let out = render(&Value::arr([r1, r2]));
        assert!(out.contains("- name: a\n"),
            "first map key should be inline on dash line; got: {out}");
        assert!(out.contains("  ecosystem: x\n"),
            "subsequent map keys should indent at depth+1; got: {out}");
        assert!(out.contains("- name: b\n"),
            "second map entry should also inline; got: {out}");
    }
}