Skip to content

Commit

Permalink
ConvertCase trait as a wrapper for other convert case functions
Browse files Browse the repository at this point in the history
ConvertCase implements convert_case() which accepts customization options to tweak behaviour of other case conversion functions. Currently, it accepts `number_starts_word` option to have word boundaries when characters in a word change from numeric to alphabetic or vice versa. 

Other case conversion functions are implemented in terms on convert_case() by passing `number_starts_word` as false by default. 

ref: withoutboats#18
ref: Peternator7/strum#72
  • Loading branch information
ssd532 committed Jan 3, 2021
1 parent f48ec3e commit 7bae08a
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 31 deletions.
162 changes: 162 additions & 0 deletions src/convert_case.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
use crate::kebab::to_kebab;
use crate::lower_camel::to_lower_camel_case;
use crate::shouty_kebab::to_shouty_kebab_case;
use crate::shouty_snake::to_shouty_snake_case;
use crate::snake::to_snake_case;
use crate::title::to_title_case;
use crate::upper_camel::to_upper_camel_case;

/// This trait defines a wrapper function for other case-conversion functions
/// in that this can tweak the behaviour of those functions based on customization options
///
///
/// ## Example:
/// ```rust
///
/// use heck::{ConvertCase, ConvertCaseOpt, Case};
///
/// let sentence = "Aes128";
/// assert_eq!(sentence.convert_case(ConvertCaseOpt { case: Case::ShoutyKebab, number_starts_word: true }),
/// "AES-128");
///

pub trait ConvertCase: ToOwned {
/// Convert this type to supported cases with options
fn convert_case(&self, opt: ConvertCaseOpt) -> String;
}

/// Options to tweak how convert_case will behave
pub struct ConvertCaseOpt {
/// supported case
pub case: Case,
/// whether numbers should start a new word
pub number_starts_word: bool,
}

/// supported cases
pub enum Case {
/// kebab-case
Kebab,
/// lowerCamelCase
LowerCamel,
/// SHOUT-KEBAB-CASE
ShoutyKebab,
/// SHOUTY_SNAKE_CASE
ShoutySnake,
/// snake_case
Snake,
/// Title Case
Title,
/// UpperCamelCase
UpperCamel,
}

pub fn convert_case(s: &str, opt: ConvertCaseOpt) -> String {
match opt.case {
Case::Kebab => to_kebab(s, opt.number_starts_word),
Case::LowerCamel => to_lower_camel_case(s, opt.number_starts_word),
Case::ShoutyKebab => to_shouty_kebab_case(s, opt.number_starts_word),
Case::ShoutySnake => to_shouty_snake_case(s, opt.number_starts_word),
Case::Snake => to_snake_case(s, opt.number_starts_word),
Case::Title => to_title_case(s, opt.number_starts_word),
Case::UpperCamel => to_upper_camel_case(s, opt.number_starts_word),
}
}
impl ConvertCase for str {
fn convert_case(&self, opt: ConvertCaseOpt) -> Self::Owned {
convert_case(self, opt)
}
}

#[cfg(test)]
mod tests {
use crate::{Case, ConvertCase, ConvertCaseOpt};

#[test]
fn number_starts_word_kebab_simple() {
assert_eq!(
"aes128".convert_case(ConvertCaseOpt {
case: Case::Kebab,
number_starts_word: true
}),
"aes-128"
);
}

#[test]
fn number_starts_word_kebab_complex() {
assert_eq!(
"aes128Key".convert_case(ConvertCaseOpt {
case: Case::Kebab,
number_starts_word: true
}),
"aes-128-key"
);
}

#[test]
fn number_starts_word_kebab_complex_underscore() {
assert_eq!(
"aes128 Key".convert_case(ConvertCaseOpt {
case: Case::Kebab,
number_starts_word: true
}),
"aes-128-key"
);
}

#[test]
fn number_starts_word_false_kebab_complex_underscore() {
assert_eq!(
"aes128 Key".convert_case(ConvertCaseOpt {
case: Case::Kebab,
number_starts_word: false
}),
"aes128-key"
);
}

#[test]
fn number_starts_word_true_title_case() {
assert_eq!(
"AES128BitKey".convert_case(ConvertCaseOpt {
case: Case::Title,
number_starts_word: true
}),
"Aes 128 Bit Key"
);
}

#[test]
fn number_starts_word_true_snake_case() {
assert_eq!(
"99BOTTLES".convert_case(ConvertCaseOpt {
case: Case::Snake,
number_starts_word: true
}),
"99_bottles"
);
}

#[test]
fn number_starts_word_true_snake_case_2() {
assert_eq!(
"abc123DEF456".convert_case(ConvertCaseOpt {
case: Case::Snake,
number_starts_word: true
}),
"abc_123_def_456"
);
}

#[test]
fn number_starts_word_true_snake_case_3() {
assert_eq!(
"ABC123dEEf456FOO".convert_case(ConvertCaseOpt {
case: Case::Snake,
number_starts_word: true
}),
"abc_123_d_e_ef_456_foo"
);
}
}
9 changes: 7 additions & 2 deletions src/kebab.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{lowercase, transform};
use crate::{lowercase, transform, ConvertCaseOpt, Case};
use crate::convert_case::convert_case;

