use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
use rspyts_core::ir::{Dtype, Manifest, Ty, TypeDecl};
use sha2::{Digest, Sha256};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
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
}
pub fn py_header(hash: &str) -> String {
format!(
"# Code generated by rspyts v{VERSION}. DO NOT EDIT.\n# rspyts:manifest-hash sha256:{hash}\n"
)
}
pub fn ts_header(hash: &str) -> String {
format!(
"// Code generated by rspyts v{VERSION}. DO NOT EDIT.\n// rspyts:manifest-hash sha256:{hash}\n"
)
}
pub const GENERATED_MARKER: &str = "Code generated by rspyts";
pub fn pascal(name: &str) -> String {
name.to_upper_camel_case()
}
pub fn py_name(wire: &str) -> String {
let snake = wire.to_snake_case();
if PY_KEYWORDS.contains(&snake.as_str()) {
format!("{snake}_")
} else {
snake
}
}
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",
];
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,
}
}
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",
}
}
pub fn find_type<'m>(m: &'m Manifest, name: &str) -> Option<&'m TypeDecl> {
m.types.iter().find(|t| t.name() == name)
}
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(),
}
}
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(),
}
}
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),
_ => {}
}
}
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()
}
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
}
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
}
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() {
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"));
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"
);
}
}