ncase 0.3.2

Enforce a case style
Documentation
use std::convert::From;

use itertools::Itertools;
#[cfg(feature = "regex")]
use regex::Regex;

use crate::case::*;

/// Sequence of words of some original string.
///
/// Divides the case style "enforcement" into two steps:
///
/// 1. Split the original string into words.
/// 1. Capitalise and concatenate the words into a new string.
pub struct Words<'a> {
    w: Vec<&'a str>,
}

impl<'a> From<&'a str> for Words<'a> {
    /// Creates a new [`Words`] for the string slice.
    ///
    /// The string is split into words by the following algorithm:
    ///
    /// 1. [Trim][str::trim] the string.
    /// 1. If the string contains whitespace, then split it by whitespace.
    /// 1. If the string contains hyphens '`-`' or underscores '`_`',
    ///    then split it by whichever of them is more frequent.
    /// 1. Split the string by case changes: '`*|Az`' and '`z|A*`'.
    fn from(s: &'a str) -> Self {
        let s = s.trim();
        let split_fn = select_split_fn(s);

        Words { w: split_fn(s) }
    }
}

impl<'a> Words<'a> {
    /// Creates a new [`Words`] of the string slice using the separator regex
    /// to split it.
    #[cfg(feature = "regex")]
    pub fn with_separator(s: &'a str, sep: &Regex) -> Words<'a> {
        Words {
            w: sep.split(s.trim()).collect(),
        }
    }

    /// Returns the words capitalised and concatenated like `camelCase`
    /// as a new [`String`].
    pub fn camel(&self) -> String {
        let mut ws = self.w.iter();
        match ws.next() {
            Some(first_w) => {
                first_w.to_lowercase() + &ws.map(|w| to_titlecase(w)).collect::<String>()
            }
            None => String::new(),
        }
    }

    /// Returns the words capitalised and concatenated like `PascalCase`
    /// as a new [`String`].
    pub fn pascal(&self) -> String {
        self.build(|w| to_titlecase(w), "")
    }

    /// Returns the words capitalised and concatenated like `kebab-case`
    /// as a new [`String`].
    pub fn kebab(&self) -> String {
        self.build(|w| w.to_lowercase(), "-")
    }

    /// Returns the words capitalised and concatenated
    /// like `SCREAMING-KEBAB-CASE` as a new [`String`].
    pub fn screaming_kebab(&self) -> String {
        self.build(|w| w.to_uppercase(), "-")
    }

    /// Returns the words capitalised and concatenated like `lower case`
    /// as a new [`String`].
    pub fn lower(&self) -> String {
        self.build(|w| w.to_lowercase(), " ")
    }

    /// Returns the words capitalised and concatenated like `UPPER CASE`
    /// as a new [`String`].
    pub fn upper(&self) -> String {
        self.build(|w| w.to_uppercase(), " ")
    }

    /// Returns the words capitalised and concatenated like `snake_case`
    /// as a new [`String`].
    pub fn snake(&self) -> String {
        self.build(|w| w.to_lowercase(), "_")
    }

    /// Returns the words capitalised and concatenated
    /// like `SCREAMING_SNAKE_CASE` as a new [`String`].
    pub fn screaming_snake(&self) -> String {
        self.build(|w| w.to_uppercase(), "_")
    }

    /// Returns the words capitalised and concatenated like `Title Case`
    /// as a new [`String`].
    pub fn title(&self) -> String {
        self.build(|w| to_titlecase(w), " ")
    }

    /// Returns the words capitalised and concatenated like `tOGGLE cASE`
    /// as a new [`String`].
    pub fn toggle(&self) -> String {
        self.build(|w| to_togglecase(w), " ")
    }

    /// Returns the words case-randomised and concatenated with a space
    /// as a new [`String`].
    #[cfg(feature = "rand")]
    pub fn random(&self) -> String {
        to_randomcase(&self.w.join(" "))
    }

    fn build(&self, case_fn: fn(&&str) -> String, sep: &str) -> String {
        // .collect().join() is 1.15-2x faster than Itertools.
        self.w.iter().map(case_fn).collect::<Vec<_>>().join(sep)
    }
}

fn select_split_fn(s: &str) -> fn(&str) -> Vec<&str> {
    if s.contains(' ') {
        split_whitespace
    } else {
        let num_both = s.chars().filter(|&c| c == '-' || c == '_').count();
        if num_both > 0 {
            let num_hyphen = s.chars().filter(|&c| c == '-').count();
            if num_hyphen * 2 >= num_both {
                split_hyphen
            } else {
                split_underscore
            }
        } else {
            split_case
        }
    }
}

fn split_whitespace(s: &str) -> Vec<&str> {
    s.split_whitespace().collect()
}

fn split_hyphen(s: &str) -> Vec<&str> {
    s.split('-').collect()
}

fn split_underscore(s: &str) -> Vec<&str> {
    s.split('_').collect()
}

// To split_case() a word is a sequence that starts with a capital character
// and continues with characters of the same case. The first word of the string,
// of course, can begin with any character.
//
// ## Examples
//
// ```
// assert_eq!(split_case("XMLHttpRequest"), vec!["XML", "Http", "Request"]);
// assert_eq!(split_case("xmlHTTPRequest"), vec!["xml", "HTTP", "Request"]);
// assert_eq!(split_case("XMLHTTPRequest"), vec!["XMLHTTP", "Request"]);
// ```
fn split_case(s: &str) -> Vec<&str> {
    s.char_indices()
        .zip(s.char_indices().skip(1))
        // Mark where the case changes.
        .filter_map(
            |((i, c0), (j, c1))| match (c0.is_uppercase(), c1.is_uppercase()) {
                // "Az", starts a word at i, terminates CAPS-words.
                (true, false) if i > 0 => Some(i),
                // "zA", starts a word at j, starts CAPS-words.
                (false, true) => Some(j),
                _ => None,
            },
        )
        // "zAz" yields position of 'A' first as j, then as i.
        .dedup()
        // Mark the end of the string.
        .chain(std::iter::once(s.len()))
        // Slice out the substrings bounded by the indices.
        // .scan() is 2x faster than .zip().map().
        .scan(0, |i, j| {
            let ss = &s[*i..j];
            *i = j;
            Some(ss)
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn select_split_fn_r_whitespace() {
        let s = "this is-a_testString";
        let act = select_split_fn(s);
        // `fn` does not implement `PartialEq` when lifetimes are involved.
        assert_eq!(split_whitespace as usize, act as usize);
    }

    #[test]
    fn select_split_fn_r_hyphen() {
        let s = "this-is-a-test_string";
        let act = select_split_fn(s);
        assert_eq!(split_hyphen as usize, act as usize);
    }

    #[test]
    fn select_split_fn_r_hyphen_over_underscore() {
        let s = "this_is-a_test-string";
        let act = select_split_fn(s);
        assert_eq!(split_hyphen as usize, act as usize);
    }

    #[test]
    fn select_split_fn_r_underscore() {
        let s = "this-is_a_test_string";
        let act = select_split_fn(s);
        assert_eq!(split_underscore as usize, act as usize);
    }

    #[test]
    fn select_split_fn_r_case() {
        let s = "ThisIsATestString";
        let act = select_split_fn(s);
        assert_eq!(split_case as usize, act as usize);
    }

    proptest! {
        #[test]
        fn p_split_case_drops_no_chars(s in r"\PC*") {
            let v = split_case(&s);
            prop_assert_eq!(&s, &v.join(""));
        }

        #[test]
        fn p_split_case_tail_words_are_title_or_upper(s in r"\PC+") {
            let v = split_case(&s);
            for w in &v[1..] {
                prop_assert!(is_titlecase(w) || is_uppercase(w));
            }
        }

        #[test]
        fn p_split_case_1_upper_word_in_row(s in r"\PC*") {
            let v = split_case(&s);
            let it = v.iter().map(|w| is_uppercase(w));
            let it1 = it.clone().skip(1);
            for (p0, p1) in it.zip(it1) {
                prop_assert!(!p0 || !p1);
            }
        }
    }

    fn is_titlecase(s: &str) -> bool {
        s.starts_with(char::is_uppercase) && !s.chars().skip(1).any(char::is_uppercase)
    }

    fn is_uppercase(s: &str) -> bool {
        s.chars().all(char::is_uppercase)
    }
}