Skip to main content

nautilus_core/string/
conversions.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! String case conversions (`snake_case`, Title Case).
17
18/// Converts a string from any common case to `snake_case`.
19///
20/// Word boundaries are detected at:
21/// - Non-alphanumeric characters (spaces, hyphens, underscores, colons, etc.)
22/// - Transitions from lowercase or digit to uppercase (`camelCase` -> `camel_case`)
23/// - Within consecutive uppercase letters, before the last if followed by lowercase
24///   (`XMLParser` -> `xml_parser`)
25#[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    // Single pass over bytes. Mode tracks the case of the last cased character
36    // within the current alphanumeric run, matching heck's word-boundary rules.
37    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/// Titlecases `s`, treating each maximal run of cased characters as a word.
169///
170/// Follows Python's `str.title()` word-boundary rules: the first character of each word uses its
171/// Unicode titlecase mapping, and the rest are lowercased. Unlike Python, a word-final `Σ`
172/// lowercases to `σ`, not `ς`.
173///
174/// # Examples
175///
176/// ```
177/// use nautilus_core::string::conversions::title_case;
178///
179/// assert_eq!(title_case("example"), "Example");
180/// assert_eq!(title_case("hello_world"), "Hello_World");
181/// assert_eq!(title_case("hello world"), "Hello World");
182/// assert_eq!(title_case(""), "");
183/// ```
184#[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    // Stable Rust does not expose `char::to_titlecase`. These Unicode 17 mappings from
228    // UnicodeData.txt and SpecialCasing.txt cannot be reproduced by keeping the first scalar of
229    // the uppercase mapping and lowercasing the rest.
230    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")] // nautilus-import-ok
323    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}