Skip to main content

dot_prop/
lib.rs

1//! # dot-prop — access nested JSON values by dot path
2//!
3//! Get, set, check, and delete a property deep inside a [`serde_json::Value`] using a dot
4//! path such as `a.b.0.c` or `a[0].b`. A faithful Rust port of the widely-used
5//! [`dot-prop`](https://www.npmjs.com/package/dot-prop) npm package.
6//!
7//! ```
8//! use serde_json::json;
9//! use dot_prop::{get_property, set_property, has_property, delete_property};
10//!
11//! let mut value = json!({ "foo": { "bar": [10, 20] } });
12//!
13//! assert_eq!(get_property(&value, "foo.bar.1"), Some(&json!(20)));
14//! assert!(has_property(&value, "foo.bar.0"));
15//!
16//! set_property(&mut value, "foo.bar.2", json!(30));
17//! assert_eq!(get_property(&value, "foo.bar.2"), Some(&json!(30)));
18//!
19//! delete_property(&mut value, "foo.bar.0");
20//! assert_eq!(get_property(&value, "foo.bar.0"), Some(&json!(null)));
21//! ```
22//!
23//! Paths use `.` to separate keys, `[i]` for array/string indices, and `\` to escape a
24//! literal `.`, `[`, or `\`. Unlike [`json-pointer`](https://crates.io/crates/json-pointer)
25//! (RFC 6901, `/`-separated), this matches the JavaScript `dot-prop` syntax.
26
27#![forbid(unsafe_code)]
28#![doc(html_root_url = "https://docs.rs/dot-prop/0.1.0")]
29// Indices are bounded by `MAX_ARRAY_INDEX` (1_000_000), so the u64 -> usize cast is safe.
30#![allow(clippy::cast_possible_truncation)]
31
32use core::fmt;
33use serde_json::{Map, Value};
34
35// Compile-test the README's examples as part of `cargo test`.
36#[cfg(doctest)]
37#[doc = include_str!("../README.md")]
38struct ReadmeDoctests;
39
40/// Keys that are rejected to prevent prototype-pollution-style paths.
41const DISALLOWED_KEYS: [&str; 3] = ["__proto__", "prototype", "constructor"];
42
43/// Maximum array index, mirroring the reference's denial-of-service guard.
44const MAX_ARRAY_INDEX: u64 = 1_000_000;
45
46/// A single component of a parsed path: an object key or an array index.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Segment {
49    /// An object key.
50    Key(String),
51    /// A non-negative array (or string) index.
52    Index(usize),
53}
54
55impl Segment {
56    /// The string form used to look the segment up in an object.
57    fn as_object_key(&self) -> String {
58        match self {
59            Segment::Key(key) => key.clone(),
60            Segment::Index(index) => index.to_string(),
61        }
62    }
63}
64
65/// An error returned by [`parse_path`] for a malformed path.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ParsePathError {
68    message: String,
69}
70
71impl fmt::Display for ParsePathError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(&self.message)
74    }
75}
76
77impl std::error::Error for ParsePathError {}
78
79/// `0` or a `[1-9][0-9]*` integer that is `<= MAX_ARRAY_INDEX`, returned as an index.
80fn coerce_to_index(segment: &str) -> Option<usize> {
81    if segment == "0" {
82        return Some(0);
83    }
84    let bytes = segment.as_bytes();
85    let leads_nonzero = matches!(bytes.first(), Some(b'1'..=b'9'));
86    if leads_nonzero && bytes.iter().all(u8::is_ascii_digit) {
87        if let Ok(number) = segment.parse::<u64>() {
88            if number <= MAX_ARRAY_INDEX {
89                return Some(number as usize);
90            }
91        }
92    }
93    None
94}
95
96/// Push a finished path segment, coercing integers. Returns `false` if the segment is a
97/// disallowed key (signaling an empty path).
98fn process_segment(segment: &str, parts: &mut Vec<Segment>) -> bool {
99    if DISALLOWED_KEYS.contains(&segment) {
100        return false;
101    }
102    if !segment.is_empty() {
103        if let Some(index) = coerce_to_index(segment) {
104            parts.push(Segment::Index(index));
105            return true;
106        }
107    }
108    parts.push(Segment::Key(segment.to_string()));
109    true
110}
111
112#[derive(Clone, Copy, PartialEq, Eq)]
113enum Part {
114    Start,
115    Property,
116    Index,
117    IndexEnd,
118}
119
120/// Parse a dot path into its segments.
121///
122/// # Errors
123/// Returns a [`ParsePathError`] for malformed index syntax (an unclosed `[`, or a non-digit
124/// inside or a stray character after an index).
125#[allow(clippy::too_many_lines)] // Faithful port of the reference state machine.
126pub fn parse_path(path: &str) -> Result<Vec<Segment>, ParsePathError> {
127    let mut parts: Vec<Segment> = Vec::new();
128    let mut current_segment = String::new();
129    let mut current_part = Part::Start;
130    let mut is_escaping = false;
131    let mut position = 0usize;
132
133    let err = |message: String| Err(ParsePathError { message });
134
135    for character in path.chars() {
136        position += 1;
137
138        if is_escaping {
139            current_segment.push(character);
140            is_escaping = false;
141            continue;
142        }
143
144        if character == '\\' {
145            if current_part == Part::Index {
146                return err(format!(
147                    "Invalid character '{character}' in an index at position {position}"
148                ));
149            }
150            if current_part == Part::IndexEnd {
151                return err(format!(
152                    "Invalid character '{character}' after an index at position {position}"
153                ));
154            }
155            is_escaping = true;
156            if current_part == Part::Start {
157                current_part = Part::Property;
158            }
159            continue;
160        }
161
162        match character {
163            '.' => {
164                if current_part == Part::Index {
165                    return err(format!(
166                        "Invalid character '{character}' in an index at position {position}"
167                    ));
168                }
169                if current_part == Part::IndexEnd {
170                    current_part = Part::Property;
171                    continue;
172                }
173                if !process_segment(&current_segment, &mut parts) {
174                    return Ok(Vec::new());
175                }
176                current_segment.clear();
177                current_part = Part::Property;
178            }
179            '[' => {
180                if current_part == Part::Index {
181                    return err(format!(
182                        "Invalid character '{character}' in an index at position {position}"
183                    ));
184                }
185                if current_part == Part::IndexEnd {
186                    current_part = Part::Index;
187                    continue;
188                }
189                if current_part == Part::Property || current_part == Part::Start {
190                    if (!current_segment.is_empty() || current_part == Part::Property)
191                        && !process_segment(&current_segment, &mut parts)
192                    {
193                        return Ok(Vec::new());
194                    }
195                    current_segment.clear();
196                }
197                current_part = Part::Index;
198            }
199            ']' if current_part == Part::Index => {
200                if current_segment.is_empty() {
201                    // Empty brackets: backtrack and treat as a literal `[]`.
202                    let last = match parts.pop() {
203                        Some(Segment::Key(key)) if !key.is_empty() => key,
204                        Some(Segment::Index(index)) if index != 0 => index.to_string(),
205                        _ => String::new(),
206                    };
207                    current_segment = format!("{last}[]");
208                    current_part = Part::Property;
209                } else {
210                    // The default case guarantees `current_segment` is all digits.
211                    match current_segment.parse::<u64>() {
212                        Ok(number)
213                            if number <= MAX_ARRAY_INDEX
214                                && current_segment == number.to_string() =>
215                        {
216                            parts.push(Segment::Index(number as usize));
217                        }
218                        _ => parts.push(Segment::Key(current_segment.clone())),
219                    }
220                    current_segment.clear();
221                    current_part = Part::IndexEnd;
222                }
223            }
224            ']' if current_part == Part::IndexEnd => {
225                return err(format!(
226                    "Invalid character '{character}' after an index at position {position}"
227                ));
228            }
229            ']' => {
230                // Property context: a literal `]`.
231                current_segment.push(character);
232            }
233            _ => {
234                if current_part == Part::Index && !character.is_ascii_digit() {
235                    return err(format!(
236                        "Invalid character '{character}' in an index at position {position}"
237                    ));
238                }
239                if current_part == Part::IndexEnd {
240                    return err(format!(
241                        "Invalid character '{character}' after an index at position {position}"
242                    ));
243                }
244                if current_part == Part::Start {
245                    current_part = Part::Property;
246                }
247                current_segment.push(character);
248            }
249        }
250    }
251
252    if is_escaping {
253        current_segment.push('\\');
254    }
255
256    match current_part {
257        Part::Property => {
258            if !process_segment(&current_segment, &mut parts) {
259                return Ok(Vec::new());
260            }
261        }
262        Part::Index => return err("Index was not closed".to_string()),
263        Part::Start => parts.push(Segment::Key(String::new())),
264        Part::IndexEnd => {}
265    }
266
267    Ok(parts)
268}
269
270fn is_container(value: &Value) -> bool {
271    value.is_object() || value.is_array()
272}
273
274/// `object[key]` for a JSON value.
275fn segment_get<'a>(value: &'a Value, segment: &Segment) -> Option<&'a Value> {
276    match value {
277        Value::Object(map) => map.get(&segment.as_object_key()),
278        Value::Array(array) => match segment {
279            Segment::Index(index) => array.get(*index),
280            Segment::Key(_) => None,
281        },
282        _ => None,
283    }
284}
285
286/// Get the value at `path`, or `None` if it is absent (or the path is malformed).
287///
288/// ```
289/// # use serde_json::json;
290/// # use dot_prop::get_property;
291/// let value = json!({ "a": { "b": 1 } });
292/// assert_eq!(get_property(&value, "a.b"), Some(&json!(1)));
293/// assert_eq!(get_property(&value, "a.x"), None);
294/// ```
295#[must_use]
296pub fn get_property<'a>(object: &'a Value, path: &str) -> Option<&'a Value> {
297    if !is_container(object) {
298        // The reference returns the value itself when it is not a container.
299        return Some(object);
300    }
301    let Ok(segments) = parse_path(path) else {
302        return None;
303    };
304    if segments.is_empty() {
305        return None;
306    }
307
308    let mut current = object;
309    let last = segments.len() - 1;
310    for (index, segment) in segments.iter().enumerate() {
311        match segment_get(current, segment) {
312            None => return None,
313            Some(value) if value.is_null() => {
314                if index != last {
315                    return None;
316                }
317                return Some(value);
318            }
319            Some(value) => current = value,
320        }
321    }
322    Some(current)
323}
324
325/// Whether `path` exists in `object`.
326#[must_use]
327pub fn has_property(object: &Value, path: &str) -> bool {
328    if !is_container(object) {
329        return false;
330    }
331    let Ok(segments) = parse_path(path) else {
332        return false;
333    };
334    if segments.is_empty() {
335        return false;
336    }
337
338    let mut current = object;
339    for segment in &segments {
340        let present = match current {
341            Value::Object(map) => map.contains_key(&segment.as_object_key()),
342            Value::Array(array) => match segment {
343                Segment::Index(index) => *index < array.len(),
344                Segment::Key(_) => false,
345            },
346            _ => return false,
347        };
348        if !present {
349            return false;
350        }
351        match segment_get(current, segment) {
352            Some(value) => current = value,
353            None => return false,
354        }
355    }
356    true
357}
358
359/// Set `value` at `path`, creating intermediate objects/arrays as needed.
360///
361/// Numeric segments create arrays; string segments create objects. Existing non-container
362/// values along the path are replaced. No-op if `object` is not a container or `path` is
363/// empty/malformed.
364///
365/// ```
366/// # use serde_json::json;
367/// # use dot_prop::{set_property, get_property};
368/// let mut value = json!({});
369/// set_property(&mut value, "a.b.0", json!("x"));
370/// assert_eq!(value, json!({ "a": { "b": ["x"] } }));
371/// ```
372pub fn set_property(object: &mut Value, path: &str, value: Value) {
373    if !is_container(object) {
374        return;
375    }
376    let Ok(segments) = parse_path(path) else {
377        return;
378    };
379    if segments.is_empty() {
380        return;
381    }
382    set_segments(object, &segments, value);
383}
384
385fn set_segments(object: &mut Value, segments: &[Segment], value: Value) {
386    let (segment, rest) = segments.split_first().expect("non-empty");
387
388    if rest.is_empty() {
389        segment_set(object, segment, value);
390        return;
391    }
392
393    let next_is_index = matches!(rest[0], Segment::Index(_));
394    let needs_container = segment_get(object, segment).map_or(true, |child| !is_container(child));
395    if needs_container {
396        let new_child = if next_is_index {
397            Value::Array(Vec::new())
398        } else {
399            Value::Object(Map::new())
400        };
401        segment_set(object, segment, new_child);
402    }
403
404    if let Some(child) = segment_get_mut(object, segment) {
405        set_segments(child, rest, value);
406    }
407}
408
409/// `object[key] = value`, growing arrays as needed.
410fn segment_set(object: &mut Value, segment: &Segment, value: Value) {
411    match object {
412        Value::Object(map) => {
413            map.insert(segment.as_object_key(), value);
414        }
415        Value::Array(array) => {
416            if let Segment::Index(index) = segment {
417                if *index >= array.len() {
418                    array.resize(*index + 1, Value::Null);
419                }
420                array[*index] = value;
421            }
422            // A string key on an array is a no-op (it would not survive JSON serialization).
423        }
424        _ => {}
425    }
426}
427
428fn segment_get_mut<'a>(object: &'a mut Value, segment: &Segment) -> Option<&'a mut Value> {
429    match object {
430        Value::Object(map) => map.get_mut(&segment.as_object_key()),
431        Value::Array(array) => match segment {
432            Segment::Index(index) => array.get_mut(*index),
433            Segment::Key(_) => None,
434        },
435        _ => None,
436    }
437}
438
439/// Delete the value at `path`. Returns whether something was removed.
440///
441/// Deleting an array element leaves a `null` hole (it does not shift other elements),
442/// matching the reference's `delete array[index]` semantics.
443pub fn delete_property(object: &mut Value, path: &str) -> bool {
444    if !is_container(object) {
445        return false;
446    }
447    let Ok(segments) = parse_path(path) else {
448        return false;
449    };
450    if segments.is_empty() {
451        return false;
452    }
453
454    let last = segments.len() - 1;
455    let mut current = object;
456    for (index, segment) in segments.iter().enumerate() {
457        if index == last {
458            return delete_segment(current, segment);
459        }
460        match segment_get_mut(current, segment) {
461            Some(child) if is_container(child) => current = child,
462            _ => return false,
463        }
464    }
465    false
466}
467
468fn delete_segment(object: &mut Value, segment: &Segment) -> bool {
469    match object {
470        Value::Object(map) => map.remove(&segment.as_object_key()).is_some(),
471        Value::Array(array) => match segment {
472            Segment::Index(index) if *index < array.len() => {
473                array[*index] = Value::Null;
474                true
475            }
476            _ => false,
477        },
478        _ => false,
479    }
480}
481
482/// Escape `.`, `[`, and `\` in a path segment so it is treated literally.
483///
484/// ```
485/// # use dot_prop::escape_path;
486/// assert_eq!(escape_path("foo.bar"), "foo\\.bar");
487/// ```
488#[must_use]
489pub fn escape_path(path: &str) -> String {
490    let mut out = String::with_capacity(path.len());
491    for c in path.chars() {
492        if matches!(c, '\\' | '.' | '[') {
493            out.push('\\');
494        }
495        out.push(c);
496    }
497    out
498}
499
500/// Build a dot path from segments. Equivalent to [`stringify_path_with`] with
501/// `prefer_dot_for_indices = false`.
502#[must_use]
503pub fn stringify_path(segments: &[Segment]) -> String {
504    stringify_path_with(segments, false)
505}
506
507/// Build a dot path from segments. When `prefer_dot_for_indices` is set, indices after the
508/// first segment are written as `.0` rather than `[0]`.
509#[must_use]
510pub fn stringify_path_with(segments: &[Segment], prefer_dot_for_indices: bool) -> String {
511    let mut out = String::new();
512    for (index, segment) in segments.iter().enumerate() {
513        match segment {
514            Segment::Index(number) => {
515                if prefer_dot_for_indices && index > 0 {
516                    out.push('.');
517                    out.push_str(&number.to_string());
518                } else {
519                    out.push('[');
520                    out.push_str(&number.to_string());
521                    out.push(']');
522                }
523            }
524            Segment::Key(key) if key.is_empty() => {
525                if index != 0 {
526                    out.push('.');
527                }
528            }
529            Segment::Key(key) => {
530                if let Some(number) = coerce_to_index(key) {
531                    if prefer_dot_for_indices && index > 0 {
532                        out.push('.');
533                        out.push_str(&number.to_string());
534                    } else {
535                        out.push('[');
536                        out.push_str(&number.to_string());
537                        out.push(']');
538                    }
539                } else {
540                    let escaped = escape_path(key);
541                    if index != 0 {
542                        out.push('.');
543                    }
544                    out.push_str(&escaped);
545                }
546            }
547        }
548    }
549    out
550}
551
552/// Entries of a value, coercing array indices to numeric segments.
553fn normalized_entries(value: &Value) -> Vec<(Segment, &Value)> {
554    match value {
555        Value::Object(map) => map
556            .iter()
557            .map(|(key, entry)| {
558                let segment =
559                    coerce_to_index(key).map_or_else(|| Segment::Key(key.clone()), Segment::Index);
560                (segment, entry)
561            })
562            .collect(),
563        Value::Array(array) => array
564            .iter()
565            .enumerate()
566            .map(|(index, entry)| (Segment::Index(index), entry))
567            .collect(),
568        _ => Vec::new(),
569    }
570}
571
572fn deep_keys_into(value: &Value, current_path: &mut Vec<Segment>, out: &mut Vec<String>) {
573    let is_empty = match value {
574        Value::Object(map) => map.is_empty(),
575        Value::Array(array) => array.is_empty(),
576        _ => true,
577    };
578    if !is_container(value) || is_empty {
579        if !current_path.is_empty() {
580            out.push(stringify_path(current_path));
581        }
582        return;
583    }
584    for (segment, entry) in normalized_entries(value) {
585        current_path.push(segment);
586        deep_keys_into(entry, current_path, out);
587        current_path.pop();
588    }
589}
590
591/// Every leaf path of `object`, as dot-path strings.
592///
593/// ```
594/// # use serde_json::json;
595/// # use dot_prop::deep_keys;
596/// let keys = deep_keys(&json!({ "a": { "b": 1 }, "c": [2] }));
597/// assert_eq!(keys, ["a.b", "c[0]"]);
598/// ```
599#[must_use]
600pub fn deep_keys(object: &Value) -> Vec<String> {
601    let mut out = Vec::new();
602    let mut path = Vec::new();
603    deep_keys_into(object, &mut path, &mut out);
604    out
605}
606
607/// Expand a flat `{ "a.b": value }` object into a nested value.
608///
609/// ```
610/// # use serde_json::json;
611/// # use dot_prop::unflatten;
612/// assert_eq!(unflatten(&json!({ "a.b": 1, "a.c": 2 })), json!({ "a": { "b": 1, "c": 2 } }));
613/// ```
614#[must_use]
615pub fn unflatten(object: &Value) -> Value {
616    let mut result = Value::Object(Map::new());
617    if let Value::Object(map) = object {
618        for (path, value) in map {
619            set_property(&mut result, path, value.clone());
620        }
621    }
622    result
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use serde_json::json;
629
630    #[test]
631    fn parse_basic() {
632        assert_eq!(
633            parse_path("a.b.c").unwrap(),
634            vec![
635                Segment::Key("a".into()),
636                Segment::Key("b".into()),
637                Segment::Key("c".into())
638            ]
639        );
640        assert_eq!(
641            parse_path("a[0].b").unwrap(),
642            vec![
643                Segment::Key("a".into()),
644                Segment::Index(0),
645                Segment::Key("b".into())
646            ]
647        );
648        assert_eq!(
649            parse_path("foo\\.bar").unwrap(),
650            vec![Segment::Key("foo.bar".into())]
651        );
652        assert_eq!(parse_path("__proto__").unwrap(), vec![]);
653        assert_eq!(
654            parse_path("a[01]").unwrap(),
655            vec![Segment::Key("a".into()), Segment::Key("01".into())]
656        );
657        assert!(parse_path("a[b]").is_err());
658        assert!(parse_path("a[").is_err());
659    }
660
661    #[test]
662    fn get() {
663        let value = json!({ "foo": { "bar": [1, 2, 3] }, "n": null });
664        assert_eq!(get_property(&value, "foo.bar.1"), Some(&json!(2)));
665        assert_eq!(get_property(&value, "foo.bar[2]"), Some(&json!(3)));
666        assert_eq!(get_property(&value, "foo.missing"), None);
667        assert_eq!(get_property(&value, "n"), Some(&json!(null)));
668        assert_eq!(get_property(&value, "n.x"), None);
669    }
670
671    #[test]
672    fn set() {
673        let mut value = json!({});
674        set_property(&mut value, "a.b.0", json!("x"));
675        assert_eq!(value, json!({ "a": { "b": ["x"] } }));
676        set_property(&mut value, "a.b.2", json!("z"));
677        assert_eq!(value, json!({ "a": { "b": ["x", null, "z"] } }));
678    }
679
680    #[test]
681    fn has_and_delete() {
682        let mut value = json!({ "a": { "b": [1, 2] } });
683        assert!(has_property(&value, "a.b.0"));
684        assert!(!has_property(&value, "a.b.5"));
685        assert!(delete_property(&mut value, "a.b.0"));
686        assert_eq!(value, json!({ "a": { "b": [null, 2] } }));
687        assert!(!delete_property(&mut value, "a.b.9"));
688    }
689
690    #[test]
691    fn keys_and_unflatten() {
692        let value = json!({ "a": { "b": 1 }, "c": [2, 3] });
693        assert_eq!(deep_keys(&value), ["a.b", "c[0]", "c[1]"]);
694        assert_eq!(
695            unflatten(&json!({ "a.b": 1, "a.c": 2 })),
696            json!({ "a": { "b": 1, "c": 2 } })
697        );
698    }
699
700    #[test]
701    fn escape_and_stringify() {
702        assert_eq!(escape_path("foo.bar[0]"), "foo\\.bar\\[0]");
703        assert_eq!(
704            stringify_path(&[
705                Segment::Key("a".into()),
706                Segment::Index(0),
707                Segment::Key("b".into())
708            ]),
709            "a[0].b"
710        );
711    }
712}