pub fn camel(value: &str) -> String {
let studly = studly(value);
let mut chars = studly.chars();
match chars.next() {
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub fn studly(value: &str) -> String {
value
.split(|c: char| c == '_' || c == '-' || c.is_whitespace())
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
pub fn snake(value: &str) -> String {
delimit(value, '_')
}
pub fn kebab(value: &str) -> String {
delimit(value, '-')
}
fn delimit(value: &str, delimiter: char) -> String {
let mut out = String::with_capacity(value.len() + 4);
let chars: Vec<char> = value.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '_' || c == '-' || c.is_whitespace() {
if !out.ends_with(delimiter) && !out.is_empty() {
out.push(delimiter);
}
continue;
}
if c.is_uppercase() && i > 0 {
let prev = chars[i - 1];
let ends_run = prev.is_lowercase()
|| prev.is_numeric()
|| chars.get(i + 1).is_some_and(|next| next.is_lowercase());
if ends_run && !out.is_empty() && !out.ends_with(delimiter) {
out.push(delimiter);
}
}
out.extend(c.to_lowercase());
}
out
}
pub fn plural(value: &str) -> String {
if value.is_empty() {
return String::new();
}
if is_plural(value) {
return value.to_string();
}
pluralize_raw(value)
}
pub fn singular(value: &str) -> String {
if value.is_empty() {
return String::new();
}
singularize_raw(value)
}
fn is_plural(value: &str) -> bool {
let singular = singularize_raw(value);
singular != value && pluralize_raw(&singular).eq_ignore_ascii_case(value)
}
fn pluralize_raw(value: &str) -> String {
let lower = value.to_lowercase();
if UNCOUNTABLE.contains(&lower.as_str()) {
return value.to_string();
}
for (singular, plural) in IRREGULAR {
if lower == *singular {
return match_case(value, plural);
}
}
let ends_with = |suffix: &str| lower.ends_with(suffix);
if ends_with("s") || ends_with("x") || ends_with("z") || ends_with("ch") || ends_with("sh") {
return format!("{value}es");
}
if ends_with("y") && !ends_with_vowel_before_y(&lower) {
return format!("{}ies", &value[..value.len() - 1]);
}
if ends_with("fe") {
return format!("{}ves", &value[..value.len() - 2]);
}
if ends_with("f") {
return format!("{}ves", &value[..value.len() - 1]);
}
format!("{value}s")
}
fn singularize_raw(value: &str) -> String {
let lower = value.to_lowercase();
if UNCOUNTABLE.contains(&lower.as_str()) {
return value.to_string();
}
for (singular, plural) in IRREGULAR {
if lower == *plural {
return match_case(value, singular);
}
if lower == *singular {
return value.to_string();
}
}
if lower.ends_with("ies") && value.len() > 3 {
return format!("{}y", &value[..value.len() - 3]);
}
if lower.ends_with("ves") && value.len() > 3 {
return format!("{}f", &value[..value.len() - 3]);
}
for suffix in ["ses", "xes", "zes", "ches", "shes"] {
if lower.ends_with(suffix) {
return value[..value.len() - 2].to_string();
}
}
if lower.ends_with('s') && !lower.ends_with("ss") {
return value[..value.len() - 1].to_string();
}
value.to_string()
}
fn ends_with_vowel_before_y(lower: &str) -> bool {
let mut chars = lower.chars().rev();
chars.next(); matches!(chars.next(), Some('a' | 'e' | 'i' | 'o' | 'u'))
}
fn match_case(source: &str, replacement: &str) -> String {
if source.chars().all(|c| c.is_uppercase() || !c.is_alphabetic()) {
return replacement.to_uppercase();
}
if source.chars().next().is_some_and(char::is_uppercase) {
let mut chars = replacement.chars();
return match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
};
}
replacement.to_string()
}
const IRREGULAR: &[(&str, &str)] = &[
("person", "people"),
("man", "men"),
("woman", "women"),
("child", "children"),
("tooth", "teeth"),
("foot", "feet"),
("mouse", "mice"),
("goose", "geese"),
("knife", "knives"),
("life", "lives"),
("wife", "wives"),
];
const UNCOUNTABLE: &[&str] = &["equipment", "information", "money", "series", "species", "data"];
pub fn slug(value: &str) -> String {
slug_with(value, '-')
}
pub fn slug_with(value: &str, separator: char) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
} else if !out.ends_with(separator) {
out.push(separator);
}
}
out.trim_matches(separator).to_string()
}
pub fn ucfirst(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub fn humanize(value: &str) -> String {
ucfirst(&snake(value).replace('_', " "))
}
pub fn class_basename(value: &str) -> &str {
let head = value.split('<').next().unwrap_or(value);
head.rsplit("::").next().unwrap_or(head)
}
pub fn limit(value: &str, limit: usize, end: &str) -> String {
if value.chars().count() <= limit {
return value.to_string();
}
let truncated: String = value.chars().take(limit).collect();
format!("{truncated}{end}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_conversions() {
assert_eq!(studly("user_profile"), "UserProfile");
assert_eq!(studly("user-profile"), "UserProfile");
assert_eq!(camel("user_profile"), "userProfile");
assert_eq!(snake("UserProfile"), "user_profile");
assert_eq!(kebab("UserProfile"), "user-profile");
assert_eq!(snake("user_profile"), "user_profile");
}
#[test]
fn capital_runs_stay_together() {
assert_eq!(snake("HTTPRequest"), "http_request");
assert_eq!(snake("ParseJSON"), "parse_json");
assert_eq!(kebab("APIToken"), "api-token");
}
#[test]
fn pluralisation_covers_the_common_endings() {
assert_eq!(plural("post"), "posts");
assert_eq!(plural("class"), "classes");
assert_eq!(plural("box"), "boxes");
assert_eq!(plural("category"), "categories");
assert_eq!(plural("day"), "days");
assert_eq!(plural("knife"), "knives");
assert_eq!(plural("person"), "people");
assert_eq!(plural("Person"), "People");
assert_eq!(plural("data"), "data");
}
#[test]
fn pluralising_an_already_plural_word_is_a_no_op() {
for word in ["posts", "classes", "boxes", "categories", "people", "knives", "days"] {
assert_eq!(plural(word), word, "plural({word})");
}
}
#[test]
fn singularisation_is_the_inverse() {
assert_eq!(singular("posts"), "post");
assert_eq!(singular("classes"), "class");
assert_eq!(singular("categories"), "category");
assert_eq!(singular("people"), "person");
assert_eq!(singular("knives"), "knife");
assert_eq!(singular("post"), "post");
}
#[test]
fn slugs_collapse_punctuation() {
assert_eq!(slug("Hello, World!"), "hello-world");
assert_eq!(slug(" spaced out "), "spaced-out");
assert_eq!(slug_with("Hello World", '_'), "hello_world");
}
#[test]
fn basenames_drop_the_module_path() {
assert_eq!(class_basename("app::models::Post"), "Post");
assert_eq!(class_basename("Post"), "Post");
assert_eq!(class_basename("core::option::Option<app::Post>"), "Option");
}
#[test]
fn limit_never_splits_a_char() {
assert_eq!(limit("hello", 10, "..."), "hello");
assert_eq!(limit("hello world", 5, "..."), "hello...");
assert_eq!(limit("héllo", 2, ""), "hé");
}
#[test]
fn humanize_reads_as_a_sentence() {
assert_eq!(humanize("email_address"), "Email address");
assert_eq!(humanize("emailAddress"), "Email address");
}
}