pub fn snake_case(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mut out = String::new();
for (index, ¤t) in chars.iter().enumerate() {
if current.is_uppercase() && index > 0 {
let previous = chars[index - 1];
let next_lower = chars.get(index + 1).is_some_and(|c| c.is_lowercase());
if previous.is_lowercase()
|| previous.is_numeric()
|| (previous.is_uppercase() && next_lower)
{
out.push('_');
}
}
out.extend(current.to_lowercase());
}
out
}
pub fn camel_case(name: &str) -> String {
name.split('_')
.filter(|segment| !segment.is_empty())
.map(|segment| {
let mut chars = segment.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
pub fn pascal_case(name: &str) -> String {
camel_case(&snake_case(name))
}
#[cfg(test)]
mod tests {
use super::{camel_case, pascal_case, snake_case};
#[test]
fn camel_case_pins_the_outputs_its_docstring_names() {
assert_eq!(camel_case("httpServer"), "HttpServer");
assert_eq!(camel_case("HTTPServer"), "HTTPServer");
assert_eq!(camel_case("foo_bar"), "FooBar");
}
#[test]
fn camel_case_is_not_injective_in_two_ways() {
assert_eq!(camel_case("foo_bar"), camel_case("fooBar"));
assert_eq!(camel_case("fooBar"), camel_case("FooBar"));
}
#[test]
fn the_two_transforms_are_incomparable() {
assert_eq!(camel_case("XY"), camel_case("x_y"));
assert_ne!(snake_case("XY"), snake_case("x_y"));
assert_ne!(camel_case("HTTPServer"), camel_case("httpServer"));
assert_eq!(snake_case("HTTPServer"), snake_case("httpServer"));
}
#[test]
fn an_acronym_stays_one_word() {
assert_eq!(snake_case("getVIN"), "get_vin");
assert_eq!(snake_case("ABC"), "abc");
}
#[test]
fn an_acronym_followed_by_a_word_splits() {
assert_eq!(snake_case("HTTPServer"), "http_server");
assert_eq!(snake_case("IOError"), "io_error");
assert_eq!(snake_case("parseHTTPResponse"), "parse_http_response");
}
#[test]
fn a_camel_case_name_splits_on_every_boundary() {
assert_eq!(snake_case("currentSpeed"), "current_speed");
assert_eq!(snake_case("speed2Target"), "speed2_target");
assert_eq!(snake_case("aB"), "a_b");
}
#[test]
fn an_underscore_already_present_is_kept() {
assert_eq!(snake_case("already_snake"), "already_snake");
assert_eq!(snake_case("mixed_CaseName"), "mixed_case_name");
}
#[test]
fn the_transform_is_idempotent() {
for name in [
"getVIN",
"HTTPServer",
"currentSpeed",
"mixed_CaseName",
"a1B2",
] {
let once = snake_case(name);
assert_eq!(snake_case(&once), once, "not idempotent on `{name}`");
}
}
#[test]
fn pascal_case_pins_the_outputs_the_design_names() {
for (input, expected) in [
("CHECK_ENGINE", "CheckEngine"),
("PARK", "Park"),
("OK", "Ok"),
("A", "A"),
("X2", "X2"),
("ABS_V2", "AbsV2"),
("V2_ABS", "V2Abs"),
("LEVEL_10", "Level10"),
("HTTP_SERVER", "HttpServer"),
("A__B", "AB"),
("A_", "A"),
("checkEngine", "CheckEngine"),
("HTTPServer", "HttpServer"),
("SELF", "Self"),
] {
assert_eq!(pascal_case(input), expected, "pascal_case(`{input}`)");
}
}
fn enumerated_names() -> Vec<String> {
let alphabet = ['a', 'B', '_', '2'];
let mut names: Vec<String> = vec!["a".to_string(), "B".to_string()];
let mut frontier = names.clone();
for _ in 1..5 {
let mut next = Vec::new();
for name in &frontier {
for c in alphabet {
next.push(format!("{name}{c}"));
}
}
names.extend(next.iter().cloned());
frontier = next;
}
names
}
#[test]
fn pascal_case_collides_wherever_snake_case_does() {
let mut by_snake: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
for name in enumerated_names() {
let pascal = pascal_case(&name);
let (first, first_pascal) = by_snake
.entry(snake_case(&name))
.or_insert_with(|| (name.clone(), pascal.clone()));
assert_eq!(
*first_pascal, pascal,
"`{first}` and `{name}` share a snake_case output but not a pascal_case one"
);
}
}
#[test]
fn pascal_case_collides_where_snake_case_does_not() {
assert_ne!(snake_case("CHECK_ENGINE"), snake_case("CHECK__ENGINE"));
assert_eq!(pascal_case("CHECK_ENGINE"), pascal_case("CHECK__ENGINE"));
}
#[test]
fn every_pascal_case_output_satisfies_non_camel_case_types() {
for name in enumerated_names() {
let pascal = pascal_case(&name);
assert!(!pascal.is_empty(), "`{name}` gives an empty name");
assert!(!pascal.contains('_'), "`{name}` gives `{pascal}`");
assert!(
pascal.starts_with(|c: char| c.is_ascii_uppercase()),
"`{name}` gives `{pascal}`"
);
}
}
}