use heck::{ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase};
use rspyts_core::ir::{Dtype, Manifest, Ty, TypeDecl};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct Provenance<'a> {
pub manifest_hash: &'a str,
pub rust_source: &'a str,
}
pub fn manifest_hash_hex(manifest: &Manifest) -> String {
rspyts_core::manifest_fingerprint(manifest)
}
pub fn py_header(provenance: &Provenance<'_>) -> String {
format!(
"# =============================================================================\n\
# Code generated by rspyts v{VERSION}. DO NOT EDIT THIS FILE.\n\
# Edit the Rust source tree instead: {}\n\
# Then regenerate these bindings with rspyts.\n\
# rspyts:manifest-hash sha256:{}\n\
# =============================================================================\n",
provenance.rust_source, provenance.manifest_hash,
)
}
pub fn ts_header(provenance: &Provenance<'_>) -> String {
format!(
"// ============================================================================\n\
// Code generated by rspyts v{VERSION}. DO NOT EDIT THIS FILE.\n\
// Edit the Rust source tree instead: {}\n\
// Then regenerate these bindings with rspyts.\n\
// rspyts:manifest-hash sha256:{}\n\
// ============================================================================\n",
provenance.rust_source, provenance.manifest_hash,
)
}
pub const GENERATED_MARKER: &str = "Code generated by rspyts";
pub fn pascal(name: &str) -> String {
name.to_upper_camel_case()
}
pub(crate) fn py_error_registry_name(name: &str) -> String {
let shouty = name.to_shouty_snake_case();
let base = shouty.strip_suffix("_ERROR").unwrap_or(&shouty);
format!("{base}_ERROR_TYPES")
}
pub(crate) fn ts_error_registry_name(name: &str) -> String {
let base = name.strip_suffix("Error").unwrap_or(name);
format!("{base}ErrorTypes")
}
pub fn py_name(name: &str) -> String {
let source = name.strip_prefix("r#").unwrap_or(name);
let mut snake: String = source
.to_snake_case()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' {
ch
} else {
'_'
}
})
.collect();
if snake.is_empty() {
snake.push_str("field");
}
if snake.starts_with('_') || snake.starts_with(|ch: char| ch.is_ascii_digit()) {
snake = format!("field_{snake}");
}
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::I8 => "Int8Array",
Dtype::U16 => "Uint16Array",
Dtype::I16 => "Int16Array",
Dtype::U32 => "Uint32Array",
Dtype::I32 => "Int32Array",
Dtype::U64 => "BigUint64Array",
Dtype::I64 => "BigInt64Array",
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!("typing.Annotated[int, pydantic.Field(ge={lo}, le={hi})]")
} else {
"int".into()
}
}
Ty::I64 | Ty::U64 => "int".into(),
Ty::F32 | Ty::F64 => "float".into(),
Ty::String => "str".into(),
Ty::Bytes => "bytes".into(),
Ty::Unit => "None".into(),
Ty::Null => "None".into(),
Ty::Option { inner } => {
let inner = py_type(inner, bounds, quote);
if inner == "None" || inner.ends_with(" | None") {
inner
} else {
format!("{inner} | None")
}
}
Ty::List { inner } => format!("list[{}]", py_type(inner, bounds, quote)),
Ty::Map { value } => format!("dict[str, {}]", py_type(value, bounds, quote)),
Ty::Tuple { items } => format!(
"tuple[{}]",
items
.iter()
.map(|item| py_type(item, bounds, quote))
.collect::<Vec<_>>()
.join(", ")
),
Ty::Ref { name } => {
if quote(name) {
format!("\"{name}\"")
} else {
name.clone()
}
}
Ty::Json => "typing.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::I64 | Ty::U64 => "bigint".into(),
Ty::String => "string".into(),
Ty::Bytes => "Uint8Array".into(),
Ty::Unit => "void".into(),
Ty::Null => "null".into(),
Ty::Option { inner } => {
let inner = ts_type(inner);
if inner == "null" || inner.ends_with(" | null") {
inner
} else {
format!("{inner} | null")
}
}
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::Tuple { items } => format!(
"[{}]",
items.iter().map(ts_type).collect::<Vec<_>>().join(", ")
),
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),
Ty::Tuple { items } => {
for item in items {
collect_refs(item, 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_delegates_to_the_core_manifest_fingerprint() {
let manifest = super::super::test_manifest::manifest();
assert_eq!(
manifest_hash_hex(&manifest),
rspyts_core::manifest_fingerprint(&manifest)
);
}
#[test]
fn py_name_snake_cases_and_avoids_keywords() {
assert_eq!(py_name("minimumValue"), "minimum_value");
assert_eq!(py_name("class"), "class_");
assert!(py_alias_roundtrips("minimum_value", "minimumValue"));
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[typing.Annotated[int, pydantic.Field(ge=0, le=255)]]"
);
assert_eq!(py_type(&ty, false, &|_| false), "list[int]");
let nullable = Ty::Option {
inner: Box::new(Ty::U8),
};
assert_eq!(
py_type(&nullable, true, &|_| false),
"typing.Annotated[int, pydantic.Field(ge=0, le=255)] | None"
);
}
#[test]
fn python_null_wrappers_collapse_to_a_single_none_type() {
let option_null = Ty::Option {
inner: Box::new(Ty::Null),
};
let nested = Ty::Option {
inner: Box::new(Ty::Option {
inner: Box::new(Ty::String),
}),
};
assert_eq!(py_type(&option_null, false, &|_| false), "None");
assert_eq!(py_type(&nested, false, &|_| false), "str | None");
assert_eq!(ts_type(&option_null), "null");
assert_eq!(ts_type(&nested), "string | null");
}
#[test]
fn option_reference_collection_is_recursive() {
let ty = Ty::Option {
inner: Box::new(Ty::List {
inner: Box::new(Ty::Ref {
name: "Nested".to_string(),
}),
}),
};
let mut refs = std::collections::BTreeSet::new();
collect_refs(&ty, &mut refs);
assert_eq!(refs.into_iter().collect::<Vec<_>>(), vec!["Nested"]);
}
#[test]
fn python_projects_native_exact_integers_and_json() {
assert_eq!(py_type(&Ty::I64, false, &|_| false), "int");
assert_eq!(py_type(&Ty::U64, false, &|_| false), "int");
assert_eq!(py_type(&Ty::Json, false, &|_| false), "typing.Any");
}
#[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"
);
}
}