1use serde::Deserialize;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CaseConvention {
22 Lower,
23 Upper,
24 Pascal,
25 Camel,
26 Snake,
27 Kebab,
28 ScreamingSnake,
29 Flat,
30}
31
32impl CaseConvention {
33 pub fn display_name(self) -> &'static str {
34 match self {
35 Self::Lower => "lowercase",
36 Self::Upper => "uppercase",
37 Self::Pascal => "PascalCase",
38 Self::Camel => "camelCase",
39 Self::Snake => "snake_case",
40 Self::Kebab => "kebab-case",
41 Self::ScreamingSnake => "SCREAMING_SNAKE_CASE",
42 Self::Flat => "flatcase",
43 }
44 }
45
46 pub fn check(self, s: &str) -> bool {
47 if s.is_empty() {
48 return false;
49 }
50 match self {
51 Self::Lower => is_lowercase(s),
52 Self::Upper => is_uppercase(s),
53 Self::Pascal => is_pascal(s),
54 Self::Camel => is_camel(s),
55 Self::Snake => is_snake(s),
56 Self::Kebab => is_kebab(s),
57 Self::ScreamingSnake => is_screaming_snake(s),
58 Self::Flat => is_flat(s),
59 }
60 }
61
62 pub fn convert(self, input: &str) -> String {
68 match self {
69 Self::Lower => input.to_ascii_lowercase(),
70 Self::Upper => input.to_ascii_uppercase(),
71 Self::Pascal => assemble_cap(&tokenize(input)),
72 Self::Camel => assemble_camel(&tokenize(input)),
73 Self::Snake => tokenize(input)
74 .iter()
75 .map(|t| t.to_ascii_lowercase())
76 .collect::<Vec<_>>()
77 .join("_"),
78 Self::Kebab => tokenize(input)
79 .iter()
80 .map(|t| t.to_ascii_lowercase())
81 .collect::<Vec<_>>()
82 .join("-"),
83 Self::ScreamingSnake => tokenize(input)
84 .iter()
85 .map(|t| t.to_ascii_uppercase())
86 .collect::<Vec<_>>()
87 .join("_"),
88 Self::Flat => tokenize(input)
89 .iter()
90 .map(|t| t.to_ascii_lowercase())
91 .collect(),
92 }
93 }
94}
95
96fn tokenize(s: &str) -> Vec<String> {
101 let chars: Vec<char> = s.chars().collect();
102 let mut tokens: Vec<String> = Vec::new();
103 let mut current = String::new();
104 for (i, &c) in chars.iter().enumerate() {
105 if matches!(c, '_' | '-' | ' ' | '.') {
106 if !current.is_empty() {
107 tokens.push(std::mem::take(&mut current));
108 }
109 continue;
110 }
111 if c.is_ascii_uppercase() && !current.is_empty() {
112 let last = current.chars().last().unwrap();
113 let next_is_lower = i + 1 < chars.len() && chars[i + 1].is_ascii_lowercase();
114 if last.is_ascii_lowercase()
118 || last.is_ascii_digit()
119 || (last.is_ascii_uppercase() && next_is_lower)
120 {
121 tokens.push(std::mem::take(&mut current));
122 }
123 }
124 current.push(c);
125 }
126 if !current.is_empty() {
127 tokens.push(current);
128 }
129 tokens
130}
131
132fn title(s: &str) -> String {
133 let mut it = s.chars();
134 match it.next() {
135 None => String::new(),
136 Some(first) => {
137 let mut out = String::with_capacity(s.len());
138 out.push(first.to_ascii_uppercase());
139 for c in it {
140 out.push(c.to_ascii_lowercase());
141 }
142 out
143 }
144 }
145}
146
147fn assemble_cap(tokens: &[String]) -> String {
148 tokens.iter().map(|t| title(t)).collect()
149}
150
151fn assemble_camel(tokens: &[String]) -> String {
152 let mut it = tokens.iter();
153 let first = it
154 .next()
155 .map(|t| t.to_ascii_lowercase())
156 .unwrap_or_default();
157 std::iter::once(first).chain(it.map(|t| title(t))).collect()
158}
159
160impl<'de> Deserialize<'de> for CaseConvention {
164 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
165 let raw: String = String::deserialize(d)?;
166 let canon: String = raw
167 .chars()
168 .filter(char::is_ascii_alphabetic)
169 .map(|c| c.to_ascii_lowercase())
170 .collect();
171 match canon.as_str() {
172 "lower" | "lowercase" => Ok(Self::Lower),
173 "upper" | "uppercase" => Ok(Self::Upper),
174 "pascal" | "pascalcase" | "uppercamel" | "uppercamelcase" => Ok(Self::Pascal),
175 "camel" | "camelcase" | "lowercamel" | "lowercamelcase" => Ok(Self::Camel),
176 "snake" | "snakecase" => Ok(Self::Snake),
177 "kebab" | "kebabcase" | "dash" | "dashcase" => Ok(Self::Kebab),
178 "screamingsnake" | "screamingsnakecase" | "upper_snake" | "uppersnakecase" => {
179 Ok(Self::ScreamingSnake)
180 }
181 "flat" | "flatcase" => Ok(Self::Flat),
182 other => Err(serde::de::Error::custom(format!(
183 "unknown case convention {raw:?} (normalized to {other:?})",
184 ))),
185 }
186 }
187}
188
189fn is_lowercase(s: &str) -> bool {
190 s.chars().all(|c| !c.is_alphabetic() || c.is_lowercase())
191}
192
193fn is_uppercase(s: &str) -> bool {
194 s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase())
195}
196
197fn is_flat(s: &str) -> bool {
198 s.chars()
199 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
200}
201
202fn is_snake(s: &str) -> bool {
203 s.chars()
204 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
205}
206
207fn is_kebab(s: &str) -> bool {
208 s.chars()
209 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
210}
211
212fn is_screaming_snake(s: &str) -> bool {
213 s.chars()
214 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
215}
216
217fn is_camel(s: &str) -> bool {
218 check_camel_like(s, false)
219}
220
221fn is_pascal(s: &str) -> bool {
222 check_camel_like(s, true)
223}
224
225fn check_camel_like(s: &str, require_upper_first: bool) -> bool {
226 let mut chars = s.chars();
227 let Some(first) = chars.next() else {
228 return false;
229 };
230 if require_upper_first {
231 if !first.is_ascii_uppercase() {
232 return false;
233 }
234 } else if !first.is_ascii_lowercase() {
235 return false;
236 }
237 chars.all(|c| c.is_ascii_alphanumeric())
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn pascal_accepts_simple() {
246 assert!(is_pascal("Button"));
247 assert!(is_pascal("FooBar"));
248 assert!(is_pascal("Foo1Bar"));
249 assert!(is_pascal("A"));
250 assert!(is_pascal("XMLParser")); }
252
253 #[test]
254 fn pascal_rejects_wrong_shapes() {
255 assert!(!is_pascal(""));
256 assert!(!is_pascal("foo"));
257 assert!(!is_pascal("Foo_Bar"));
258 assert!(!is_pascal("Foo-Bar"));
259 }
260
261 #[test]
262 fn camel_accepts_simple() {
263 assert!(is_camel("fooBar"));
264 assert!(is_camel("foo"));
265 assert!(is_camel("ssrVFor"));
266 assert!(is_camel("getXMLParser")); assert!(is_camel("foo1Bar"));
268 }
269
270 #[test]
271 fn camel_rejects_wrong_shapes() {
272 assert!(!is_camel("FooBar"));
273 assert!(!is_camel(""));
274 assert!(!is_camel("foo_bar"));
275 }
276
277 #[test]
278 fn snake_kebab() {
279 assert!(is_snake("foo_bar_baz"));
280 assert!(!is_snake("fooBar"));
281 assert!(is_kebab("foo-bar-baz"));
282 assert!(!is_kebab("foo_bar"));
283 }
284
285 #[test]
286 fn screaming_snake() {
287 assert!(is_screaming_snake("FOO_BAR"));
288 assert!(is_screaming_snake("HELLO_2_WORLD"));
289 assert!(!is_screaming_snake("Foo_Bar"));
290 }
291
292 #[test]
293 fn flat_vs_lower() {
294 assert!(is_flat("helloworld"));
295 assert!(!is_flat("hello_world"));
296 assert!(is_lowercase("hello_world")); }
298
299 #[test]
300 fn convert_snake_from_various_shapes() {
301 assert_eq!(CaseConvention::Snake.convert("FooBar"), "foo_bar");
302 assert_eq!(CaseConvention::Snake.convert("fooBar"), "foo_bar");
303 assert_eq!(CaseConvention::Snake.convert("foo-bar"), "foo_bar");
304 assert_eq!(CaseConvention::Snake.convert("XMLParser"), "xml_parser");
305 assert_eq!(CaseConvention::Snake.convert("hello"), "hello");
306 }
307
308 #[test]
309 fn convert_pascal_from_various_shapes() {
310 assert_eq!(CaseConvention::Pascal.convert("foo_bar"), "FooBar");
311 assert_eq!(CaseConvention::Pascal.convert("foo-bar"), "FooBar");
312 assert_eq!(CaseConvention::Pascal.convert("fooBar"), "FooBar");
313 assert_eq!(CaseConvention::Pascal.convert("hello"), "Hello");
314 }
315
316 #[test]
317 fn convert_camel_kebab_screaming_flat() {
318 assert_eq!(CaseConvention::Camel.convert("FooBar"), "fooBar");
319 assert_eq!(CaseConvention::Kebab.convert("FooBar"), "foo-bar");
320 assert_eq!(CaseConvention::ScreamingSnake.convert("FooBar"), "FOO_BAR");
321 assert_eq!(CaseConvention::Flat.convert("FooBar"), "foobar");
322 }
323
324 #[test]
325 fn convert_is_idempotent_on_already_correct_input() {
326 assert_eq!(CaseConvention::Snake.convert("foo_bar"), "foo_bar");
327 assert_eq!(CaseConvention::Pascal.convert("FooBar"), "FooBar");
328 assert_eq!(CaseConvention::Kebab.convert("foo-bar"), "foo-bar");
329 }
330
331 #[test]
332 fn alias_deserialization() {
333 use serde_yaml_ng::from_str;
334 let cases = &[
335 ("PascalCase", CaseConvention::Pascal),
336 ("pascal", CaseConvention::Pascal),
337 ("pascal-case", CaseConvention::Pascal),
338 ("UpperCamelCase", CaseConvention::Pascal),
339 ("camelCase", CaseConvention::Camel),
340 ("camel", CaseConvention::Camel),
341 ("kebab-case", CaseConvention::Kebab),
342 ("KEBAB", CaseConvention::Kebab),
343 ("snake_case", CaseConvention::Snake),
344 ("SCREAMING_SNAKE_CASE", CaseConvention::ScreamingSnake),
345 ("flatcase", CaseConvention::Flat),
346 ];
347 for (input, expected) in cases {
348 let parsed: CaseConvention = from_str(&format!("\"{input}\"")).unwrap();
349 assert_eq!(parsed, *expected, "input = {input}");
350 }
351 }
352}