fn take_chars(value: &str, count: usize) -> String {
value.chars().take(count).collect()
}
fn capitalize_word(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => {
let mut result = String::new();
result.extend(first.to_uppercase());
result.extend(chars.flat_map(char::to_lowercase));
result
}
None => String::new(),
}
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => {
let mut result = String::new();
result.extend(first.to_uppercase());
result.push_str(chars.as_str());
result
}
None => String::new(),
}
}
fn trim_edge(value: &str, needle: char) -> String {
value.trim_matches(needle).to_string()
}
fn basic_underscore(value: &str) -> String {
let chars: Vec<char> = value.chars().collect();
let mut result = String::new();
for (index, current) in chars.iter().copied().enumerate() {
let previous = index.checked_sub(1).and_then(|idx| chars.get(idx)).copied();
let next = chars.get(index + 1).copied();
if current == ':' && next == Some(':') {
continue;
}
if current == '_' || current == '-' || current.is_whitespace() || !current.is_alphanumeric()
{
if !result.is_empty() && !result.ends_with('_') {
result.push('_');
}
continue;
}
let should_insert_separator = if current.is_uppercase() {
let previous_is_lower_or_digit = previous
.map(|ch| ch.is_lowercase() || ch.is_ascii_digit())
.unwrap_or(false);
let previous_is_upper = previous.map(char::is_uppercase).unwrap_or(false);
let next_is_lower = next.map(char::is_lowercase).unwrap_or(false);
!result.is_empty()
&& !result.ends_with('_')
&& (previous_is_lower_or_digit || (previous_is_upper && next_is_lower))
} else {
false
};
if should_insert_separator {
result.push('_');
}
result.extend(current.to_lowercase());
}
trim_edge(&result, '_')
}
fn basic_pluralize(value: &str) -> String {
let lower = value.to_ascii_lowercase();
if lower.ends_with("ch")
|| lower.ends_with("sh")
|| lower.ends_with('s')
|| lower.ends_with('x')
|| lower.ends_with('z')
{
format!("{value}es")
} else if lower.ends_with('y') {
let stem = &value[..value.len().saturating_sub(1)];
let previous = stem.chars().last();
let ends_with_consonant = previous
.map(|ch| {
matches!(
ch.to_ascii_lowercase(),
'b' | 'c'
| 'd'
| 'f'
| 'g'
| 'h'
| 'j'
| 'k'
| 'l'
| 'm'
| 'n'
| 'p'
| 'q'
| 'r'
| 's'
| 't'
| 'v'
| 'w'
| 'x'
| 'y'
| 'z'
)
})
.unwrap_or(false);
if ends_with_consonant {
format!("{stem}ies")
} else {
format!("{value}s")
}
} else {
format!("{value}s")
}
}
fn basic_singularize(value: &str) -> String {
let lower = value.to_ascii_lowercase();
if lower.ends_with("ies") && value.chars().count() > 3 {
let stem = &value[..value.len().saturating_sub(3)];
format!("{stem}y")
} else if lower.ends_with("ches")
|| lower.ends_with("shes")
|| lower.ends_with("ses")
|| lower.ends_with("xes")
|| lower.ends_with("zes")
{
value[..value.len().saturating_sub(2)].to_string()
} else if lower.ends_with('s') && !lower.ends_with("ss") && value.chars().count() > 1 {
value[..value.len().saturating_sub(1)].to_string()
} else {
value.to_string()
}
}
fn transliterate_char(value: char) -> Option<&'static str> {
match value {
'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => Some("a"),
'ç' | 'ć' | 'ĉ' | 'ċ' | 'č' => Some("c"),
'ď' | 'đ' => Some("d"),
'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => Some("e"),
'ƒ' => Some("f"),
'ĝ' | 'ğ' | 'ġ' | 'ģ' => Some("g"),
'ĥ' | 'ħ' => Some("h"),
'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' | 'ı' => Some("i"),
'ĵ' => Some("j"),
'ķ' => Some("k"),
'ĺ' | 'ļ' | 'ľ' | 'ŀ' | 'ł' => Some("l"),
'ñ' | 'ń' | 'ņ' | 'ň' | 'ʼn' | 'ŋ' => Some("n"),
'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => Some("o"),
'œ' => Some("oe"),
'ŕ' | 'ŗ' | 'ř' => Some("r"),
'ś' | 'ŝ' | 'ş' | 'š' | 'ß' => Some("s"),
'ţ' | 'ť' | 'ŧ' => Some("t"),
'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => Some("u"),
'ŵ' => Some("w"),
'ý' | 'ÿ' | 'ŷ' => Some("y"),
'ź' | 'ż' | 'ž' => Some("z"),
'æ' => Some("ae"),
_ => None,
}
}
fn parameterize_string(value: &str) -> String {
let mut result = String::new();
let mut pending_separator = false;
for current in value.chars() {
if current.is_ascii_alphanumeric() {
if pending_separator && !result.is_empty() {
result.push('-');
}
pending_separator = false;
result.extend(current.to_lowercase());
continue;
}
let lowercase = current.to_lowercase().next().unwrap_or(current);
if let Some(replacement) = transliterate_char(lowercase) {
if pending_separator && !result.is_empty() {
result.push('-');
}
pending_separator = false;
result.push_str(replacement);
continue;
}
if current.is_alphanumeric() {
if pending_separator && !result.is_empty() {
result.push('-');
}
pending_separator = false;
result.extend(current.to_lowercase());
continue;
}
pending_separator = true;
}
trim_edge(&result, '-')
}
pub trait StringExt {
fn is_blank(&self) -> bool;
fn is_present(&self) -> bool {
!self.is_blank()
}
fn truncate(&self, length: usize) -> String {
self.truncate_with(length, "...")
}
fn truncate_with(&self, length: usize, omission: &str) -> String;
fn truncate_words(&self, count: usize) -> String {
self.truncate_words_with(count, "...")
}
fn truncate_words_with(&self, count: usize, omission: &str) -> String;
fn squish(&self) -> String;
fn remove(&self, pattern: &str) -> String;
fn presence(&self) -> Option<&str>;
fn camelize(&self) -> String;
fn underscore(&self) -> String;
fn dasherize(&self) -> String;
fn titleize(&self) -> String;
fn tableize(&self) -> String;
fn classify(&self) -> String;
fn pluralize(&self) -> String;
fn singularize(&self) -> String;
fn humanize(&self) -> String;
fn foreign_key(&self) -> String;
fn parameterize(&self) -> String;
}
impl StringExt for str {
fn is_blank(&self) -> bool {
self.trim().is_empty()
}
fn truncate_with(&self, length: usize, omission: &str) -> String {
let char_count = self.chars().count();
if char_count <= length {
return self.to_string();
}
let omission_chars = omission.chars().count();
if length <= omission_chars {
return take_chars(omission, length);
}
let visible = length - omission_chars;
format!("{}{}", take_chars(self, visible), omission)
}
fn truncate_words_with(&self, count: usize, omission: &str) -> String {
let words: Vec<&str> = self.split_whitespace().collect();
if words.len() <= count {
return self.to_string();
}
if count == 0 {
return omission.to_string();
}
format!("{}{}", words[..count].join(" "), omission)
}
fn squish(&self) -> String {
self.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn remove(&self, pattern: &str) -> String {
if pattern.is_empty() {
return self.to_string();
}
self.replace(pattern, "")
}
fn presence(&self) -> Option<&str> {
if self.is_blank() { None } else { Some(self) }
}
fn camelize(&self) -> String {
self.split(|ch: char| ch == '_' || ch == '-' || ch.is_whitespace() || ch == '/')
.filter(|part| !part.is_empty())
.map(capitalize_word)
.collect::<Vec<_>>()
.join("")
}
fn underscore(&self) -> String {
basic_underscore(self)
}
fn dasherize(&self) -> String {
self.underscore().replace('_', "-")
}
fn titleize(&self) -> String {
self.underscore()
.split('_')
.filter(|part| !part.is_empty())
.map(capitalize_word)
.collect::<Vec<_>>()
.join(" ")
}
fn tableize(&self) -> String {
self.underscore().pluralize()
}
fn classify(&self) -> String {
self.singularize().camelize()
}
fn pluralize(&self) -> String {
basic_pluralize(self)
}
fn singularize(&self) -> String {
basic_singularize(self)
}
fn humanize(&self) -> String {
let underscored = self.underscore();
let without_id = underscored.strip_suffix("_id").unwrap_or(&underscored);
let humanized = without_id
.split('_')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
capitalize_first(&humanized)
}
fn foreign_key(&self) -> String {
let class_name = self.rsplit("::").next().unwrap_or(self);
format!("{}_id", class_name.underscore())
}
fn parameterize(&self) -> String {
parameterize_string(self)
}
}
impl StringExt for String {
fn is_blank(&self) -> bool {
self.as_str().is_blank()
}
fn truncate_with(&self, length: usize, omission: &str) -> String {
self.as_str().truncate_with(length, omission)
}
fn truncate_words_with(&self, count: usize, omission: &str) -> String {
self.as_str().truncate_words_with(count, omission)
}
fn squish(&self) -> String {
self.as_str().squish()
}
fn remove(&self, pattern: &str) -> String {
self.as_str().remove(pattern)
}
fn presence(&self) -> Option<&str> {
self.as_str().presence()
}
fn camelize(&self) -> String {
self.as_str().camelize()
}
fn underscore(&self) -> String {
self.as_str().underscore()
}
fn dasherize(&self) -> String {
self.as_str().dasherize()
}
fn titleize(&self) -> String {
self.as_str().titleize()
}
fn tableize(&self) -> String {
self.as_str().tableize()
}
fn classify(&self) -> String {
self.as_str().classify()
}
fn pluralize(&self) -> String {
self.as_str().pluralize()
}
fn singularize(&self) -> String {
self.as_str().singularize()
}
fn humanize(&self) -> String {
self.as_str().humanize()
}
fn foreign_key(&self) -> String {
self.as_str().foreign_key()
}
fn parameterize(&self) -> String {
self.as_str().parameterize()
}
}
#[cfg(test)]
mod tests {
use super::StringExt;
#[test]
fn is_blank_for_empty_string() {
assert!("".is_blank());
}
#[test]
fn is_blank_for_spaces() {
assert!(" ".is_blank());
}
#[test]
fn is_blank_for_tabs_and_newlines() {
assert!("\t\n".is_blank());
}
#[test]
fn is_blank_is_false_for_content() {
assert!(!" hello ".is_blank());
}
#[test]
fn is_present_is_true_for_content() {
assert!("hello".is_present());
}
#[test]
fn is_present_is_false_for_empty_string() {
assert!(!"".is_present());
}
#[test]
fn is_present_is_false_for_whitespace() {
assert!(!" ".is_present());
}
#[test]
fn truncate_shortens_and_appends_default_omission() {
assert_eq!("Hello...", "Hello World".truncate(8));
}
#[test]
fn truncate_with_custom_omission() {
assert_eq!("Hello***", "Hello World".truncate_with(8, "***"));
}
#[test]
fn truncate_returns_original_when_short_enough() {
assert_eq!("Hello", "Hello".truncate(10));
}
#[test]
fn truncate_returns_truncated_omission_when_length_is_shorter_than_omission() {
assert_eq!("..", "Hello".truncate(2));
}
#[test]
fn truncate_words_shortens_on_word_boundary() {
assert_eq!(
"Oh dear! Oh dear!...",
"Oh dear! Oh dear! I shall be late!".truncate_words(4)
);
}
#[test]
fn truncate_words_with_custom_omission() {
assert_eq!(
"Oh dear! Oh dear! [more]",
"Oh dear! Oh dear! I shall be late!".truncate_words_with(4, " [more]")
);
}
#[test]
fn truncate_words_returns_original_when_short_enough() {
assert_eq!("hello world", "hello world".truncate_words(2));
}
#[test]
fn truncate_words_zero_count_returns_omission() {
assert_eq!("...", "hello world".truncate_words(0));
}
#[test]
fn squish_collapses_internal_whitespace() {
assert_eq!("foo bar baz", " foo bar \n baz ".squish());
}
#[test]
fn squish_returns_empty_string_for_blank_input() {
assert_eq!("", "\n\t ".squish());
}
#[test]
fn remove_deletes_all_occurrences() {
assert_eq!("World", "Hello World".remove("Hello "));
}
#[test]
fn remove_returns_original_when_pattern_missing() {
assert_eq!("Hello World", "Hello World".remove("Goodbye"));
}
#[test]
fn presence_returns_some_for_present_string() {
assert_eq!(Some("hello"), "hello".presence());
}
#[test]
fn presence_returns_none_for_empty_string() {
assert_eq!(None, "".presence());
}
#[test]
fn presence_returns_none_for_whitespace() {
assert_eq!(None, " ".presence());
}
#[test]
fn camelize_converts_underscored_text() {
assert_eq!("ActiveModel", "active_model".camelize());
}
#[test]
fn underscore_converts_camel_case() {
assert_eq!("active_model", "ActiveModel".underscore());
}
#[test]
fn underscore_handles_acronyms() {
assert_eq!("html_tidy_generator", "HTMLTidyGenerator".underscore());
}
#[test]
fn dasherize_converts_underscores_to_dashes() {
assert_eq!("active-model", "active_model".dasherize());
}
#[test]
fn titleize_converts_to_title_case() {
assert_eq!(
"Man From The Boondocks",
"man_from_the_boondocks".titleize()
);
}
#[test]
fn tableize_converts_class_name_to_plural_table_name() {
assert_eq!("fancy_categories", "FancyCategory".tableize());
}
#[test]
fn classify_converts_table_name_to_class_name() {
assert_eq!("FancyCategory", "fancy_categories".classify());
}
#[test]
fn pluralize_handles_regular_nouns() {
assert_eq!("posts", "post".pluralize());
}
#[test]
fn pluralize_handles_words_ending_in_y() {
assert_eq!("categories", "category".pluralize());
}
#[test]
fn singularize_handles_regular_nouns() {
assert_eq!("post", "posts".singularize());
}
#[test]
fn singularize_handles_words_ending_in_ies() {
assert_eq!("category", "categories".singularize());
}
#[test]
fn humanize_replaces_underscores_and_capitalizes() {
assert_eq!("Employee salary", "employee_salary".humanize());
}
#[test]
fn humanize_strips_id_suffix() {
assert_eq!("Author", "author_id".humanize());
}
#[test]
fn foreign_key_converts_class_name_to_identifier_column() {
assert_eq!("message_id", "Message".foreign_key());
}
#[test]
fn foreign_key_demodulizes_namespaced_class_name() {
assert_eq!("post_id", "Admin::Post".foreign_key());
}
#[test]
fn parameterize_creates_url_friendly_slug() {
assert_eq!("donald-e-knuth", "Donald E. Knuth".parameterize());
}
#[test]
fn parameterize_collapses_duplicate_separators() {
assert_eq!("pencils-pens-paper", "Pencils, pens & paper".parameterize());
}
#[test]
fn string_impl_delegates_to_str_impl() {
let value = String::from("Donald E. Knuth");
assert_eq!("donald-e-knuth", value.parameterize());
}
#[test]
fn truncate_counts_characters_for_multibyte_input() {
assert_eq!("あ...", "ありがとう".truncate(4));
}
#[test]
fn truncate_with_multibyte_omission_respects_character_length() {
assert_eq!("He🦀", "Hello".truncate_with(3, "🦀"));
assert_eq!("🦀", "Hello".truncate_with(1, "🦀"));
}
#[test]
fn truncate_words_normalizes_whitespace_between_words() {
assert_eq!(
"Hello brave new...",
"Hello\n brave\tnew world".truncate_words(3)
);
}
#[test]
fn squish_collapses_unicode_whitespace() {
let value = "\u{205F}\u{3000}foo\u{0085}\t bar\u{00A0}";
assert_eq!("foo bar", value.squish());
}
#[test]
fn camelize_splits_on_mixed_separators() {
assert_eq!("ActiveModelErrorsApi", "active-model/errors api".camelize());
}
#[test]
fn titleize_handles_camel_case_and_punctuation() {
assert_eq!("X Men The Last Stand", "x-men: theLastStand".titleize());
assert_eq!("Html Tidy Generator", "HTMLTidyGenerator".titleize());
}
#[test]
fn pluralize_handles_es_endings_and_vowel_y() {
assert_eq!("boxes", "box".pluralize());
assert_eq!("dishes", "dish".pluralize());
assert_eq!("boys", "boy".pluralize());
}
#[test]
fn singularize_preserves_double_s_words_and_reverses_es_endings() {
assert_eq!("glass", "glass".singularize());
assert_eq!("bus", "buses".singularize());
}
#[test]
fn humanize_collapses_extra_separators_before_stripping_id_suffix() {
assert_eq!("Author", "__author__id__".humanize());
}
#[test]
fn parameterize_transliterates_and_trims_edge_separators() {
assert_eq!("creme-brulee", " Crème brûlée! ".parameterize());
assert_eq!("", " -_- ".parameterize());
}
#[test]
fn is_blank_handles_unicode_whitespace() {
assert!("\u{3000}\u{205F}".is_blank());
}
#[test]
fn is_present_is_false_for_unicode_whitespace() {
assert!(!"\u{3000}\n".is_present());
}
#[test]
fn truncate_with_zero_length_returns_empty_string() {
assert_eq!("", "Hello".truncate(0));
}
#[test]
fn truncate_with_empty_omission_keeps_visible_prefix() {
assert_eq!("Hell", "Hello".truncate_with(4, ""));
}
#[test]
fn truncate_with_long_omission_truncates_the_omission_itself() {
assert_eq!("[", "Hello".truncate_with(1, "[more]"));
}
#[test]
fn truncate_with_exact_length_returns_original() {
assert_eq!("Hello", "Hello".truncate_with(5, "[cut]"));
}
#[test]
fn truncate_words_with_custom_omission_keeps_original_spacing_when_not_truncated() {
assert_eq!(
"Hello world",
"Hello world".truncate_words_with(2, " [more]")
);
}
#[test]
fn truncate_words_with_custom_omission_truncates_to_word_boundary() {
assert_eq!(
"Hello [more]",
"Hello world again".truncate_words_with(1, " [more]")
);
}
#[test]
fn truncate_words_strips_leading_and_trailing_whitespace_when_truncating() {
assert_eq!(
"hello world...",
" hello world again ".truncate_words(2)
);
}
#[test]
fn squish_preserves_single_word_input() {
assert_eq!("hello", "hello".squish());
}
#[test]
fn remove_empty_pattern_returns_original_string() {
assert_eq!("banana", "banana".remove(""));
}
#[test]
fn remove_eliminates_multiple_non_overlapping_matches() {
assert_eq!("ba", "banana".remove("an"));
}
#[test]
fn presence_returns_some_for_multibyte_content() {
assert_eq!(Some("ありがとう"), "ありがとう".presence());
}
#[test]
fn camelize_discards_empty_segments() {
assert_eq!("ActiveModelErrors", "___active__model///errors".camelize());
}
#[test]
fn underscore_handles_namespaced_constants() {
assert_eq!("admin_html_parser", "Admin::HTMLParser".underscore());
}
#[test]
fn underscore_handles_mixed_punctuation_and_digits() {
assert_eq!(
"active_model2_json_api",
"ActiveModel2JSON API".underscore()
);
}
#[test]
fn dasherize_normalizes_mixed_separators() {
assert_eq!("active-model-errors", "active model/errors".dasherize());
}
#[test]
fn titleize_normalizes_repeated_separators() {
assert_eq!("Employee Salary Id", "__employee__salary__id__".titleize());
}
#[test]
fn tableize_handles_namespaced_class_names() {
assert_eq!("admin_user_profiles", "Admin::UserProfile".tableize());
}
#[test]
fn classify_handles_plural_snake_case() {
assert_eq!("UserProfile", "user_profiles".classify());
}
#[test]
fn pluralize_handles_words_ending_in_s_and_z() {
assert_eq!("buses", "bus".pluralize());
assert_eq!("buzzes", "buzz".pluralize());
}
#[test]
fn singularize_handles_words_ending_in_xes_and_plain_words() {
assert_eq!("box", "boxes".singularize());
assert_eq!("fish", "fish".singularize());
}
#[test]
fn humanize_collapses_duplicate_underscores() {
assert_eq!("Employee salary", "employee__salary__".humanize());
}
#[test]
fn foreign_key_handles_acronym_class_names() {
assert_eq!("html_parser_id", "HTMLParser".foreign_key());
}
#[test]
fn foreign_key_handles_namespaced_acronym_class_names() {
assert_eq!("html_parser_id", "Admin::HTMLParser".foreign_key());
}
#[test]
fn parameterize_preserves_non_latin_alphanumerics() {
assert_eq!("東京-2024", "東京 2024".parameterize());
}
#[test]
fn parameterize_transliterates_multichar_ligatures() {
assert_eq!("aeroskobing", "Ærøskøbing".parameterize());
}
#[test]
fn parameterize_collapses_mixed_punctuation_runs() {
assert_eq!("foo-bar-baz", "foo---bar___baz".parameterize());
}
}