1fn take_chars(value: &str, count: usize) -> String {
2 value.chars().take(count).collect()
3}
4
5fn capitalize_word(value: &str) -> String {
6 let mut chars = value.chars();
7 match chars.next() {
8 Some(first) => {
9 let mut result = String::new();
10 result.extend(first.to_uppercase());
11 result.extend(chars.flat_map(char::to_lowercase));
12 result
13 }
14 None => String::new(),
15 }
16}
17
18fn capitalize_first(value: &str) -> String {
19 let mut chars = value.chars();
20 match chars.next() {
21 Some(first) => {
22 let mut result = String::new();
23 result.extend(first.to_uppercase());
24 result.push_str(chars.as_str());
25 result
26 }
27 None => String::new(),
28 }
29}
30
31fn trim_edge(value: &str, needle: char) -> String {
32 value.trim_matches(needle).to_string()
33}
34
35fn basic_underscore(value: &str) -> String {
36 let chars: Vec<char> = value.chars().collect();
37 let mut result = String::new();
38
39 for (index, current) in chars.iter().copied().enumerate() {
40 let previous = index.checked_sub(1).and_then(|idx| chars.get(idx)).copied();
41 let next = chars.get(index + 1).copied();
42
43 if current == ':' && next == Some(':') {
44 continue;
45 }
46
47 if current == '_' || current == '-' || current.is_whitespace() || !current.is_alphanumeric()
48 {
49 if !result.is_empty() && !result.ends_with('_') {
50 result.push('_');
51 }
52 continue;
53 }
54
55 let should_insert_separator = if current.is_uppercase() {
56 let previous_is_lower_or_digit = previous
57 .map(|ch| ch.is_lowercase() || ch.is_ascii_digit())
58 .unwrap_or(false);
59 let previous_is_upper = previous.map(char::is_uppercase).unwrap_or(false);
60 let next_is_lower = next.map(char::is_lowercase).unwrap_or(false);
61
62 !result.is_empty()
63 && !result.ends_with('_')
64 && (previous_is_lower_or_digit || (previous_is_upper && next_is_lower))
65 } else {
66 false
67 };
68
69 if should_insert_separator {
70 result.push('_');
71 }
72
73 result.extend(current.to_lowercase());
74 }
75
76 trim_edge(&result, '_')
77}
78
79fn basic_pluralize(value: &str) -> String {
80 let lower = value.to_ascii_lowercase();
81
82 if lower.ends_with("ch")
83 || lower.ends_with("sh")
84 || lower.ends_with('s')
85 || lower.ends_with('x')
86 || lower.ends_with('z')
87 {
88 format!("{value}es")
89 } else if lower.ends_with('y') {
90 let stem = &value[..value.len().saturating_sub(1)];
91 let previous = stem.chars().last();
92 let ends_with_consonant = previous
93 .map(|ch| {
94 matches!(
95 ch.to_ascii_lowercase(),
96 'b' | 'c'
97 | 'd'
98 | 'f'
99 | 'g'
100 | 'h'
101 | 'j'
102 | 'k'
103 | 'l'
104 | 'm'
105 | 'n'
106 | 'p'
107 | 'q'
108 | 'r'
109 | 's'
110 | 't'
111 | 'v'
112 | 'w'
113 | 'x'
114 | 'y'
115 | 'z'
116 )
117 })
118 .unwrap_or(false);
119
120 if ends_with_consonant {
121 format!("{stem}ies")
122 } else {
123 format!("{value}s")
124 }
125 } else {
126 format!("{value}s")
127 }
128}
129
130fn basic_singularize(value: &str) -> String {
131 let lower = value.to_ascii_lowercase();
132
133 if lower.ends_with("ies") && value.chars().count() > 3 {
134 let stem = &value[..value.len().saturating_sub(3)];
135 format!("{stem}y")
136 } else if lower.ends_with("ches")
137 || lower.ends_with("shes")
138 || lower.ends_with("ses")
139 || lower.ends_with("xes")
140 || lower.ends_with("zes")
141 {
142 value[..value.len().saturating_sub(2)].to_string()
143 } else if lower.ends_with('s') && !lower.ends_with("ss") && value.chars().count() > 1 {
144 value[..value.len().saturating_sub(1)].to_string()
145 } else {
146 value.to_string()
147 }
148}
149
150fn transliterate_char(value: char) -> Option<&'static str> {
151 match value {
152 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => Some("a"),
153 'ç' | 'ć' | 'ĉ' | 'ċ' | 'č' => Some("c"),
154 'ď' | 'đ' => Some("d"),
155 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => Some("e"),
156 'ƒ' => Some("f"),
157 'ĝ' | 'ğ' | 'ġ' | 'ģ' => Some("g"),
158 'ĥ' | 'ħ' => Some("h"),
159 'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' | 'ı' => Some("i"),
160 'ĵ' => Some("j"),
161 'ķ' => Some("k"),
162 'ĺ' | 'ļ' | 'ľ' | 'ŀ' | 'ł' => Some("l"),
163 'ñ' | 'ń' | 'ņ' | 'ň' | 'ʼn' | 'ŋ' => Some("n"),
164 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => Some("o"),
165 'œ' => Some("oe"),
166 'ŕ' | 'ŗ' | 'ř' => Some("r"),
167 'ś' | 'ŝ' | 'ş' | 'š' | 'ß' => Some("s"),
168 'ţ' | 'ť' | 'ŧ' => Some("t"),
169 'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => Some("u"),
170 'ŵ' => Some("w"),
171 'ý' | 'ÿ' | 'ŷ' => Some("y"),
172 'ź' | 'ż' | 'ž' => Some("z"),
173 'æ' => Some("ae"),
174 _ => None,
175 }
176}
177
178fn parameterize_string(value: &str) -> String {
179 let mut result = String::new();
180 let mut pending_separator = false;
181
182 for current in value.chars() {
183 if current.is_ascii_alphanumeric() {
184 if pending_separator && !result.is_empty() {
185 result.push('-');
186 }
187 pending_separator = false;
188 result.extend(current.to_lowercase());
189 continue;
190 }
191
192 let lowercase = current.to_lowercase().next().unwrap_or(current);
193 if let Some(replacement) = transliterate_char(lowercase) {
194 if pending_separator && !result.is_empty() {
195 result.push('-');
196 }
197 pending_separator = false;
198 result.push_str(replacement);
199 continue;
200 }
201
202 if current.is_alphanumeric() {
203 if pending_separator && !result.is_empty() {
204 result.push('-');
205 }
206 pending_separator = false;
207 result.extend(current.to_lowercase());
208 continue;
209 }
210
211 pending_separator = true;
212 }
213
214 trim_edge(&result, '-')
215}
216
217pub trait StringExt {
219 fn is_blank(&self) -> bool;
221
222 fn is_present(&self) -> bool {
224 !self.is_blank()
225 }
226
227 fn truncate(&self, length: usize) -> String {
229 self.truncate_with(length, "...")
230 }
231
232 fn truncate_with(&self, length: usize, omission: &str) -> String;
234
235 fn truncate_words(&self, count: usize) -> String {
237 self.truncate_words_with(count, "...")
238 }
239
240 fn truncate_words_with(&self, count: usize, omission: &str) -> String;
242
243 fn squish(&self) -> String;
245
246 fn remove(&self, pattern: &str) -> String;
248
249 fn presence(&self) -> Option<&str>;
251
252 fn camelize(&self) -> String;
254
255 fn underscore(&self) -> String;
257
258 fn dasherize(&self) -> String;
260
261 fn titleize(&self) -> String;
263
264 fn tableize(&self) -> String;
266
267 fn classify(&self) -> String;
269
270 fn pluralize(&self) -> String;
272
273 fn singularize(&self) -> String;
275
276 fn humanize(&self) -> String;
278
279 fn foreign_key(&self) -> String;
281
282 fn parameterize(&self) -> String;
284}
285
286impl StringExt for str {
287 fn is_blank(&self) -> bool {
288 self.trim().is_empty()
289 }
290
291 fn truncate_with(&self, length: usize, omission: &str) -> String {
292 let char_count = self.chars().count();
293 if char_count <= length {
294 return self.to_string();
295 }
296
297 let omission_chars = omission.chars().count();
298 if length <= omission_chars {
299 return take_chars(omission, length);
300 }
301
302 let visible = length - omission_chars;
303 format!("{}{}", take_chars(self, visible), omission)
304 }
305
306 fn truncate_words_with(&self, count: usize, omission: &str) -> String {
307 let words: Vec<&str> = self.split_whitespace().collect();
308 if words.len() <= count {
309 return self.to_string();
310 }
311
312 if count == 0 {
313 return omission.to_string();
314 }
315
316 format!("{}{}", words[..count].join(" "), omission)
317 }
318
319 fn squish(&self) -> String {
320 self.split_whitespace().collect::<Vec<_>>().join(" ")
321 }
322
323 fn remove(&self, pattern: &str) -> String {
324 if pattern.is_empty() {
325 return self.to_string();
326 }
327
328 self.replace(pattern, "")
329 }
330
331 fn presence(&self) -> Option<&str> {
332 if self.is_blank() { None } else { Some(self) }
333 }
334
335 fn camelize(&self) -> String {
336 self.split(|ch: char| ch == '_' || ch == '-' || ch.is_whitespace() || ch == '/')
337 .filter(|part| !part.is_empty())
338 .map(capitalize_word)
339 .collect::<Vec<_>>()
340 .join("")
341 }
342
343 fn underscore(&self) -> String {
344 basic_underscore(self)
345 }
346
347 fn dasherize(&self) -> String {
348 self.underscore().replace('_', "-")
349 }
350
351 fn titleize(&self) -> String {
352 self.underscore()
353 .split('_')
354 .filter(|part| !part.is_empty())
355 .map(capitalize_word)
356 .collect::<Vec<_>>()
357 .join(" ")
358 }
359
360 fn tableize(&self) -> String {
361 self.underscore().pluralize()
362 }
363
364 fn classify(&self) -> String {
365 self.singularize().camelize()
366 }
367
368 fn pluralize(&self) -> String {
369 basic_pluralize(self)
370 }
371
372 fn singularize(&self) -> String {
373 basic_singularize(self)
374 }
375
376 fn humanize(&self) -> String {
377 let underscored = self.underscore();
378 let without_id = underscored.strip_suffix("_id").unwrap_or(&underscored);
379 let humanized = without_id
380 .split('_')
381 .filter(|part| !part.is_empty())
382 .collect::<Vec<_>>()
383 .join(" ");
384 capitalize_first(&humanized)
385 }
386
387 fn foreign_key(&self) -> String {
388 let class_name = self.rsplit("::").next().unwrap_or(self);
389 format!("{}_id", class_name.underscore())
390 }
391
392 fn parameterize(&self) -> String {
393 parameterize_string(self)
394 }
395}
396
397impl StringExt for String {
398 fn is_blank(&self) -> bool {
399 self.as_str().is_blank()
400 }
401
402 fn truncate_with(&self, length: usize, omission: &str) -> String {
403 self.as_str().truncate_with(length, omission)
404 }
405
406 fn truncate_words_with(&self, count: usize, omission: &str) -> String {
407 self.as_str().truncate_words_with(count, omission)
408 }
409
410 fn squish(&self) -> String {
411 self.as_str().squish()
412 }
413
414 fn remove(&self, pattern: &str) -> String {
415 self.as_str().remove(pattern)
416 }
417
418 fn presence(&self) -> Option<&str> {
419 self.as_str().presence()
420 }
421
422 fn camelize(&self) -> String {
423 self.as_str().camelize()
424 }
425
426 fn underscore(&self) -> String {
427 self.as_str().underscore()
428 }
429
430 fn dasherize(&self) -> String {
431 self.as_str().dasherize()
432 }
433
434 fn titleize(&self) -> String {
435 self.as_str().titleize()
436 }
437
438 fn tableize(&self) -> String {
439 self.as_str().tableize()
440 }
441
442 fn classify(&self) -> String {
443 self.as_str().classify()
444 }
445
446 fn pluralize(&self) -> String {
447 self.as_str().pluralize()
448 }
449
450 fn singularize(&self) -> String {
451 self.as_str().singularize()
452 }
453
454 fn humanize(&self) -> String {
455 self.as_str().humanize()
456 }
457
458 fn foreign_key(&self) -> String {
459 self.as_str().foreign_key()
460 }
461
462 fn parameterize(&self) -> String {
463 self.as_str().parameterize()
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::StringExt;
470
471 #[test]
472 fn is_blank_for_empty_string() {
473 assert!("".is_blank());
474 }
475
476 #[test]
477 fn is_blank_for_spaces() {
478 assert!(" ".is_blank());
479 }
480
481 #[test]
482 fn is_blank_for_tabs_and_newlines() {
483 assert!("\t\n".is_blank());
484 }
485
486 #[test]
487 fn is_blank_is_false_for_content() {
488 assert!(!" hello ".is_blank());
489 }
490
491 #[test]
492 fn is_present_is_true_for_content() {
493 assert!("hello".is_present());
494 }
495
496 #[test]
497 fn is_present_is_false_for_empty_string() {
498 assert!(!"".is_present());
499 }
500
501 #[test]
502 fn is_present_is_false_for_whitespace() {
503 assert!(!" ".is_present());
504 }
505
506 #[test]
507 fn truncate_shortens_and_appends_default_omission() {
508 assert_eq!("Hello...", "Hello World".truncate(8));
509 }
510
511 #[test]
512 fn truncate_with_custom_omission() {
513 assert_eq!("Hello***", "Hello World".truncate_with(8, "***"));
514 }
515
516 #[test]
517 fn truncate_returns_original_when_short_enough() {
518 assert_eq!("Hello", "Hello".truncate(10));
519 }
520
521 #[test]
522 fn truncate_returns_truncated_omission_when_length_is_shorter_than_omission() {
523 assert_eq!("..", "Hello".truncate(2));
524 }
525
526 #[test]
527 fn truncate_words_shortens_on_word_boundary() {
528 assert_eq!(
529 "Oh dear! Oh dear!...",
530 "Oh dear! Oh dear! I shall be late!".truncate_words(4)
531 );
532 }
533
534 #[test]
535 fn truncate_words_with_custom_omission() {
536 assert_eq!(
537 "Oh dear! Oh dear! [more]",
538 "Oh dear! Oh dear! I shall be late!".truncate_words_with(4, " [more]")
539 );
540 }
541
542 #[test]
543 fn truncate_words_returns_original_when_short_enough() {
544 assert_eq!("hello world", "hello world".truncate_words(2));
545 }
546
547 #[test]
548 fn truncate_words_zero_count_returns_omission() {
549 assert_eq!("...", "hello world".truncate_words(0));
550 }
551
552 #[test]
553 fn squish_collapses_internal_whitespace() {
554 assert_eq!("foo bar baz", " foo bar \n baz ".squish());
555 }
556
557 #[test]
558 fn squish_returns_empty_string_for_blank_input() {
559 assert_eq!("", "\n\t ".squish());
560 }
561
562 #[test]
563 fn remove_deletes_all_occurrences() {
564 assert_eq!("World", "Hello World".remove("Hello "));
565 }
566
567 #[test]
568 fn remove_returns_original_when_pattern_missing() {
569 assert_eq!("Hello World", "Hello World".remove("Goodbye"));
570 }
571
572 #[test]
573 fn presence_returns_some_for_present_string() {
574 assert_eq!(Some("hello"), "hello".presence());
575 }
576
577 #[test]
578 fn presence_returns_none_for_empty_string() {
579 assert_eq!(None, "".presence());
580 }
581
582 #[test]
583 fn presence_returns_none_for_whitespace() {
584 assert_eq!(None, " ".presence());
585 }
586
587 #[test]
588 fn camelize_converts_underscored_text() {
589 assert_eq!("ActiveModel", "active_model".camelize());
590 }
591
592 #[test]
593 fn underscore_converts_camel_case() {
594 assert_eq!("active_model", "ActiveModel".underscore());
595 }
596
597 #[test]
598 fn underscore_handles_acronyms() {
599 assert_eq!("html_tidy_generator", "HTMLTidyGenerator".underscore());
600 }
601
602 #[test]
603 fn dasherize_converts_underscores_to_dashes() {
604 assert_eq!("active-model", "active_model".dasherize());
605 }
606
607 #[test]
608 fn titleize_converts_to_title_case() {
609 assert_eq!(
610 "Man From The Boondocks",
611 "man_from_the_boondocks".titleize()
612 );
613 }
614
615 #[test]
616 fn tableize_converts_class_name_to_plural_table_name() {
617 assert_eq!("fancy_categories", "FancyCategory".tableize());
618 }
619
620 #[test]
621 fn classify_converts_table_name_to_class_name() {
622 assert_eq!("FancyCategory", "fancy_categories".classify());
623 }
624
625 #[test]
626 fn pluralize_handles_regular_nouns() {
627 assert_eq!("posts", "post".pluralize());
628 }
629
630 #[test]
631 fn pluralize_handles_words_ending_in_y() {
632 assert_eq!("categories", "category".pluralize());
633 }
634
635 #[test]
636 fn singularize_handles_regular_nouns() {
637 assert_eq!("post", "posts".singularize());
638 }
639
640 #[test]
641 fn singularize_handles_words_ending_in_ies() {
642 assert_eq!("category", "categories".singularize());
643 }
644
645 #[test]
646 fn humanize_replaces_underscores_and_capitalizes() {
647 assert_eq!("Employee salary", "employee_salary".humanize());
648 }
649
650 #[test]
651 fn humanize_strips_id_suffix() {
652 assert_eq!("Author", "author_id".humanize());
653 }
654
655 #[test]
656 fn foreign_key_converts_class_name_to_identifier_column() {
657 assert_eq!("message_id", "Message".foreign_key());
658 }
659
660 #[test]
661 fn foreign_key_demodulizes_namespaced_class_name() {
662 assert_eq!("post_id", "Admin::Post".foreign_key());
663 }
664
665 #[test]
666 fn parameterize_creates_url_friendly_slug() {
667 assert_eq!("donald-e-knuth", "Donald E. Knuth".parameterize());
668 }
669
670 #[test]
671 fn parameterize_collapses_duplicate_separators() {
672 assert_eq!("pencils-pens-paper", "Pencils, pens & paper".parameterize());
673 }
674
675 #[test]
676 fn string_impl_delegates_to_str_impl() {
677 let value = String::from("Donald E. Knuth");
678 assert_eq!("donald-e-knuth", value.parameterize());
679 }
680
681 #[test]
682 fn truncate_counts_characters_for_multibyte_input() {
683 assert_eq!("あ...", "ありがとう".truncate(4));
684 }
685
686 #[test]
687 fn truncate_with_multibyte_omission_respects_character_length() {
688 assert_eq!("He🦀", "Hello".truncate_with(3, "🦀"));
689 assert_eq!("🦀", "Hello".truncate_with(1, "🦀"));
690 }
691
692 #[test]
693 fn truncate_words_normalizes_whitespace_between_words() {
694 assert_eq!(
695 "Hello brave new...",
696 "Hello\n brave\tnew world".truncate_words(3)
697 );
698 }
699
700 #[test]
701 fn squish_collapses_unicode_whitespace() {
702 let value = "\u{205F}\u{3000}foo\u{0085}\t bar\u{00A0}";
703 assert_eq!("foo bar", value.squish());
704 }
705
706 #[test]
707 fn camelize_splits_on_mixed_separators() {
708 assert_eq!("ActiveModelErrorsApi", "active-model/errors api".camelize());
709 }
710
711 #[test]
712 fn titleize_handles_camel_case_and_punctuation() {
713 assert_eq!("X Men The Last Stand", "x-men: theLastStand".titleize());
714 assert_eq!("Html Tidy Generator", "HTMLTidyGenerator".titleize());
715 }
716
717 #[test]
718 fn pluralize_handles_es_endings_and_vowel_y() {
719 assert_eq!("boxes", "box".pluralize());
720 assert_eq!("dishes", "dish".pluralize());
721 assert_eq!("boys", "boy".pluralize());
722 }
723
724 #[test]
725 fn singularize_preserves_double_s_words_and_reverses_es_endings() {
726 assert_eq!("glass", "glass".singularize());
727 assert_eq!("bus", "buses".singularize());
728 }
729
730 #[test]
731 fn humanize_collapses_extra_separators_before_stripping_id_suffix() {
732 assert_eq!("Author", "__author__id__".humanize());
733 }
734
735 #[test]
736 fn parameterize_transliterates_and_trims_edge_separators() {
737 assert_eq!("creme-brulee", " Crème brûlée! ".parameterize());
738 assert_eq!("", " -_- ".parameterize());
739 }
740
741 #[test]
742 fn is_blank_handles_unicode_whitespace() {
743 assert!("\u{3000}\u{205F}".is_blank());
744 }
745
746 #[test]
747 fn is_present_is_false_for_unicode_whitespace() {
748 assert!(!"\u{3000}\n".is_present());
749 }
750
751 #[test]
752 fn truncate_with_zero_length_returns_empty_string() {
753 assert_eq!("", "Hello".truncate(0));
754 }
755
756 #[test]
757 fn truncate_with_empty_omission_keeps_visible_prefix() {
758 assert_eq!("Hell", "Hello".truncate_with(4, ""));
759 }
760
761 #[test]
762 fn truncate_with_long_omission_truncates_the_omission_itself() {
763 assert_eq!("[", "Hello".truncate_with(1, "[more]"));
764 }
765
766 #[test]
767 fn truncate_with_exact_length_returns_original() {
768 assert_eq!("Hello", "Hello".truncate_with(5, "[cut]"));
769 }
770
771 #[test]
772 fn truncate_words_with_custom_omission_keeps_original_spacing_when_not_truncated() {
773 assert_eq!(
774 "Hello world",
775 "Hello world".truncate_words_with(2, " [more]")
776 );
777 }
778
779 #[test]
780 fn truncate_words_with_custom_omission_truncates_to_word_boundary() {
781 assert_eq!(
782 "Hello [more]",
783 "Hello world again".truncate_words_with(1, " [more]")
784 );
785 }
786
787 #[test]
788 fn truncate_words_strips_leading_and_trailing_whitespace_when_truncating() {
789 assert_eq!(
790 "hello world...",
791 " hello world again ".truncate_words(2)
792 );
793 }
794
795 #[test]
796 fn squish_preserves_single_word_input() {
797 assert_eq!("hello", "hello".squish());
798 }
799
800 #[test]
801 fn remove_empty_pattern_returns_original_string() {
802 assert_eq!("banana", "banana".remove(""));
803 }
804
805 #[test]
806 fn remove_eliminates_multiple_non_overlapping_matches() {
807 assert_eq!("ba", "banana".remove("an"));
808 }
809
810 #[test]
811 fn presence_returns_some_for_multibyte_content() {
812 assert_eq!(Some("ありがとう"), "ありがとう".presence());
813 }
814
815 #[test]
816 fn camelize_discards_empty_segments() {
817 assert_eq!("ActiveModelErrors", "___active__model///errors".camelize());
818 }
819
820 #[test]
821 fn underscore_handles_namespaced_constants() {
822 assert_eq!("admin_html_parser", "Admin::HTMLParser".underscore());
823 }
824
825 #[test]
826 fn underscore_handles_mixed_punctuation_and_digits() {
827 assert_eq!(
828 "active_model2_json_api",
829 "ActiveModel2JSON API".underscore()
830 );
831 }
832
833 #[test]
834 fn dasherize_normalizes_mixed_separators() {
835 assert_eq!("active-model-errors", "active model/errors".dasherize());
836 }
837
838 #[test]
839 fn titleize_normalizes_repeated_separators() {
840 assert_eq!("Employee Salary Id", "__employee__salary__id__".titleize());
841 }
842
843 #[test]
844 fn tableize_handles_namespaced_class_names() {
845 assert_eq!("admin_user_profiles", "Admin::UserProfile".tableize());
846 }
847
848 #[test]
849 fn classify_handles_plural_snake_case() {
850 assert_eq!("UserProfile", "user_profiles".classify());
851 }
852
853 #[test]
854 fn pluralize_handles_words_ending_in_s_and_z() {
855 assert_eq!("buses", "bus".pluralize());
856 assert_eq!("buzzes", "buzz".pluralize());
857 }
858
859 #[test]
860 fn singularize_handles_words_ending_in_xes_and_plain_words() {
861 assert_eq!("box", "boxes".singularize());
862 assert_eq!("fish", "fish".singularize());
863 }
864
865 #[test]
866 fn humanize_collapses_duplicate_underscores() {
867 assert_eq!("Employee salary", "employee__salary__".humanize());
868 }
869
870 #[test]
871 fn foreign_key_handles_acronym_class_names() {
872 assert_eq!("html_parser_id", "HTMLParser".foreign_key());
873 }
874
875 #[test]
876 fn foreign_key_handles_namespaced_acronym_class_names() {
877 assert_eq!("html_parser_id", "Admin::HTMLParser".foreign_key());
878 }
879
880 #[test]
881 fn parameterize_preserves_non_latin_alphanumerics() {
882 assert_eq!("東京-2024", "東京 2024".parameterize());
883 }
884
885 #[test]
886 fn parameterize_transliterates_multichar_ligatures() {
887 assert_eq!("aeroskobing", "Ærøskøbing".parameterize());
888 }
889
890 #[test]
891 fn parameterize_collapses_mixed_punctuation_runs() {
892 assert_eq!("foo-bar-baz", "foo---bar___baz".parameterize());
893 }
894}