/// This trait defines a kebab case conversion.
///
Expand All @@ -17,9 +18,13 @@ pub trait ToKebabCase: ToOwned {
fn to_kebab_case(&self) -> Self::Owned;
}

pub fn to_kebab(s: &str, number_starts_word: bool) -> String {
transform(s, number_starts_word, lowercase, |s| s.push('-'))
}

impl ToKebabCase for str {
fn to_kebab_case(&self) -> Self::Owned {
transform(self, lowercase, |s| s.push('-'))
convert_case(&self, ConvertCaseOpt {case: Case::Kebab, number_starts_word: false})
}
}

Expand Down
20 changes: 17 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ mod shouty_snake;
mod snake;
mod title;
mod upper_camel;
mod convert_case;

pub use kebab::ToKebabCase;
pub use lower_camel::ToLowerCamelCase;
Expand All @@ -55,10 +56,11 @@ pub use shouty_snake::{ToShoutySnakeCase, ToShoutySnekCase};
pub use snake::{ToSnakeCase, ToSnekCase};
pub use title::ToTitleCase;
pub use upper_camel::{ToPascalCase, ToUpperCamelCase};
pub use convert_case::{ConvertCase, Case, ConvertCaseOpt};

use unicode_segmentation::UnicodeSegmentation;

fn transform<F, G>(s: &str, with_word: F, boundary: G) -> String
fn transform<F, G>(s: &str, number_starts_word: bool, with_word: F, boundary: G) -> String
where
F: Fn(&str, &mut String),
G: Fn(&mut String),
Expand All @@ -81,6 +83,8 @@ where
Lowercase,
/// The previous cased character in the current word is uppercase.
Uppercase,
/// The previous cased character in the current word is numeric
Numeric
}

let mut out = String::new();
Expand All @@ -107,13 +111,23 @@ where
WordMode::Lowercase
} else if c.is_uppercase() {
WordMode::Uppercase

// set numeric only if number_starts_word
// so that it does not affect regular processing
// when number_starts_word is false
} else if number_starts_word && c.is_numeric() {
WordMode::Numeric
} else {
mode
};

