Skip to main content

camelcase_keys/
lib.rs

1//! # camelcase-keys — convert JSON object keys to `camelCase`
2//!
3//! Recursively convert the keys of a [`serde_json::Value`] to `camelCase`. A faithful Rust
4//! port of the widely-used
5//! [`camelcase-keys`](https://www.npmjs.com/package/camelcase-keys) npm package, built on the
6//! [`camelcase`](https://crates.io/crates/camelcase) crate.
7//!
8//! ```
9//! use serde_json::json;
10//! use camelcase_keys::{camelcase_keys, camelcase_keys_with, Options};
11//!
12//! assert_eq!(
13//!     camelcase_keys(&json!({ "foo_bar": 1, "baz-qux": 2 })),
14//!     json!({ "fooBar": 1, "bazQux": 2 })
15//! );
16//!
17//! // Recurse into nested objects/arrays with `deep`:
18//! assert_eq!(
19//!     camelcase_keys_with(&json!({ "foo_bar": { "nested_key": 1 } }), &Options::new().deep(true)),
20//!     json!({ "fooBar": { "nestedKey": 1 } })
21//! );
22//! ```
23//!
24//! Numeric-looking keys (`"0"`, `"42"`, `"3.14"`, …) are preserved, and the `pascal_case`,
25//! `preserve_consecutive_uppercase`, `exclude`, and `stop_paths` options match the reference.
26
27#![forbid(unsafe_code)]
28#![doc(html_root_url = "https://docs.rs/camelcase-keys/0.1.0")]
29
30use camelcase::{camel_case_with, Options as CamelOptions};
31use serde_json::{Map, Value};
32
33// Compile-test the README's examples as part of `cargo test`.
34#[cfg(doctest)]
35#[doc = include_str!("../README.md")]
36struct ReadmeDoctests;
37
38/// Options controlling [`camelcase_keys_with`].
39#[derive(Debug, Clone, Default)]
40pub struct Options {
41    deep: bool,
42    pascal_case: bool,
43    preserve_consecutive_uppercase: bool,
44    exclude: Vec<String>,
45    stop_paths: Vec<String>,
46}
47
48impl Options {
49    /// Default options (shallow, `camelCase`).
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Recurse into nested objects and arrays.
56    #[must_use]
57    pub fn deep(mut self, value: bool) -> Self {
58        self.deep = value;
59        self
60    }
61
62    /// Produce `PascalCase` keys instead of `camelCase`.
63    #[must_use]
64    pub fn pascal_case(mut self, value: bool) -> Self {
65        self.pascal_case = value;
66        self
67    }
68
69    /// Preserve consecutive uppercase letters (`HTTP_status` → `HTTPStatus`).
70    #[must_use]
71    pub fn preserve_consecutive_uppercase(mut self, value: bool) -> Self {
72        self.preserve_consecutive_uppercase = value;
73        self
74    }
75
76    /// Keys to leave untouched (compared against the original key).
77    #[must_use]
78    pub fn exclude<I, S>(mut self, keys: I) -> Self
79    where
80        I: IntoIterator<Item = S>,
81        S: Into<String>,
82    {
83        self.exclude = keys.into_iter().map(Into::into).collect();
84        self
85    }
86
87    /// Dot-paths (built from the original keys) at which to stop recursing.
88    #[must_use]
89    pub fn stop_paths<I, S>(mut self, paths: I) -> Self
90    where
91        I: IntoIterator<Item = S>,
92        S: Into<String>,
93    {
94        self.stop_paths = paths.into_iter().map(Into::into).collect();
95        self
96    }
97
98    fn camel_options(&self) -> CamelOptions {
99        CamelOptions::new()
100            .pascal_case(self.pascal_case)
101            .preserve_consecutive_uppercase(self.preserve_consecutive_uppercase)
102    }
103}
104
105/// Convert the keys of `value` to `camelCase` using the default options (shallow).
106///
107/// ```
108/// # use serde_json::json;
109/// # use camelcase_keys::camelcase_keys;
110/// assert_eq!(camelcase_keys(&json!({ "a_b": 1 })), json!({ "aB": 1 }));
111/// ```
112#[must_use]
113pub fn camelcase_keys(value: &Value) -> Value {
114    camelcase_keys_with(value, &Options::new())
115}
116
117/// Convert the keys of `value` to `camelCase` with the given [`Options`].
118#[must_use]
119pub fn camelcase_keys_with(value: &Value, options: &Options) -> Value {
120    transform(value, options, None)
121}
122
123/// A value `camelcase-keys` recurses into (an object or array; JSON has no other containers).
124fn is_recursable(value: &Value) -> bool {
125    value.is_object() || value.is_array()
126}
127
128fn transform(value: &Value, options: &Options, parent_path: Option<&str>) -> Value {
129    match value {
130        Value::Array(array) => Value::Array(
131            array
132                .iter()
133                .map(|item| {
134                    if is_recursable(item) {
135                        transform(item, options, parent_path)
136                    } else {
137                        item.clone()
138                    }
139                })
140                .collect(),
141        ),
142        Value::Object(map) => {
143            let mut result = Map::new();
144            for (key, val) in map {
145                let mut new_value = val.clone();
146
147                if options.deep && is_recursable(val) {
148                    let path = match parent_path {
149                        Some(parent) => format!("{parent}.{key}"),
150                        None => key.clone(),
151                    };
152                    if !options.stop_paths.iter().any(|stop| stop == &path) {
153                        new_value = match val {
154                            Value::Array(items) => Value::Array(
155                                items
156                                    .iter()
157                                    .map(|item| {
158                                        if is_recursable(item) {
159                                            transform(item, options, Some(&path))
160                                        } else {
161                                            item.clone()
162                                        }
163                                    })
164                                    .collect(),
165                            ),
166                            _ => transform(val, options, Some(&path)),
167                        };
168                    }
169                }
170
171                let new_key = if is_numeric_key(key) || options.exclude.iter().any(|e| e == key) {
172                    key.clone()
173                } else {
174                    camel_case_with(key, options.camel_options())
175                };
176                result.insert(new_key, new_value);
177            }
178            Value::Object(result)
179        }
180        other => other.clone(),
181    }
182}
183
184/// Characters JavaScript's `String.prototype.trim` (and `Number`) treat as whitespace.
185fn is_js_whitespace(c: char) -> bool {
186    matches!(
187        c,
188        '\u{0009}'
189            | '\u{000A}'
190            | '\u{000B}'
191            | '\u{000C}'
192            | '\u{000D}'
193            | '\u{0020}'
194            | '\u{00A0}'
195            | '\u{1680}'
196            | '\u{2000}'
197            ..='\u{200A}'
198                | '\u{2028}'
199                | '\u{2029}'
200                | '\u{202F}'
201                | '\u{205F}'
202                | '\u{3000}'
203                | '\u{FEFF}'
204    )
205}
206
207/// `key.trim() !== '' && !Number.isNaN(Number(key))` — keys that coerce to a number.
208fn is_numeric_key(key: &str) -> bool {
209    let trimmed = key.trim_matches(is_js_whitespace);
210    !trimmed.is_empty() && number_is_not_nan(trimmed)
211}
212
213/// Whether `String(value) === text` coerces to a non-`NaN` `Number` (the string is already
214/// trimmed and non-empty).
215fn number_is_not_nan(text: &str) -> bool {
216    let (had_sign, rest) = match text.as_bytes().first() {
217        Some(b'+' | b'-') => (true, &text[1..]),
218        _ => (false, text),
219    };
220    if rest.is_empty() {
221        return false;
222    }
223    if rest == "Infinity" {
224        return true;
225    }
226    // Hex / binary / octal literals do not allow a sign.
227    if !had_sign {
228        if let Some(digits) = strip_radix_prefix(rest, b'x') {
229            return !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_hexdigit());
230        }
231        if let Some(digits) = strip_radix_prefix(rest, b'b') {
232            return !digits.is_empty() && digits.bytes().all(|b| b == b'0' || b == b'1');
233        }
234        if let Some(digits) = strip_radix_prefix(rest, b'o') {
235            return !digits.is_empty() && digits.bytes().all(|b| (b'0'..=b'7').contains(&b));
236        }
237    }
238    is_decimal_literal(rest)
239}
240
241/// Strip a `0x` / `0b` / `0o` prefix (case-insensitive `letter`), returning the digits.
242fn strip_radix_prefix(s: &str, letter: u8) -> Option<&str> {
243    let bytes = s.as_bytes();
244    if bytes.len() >= 2 && bytes[0] == b'0' && (bytes[1] == letter || bytes[1] == letter - 32) {
245        Some(&s[2..])
246    } else {
247        None
248    }
249}
250
251/// A JavaScript unsigned decimal literal: `123`, `123.`, `1.5`, `.5`, `1e3`, `1.5e-3`.
252fn is_decimal_literal(s: &str) -> bool {
253    let (mantissa, exponent) = match s.find(['e', 'E']) {
254        Some(index) => (&s[..index], Some(&s[index + 1..])),
255        None => (s, None),
256    };
257
258    if let Some(exponent) = exponent {
259        let digits = match exponent.as_bytes().first() {
260            Some(b'+' | b'-') => &exponent[1..],
261            _ => exponent,
262        };
263        if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
264            return false;
265        }
266    }
267
268    let mut dots = 0;
269    let mut digits = 0;
270    for byte in mantissa.bytes() {
271        match byte {
272            b'.' => dots += 1,
273            b'0'..=b'9' => digits += 1,
274            _ => return false,
275        }
276    }
277    dots <= 1 && digits >= 1
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284
285    #[test]
286    fn shallow() {
287        assert_eq!(
288            camelcase_keys(&json!({ "foo_bar": 1, "baz-qux": 2 })),
289            json!({ "fooBar": 1, "bazQux": 2 })
290        );
291        // Default is shallow: nested keys untouched.
292        assert_eq!(
293            camelcase_keys(&json!({ "foo_bar": { "nested_key": 1 } })),
294            json!({ "fooBar": { "nested_key": 1 } })
295        );
296    }
297
298    #[test]
299    fn deep_and_arrays() {
300        assert_eq!(
301            camelcase_keys_with(
302                &json!({ "foo_bar": { "nested_key": 1 } }),
303                &Options::new().deep(true)
304            ),
305            json!({ "fooBar": { "nestedKey": 1 } })
306        );
307        assert_eq!(
308            camelcase_keys(&json!([{ "a_b": 1 }, { "c_d": 2 }])),
309            json!([{ "aB": 1 }, { "cD": 2 }])
310        );
311        assert_eq!(
312            camelcase_keys_with(
313                &json!({ "a_b": [[{ "c_d": 1 }]] }),
314                &Options::new().deep(true)
315            ),
316            json!({ "aB": [[{ "cD": 1 }]] })
317        );
318    }
319
320    #[test]
321    fn numeric_keys_preserved() {
322        assert_eq!(
323            camelcase_keys(&json!({ "0": 1, "42": 2, "3.14": 3, "foo_bar": 4 })),
324            json!({ "0": 1, "42": 2, "3.14": 3, "fooBar": 4 })
325        );
326        assert!(is_numeric_key("1e3"));
327        assert!(is_numeric_key("Infinity"));
328        assert!(is_numeric_key("0x1f"));
329        assert!(is_numeric_key(".5"));
330        assert!(!is_numeric_key("12abc"));
331        assert!(!is_numeric_key("1_0"));
332        assert!(!is_numeric_key(" "));
333    }
334
335    #[test]
336    fn options() {
337        assert_eq!(
338            camelcase_keys_with(
339                &json!({ "first_name": 1, "last_name": 2 }),
340                &Options::new().exclude(["first_name"])
341            ),
342            json!({ "first_name": 1, "lastName": 2 })
343        );
344        assert_eq!(
345            camelcase_keys_with(
346                &json!({ "a_b": { "c_d": 1 } }),
347                &Options::new().deep(true).stop_paths(["a_b"])
348            ),
349            json!({ "aB": { "c_d": 1 } })
350        );
351        assert_eq!(
352            camelcase_keys_with(&json!({ "foo_bar": 1 }), &Options::new().pascal_case(true)),
353            json!({ "FooBar": 1 })
354        );
355        assert_eq!(
356            camelcase_keys_with(
357                &json!({ "HTTP_status": 1 }),
358                &Options::new().preserve_consecutive_uppercase(true)
359            ),
360            json!({ "HTTPStatus": 1 })
361        );
362    }
363
364    #[test]
365    fn scalars_passthrough() {
366        assert_eq!(camelcase_keys(&json!(42)), json!(42));
367        assert_eq!(camelcase_keys(&json!("str")), json!("str"));
368        assert_eq!(camelcase_keys(&json!(null)), json!(null));
369    }
370}