1#![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#[cfg(doctest)]
35#[doc = include_str!("../README.md")]
36struct ReadmeDoctests;
37
38#[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 #[must_use]
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 #[must_use]
57 pub fn deep(mut self, value: bool) -> Self {
58 self.deep = value;
59 self
60 }
61
62 #[must_use]
64 pub fn pascal_case(mut self, value: bool) -> Self {
65 self.pascal_case = value;
66 self
67 }
68
69 #[must_use]
71 pub fn preserve_consecutive_uppercase(mut self, value: bool) -> Self {
72 self.preserve_consecutive_uppercase = value;
73 self
74 }
75
76 #[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 #[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#[must_use]
113pub fn camelcase_keys(value: &Value) -> Value {
114 camelcase_keys_with(value, &Options::new())
115}
116
117#[must_use]
119pub fn camelcase_keys_with(value: &Value, options: &Options) -> Value {
120 transform(value, options, None)
121}
122
123fn 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
184fn 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
207fn 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
213fn 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 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
241fn 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
251fn 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 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}