// Word boundary after if next is underscore or current is
// When number_starts_word is false: Word boundary after if next is underscore or current is
// not uppercase and next is uppercase
if next == '_' || (next_mode == WordMode::Lowercase && next.is_uppercase()) {
// When number_starts_word is true: word boundary after when mode changes from alpha to numeric or numeric to alpha
if next == '_' ||
(next_mode == WordMode::Lowercase && next.is_uppercase()) ||
(number_starts_word && next_mode == WordMode::Numeric && (next.is_uppercase() || next.is_lowercase())) ||
(number_starts_word && next.is_numeric() && (next_mode == WordMode::Lowercase || next_mode == WordMode::Uppercase)) {
if !first_word {
boundary(&mut out);
}
Expand Down
31 changes: 18 additions & 13 deletions src/lower_camel.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{capitalize, lowercase, transform};
use crate::{capitalize, lowercase, transform, ConvertCaseOpt, Case};
use crate::convert_case::convert_case;

/// This trait defines a lower camel case conversion.
///
Expand All @@ -18,19 +19,23 @@ pub trait ToLowerCamelCase: ToOwned {
fn to_lower_camel_case(&self) -> Self::Owned;
}

pub fn to_lower_camel_case(s: &str, number_starts_word: bool) -> String {
transform(
s, number_starts_word,
|s, out| {
if out.is_empty() {
lowercase(s, out);
} else {
capitalize(s, out)
}
},
|_| {},
)
}

impl ToLowerCamelCase for str {
fn to_lower_camel_case(&self) -> String {
transform(
self,
|s, out| {
if out.is_empty() {
lowercase(s, out);
} else {
capitalize(s, out)
}
},
|_| {},
)
fn to_lower_camel_case(&self) -> Self::Owned {
convert_case(&self, ConvertCaseOpt {case: Case::LowerCamel, number_starts_word: false})
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/shouty_kebab.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{transform, uppercase};
use crate::{transform, uppercase, ConvertCaseOpt, Case};
use crate::convert_case::convert_case;

/// This trait defines a shouty kebab case conversion.
///
Expand All @@ -18,9 +19,13 @@ pub trait ToShoutyKebabCase: ToOwned {
fn to_shouty_kebab_case(&self) -> Self::Owned;
}

pub fn to_shouty_kebab_case(s: &str, number_starts_word: bool) -> String {
transform(s, number_starts_word, uppercase, |s| s.push('-'))
}

impl ToShoutyKebabCase for str {
fn to_shouty_kebab_case(&self) -> Self::Owned {
transform(self, uppercase, |s| s.push('-'))
convert_case(&self, ConvertCaseOpt {case: Case::ShoutyKebab, number_starts_word: false})
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/shouty_snake.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{transform, uppercase};
use crate::{transform, uppercase, ConvertCaseOpt, Case};
use crate::convert_case::convert_case;

/// This trait defines a shouty snake case conversion.
///
Expand Down Expand Up @@ -32,9 +33,13 @@ impl<T: ?Sized + ToShoutySnakeCase> ToShoutySnekCase for T {
}
}

pub fn to_shouty_snake_case(s: &str, numbers_starts_word: bool) -> String {
transform(s, numbers_starts_word, uppercase, |s| s.push('_'))
}

impl ToShoutySnakeCase for str {
fn to_shouty_snake_case(&self) -> Self::Owned {
transform(self, uppercase, |s| s.push('_'))
convert_case(&self, ConvertCaseOpt {case: Case::ShoutySnake, number_starts_word: false})
}
}

Expand Down
11 changes: 8 additions & 3 deletions src/snake.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{lowercase, transform};
use crate::{lowercase, transform, ConvertCaseOpt, Case};
use crate::convert_case::convert_case;

/// This trait defines a snake case conversion.
///
Expand Down Expand Up @@ -30,9 +31,13 @@ impl<T: ?Sized + ToSnakeCase> ToSnekCase for T {
}
}

pub fn to_snake_case(s: &str, number_starts_word: bool) -> String {
transform(s, number_starts_word, lowercase, |s| s.push('_'))
}

impl ToSnakeCase for str {
fn to_snake_case(&self) -> String {
transform(self, lowercase, |s| s.push('_'))
fn to_snake_case(&self) -> Self::Owned {
convert_case(&self, ConvertCaseOpt {case: Case::Snake, number_starts_word: false})
}
}

Expand Down
17 changes: 14 additions & 3 deletions src/title.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{capitalize, transform};
use crate::convert_case::convert_case;
use crate::{capitalize, transform, Case, ConvertCaseOpt};

/// This trait defines a title case conversion.
///
Expand All @@ -18,9 +19,19 @@ pub trait ToTitleCase: ToOwned {
fn to_title_case(&self) -> Self::Owned;
}

pub fn to_title_case(s: &str, numbers_starts_word: bool) -> String {
transform(s, numbers_starts_word, capitalize, |s| s.push(' '))
}

impl ToTitleCase for str {
fn to_title_case(&self) -> String {
transform(self, capitalize, |s| s.push(' '))
fn to_title_case(&self) -> Self::Owned {
convert_case(
&self,
ConvertCaseOpt {
case: Case::Title,
number_starts_word: false,
},
)
}
}

Expand Down
Loading

0 comments on commit 7bae08a

Please sign in to comment.