pub fn split_snake(s: &str) -> Vec<&str> {
s.split('_').filter(|p| !p.is_empty()).collect()
}
pub fn split_kebab(s: &str) -> Vec<&str> {
s.split('-').filter(|p| !p.is_empty()).collect()
}
pub fn split_camel(s: &str) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
if chars.is_empty() {
return Vec::new();
}
let mut words = Vec::new();
let mut cur = String::new();
for i in 0..chars.len() {
let c = chars[i];
if i > 0 && c.is_uppercase() {
let prev = chars[i - 1];
let next = chars.get(i + 1).copied();
let prev_lower_or_digit = prev.is_lowercase() || prev.is_ascii_digit();
let acronym_end = prev.is_uppercase() && next.is_some_and(|n| n.is_lowercase());
if (prev_lower_or_digit || acronym_end) && !cur.is_empty() {
words.push(cur.clone());
cur.clear();
}
}
for lc in c.to_lowercase() {
cur.push(lc);
}
}
if !cur.is_empty() {
words.push(cur);
}
words
}
pub fn split_identifier(s: &str) -> Vec<String> {
s.split(['_', '-'])
.filter(|part| !part.is_empty())
.flat_map(split_camel)
.collect()
}
pub fn uppercase_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub fn lowercase_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_lowercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub fn words_to_camel<S: AsRef<str>>(words: &[S]) -> String {
let mut out = String::new();
for (i, w) in words.iter().enumerate() {
let lower = w.as_ref().to_lowercase();
if i == 0 {
out.push_str(&lower);
} else {
out.push_str(&uppercase_first(&lower));
}
}
out
}
pub fn words_to_pascal<S: AsRef<str>>(words: &[S]) -> String {
words
.iter()
.map(|w| uppercase_first(&w.as_ref().to_lowercase()))
.collect()
}
pub fn words_to_snake<S: AsRef<str>>(words: &[S]) -> String {
join_lowercase(words, '_')
}
pub fn words_to_kebab<S: AsRef<str>>(words: &[S]) -> String {
join_lowercase(words, '-')
}
fn join_lowercase<S: AsRef<str>>(words: &[S], separator: char) -> String {
let mut out = String::new();
for (i, w) in words.iter().enumerate() {
if i > 0 {
out.push(separator);
}
out.push_str(&w.as_ref().to_lowercase());
}
out
}
pub fn to_snake_case(s: &str) -> String {
words_to_snake(&split_identifier(s))
}
pub fn to_pascal_case(s: &str) -> String {
words_to_pascal(&split_identifier(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acronyms_stay_one_word() {
assert_eq!(to_snake_case("HTTPServer"), "http_server");
assert_eq!(to_snake_case("PRReview"), "pr_review");
assert_eq!(to_pascal_case("http_server"), "HttpServer");
}
#[test]
fn conversion_is_idempotent_on_already_converted_input() {
assert_eq!(to_snake_case("foo_bar"), "foo_bar");
assert_eq!(to_pascal_case("FooBar"), "FooBar");
}
#[test]
fn split_identifier_accepts_mixed_separators() {
assert_eq!(
split_identifier("some-mixedCase_name"),
vec!["some", "mixed", "case", "name"]
);
assert_eq!(split_identifier("__leading"), vec!["leading"]);
assert!(split_identifier("").is_empty());
}
#[test]
fn digits_start_a_new_word_only_at_a_capital() {
assert_eq!(to_snake_case("v2Endpoint"), "v2_endpoint");
}
}