Skip to main content

derive_inflection/
lib.rs

1//! Case inflection for derive macros.
2//!
3//! Provides the standard set of `rename_all` case transformations used by
4//! serde, ts-rs, flowjs-rs, and similar derive crates.
5//!
6//! ```
7//! use derive_inflection::Inflection;
8//!
9//! assert_eq!(Inflection::Camel.apply("first_name"), "firstName");
10//! assert_eq!(Inflection::Snake.apply("firstName"), "first_name");
11//! assert_eq!(Inflection::Kebab.apply("firstName"), "first-name");
12//! assert_eq!(Inflection::ScreamingSnake.apply("firstName"), "FIRST_NAME");
13//! ```
14
15/// Field/variant name inflection.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum Inflection {
18    /// `"lowercase"` — all lowercase, no separators.
19    Lower,
20    /// `"UPPERCASE"` — all uppercase, no separators.
21    Upper,
22    /// `"camelCase"`
23    Camel,
24    /// `"snake_case"`
25    Snake,
26    /// `"PascalCase"`
27    Pascal,
28    /// `"SCREAMING_SNAKE_CASE"`
29    ScreamingSnake,
30    /// `"kebab-case"`
31    Kebab,
32    /// `"SCREAMING-KEBAB-CASE"`
33    ScreamingKebab,
34}
35
36impl Inflection {
37    /// Parse from the standard `rename_all` string values.
38    ///
39    /// Returns `None` for unrecognized values.
40    pub fn parse(s: &str) -> Option<Self> {
41        match s {
42            "lowercase" => Some(Self::Lower),
43            "UPPERCASE" => Some(Self::Upper),
44            "camelCase" => Some(Self::Camel),
45            "snake_case" => Some(Self::Snake),
46            "PascalCase" => Some(Self::Pascal),
47            "SCREAMING_SNAKE_CASE" => Some(Self::ScreamingSnake),
48            "kebab-case" => Some(Self::Kebab),
49            "SCREAMING-KEBAB-CASE" => Some(Self::ScreamingKebab),
50            _ => None,
51        }
52    }
53
54    /// All accepted `rename_all` values.
55    pub const VALID_VALUES: &[&str] = &[
56        "lowercase",
57        "UPPERCASE",
58        "camelCase",
59        "snake_case",
60        "PascalCase",
61        "SCREAMING_SNAKE_CASE",
62        "kebab-case",
63        "SCREAMING-KEBAB-CASE",
64    ];
65
66    /// Apply the inflection to a string.
67    pub fn apply(&self, s: &str) -> String {
68        match self {
69            Self::Lower => s.to_lowercase(),
70            Self::Upper => s.to_uppercase(),
71            Self::Snake => to_snake_case(s),
72            Self::ScreamingSnake => to_snake_case(s).to_uppercase(),
73            Self::Camel => to_camel_case(s),
74            Self::Pascal => to_pascal_case(s),
75            Self::Kebab => to_snake_case(s).replace('_', "-"),
76            Self::ScreamingKebab => to_snake_case(s).to_uppercase().replace('_', "-"),
77        }
78    }
79}
80
81fn to_snake_case(s: &str) -> String {
82    let mut result = String::new();
83    for (i, ch) in s.chars().enumerate() {
84        if ch.is_uppercase() {
85            if i > 0 {
86                result.push('_');
87            }
88            result.push(ch.to_lowercase().next().unwrap());
89        } else {
90            result.push(ch);
91        }
92    }
93    result
94}
95
96fn to_camel_case(s: &str) -> String {
97    let pascal = to_pascal_case(s);
98    let mut chars = pascal.chars();
99    match chars.next() {
100        None => String::new(),
101        Some(first) => first.to_lowercase().to_string() + chars.as_str(),
102    }
103}
104
105fn to_pascal_case(s: &str) -> String {
106    s.split('_')
107        .map(|word| {
108            let mut chars = word.chars();
109            match chars.next() {
110                None => String::new(),
111                Some(first) => first.to_uppercase().to_string() + chars.as_str(),
112            }
113        })
114        .collect()
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn parse_all_values() {
123        for val in Inflection::VALID_VALUES {
124            assert!(Inflection::parse(val).is_some(), "should parse: {val}");
125        }
126        assert!(Inflection::parse("invalid").is_none());
127    }
128
129    #[test]
130    fn camel_case() {
131        assert_eq!(Inflection::Camel.apply("first_name"), "firstName");
132        assert_eq!(Inflection::Camel.apply("FirstName"), "firstName");
133    }
134
135    #[test]
136    fn snake_case() {
137        assert_eq!(Inflection::Snake.apply("firstName"), "first_name");
138        assert_eq!(Inflection::Snake.apply("FirstName"), "first_name");
139    }
140
141    #[test]
142    fn pascal_case() {
143        assert_eq!(Inflection::Pascal.apply("first_name"), "FirstName");
144    }
145
146    #[test]
147    fn kebab_case() {
148        assert_eq!(Inflection::Kebab.apply("first_name"), "first-name");
149        assert_eq!(Inflection::Kebab.apply("FirstName"), "first-name");
150    }
151
152    #[test]
153    fn screaming_snake() {
154        assert_eq!(Inflection::ScreamingSnake.apply("firstName"), "FIRST_NAME");
155    }
156
157    #[test]
158    fn screaming_kebab() {
159        assert_eq!(Inflection::ScreamingKebab.apply("firstName"), "FIRST-NAME");
160    }
161
162    #[test]
163    fn lower_upper() {
164        assert_eq!(Inflection::Lower.apply("FooBar"), "foobar");
165        assert_eq!(Inflection::Upper.apply("FooBar"), "FOOBAR");
166    }
167}