Skip to main content

rustrails_support/
string_ext.rs

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
217/// Extension trait for string-like types.
218pub trait StringExt {
219    /// Returns true if the string is empty or contains only whitespace.
220    fn is_blank(&self) -> bool;
221
222    /// Returns true if the string is not blank.
223    fn is_present(&self) -> bool {
224        !self.is_blank()
225    }
226
227    /// Truncates to `length` characters, appending `...` when needed.
228    fn truncate(&self, length: usize) -> String {
229        self.truncate_with(length, "...")
230    }
231
232    /// Truncates to `length` characters, appending `omission` when needed.
233    fn truncate_with(&self, length: usize, omission: &str) -> String;
234
235    /// Truncates on word boundary, appending `...` when needed.
236    fn truncate_words(&self, count: usize) -> String {
237        self.truncate_words_with(count, "...")
238    }
239
240    /// Truncates on word boundary, appending `omission` when needed.
241    fn truncate_words_with(&self, count: usize, omission: &str) -> String;
242
243    /// Strips leading/trailing whitespace and collapses internal whitespace to a single space.
244    fn squish(&self) -> String;
245
246    /// Removes all occurrences of the pattern from the string.
247    fn remove(&self, pattern: &str) -> String;
248
249    /// Returns the string if present, otherwise `None`.
250    fn presence(&self) -> Option<&str>;
251
252    /// Converts to CamelCase.
253    fn camelize(&self) -> String;
254
255    /// Converts to snake_case.
256    fn underscore(&self) -> String;
257
258    /// Converts to dash-case.
259    fn dasherize(&self) -> String;
260
261    /// Converts to Title Case.
262    fn titleize(&self) -> String;
263
264    /// Converts to a table name.
265    fn tableize(&self) -> String;
266
267    /// Converts to a class name.
268    fn classify(&self) -> String;
269
270    /// Converts to plural form.
271    fn pluralize(&self) -> String;
272
273    /// Converts to singular form.
274    fn singularize(&self) -> String;
275
276    /// Converts to a humanized form.
277    fn humanize(&self) -> String;
278
279    /// Creates a foreign key name.
280    fn foreign_key(&self) -> String;
281
282    /// Creates a URL-friendly slug.
283    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}