use std::{char::ToUppercase, collections::HashSet, iter::Peekable, sync::LazyLock};
use any_ascii::any_ascii;
use inflections::Inflect;
use regex::Regex;
static FORBIDDEN_IDENTIFIERS: LazyLock<HashSet<&str>> = LazyLock::new(|| {
[
"as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for", "if", "impl", "in",
"let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "static", "struct", "super", "trait", "true",
"type", "unsafe", "use", "where", "while", "async", "await", "dyn", "try", "abstract", "become", "box", "do",
"final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "gen",
"self", "Self",
]
.into_iter()
.collect()
});
static RESERVED_PASCAL_CASE: LazyLock<HashSet<&str>> = LazyLock::new(|| {
["Clone", "Copy", "Display", "Self", "Send", "Sync", "Type", "Vec"]
.into_iter()
.collect()
});
static INVALID_CHARS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^A-Za-z0-9_]+").unwrap());
static MULTI_UNDERSCORE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"_+").unwrap());
pub(crate) fn sanitize(input: &str) -> String {
if input.is_empty() {
return String::new();
}
let ascii = any_ascii(input);
let replaced = INVALID_CHARS_RE.replace_all(&ascii, "_");
let collapsed = MULTI_UNDERSCORE_RE.replace_all(&replaced, "_");
collapsed.trim_matches('_').to_string()
}
pub(crate) fn to_rust_field_name(name: &str) -> String {
let (has_leading_minus, name_without_minus) = if let Some(stripped) = name.strip_prefix('-') {
(true, stripped)
} else {
(false, name)
};
let mut ident = sanitize(name_without_minus).to_snake_case();
if ident.is_empty() {
return "_".to_string();
}
if has_leading_minus {
ident = format!("negative_{ident}");
}
if ident == "self" {
return "self_".to_string();
}
if FORBIDDEN_IDENTIFIERS.contains(ident.as_str()) {
return format!("r#{ident}");
}
if ident.starts_with(|c: char| c.is_ascii_digit()) {
ident.insert(0, '_');
}
ident
}
pub(crate) fn to_rust_type_name(name: &str) -> String {
use crate::reserved::CapitalizeWordsExt;
let (has_leading_minus, name_without_minus) = if let Some(stripped) = name.strip_prefix('-') {
(true, stripped)
} else {
(false, name)
};
let has_separators = name_without_minus.contains(['-', '_', '.', ' ']);
let has_upper = name_without_minus.chars().any(|c| c.is_ascii_uppercase());
let has_lower = name_without_minus.chars().any(|c| c.is_ascii_lowercase());
let appears_mixed_case = !has_separators && has_upper && has_lower;
let mut ident = if appears_mixed_case {
let ascii = any_ascii(name_without_minus);
let cleaned: String = ascii.chars().filter(char::is_ascii_alphanumeric).collect();
if cleaned.is_empty() {
cleaned
} else {
let mut chars = cleaned.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
} else {
let ascii = any_ascii(name_without_minus);
ascii
.chars()
.capitalize_words(|c| !c.is_ascii_alphanumeric())
.filter(char::is_ascii_alphanumeric) .collect()
};
if ident.is_empty() {
return "Unnamed".to_string();
}
if has_leading_minus {
ident = format!("Negative{ident}");
}
if RESERVED_PASCAL_CASE.contains(ident.as_str()) {
return format!("r#{ident}");
}
if ident.starts_with(|c: char| c.is_ascii_digit()) {
ident.insert(0, 'T');
}
ident
}
pub(crate) fn regex_const_name(key: &[&str]) -> String {
let joined = key.iter().map(|part| sanitize(part)).collect::<Vec<_>>().join("_");
let mut ident = joined.to_constant_case();
if ident.starts_with(|c: char| c.is_ascii_digit()) {
ident.insert(0, '_');
}
format!("REGEX_{ident}")
}
pub(crate) fn header_const_name(header: &str) -> String {
let sanitized = sanitize(header);
if sanitized.is_empty() {
return "HEADER".to_string();
}
let mut ident = sanitized.to_constant_case();
if ident.starts_with(|c: char| c.is_ascii_digit()) {
ident.insert(0, '_');
}
ident
}
pub trait CapitalizeWordsExt: Iterator<Item = char> {
fn capitalize_words<F>(self, is_separator: F) -> CapitalizeWords<Self, F>
where
Self: Sized,
F: Fn(char) -> bool;
}
impl<I> CapitalizeWordsExt for I
where
I: Iterator<Item = char>,
{
fn capitalize_words<F>(self, is_separator: F) -> CapitalizeWords<Self, F>
where
Self: Sized,
F: Fn(char) -> bool,
{
CapitalizeWords {
iter: self.peekable(),
is_separator,
capitalize_next: true,
pending_upper: None,
}
}
}
#[derive(Clone)]
pub struct CapitalizeWords<I, F>
where
I: Iterator<Item = char>,
F: Fn(char) -> bool,
{
iter: Peekable<I>,
is_separator: F,
capitalize_next: bool,
pending_upper: Option<ToUppercase>,
}
impl<I, F> Iterator for CapitalizeWords<I, F>
where
I: Iterator<Item = char>,
F: Fn(char) -> bool,
{
type Item = char;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(ref mut upper_iter) = self.pending_upper {
if let Some(c) = upper_iter.next() {
return Some(c);
}
self.pending_upper = None;
}
let c = self.iter.next()?;
if (self.is_separator)(c) {
self.capitalize_next = self.iter.peek().is_some_and(|next_c| !(self.is_separator)(*next_c));
Some(c)
} else if self.capitalize_next {
self.capitalize_next = false;
self.pending_upper = Some(c.to_uppercase());
self.pending_upper.as_mut().unwrap().next()
} else {
Some(c)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_field_names() {
assert_eq!(to_rust_field_name("foo-bar"), "foo_bar");
assert_eq!(to_rust_field_name("match"), "r#match");
assert_eq!(to_rust_field_name("self"), "self_");
assert_eq!(to_rust_field_name("123name"), "_123name");
assert_eq!(to_rust_field_name(""), "_");
assert_eq!(to_rust_field_name(" "), "_");
}
#[test]
fn test_field_names_negative_prefix() {
assert_eq!(to_rust_field_name("-created-date"), "negative_created_date");
assert_eq!(to_rust_field_name("-id"), "negative_id");
assert_eq!(to_rust_field_name("-modified-date"), "negative_modified_date");
assert_eq!(to_rust_field_name("-"), "_");
}
#[test]
fn test_type_names() {
assert_eq!(to_rust_type_name("oAuth"), "OAuth");
assert_eq!(to_rust_type_name("-INF"), "NegativeINF");
assert_eq!(to_rust_type_name("123Response"), "T123Response");
assert_eq!(to_rust_type_name(""), "Unnamed");
assert_eq!(to_rust_type_name(" "), "Unnamed");
}
#[test]
fn test_type_names_preserve_pascal_case() {
assert_eq!(
to_rust_type_name("BetaResponseMCPToolUseBlock"),
"BetaResponseMCPToolUseBlock"
);
assert_eq!(to_rust_type_name("XMLHttpRequest"), "XMLHttpRequest");
assert_eq!(to_rust_type_name("IOError"), "IOError");
assert_eq!(to_rust_type_name("HTTPSConnection"), "HTTPSConnection");
assert_eq!(
to_rust_type_name("betaResponseMCPToolUseBlock"),
"BetaResponseMCPToolUseBlock"
);
assert_eq!(to_rust_type_name("xmlHttpRequest"), "XmlHttpRequest");
assert_eq!(
to_rust_type_name("beta_response_mcp_tool_use_block"),
"BetaResponseMcpToolUseBlock"
);
assert_eq!(
to_rust_type_name("beta-response-mcp-tool-use-block"),
"BetaResponseMcpToolUseBlock"
);
assert_eq!(to_rust_type_name("beta_ResponseMCP"), "BetaResponseMCP");
assert_eq!(to_rust_type_name("Beta-Response-MCP"), "BetaResponseMCP");
}
#[test]
fn test_type_names_negative_prefix() {
assert_eq!(to_rust_type_name("-created-date"), "NegativeCreatedDate");
assert_eq!(to_rust_type_name("-id"), "NegativeId");
assert_eq!(to_rust_type_name("-modified-date"), "NegativeModifiedDate");
assert_eq!(to_rust_type_name("-child-position"), "NegativeChildPosition");
assert_eq!(to_rust_type_name("-"), "Unnamed");
}
#[test]
fn test_type_name_reserved_pascal() {
assert_eq!(to_rust_type_name("clone"), "r#Clone");
assert_eq!(to_rust_type_name("Vec"), "r#Vec");
}
#[test]
fn test_const_name() {
assert_eq!(regex_const_name(&["foo.bar", "baz"]), "REGEX_FOO_BAR_BAZ");
assert_eq!(regex_const_name(&["1a", "2b"]), "REGEX__1A_2B");
}
#[test]
fn test_header_const_name() {
assert_eq!(header_const_name("x-my-header"), "X_MY_HEADER");
assert_eq!(header_const_name("Content-Type"), "CONTENT_TYPE");
assert_eq!(header_const_name("123-custom"), "_123_CUSTOM");
assert_eq!(header_const_name(""), "HEADER");
}
}