Skip to main content

decamelize/
lib.rs

1//! # decamelize — convert camelCase to a separated lower-case string
2//!
3//! Turn `camelCase` (or `PascalCase`) into a separated, lower-case string — `fooBar` →
4//! `foo_bar`, `unicornsAndRainbows` → `unicorns_and_rainbows`. A faithful Rust port of the
5//! widely-used [`decamelize`](https://www.npmjs.com/package/decamelize) npm package.
6//!
7//! ```
8//! use decamelize::decamelize;
9//!
10//! assert_eq!(decamelize("unicornsAndRainbows"), "unicorns_and_rainbows");
11//! assert_eq!(decamelize("XMLHttpRequest"), "xml_http_request");
12//! assert_eq!(decamelize("testGUILabel"), "test_gui_label");
13//! ```
14//!
15//! Use [`decamelize_with`] for a custom separator or to preserve consecutive uppercase
16//! runs:
17//!
18//! ```
19//! use decamelize::decamelize_with;
20//!
21//! assert_eq!(decamelize_with("fooBar", "-", false), "foo-bar");
22//! assert_eq!(decamelize_with("testGUILabel", "_", true), "test_GUI_label");
23//! ```
24//!
25//! **Zero dependencies** and `#![no_std]`.
26
27#![no_std]
28#![forbid(unsafe_code)]
29#![doc(html_root_url = "https://docs.rs/decamelize/0.1.0")]
30
31extern crate alloc;
32
33use alloc::string::String;
34use alloc::vec::Vec;
35
36// Compile-test the README's examples as part of `cargo test`.
37#[cfg(doctest)]
38#[doc = include_str!("../README.md")]
39struct ReadmeDoctests;
40
41/// Convert `text` from camelCase to a `_`-separated lower-case string.
42///
43/// Equivalent to [`decamelize_with(text, "_", false)`](decamelize_with).
44///
45/// ```
46/// # use decamelize::decamelize;
47/// assert_eq!(decamelize("fooBar"), "foo_bar");
48/// ```
49#[must_use]
50pub fn decamelize(text: &str) -> String {
51    decamelize_with(text, "_", false)
52}
53
54/// Convert `text` from camelCase using a custom `separator`.
55///
56/// When `preserve_consecutive_uppercase` is `true`, runs of consecutive uppercase letters
57/// keep their case (`testGUILabel` → `test_GUI_label`); otherwise the whole result is
58/// lower-cased.
59///
60/// ```
61/// # use decamelize::decamelize_with;
62/// assert_eq!(decamelize_with("fooBarBaz", ".", false), "foo.bar.baz");
63/// ```
64#[must_use]
65pub fn decamelize_with(
66    text: &str,
67    separator: &str,
68    preserve_consecutive_uppercase: bool,
69) -> String {
70    // Matches the reference's UTF-16 length check.
71    if text.encode_utf16().take(2).count() < 2 {
72        return if preserve_consecutive_uppercase {
73            text.into()
74        } else {
75            text.to_lowercase()
76        };
77    }
78
79    // Split a lowercase letter or digit followed by an uppercase letter.
80    let split = split_lower_then_upper(text, separator);
81
82    if preserve_consecutive_uppercase {
83        let lowered = lowercase_isolated(&split);
84        split_upper_run(&lowered, separator, true)
85    } else {
86        split_upper_run(&split, separator, false).to_lowercase()
87    }
88}
89
90fn is_upper(c: char) -> bool {
91    c.is_uppercase()
92}
93
94fn is_lower(c: char) -> bool {
95    c.is_lowercase()
96}
97
98/// `\d` (ASCII digits, as in JavaScript regular expressions).
99fn is_digit(c: char) -> bool {
100    c.is_ascii_digit()
101}
102
103fn is_upper_or_digit(c: char) -> bool {
104    is_upper(c) || is_digit(c)
105}
106
107/// `([\p{Ll}\d])(\p{Lu})` → insert `separator` between each lowercase/digit → uppercase
108/// transition.
109fn split_lower_then_upper(text: &str, separator: &str) -> String {
110    let mut out = String::with_capacity(text.len());
111    let mut prev: Option<char> = None;
112    for c in text.chars() {
113        if let Some(p) = prev {
114            if (is_lower(p) || is_digit(p)) && is_upper(c) {
115                out.push_str(separator);
116            }
117        }
118        out.push(c);
119        prev = Some(c);
120    }
121    out
122}
123
124/// `((?<![\p{Lu}\d])[\p{Lu}\d](?![\p{Lu}\d]))` → lowercase any single uppercase/digit that
125/// is not adjacent to another uppercase/digit.
126fn lowercase_isolated(text: &str) -> String {
127    let chars: Vec<char> = text.chars().collect();
128    let mut out = String::with_capacity(text.len());
129    for (i, &c) in chars.iter().enumerate() {
130        let isolated = is_upper_or_digit(c)
131            && (i == 0 || !is_upper_or_digit(chars[i - 1]))
132            && (i + 1 >= chars.len() || !is_upper_or_digit(chars[i + 1]));
133        if isolated {
134            out.extend(c.to_lowercase());
135        } else {
136            out.push(c);
137        }
138    }
139    out
140}
141
142/// Split the boundary between a run of uppercase letters and a following lowercase word.
143///
144/// This implements both `(\p{Lu})(\p{Lu}\p{Ll}+)` (non-preserve) and
145/// `(?<!\p{Lu})(\p{Lu}+)(\p{Lu}\p{Ll}+)` (preserve): in either case the separator is
146/// inserted before the **last** uppercase letter of a run (length ≥ 2) that is immediately
147/// followed by a lowercase letter. When `lowercase_moved` is set, that moved last letter is
148/// lower-cased (the rest of the run keeps its case).
149fn split_upper_run(text: &str, separator: &str, lowercase_moved: bool) -> String {
150    let chars: Vec<char> = text.chars().collect();
151    let n = chars.len();
152    let mut out = String::with_capacity(text.len() + separator.len());
153    let mut i = 0;
154    while i < n {
155        if is_upper(chars[i]) && (i == 0 || !is_upper(chars[i - 1])) {
156            // Maximal uppercase run [i, j).
157            let mut j = i;
158            while j < n && is_upper(chars[j]) {
159                j += 1;
160            }
161            let run_len = j - i;
162            if run_len >= 2 && j < n && is_lower(chars[j]) {
163                for &c in &chars[i..j - 1] {
164                    out.push(c);
165                }
166                out.push_str(separator);
167                if lowercase_moved {
168                    out.extend(chars[j - 1].to_lowercase());
169                } else {
170                    out.push(chars[j - 1]);
171                }
172            } else {
173                for &c in &chars[i..j] {
174                    out.push(c);
175                }
176            }
177            i = j;
178        } else {
179            out.push(chars[i]);
180            i += 1;
181        }
182    }
183    out
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn basic() {
192        assert_eq!(decamelize("unicornsAndRainbows"), "unicorns_and_rainbows");
193        assert_eq!(decamelize("fooBar"), "foo_bar");
194        assert_eq!(decamelize("p2pNetwork"), "p2p_network");
195    }
196
197    #[test]
198    fn acronyms() {
199        assert_eq!(decamelize("XMLHttpRequest"), "xml_http_request");
200        assert_eq!(decamelize("testGUILabel"), "test_gui_label");
201        assert_eq!(decamelize("testID"), "test_id");
202        assert_eq!(decamelize("ABCdef"), "ab_cdef");
203        assert_eq!(decamelize("fooBAR"), "foo_bar");
204    }
205
206    #[test]
207    fn digits_and_short() {
208        assert_eq!(decamelize("foo2bar"), "foo2bar");
209        assert_eq!(decamelize("test123Number"), "test123_number");
210        assert_eq!(decamelize("ID"), "id");
211        assert_eq!(decamelize("a"), "a");
212        assert_eq!(decamelize("A"), "a");
213        assert_eq!(decamelize(""), "");
214        assert_eq!(decamelize("__foo__bar__"), "__foo__bar__");
215    }
216
217    #[test]
218    fn separators() {
219        assert_eq!(decamelize_with("fooBar", "-", false), "foo-bar");
220        assert_eq!(
221            decamelize_with("unicornsAndRainbows", " ", false),
222            "unicorns and rainbows"
223        );
224        assert_eq!(
225            decamelize_with("XMLHttpRequest", "-", false),
226            "xml-http-request"
227        );
228    }
229
230    #[test]
231    fn preserve_consecutive_uppercase() {
232        assert_eq!(decamelize_with("testGUILabel", "_", true), "test_GUI_label");
233        assert_eq!(
234            decamelize_with("XMLHttpRequest", "_", true),
235            "XML_http_request"
236        );
237        assert_eq!(decamelize_with("testID", "_", true), "test_ID");
238        assert_eq!(decamelize_with("fooBAR", "_", true), "foo_BAR");
239        assert_eq!(decamelize_with("ABCdef", "_", true), "AB_cdef");
240        assert_eq!(decamelize_with("A", "_", true), "A");
241    }
242}