1#[must_use]
26pub fn to_snake_case(s: &str) -> String {
27 if s.is_ascii() {
28 to_snake_case_ascii(s.as_bytes())
29 } else {
30 to_snake_case_unicode(s)
31 }
32}
33
34fn to_snake_case_ascii(bytes: &[u8]) -> String {
35 const BOUNDARY: u8 = 0;
38 const LOWER: u8 = 1;
39 const UPPER: u8 = 2;
40
41 let len = bytes.len();
42 let mut result = String::with_capacity(len + len / 4);
43 let mut first_word = true;
44 let mut mode: u8 = BOUNDARY;
45 let mut word_start = 0;
46 let mut i = 0;
47
48 while i < len {
49 let b = bytes[i];
50
51 if !b.is_ascii_alphanumeric() {
52 if word_start < i {
53 push_lower_ascii(&mut result, &bytes[word_start..i], &mut first_word);
54 }
55 word_start = i + 1;
56 mode = BOUNDARY;
57 i += 1;
58 continue;
59 }
60
61 let next_mode = if b.is_ascii_lowercase() {
62 LOWER
63 } else if b.is_ascii_uppercase() {
64 UPPER
65 } else {
66 mode
67 };
68
69 if i + 1 < len && bytes[i + 1].is_ascii_alphanumeric() {
70 let next = bytes[i + 1];
71
72 if next_mode == LOWER && next.is_ascii_uppercase() {
73 push_lower_ascii(&mut result, &bytes[word_start..=i], &mut first_word);
74 word_start = i + 1;
75 mode = BOUNDARY;
76 } else if mode == UPPER && b.is_ascii_uppercase() && next.is_ascii_lowercase() {
77 if word_start < i {
78 push_lower_ascii(&mut result, &bytes[word_start..i], &mut first_word);
79 }
80 word_start = i;
81 mode = BOUNDARY;
82 } else {
83 mode = next_mode;
84 }
85 }
86
87 i += 1;
88 }
89
90 if word_start < len && bytes[word_start].is_ascii_alphanumeric() {
91 push_lower_ascii(&mut result, &bytes[word_start..], &mut first_word);
92 }
93
94 result
95}
96
97fn push_lower_ascii(result: &mut String, word: &[u8], first_word: &mut bool) {
98 if !*first_word {
99 result.push('_');
100 }
101 *first_word = false;
102
103 for &b in word {
104 result.push(char::from(b.to_ascii_lowercase()));
105 }
106}
107
108fn to_snake_case_unicode(s: &str) -> String {
109 #[derive(Clone, Copy, PartialEq)]
110 enum Mode {
111 Boundary,
112 Lowercase,
113 Uppercase,
114 }
115
116 let mut result = String::with_capacity(s.len() + s.len() / 4);
117 let mut first_word = true;
118
119 for word in s.split(|c: char| !c.is_alphanumeric()) {
120 let mut char_indices = word.char_indices().peekable();
121 let mut init = 0;
122 let mut mode = Mode::Boundary;
123
124 while let Some((i, c)) = char_indices.next() {
125 if let Some(&(next_i, next)) = char_indices.peek() {
126 let next_mode = if c.is_lowercase() {
127 Mode::Lowercase
128 } else if c.is_uppercase() {
129 Mode::Uppercase
130 } else {
131 mode
132 };
133
134 if next_mode == Mode::Lowercase && next.is_uppercase() {
135 push_lower_unicode(&mut result, &word[init..next_i], &mut first_word);
136 init = next_i;
137 mode = Mode::Boundary;
138 } else if mode == Mode::Uppercase && c.is_uppercase() && next.is_lowercase() {
139 push_lower_unicode(&mut result, &word[init..i], &mut first_word);
140 init = i;
141 mode = Mode::Boundary;
142 } else {
143 mode = next_mode;
144 }
145 } else {
146 push_lower_unicode(&mut result, &word[init..], &mut first_word);
147 break;
148 }
149 }
150 }
151
152 result
153}
154
155fn push_lower_unicode(result: &mut String, word: &str, first_word: &mut bool) {
156 if !*first_word {
157 result.push('_');
158 }
159 *first_word = false;
160
161 for c in word.chars() {
162 for lc in c.to_lowercase() {
163 result.push(lc);
164 }
165 }
166}
167
168#[must_use]
185pub fn title_case(s: &str) -> String {
186 if s.is_ascii() {
187 title_case_ascii(s.as_bytes())
188 } else {
189 title_case_unicode(s)
190 }
191}
192
193fn title_case_ascii(bytes: &[u8]) -> String {
194 let mut out = String::with_capacity(bytes.len());
195 let mut prev_cased = false;
196 for &byte in bytes {
197 let byte = if prev_cased {
198 byte.to_ascii_lowercase()
199 } else {
200 byte.to_ascii_uppercase()
201 };
202
203 out.push(char::from(byte));
204 prev_cased = byte.is_ascii_alphabetic();
205 }
206
207 out
208}
209
210fn title_case_unicode(s: &str) -> String {
211 let mut out = String::with_capacity(s.len());
212 let mut prev_cased = false;
213 for ch in s.chars() {
214 if prev_cased {
215 out.extend(ch.to_lowercase());
216 } else {
217 push_titlecase(&mut out, ch);
218 }
219
220 prev_cased = is_cased(ch);
221 }
222
223 out
224}
225
226fn push_titlecase(out: &mut String, ch: char) {
227 match ch {
231 '\u{149}' => out.push_str("\u{2BC}N"),
232 '\u{1C4}'..='\u{1C6}' => out.push('\u{1C5}'),
233 '\u{1C7}'..='\u{1C9}' => out.push('\u{1C8}'),
234 '\u{1CA}'..='\u{1CC}' => out.push('\u{1CB}'),
235 '\u{1F1}'..='\u{1F3}' => out.push('\u{1F2}'),
236 '\u{10D0}'..='\u{10FA}'
237 | '\u{10FD}'..='\u{10FF}'
238 | '\u{1F88}'..='\u{1F8F}'
239 | '\u{1F98}'..='\u{1F9F}'
240 | '\u{1FA8}'..='\u{1FAF}' => out.push(ch),
241 '\u{1F80}'..='\u{1F87}' | '\u{1F90}'..='\u{1F97}' | '\u{1FA0}'..='\u{1FA7}' => {
242 out.push(
243 char::from_u32(ch as u32 + 8).expect("titlecase mapping must be valid Unicode"),
244 );
245 }
246 '\u{1FB2}' => out.push_str("\u{1FBA}\u{345}"),
247 '\u{1FB3}' | '\u{1FBC}' => out.push('\u{1FBC}'),
248 '\u{1FB4}' => out.push_str("\u{386}\u{345}"),
249 '\u{1FB7}' => out.push_str("\u{391}\u{342}\u{345}"),
250 '\u{1FC2}' => out.push_str("\u{1FCA}\u{345}"),
251 '\u{1FC3}' | '\u{1FCC}' => out.push('\u{1FCC}'),
252 '\u{1FC4}' => out.push_str("\u{389}\u{345}"),
253 '\u{1FC7}' => out.push_str("\u{397}\u{342}\u{345}"),
254 '\u{1FF2}' => out.push_str("\u{1FFA}\u{345}"),
255 '\u{1FF3}' | '\u{1FFC}' => out.push('\u{1FFC}'),
256 '\u{1FF4}' => out.push_str("\u{38F}\u{345}"),
257 '\u{1FF7}' => out.push_str("\u{3A9}\u{342}\u{345}"),
258 _ => {
259 let mut uppercase = ch.to_uppercase();
260 if let Some(first) = uppercase.next() {
261 out.push(first);
262 }
263
264 for ch in uppercase {
265 out.extend(ch.to_lowercase());
266 }
267 }
268 }
269}
270
271fn is_cased(ch: char) -> bool {
272 ch.is_lowercase() || ch.is_uppercase() || ch.to_lowercase().next() != Some(ch)
273}
274
275#[cfg(test)]
276mod tests {
277 use rstest::rstest;
278
279 use super::*;
280
281 #[rstest]
282 #[case("CamelCase", "camel_case")]
283 #[case("This is Human case.", "this_is_human_case")]
284 #[case(
285 "MixedUP CamelCase, with some Spaces",
286 "mixed_up_camel_case_with_some_spaces"
287 )]
288 #[case(
289 "mixed_up_ snake_case with some _spaces",
290 "mixed_up_snake_case_with_some_spaces"
291 )]
292 #[case("kebab-case", "kebab_case")]
293 #[case("SHOUTY_SNAKE_CASE", "shouty_snake_case")]
294 #[case("snake_case", "snake_case")]
295 #[case("XMLHttpRequest", "xml_http_request")]
296 #[case("FIELD_NAME11", "field_name11")]
297 #[case("99BOTTLES", "99bottles")]
298 #[case("abc123def456", "abc123def456")]
299 #[case("abc123DEF456", "abc123_def456")]
300 #[case("abc123Def456", "abc123_def456")]
301 #[case("abc123DEf456", "abc123_d_ef456")]
302 #[case("ABC123def456", "abc123def456")]
303 #[case("ABC123DEF456", "abc123def456")]
304 #[case("ABC123Def456", "abc123_def456")]
305 #[case("ABC123DEf456", "abc123d_ef456")]
306 #[case("ABC123dEEf456FOO", "abc123d_e_ef456_foo")]
307 #[case("abcDEF", "abc_def")]
308 #[case("ABcDE", "a_bc_de")]
309 #[case("", "")]
310 #[case("A", "a")]
311 #[case("AB", "ab")]
312 #[case("PascalCase", "pascal_case")]
313 #[case("camelCase", "camel_case")]
314 #[case("getHTTPResponse", "get_http_response")]
315 #[case("Level1", "level1")]
316 #[case("OrderBookDelta", "order_book_delta")]
317 #[case("IOError", "io_error")]
318 #[case("SimpleHTTPServer", "simple_http_server")]
319 #[case("version2Release", "version2_release")]
320 #[case("ALLCAPS", "allcaps")]
321 #[case("é--x", "é_x")]
322 #[case("nautilus_model::data::bar::Bar", "nautilus_model_data_bar_bar")] fn test_to_snake_case(#[case] input: &str, #[case] expected: &str) {
324 assert_eq!(to_snake_case(input), expected);
325 }
326
327 #[rstest]
328 #[case("", "")]
329 #[case("a", "A")]
330 #[case("example", "Example")]
331 #[case("EXAMPLE", "Example")]
332 #[case("ΟΣ", "Οσ")]
333 #[case("hello_world", "Hello_World")]
334 #[case("hello-world", "Hello-World")]
335 #[case("hello world", "Hello World")]
336 #[case("hELLO wORLD", "Hello World")]
337 #[case("123abc", "123Abc")]
338 #[case("_leading", "_Leading")]
339 #[case("\u{1C6}", "\u{1C5}")]
340 #[case("\u{1C9}", "\u{1C8}")]
341 #[case("\u{1CC}", "\u{1CB}")]
342 #[case("dz", "Dz")]
343 #[case("ffi", "Ffi")]
344 #[case("\u{149}", "\u{2BC}N")]
345 #[case("\u{10D0}", "\u{10D0}")]
346 #[case("\u{1F80}", "\u{1F88}")]
347 #[case("\u{1FB2}", "\u{1FBA}\u{345}")]
348 #[case("\u{1FB3}", "\u{1FBC}")]
349 #[case("\u{1FB4}", "\u{386}\u{345}")]
350 #[case("\u{1FB7}", "\u{391}\u{342}\u{345}")]
351 #[case("\u{1FC2}", "\u{1FCA}\u{345}")]
352 #[case("\u{1FC3}", "\u{1FCC}")]
353 #[case("\u{1FC4}", "\u{389}\u{345}")]
354 #[case("\u{1FC7}", "\u{397}\u{342}\u{345}")]
355 #[case("\u{1FF2}", "\u{1FFA}\u{345}")]
356 #[case("\u{1FF3}", "\u{1FFC}")]
357 #[case("\u{1FF4}", "\u{38F}\u{345}")]
358 #[case("\u{1FF7}", "\u{3A9}\u{342}\u{345}")]
359 #[case("\u{4E2D}a", "\u{4E2D}A")]
360 #[case("ªa", "ªa")]
361 #[case("Dza", "Dza")]
362 fn test_title_case(#[case] input: &str, #[case] expected: &str) {
363 assert_eq!(title_case(input), expected);
364 }
365
366 #[rstest]
367 fn test_title_case_unicode_version() {
368 assert_eq!(char::UNICODE_VERSION, (17, 0, 0));
369 }
370}