rspyts-cli 0.1.0

The rspyts code generator: emits pydantic models, TypeScript types, and JSON Schema from a bridged Rust crate.
//! Shared helpers for the three emitters: headers, hashing, casing,
//! and the Python/TypeScript projections of [`Ty`].

use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
use rspyts_core::ir::{Dtype, Manifest, Ty, TypeDecl};
use sha2::{Digest, Sha256};

/// The rspyts version stamped into every generated file header.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

/// Lowercase hex SHA-256 of the manifest JSON bytes as returned by the
/// module — the provenance line in every generated file.
pub fn manifest_hash_hex(manifest_json: &[u8]) -> String {
    let digest = Sha256::digest(manifest_json);
    let mut hex = String::with_capacity(64);
    for byte in digest {
        use std::fmt::Write as _;
        write!(hex, "{byte:02x}").expect("writing to String cannot fail");
    }
    hex
}

/// The two-line header for generated Python files.
pub fn py_header(hash: &str) -> String {
    format!(
        "# Code generated by rspyts v{VERSION}. DO NOT EDIT.\n# rspyts:manifest-hash sha256:{hash}\n"
    )
}

/// The two-line header for generated TypeScript files.
pub fn ts_header(hash: &str) -> String {
    format!(
        "// Code generated by rspyts v{VERSION}. DO NOT EDIT.\n// rspyts:manifest-hash sha256:{hash}\n"
    )
}

/// The marker `generate` requires in a file's first line before it will
/// delete it as no-longer-emitted, and `check` uses to spot strays.
pub const GENERATED_MARKER: &str = "Code generated by rspyts";

/// `demo-crate` → `DemoCrate` (for `{PascalCrateName}Client`).
pub fn pascal(name: &str) -> String {
    name.to_upper_camel_case()
}

/// Wire (camelCase) name → Python (snake_case) attribute name, with a
/// trailing underscore when the result would shadow a Python keyword.
pub fn py_name(wire: &str) -> String {
    let snake = wire.to_snake_case();
    if PY_KEYWORDS.contains(&snake.as_str()) {
        format!("{snake}_")
    } else {
        snake
    }
}

/// Would pydantic's `to_camel` alias generator round-trip this Python
/// name back to `wire`? When not, the field needs an explicit alias.
pub fn py_alias_roundtrips(py_name: &str, wire: &str) -> bool {
    py_name.to_lower_camel_case() == wire
}

const PY_KEYWORDS: &[&str] = &[
    "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue",
    "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import",
    "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
    "with", "yield",
];

/// Inclusive integer bounds for bounded scalar kinds.
pub fn int_bounds(ty: &Ty) -> Option<(i64, u64)> {
    match ty {
        Ty::U8 => Some((0, u8::MAX as u64)),
        Ty::U16 => Some((0, u16::MAX as u64)),
        Ty::U32 => Some((0, u32::MAX as u64)),
        Ty::I8 => Some((i8::MIN as i64, i8::MAX as u64)),
        Ty::I16 => Some((i16::MIN as i64, i16::MAX as u64)),
        Ty::I32 => Some((i32::MIN as i64, i32::MAX as u64)),
        _ => None,
    }
}

/// The TypeScript typed-array class for a dtype.
pub fn ts_typed_array(dt: Dtype) -> &'static str {
    match dt {
        Dtype::U8 => "Uint8Array",
        Dtype::I16 => "Int16Array",
        Dtype::I32 => "Int32Array",
        Dtype::F32 => "Float32Array",
        Dtype::F64 => "Float64Array",
    }
}

/// Look up a named type declaration.
pub fn find_type<'m>(m: &'m Manifest, name: &str) -> Option<&'m TypeDecl> {
    m.types.iter().find(|t| t.name() == name)
}

/// Python annotation for `ty`.
///
/// `bounds`: emit `Annotated[int, Field(ge=…, le=…)]` for bounded
/// integers (model fields) instead of plain `int` (signatures).
/// `quote`: names that must be forward-referenced as `"Name"` because
/// they are not yet defined at this point in `models.py`.
pub fn py_type(ty: &Ty, bounds: bool, quote: &dyn Fn(&str) -> bool) -> String {
    match ty {
        Ty::Bool => "bool".into(),
        Ty::U8 | Ty::U16 | Ty::U32 | Ty::I8 | Ty::I16 | Ty::I32 => {
            if bounds {
                let (lo, hi) = int_bounds(ty).expect("bounded integer kind");
                format!("Annotated[int, Field(ge={lo}, le={hi})]")
            } else {
                "int".into()
            }
        }
        Ty::F32 | Ty::F64 => "float".into(),
        Ty::String => "str".into(),
        Ty::Unit => "None".into(),
        Ty::Option { inner } => format!("{} | None", py_type(inner, bounds, quote)),
        Ty::List { inner } => format!("list[{}]", py_type(inner, bounds, quote)),
        Ty::Map { value } => format!("dict[str, {}]", py_type(value, bounds, quote)),
        Ty::Ref { name } => {
            if quote(name) {
                format!("\"{name}\"")
            } else {
                name.clone()
            }
        }
        Ty::Json => "Any".into(),
        Ty::Buf { .. } | Ty::Slice { .. } => "np.ndarray".into(),
    }
}

