convert_case/
lib.rs

1//! Converts to and from various cases.
2//!
3//! # Command Line Utility `ccase`
4//!
5//! This library was developed for the purposes of a command line utility for converting
6//! the case of strings and filenames.  You can check out 
7//! [`ccase` on Github](https://github.com/rutrum/convert-case/tree/master/ccase).
8//!
9//! # Rust Library
10//!
11//! Provides a [`Case`](enum.Case.html) enum which defines a variety of cases to convert into.
12//! Strings have implemented the [`Casing`](trait.Casing.html) trait, which adds methods for 
13//! case conversion.
14//!
15//! You can convert strings into a case using the [`to_case`](Casing::to_case) method.
16//! ```
17//! use convert_case::{Case, Casing};
18//!
19//! assert_eq!("Ronnie James Dio", "ronnie james dio".to_case(Case::Title));
20//! assert_eq!("ronnieJamesDio", "Ronnie_James_dio".to_case(Case::Camel));
21//! assert_eq!("Ronnie-James-Dio", "RONNIE_JAMES_DIO".to_case(Case::Train));
22//! ```
23//!
24//! By default, `to_case` will split along a set of default word boundaries, that is
25//! * space characters ` `,
26//! * underscores `_`,
27//! * hyphens `-`,
28//! * changes in capitalization from lowercase to uppercase `aA`,
29//! * adjacent digits and letters `a1`, `1a`, `A1`, `1A`,
30//! * and acroynms `AAa` (as in `HTTPRequest`).
31//!
32//! For more accuracy, the `from_case` method splits based on the word boundaries
33//! of a particular case.  For example, splitting from snake case will only use
34//! underscores as word boundaries.
35//! ```
36//! use convert_case::{Case, Casing};
37//!
38//! assert_eq!(
39//!     "2020 04 16 My Cat Cali",
40//!     "2020-04-16_my_cat_cali".to_case(Case::Title)
41//! );
42//! assert_eq!(
43//!     "2020-04-16 My Cat Cali",
44//!     "2020-04-16_my_cat_cali".from_case(Case::Snake).to_case(Case::Title)
45//! );
46//! ```
47//!
48//! Case conversion can detect acronyms for camel-like strings.  It also ignores any leading, 
49//! trailing, or duplicate delimiters.
50//! ```
51//! use convert_case::{Case, Casing};
52//!
53//! assert_eq!("io_stream", "IOStream".to_case(Case::Snake));
54//! assert_eq!("my_json_parser", "myJSONParser".to_case(Case::Snake));
55//!
56//! assert_eq!("weird_var_name", "__weird--var _name-".to_case(Case::Snake));
57//! ```
58//!
59//! It also works non-ascii characters.  However, no inferences on the language itself is made.
60//! For instance, the digraph `ij` in Dutch will not be capitalized, because it is represented
61//! as two distinct Unicode characters.  However, `æ` would be capitalized.  Accuracy with unicode
62//! characters is done using the `unicode-segmentation` crate, the sole dependency of this crate.
63//! ```
64//! use convert_case::{Case, Casing};
65//!
66//! assert_eq!("granat-äpfel", "GranatÄpfel".to_case(Case::Kebab));
67//! assert_eq!("Перспектива 24", "ПЕРСПЕКТИВА24".to_case(Case::Title));
68//!
69//! // The example from str::to_lowercase documentation
70//! let odysseus = "ὈΔΥΣΣΕΎΣ";
71//! assert_eq!("ὀδυσσεύς", odysseus.to_case(Case::Lower));
72//! ```
73//!
74//! By default, characters followed by digits and vice-versa are
75//! considered word boundaries.  In addition, any special ASCII characters (besides `_` and `-`)
76//! are ignored.
77//! ```
78//! use convert_case::{Case, Casing};
79//!
80//! assert_eq!("e_5150", "E5150".to_case(Case::Snake));
81//! assert_eq!("10,000_days", "10,000Days".to_case(Case::Snake));
82//! assert_eq!("HELLO, WORLD!", "Hello, world!".to_case(Case::Upper));
83//! assert_eq!("One\ntwo\nthree", "ONE\nTWO\nTHREE".to_case(Case::Title));
84//! ```
85//!
86//! You can also test what case a string is in.
87//! ```
88//! use convert_case::{Case, Casing};
89//!
90//! assert!( "css-class-name".is_case(Case::Kebab));
91//! assert!(!"css-class-name".is_case(Case::Snake));
92//! assert!(!"UPPER_CASE_VAR".is_case(Case::Snake));
93//! ```
94//!
95//! # Note on Accuracy
96//!
97//! The `Casing` methods `from_case` and `to_case` do not fail.  Conversion to a case will always
98//! succeed.  However, the results can still be unexpected.  Failure to detect any word boundaries
99//! for a particular case means the entire string will be considered a single word.
100//! ```
101//! use convert_case::{Case, Casing};
102//!
103//! // Mistakenly parsing using Case::Snake
104//! assert_eq!("My-kebab-var", "my-kebab-var".from_case(Case::Snake).to_case(Case::Title));
105//!
106//! // Converts using an unexpected method
107//! assert_eq!("my_kebab_like_variable", "myKebab-like-variable".to_case(Case::Snake));
108//! ```
109//!
110//! # Boundary Specificity
111//!
112//! It can be difficult to determine how to split a string into words.  That is why this case
113//! provides the [`from_case`](Casing::from_case) functionality, but sometimes that isn't enough
114//! to meet a specific use case.
115//!
116//! Take an identifier has the word `2D`, such as `scale2D`.  No exclusive usage of `from_case` will
117//! be enough to solve the problem.  In this case we can further specify which boundaries to split
118//! the string on.  `convert_case` provides some patterns for achieving this specificity.
119//! We can specify what boundaries we want to split on using the [`Boundary` enum](Boundary).
120//! ```
121//! use convert_case::{Boundary, Case, Casing};
122//!
123//! // Not quite what we want
124//! assert_eq!(
125//!     "scale_2_d",
126//!     "scale2D"
127//!         .from_case(Case::Camel)
128//!         .to_case(Case::Snake)
129//! );
130//!
131//! // Remove boundary from Case::Camel
132//! assert_eq!(
133//!     "scale_2d",
134//!     "scale2D"
135//!         .from_case(Case::Camel)
136//!         .without_boundaries(&[Boundary::DigitUpper, Boundary::DigitLower])
137//!         .to_case(Case::Snake)
138//! );
139//!
140//! // Write boundaries explicitly
141//! assert_eq!(
142//!     "scale_2d",
143//!     "scale2D"
144//!         .with_boundaries(&[Boundary::LowerDigit])
145//!         .to_case(Case::Snake)
146//! );
147//! ```
148//!
149//! The `Casing` trait provides initial methods, but any subsequent methods that do not resolve
150//! the conversion return a [`StateConverter`] struct.  It contains similar methods as `Casing`.
151//!
152//! # Custom Cases
153//!
154//! Because `Case` is an enum, you can't create your own variant for your use case.  However
155//! the parameters for case conversion have been encapsulated into the [`Converter`] struct
156//! which can be used for specific use cases.
157//!
158//! Suppose you wanted to format a word like camel case, where the first word is lower case and the
159//! rest are capitalized.  But you want to include a delimeter like underscore.  This case isn't
160//! available as a `Case` variant, but you can create it by constructing the parameters of the
161//! `Converter`.
162//! ```
163//! use convert_case::{Case, Casing, Converter, Pattern};
164//!
165//! let conv = Converter::new()
166//!     .set_pattern(Pattern::Camel)
167//!     .set_delim("_");
168//!
169//! assert_eq!(
170//!     "my_Special_Case",
171//!     conv.convert("My Special Case")
172//! )
173//! ```
174//! Just as with the `Casing` trait, you can also manually set the boundaries strings are split 
175//! on.  You can use any of the [`Pattern`] variants available.  This even includes [`Pattern::Sentence`]
176//! which isn't used in any `Case` variant.  You can also set no pattern at all, which will
177//! maintain the casing of each letter in the input string.  You can also, of course, set any string as your
178//! delimeter.
179//!
180//! For more details on how strings are converted, see the docs for [`Converter`].
181//!
182//! # Random Feature
183//!
184//! To ensure this library had zero dependencies, randomness was moved to the _random_ feature,
185//! which requires the `rand` crate. You can enable this feature by including the
186//! following in your `Cargo.toml`.
187//! ```{toml}
188//! [dependencies]
189//! convert_case = { version = "^0.3.0", features = ["random"] }
190//! ```
191//! This will add two additional cases: Random and PseudoRandom.  You can read about their
192//! construction in the [Case enum](enum.Case.html).
193
194mod case;
195mod converter;
196mod pattern;
197mod segmentation;
198
199pub use case::Case;
200pub use converter::Converter;
201pub use pattern::Pattern;
202pub use segmentation::Boundary;
203
204/// Describes items that can be converted into a case.  This trait is used
205/// in conjunction with the [`StateConverter`] struct which is returned from a couple
206/// methods on `Casing`.
207///
208/// Implemented for strings `&str`, `String`, and `&String`.
209pub trait Casing<T: AsRef<str>> {
210
211    /// Convert the string into the given case.  It will reference `self` and create a new
212    /// `String` with the same pattern and delimeter as `case`.  It will split on boundaries
213    /// defined at [`Boundary::defaults()`].
214    /// ```
215    /// use convert_case::{Case, Casing};
216    ///
217    /// assert_eq!(
218    ///     "tetronimo-piece-border",
219    ///     "Tetronimo piece border".to_case(Case::Kebab)
220    /// );
221    /// ```
222    fn to_case(&self, case: Case) -> String;
223
224    /// Start the case conversion by storing the boundaries associated with the given case.
225    /// ```
226    /// use convert_case::{Case, Casing};
227    ///
228    /// assert_eq!(
229    ///     "2020-08-10_dannie_birthday",
230    ///     "2020-08-10 Dannie Birthday"
231    ///         .from_case(Case::Title)
232    ///         .to_case(Case::Snake)
233    /// );
234    /// ```
235    #[allow(clippy::wrong_self_convention)]
236    fn from_case(&self, case: Case) -> StateConverter<T>;
237
238    /// Creates a `StateConverter` struct initialized with the boundaries
239    /// provided.
240    /// ```
241    /// use convert_case::{Boundary, Case, Casing};
242    ///
243    /// assert_eq!(
244    ///     "e1_m1_hangar",
245    ///     "E1M1 Hangar"
246    ///         .with_boundaries(&[Boundary::DigitUpper, Boundary::Space])
247    ///         .to_case(Case::Snake)
248    /// );
249    /// ```
250    fn with_boundaries(&self, bs: &[Boundary]) -> StateConverter<T>;
251
252    /// Determines if `self` is of the given case.  This is done simply by applying
253    /// the conversion and seeing if the result is the same.
254    /// ```
255    /// use convert_case::{Case, Casing};
256    /// 
257    /// assert!( "kebab-case-string".is_case(Case::Kebab));
258    /// assert!( "Train-Case-String".is_case(Case::Train));
259    ///
260    /// assert!(!"kebab-case-string".is_case(Case::Snake));
261    /// assert!(!"kebab-case-string".is_case(Case::Train));
262    /// ```
263    fn is_case(&self, case: Case) -> bool;
264}
265
266impl<T: AsRef<str>> Casing<T> for T
267where
268    String: PartialEq<T>,
269{
270    fn to_case(&self, case: Case) -> String {
271        StateConverter::new(self).to_case(case)
272    }
273
274    fn with_boundaries(&self, bs: &[Boundary]) -> StateConverter<T> {
275        StateConverter::new(self).with_boundaries(bs)
276    }
277
278    fn from_case(&self, case: Case) -> StateConverter<T> {
279        StateConverter::new_from_case(self, case)
280    }
281
282    fn is_case(&self, case: Case) -> bool {
283        &self.to_case(case) == self
284    }
285}
286
287/// Holds information about parsing before converting into a case.
288///
289/// This struct is used when invoking the `from_case` and `with_boundaries` methods on
290/// `Casing`.  For a more fine grained approach to case conversion, consider using the [`Converter`]
291/// struct.
292/// ```
293/// use convert_case::{Case, Casing};
294///
295/// let title = "ninety-nine_problems".from_case(Case::Snake).to_case(Case::Title);
296/// assert_eq!("Ninety-nine Problems", title);
297/// ```
298pub struct StateConverter<'a, T: AsRef<str>> {
299    s: &'a T,
300    conv: Converter,
301}
302
303impl<'a, T: AsRef<str>> StateConverter<'a, T> {
304    /// Only called by Casing function to_case()
305    fn new(s: &'a T) -> Self {
306        Self {
307            s,
308            conv: Converter::new(),
309        }
310    }
311
312    /// Only called by Casing function from_case()
313    fn new_from_case(s: &'a T, case: Case) -> Self {
314        Self {
315            s,
316            conv: Converter::new().from_case(case),
317        }
318    }
319
320    /// Uses the boundaries associated with `case` for word segmentation.  This
321    /// will overwrite any boundary information initialized before.  This method is
322    /// likely not useful, but provided anyway.
323    /// ```
324    /// use convert_case::{Case, Casing};
325    ///
326    /// let name = "Chuck Schuldiner"
327    ///     .from_case(Case::Snake) // from Casing trait
328    ///     .from_case(Case::Title) // from StateConverter, overwrites previous
329    ///     .to_case(Case::Kebab);
330    /// assert_eq!("chuck-schuldiner", name);
331    /// ```
332    pub fn from_case(self, case: Case) -> Self {
333        Self {
334            conv: self.conv.from_case(case),
335            ..self
336        }
337    }
338
339    /// Overwrites boundaries for word segmentation with those provided.  This will overwrite
340    /// any boundary information initialized before.  This method is likely not useful, but
341    /// provided anyway.
342    /// ```
343    /// use convert_case::{Boundary, Case, Casing};
344    ///
345    /// let song = "theHumbling river-puscifer"
346    ///     .from_case(Case::Kebab) // from Casing trait
347    ///     .with_boundaries(&[Boundary::Space, Boundary::LowerUpper]) // overwrites `from_case`
348    ///     .to_case(Case::Pascal);
349    /// assert_eq!("TheHumblingRiver-puscifer", song);  // doesn't split on hyphen `-`
350    /// ```
351    pub fn with_boundaries(self, bs: &[Boundary]) -> Self {
352        Self {
353            s: self.s,
354            conv: self.conv.set_boundaries(bs),
355        }
356    }
357
358    /// Removes any boundaries that were already initialized.  This is particularly useful when a
359    /// case like `Case::Camel` has a lot of associated word boundaries, but you want to exclude
360    /// some.
361    /// ```
362    /// use convert_case::{Boundary, Case, Casing};
363    ///
364    /// assert_eq!(
365    ///     "2d_transformation",
366    ///     "2dTransformation"
367    ///         .from_case(Case::Camel)
368    ///         .without_boundaries(&Boundary::digits())
369    ///         .to_case(Case::Snake)
370    /// );
371    /// ```
372    pub fn without_boundaries(self, bs: &[Boundary]) -> Self {
373        Self {
374            s: self.s,
375            conv: self.conv.remove_boundaries(bs),
376        }
377    }
378
379    /// Consumes the `StateConverter` and returns the converted string.
380    /// ```
381    /// use convert_case::{Boundary, Case, Casing};
382    ///
383    /// assert_eq!(
384    ///     "ice-cream social",
385    ///     "Ice-Cream Social".from_case(Case::Title).to_case(Case::Lower)
386    /// );
387    /// ```
388    pub fn to_case(self, case: Case) -> String {
389        self.conv.to_case(case).convert(self.s)
390    }
391}
392
393#[cfg(test)]
394mod test {
395    use super::*;
396    use strum::IntoEnumIterator;
397
398    fn possible_cases(s: &str) -> Vec<Case> {
399        Case::deterministic_cases()
400            .into_iter()
401            .filter(|case| s.from_case(*case).to_case(*case) == s)
402            .collect()
403    }
404
405    #[test]
406    fn lossless_against_lossless() {
407        let examples = vec![
408            (Case::Lower, "my variable 22 name"),
409            (Case::Upper, "MY VARIABLE 22 NAME"),
410            (Case::Title, "My Variable 22 Name"),
411            (Case::Camel, "myVariable22Name"),
412            (Case::Pascal, "MyVariable22Name"),
413            (Case::Snake, "my_variable_22_name"),
414            (Case::UpperSnake, "MY_VARIABLE_22_NAME"),
415            (Case::Kebab, "my-variable-22-name"),
416            (Case::Cobol, "MY-VARIABLE-22-NAME"),
417            (Case::Toggle, "mY vARIABLE 22 nAME"),
418            (Case::Train, "My-Variable-22-Name"),
419            (Case::Alternating, "mY vArIaBlE 22 nAmE"),
420        ];
421
422        for (case_a, str_a) in examples.iter() {
423            for (case_b, str_b) in examples.iter() {
424                assert_eq!(*str_a, str_b.from_case(*case_b).to_case(*case_a))
425            }
426        }
427    }
428
429    #[test]
430    fn obvious_default_parsing() {
431        let examples = vec![
432            "SuperMario64Game",
433            "super-mario64-game",
434            "superMario64 game",
435            "Super Mario 64_game",
436            "SUPERMario 64-game",
437            "super_mario-64 game",
438        ];
439
440        for example in examples {
441            assert_eq!("super_mario_64_game", example.to_case(Case::Snake));
442        }
443    }
444
445    #[test]
446    fn multiline_strings() {
447        assert_eq!("One\ntwo\nthree", "one\ntwo\nthree".to_case(Case::Title));
448    }
449
450    #[test]
451    fn camel_case_acroynms() {
452        assert_eq!(
453            "xml_http_request",
454            "XMLHttpRequest".from_case(Case::Camel).to_case(Case::Snake)
455        );
456        assert_eq!(
457            "xml_http_request",
458            "XMLHttpRequest"
459                .from_case(Case::UpperCamel)
460                .to_case(Case::Snake)
461        );
462        assert_eq!(
463            "xml_http_request",
464            "XMLHttpRequest"
465                .from_case(Case::Pascal)
466                .to_case(Case::Snake)
467        );
468    }
469
470    #[test]
471    fn leading_tailing_delimeters() {
472        assert_eq!(
473            "leading_underscore",
474            "_leading_underscore"
475                .from_case(Case::Snake)
476                .to_case(Case::Snake)
477        );
478        assert_eq!(
479            "tailing_underscore",
480            "tailing_underscore_"
481                .from_case(Case::Snake)
482                .to_case(Case::Snake)
483        );
484        assert_eq!(
485            "leading_hyphen",
486            "-leading-hyphen"
487                .from_case(Case::Kebab)
488                .to_case(Case::Snake)
489        );
490        assert_eq!(
491            "tailing_hyphen",
492            "tailing-hyphen-"
493                .from_case(Case::Kebab)
494                .to_case(Case::Snake)
495        );
496    }
497
498    #[test]
499    fn double_delimeters() {
500        assert_eq!(
501            "many_underscores",
502            "many___underscores"
503                .from_case(Case::Snake)
504                .to_case(Case::Snake)
505        );
506        assert_eq!(
507            "many-underscores",
508            "many---underscores"
509                .from_case(Case::Kebab)
510                .to_case(Case::Kebab)
511        );
512    }
513
514    #[test]
515    fn early_word_boundaries() {
516        assert_eq!(
517            "a_bagel",
518            "aBagel".from_case(Case::Camel).to_case(Case::Snake)
519        );
520    }
521
522    #[test]
523    fn late_word_boundaries() {
524        assert_eq!(
525            "team_a",
526            "teamA".from_case(Case::Camel).to_case(Case::Snake)
527        );
528    }
529
530    #[test]
531    fn empty_string() {
532        for (case_a, case_b) in Case::iter().zip(Case::iter()) {
533            assert_eq!("", "".from_case(case_a).to_case(case_b));
534        }
535    }
536
537    #[test]
538    fn owned_string() {
539        assert_eq!(
540            "test_variable",
541            String::from("TestVariable").to_case(Case::Snake)
542        )
543    }
544
545    #[test]
546    fn default_all_boundaries() {
547        assert_eq!(
548            "abc_abc_abc_abc_abc_abc",
549            "ABC-abc_abcAbc ABCAbc".to_case(Case::Snake)
550        );
551    }
552
553    #[test]
554    fn alternating_ignore_symbols() {
555        assert_eq!("tHaT's", "that's".to_case(Case::Alternating));
556    }
557
558    #[test]
559    fn string_is_snake() {
560        assert!("im_snake_case".is_case(Case::Snake));
561        assert!(!"im_NOTsnake_case".is_case(Case::Snake));
562    }
563
564    #[test]
565    fn string_is_kebab() {
566        assert!("im-kebab-case".is_case(Case::Kebab));
567        assert!(!"im_not_kebab".is_case(Case::Kebab));
568    }
569
570    #[test]
571    fn remove_boundaries() {
572        assert_eq!(
573            "m02_s05_binary_trees.pdf",
574            "M02S05BinaryTrees.pdf"
575                .from_case(Case::Pascal)
576                .without_boundaries(&[Boundary::UpperDigit])
577                .to_case(Case::Snake)
578        );
579    }
580
581    #[test]
582    fn with_boundaries() {
583        assert_eq!(
584            "my-dumb-file-name",
585            "my_dumbFileName"
586                .with_boundaries(&[Boundary::Underscore, Boundary::LowerUpper])
587                .to_case(Case::Kebab)
588        );
589    }
590
591    #[cfg(feature = "random")]
592    #[test]
593    fn random_case_boundaries() {
594        for random_case in Case::random_cases() {
595            assert_eq!(
596                "split_by_spaces",
597                "Split By Spaces"
598                    .from_case(random_case)
599                    .to_case(Case::Snake)
600            );
601        }
602    }
603
604    #[test]
605    fn multiple_from_case() {
606        assert_eq!(
607            "longtime_nosee",
608            "LongTime NoSee"
609                .from_case(Case::Camel)
610                .from_case(Case::Title)
611                .to_case(Case::Snake),
612        )
613    }
614
615    use std::collections::HashSet;
616    use std::iter::FromIterator;
617
618    #[test]
619    fn detect_many_cases() {
620        let lower_cases_vec = possible_cases(&"asef");
621        let lower_cases_set = HashSet::from_iter(lower_cases_vec.into_iter());
622        let mut actual = HashSet::new();
623        actual.insert(Case::Lower);
624        actual.insert(Case::Camel);
625        actual.insert(Case::Snake);
626        actual.insert(Case::Kebab);
627        actual.insert(Case::Flat);
628        assert_eq!(lower_cases_set, actual);
629
630        let lower_cases_vec = possible_cases(&"asefCase");
631        let lower_cases_set = HashSet::from_iter(lower_cases_vec.into_iter());
632        let mut actual = HashSet::new();
633        actual.insert(Case::Camel);
634        assert_eq!(lower_cases_set, actual);
635    }
636
637    #[test]
638    fn detect_each_case() {
639        let s = "My String Identifier".to_string();
640        for case in Case::deterministic_cases() {
641            let new_s = s.from_case(case).to_case(case);
642            let possible = possible_cases(&new_s);
643            println!("{} {:?} {:?}", new_s, case, possible);
644            assert!(possible.iter().any(|c| c == &case));
645        }
646    }
647
648    // From issue https://github.com/rutrum/convert-case/issues/8
649    #[test]
650    fn accent_mark() {
651        let s = "música moderna".to_string();
652        assert_eq!("MúsicaModerna", s.to_case(Case::Pascal));
653    }
654
655    // From issue https://github.com/rutrum/convert-case/issues/4
656    #[test]
657    fn russian() {
658        let s = "ПЕРСПЕКТИВА24".to_string();
659        let _n = s.to_case(Case::Title);
660    }
661}