//! Casing transforms — port of `protocol/mizan-generate/generator/lib/casing.mjs`. //! //! The Mizan IR uses snake_case names (`user_id`, `update_profile`). Per-target //! identifier conventions vary: TypeScript wants `pascalCase`/`camelCase`, //! Rust wants `snake_case` (with `r#`-escaping for keywords). These helpers //! pin the conversion so emit-targets share one vocabulary. fn split_parts(s: &str) -> Vec<&str> { s.split(|c: char| c == '.' || c == '-' || c == '_') .filter(|p| !p.is_empty()) .collect() } fn uppercase_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(first) => first.to_uppercase().chain(chars).collect(), None => String::new(), } } fn lowercase_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { Some(first) => first.to_lowercase().chain(chars).collect(), None => String::new(), } } pub fn pascal_case(s: &str) -> String { split_parts(s).into_iter().map(uppercase_first).collect() } pub fn camel_case(s: &str) -> String { let pascal = pascal_case(s); lowercase_first(&pascal) } /// Insert underscores at lowercase/digit-to-uppercase boundaries, unify with /// the existing `.`/`-`/`_` separators, then lowercase + join. pub fn snake_case(s: &str) -> String { let mut with_boundaries = String::with_capacity(s.len() + 4); let mut prev: Option = None; for c in s.chars() { if let Some(p) = prev { if (p.is_ascii_lowercase() || p.is_ascii_digit()) && c.is_ascii_uppercase() { with_boundaries.push('_'); } } with_boundaries.push(c); prev = Some(c); } split_parts(&with_boundaries) .into_iter() .map(|p| p.to_ascii_lowercase()) .collect::>() .join("_") } /// Rust reserved words that can be escaped via `r#` (excludes `crate`, `self`, /// `Self`, `super`, `extern`, which can't be raw-escaped on stable). const RUST_RAW_KEYWORDS: &[&str] = &[ "as", "break", "const", "continue", "else", "enum", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "static", "struct", "trait", "true", "type", "unsafe", "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "union", ]; const RUST_HARD_RESERVED: &[&str] = &["crate", "self", "Self", "super", "extern"]; pub fn rust_ident(name: &str) -> String { let snake = snake_case(name); if RUST_HARD_RESERVED.contains(&snake.as_str()) { format!("{snake}_") } else if RUST_RAW_KEYWORDS.contains(&snake.as_str()) { format!("r#{snake}") } else { snake } } pub fn rust_type_ident(name: &str) -> String { let pascal = pascal_case(name); if RUST_HARD_RESERVED.contains(&pascal.as_str()) { format!("{pascal}_") } else if RUST_RAW_KEYWORDS.contains(&pascal.as_str()) { format!("r#{pascal}") } else { pascal } } #[cfg(test)] mod tests { use super::*; #[test] fn pascal_case_matches_js_codegen() { assert_eq!(pascal_case("user_profile"), "UserProfile"); assert_eq!(pascal_case("find-user"), "FindUser"); assert_eq!(pascal_case("api.v1.users"), "ApiV1Users"); assert_eq!(pascal_case(""), ""); } #[test] fn camel_case_matches_js_codegen() { assert_eq!(camel_case("user_profile"), "userProfile"); assert_eq!(camel_case("UpdateProfile"), "updateProfile"); } #[test] fn snake_case_inserts_pascal_boundaries() { assert_eq!(snake_case("UserProfile"), "user_profile"); assert_eq!(snake_case("camelCase"), "camel_case"); assert_eq!(snake_case("already_snake"), "already_snake"); assert_eq!(snake_case("HTTPResponse"), "httpresponse"); // matches JS behavior } #[test] fn rust_ident_escapes_keywords() { assert_eq!(rust_ident("type"), "r#type"); assert_eq!(rust_ident("normal"), "normal"); assert_eq!(rust_ident("self"), "self_"); } }