/// TypeScript type for `ty`. `Unit` renders as `void` (return-only).
pub fn ts_type(ty: &Ty) -> String {
    match ty {
        Ty::Bool => "boolean".into(),
        Ty::U8 | Ty::U16 | Ty::U32 | Ty::I8 | Ty::I16 | Ty::I32 | Ty::F32 | Ty::F64 => {
            "number".into()
        }
        Ty::String => "string".into(),
        Ty::Unit => "void".into(),
        Ty::Option { inner } => format!("{} | null", ts_type(inner)),
        Ty::List { inner } => {
            let inner = ts_type(inner);
            if inner.contains(' ') {
                format!("({inner})[]")
            } else {
                format!("{inner}[]")
            }
        }
        Ty::Map { value } => format!("Record<string, {}>", ts_type(value)),
        Ty::Ref { name } => name.clone(),
        Ty::Json => "unknown".into(),
        Ty::Buf { dt } | Ty::Slice { dt } => ts_typed_array(*dt).into(),
    }
}

/// Collect the names of every `Ty::Ref` in `ty` into `out` (recursive).
pub fn collect_refs(ty: &Ty, out: &mut std::collections::BTreeSet<String>) {
    match ty {
        Ty::Ref { name } => {
            out.insert(name.clone());
        }
        Ty::Option { inner } | Ty::List { inner } => collect_refs(inner, out),
        Ty::Map { value } => collect_refs(value, out),
        _ => {}
    }
}

/// Split docs into trimmed lines, dropping leading/trailing empties.
pub fn doc_lines(docs: &str) -> Vec<&str> {
    let lines: Vec<&str> = docs.lines().map(str::trim_end).collect();
    let start = lines.iter().position(|l| !l.trim().is_empty());
    let Some(start) = start else {
        return Vec::new();
    };
    let end = lines
        .iter()
        .rposition(|l| !l.trim().is_empty())
        .expect("start exists");
    lines[start..=end].to_vec()
}

/// A Python docstring block at `indent`, or empty when no docs. Always
/// multiline: the quotes sit on their own lines, never around the text.
pub fn py_docstring(docs: &str, indent: &str) -> String {
    let lines = doc_lines(docs);
    if lines.is_empty() {
        return String::new();
    }
    let escape = |s: &str| s.replace('\\', "\\\\").replace("\"\"\"", "\\\"\\\"\\\"");
    let mut out = format!("{indent}\"\"\"\n");
    for line in &lines {
        if line.trim().is_empty() {
            out.push('\n');
        } else {
            out.push_str(indent);
            out.push_str(&escape(line));
            out.push('\n');
        }
    }
    out.push_str(indent);
    out.push_str("\"\"\"\n");
    out
}

/// A TypeScript `/** … */` doc comment at `indent` from raw lines, or
/// empty when there are none.
pub fn ts_doc_from_lines(lines: &[String], indent: &str) -> String {
    if lines.is_empty() {
        return String::new();
    }
    let escape = |s: &str| s.replace("*/", "*\\/");
    if lines.len() == 1 {
        return format!("{indent}/** {} */\n", escape(&lines[0]));
    }
    let mut out = format!("{indent}/**\n");
    for line in lines {
        if line.trim().is_empty() {
            out.push_str(indent);
            out.push_str(" *\n");
        } else {
            out.push_str(indent);
            out.push_str(" * ");
            out.push_str(&escape(line));
            out.push('\n');
        }
    }
    out.push_str(indent);
    out.push_str(" */\n");
    out
}

/// A TypeScript doc comment from a manifest docs string.
pub fn ts_doc(docs: &str, indent: &str) -> String {
    let lines: Vec<String> = doc_lines(docs).into_iter().map(str::to_string).collect();
    ts_doc_from_lines(&lines, indent)
}

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

    #[test]
    fn hash_is_lowercase_hex_sha256() {
        // SHA-256 of the empty string, a well-known vector.
        assert_eq!(
            manifest_hash_hex(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn py_name_snake_cases_and_avoids_keywords() {
        assert_eq!(py_name("minDurationS"), "min_duration_s");
        assert_eq!(py_name("class"), "class_");
        assert!(py_alias_roundtrips("min_duration_s", "minDurationS"));
        // pydantic's to_camel drops the trailing underscore, just like
        // heck: keyword-renamed attributes need no explicit alias.
        assert!(py_alias_roundtrips("class_", "class"));
        assert!(!py_alias_roundtrips("kind", "Type"));
    }

    #[test]
    fn ts_list_of_union_is_parenthesized() {
        let ty = Ty::List {
            inner: Box::new(Ty::Option {
                inner: Box::new(Ty::F64),
            }),
        };
        assert_eq!(ts_type(&ty), "(number | null)[]");
    }

    #[test]
    fn py_nested_bounded_int_uses_annotated() {
        let ty = Ty::List {
            inner: Box::new(Ty::U8),
        };
        assert_eq!(
            py_type(&ty, true, &|_| false),
            "list[Annotated[int, Field(ge=0, le=255)]]"
        );
        assert_eq!(py_type(&ty, false, &|_| false), "list[int]");
    }

    #[test]
    fn multiline_docs_render_in_both_languages() {
        let docs = "First line.\n\nSecond paragraph.";
        assert_eq!(
            py_docstring(docs, "    "),
            "    \"\"\"\n    First line.\n\n    Second paragraph.\n    \"\"\"\n"
        );
        assert_eq!(
            ts_doc(docs, ""),
            "/**\n * First line.\n *\n * Second paragraph.\n */\n"
        );
    }
}