Skip to main content

yaml_edit/path/
mod.rs

1//! Path-based access to YAML documents.
2//!
3//! Provides convenient dot-separated path syntax for accessing nested YAML values
4//! like `"server.host"` or `"database.primary.port"`.
5//!
6//! Operations:
7//! [`try_get_path`](YamlPath::try_get_path),
8//! [`try_set_path`](YamlPath::try_set_path),
9//! [`try_remove_path`](YamlPath::try_remove_path).
10//! The `try_` variants return [`Result<_, PathError>`](PathError) so
11//! malformed paths, empty paths, missing keys, and container-type
12//! mismatches are visible rather than silently swallowed.
13//!
14//! The legacy `get_path` / `set_path` / `remove_path` methods still
15//! exist as thin wrappers that discard `PathError`; they are deprecated
16//! and will emit warnings.
17//!
18//! # Example
19//!
20//! ```
21//! use yaml_edit::{Document, path::YamlPath};
22//! use std::str::FromStr;
23//!
24//! let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
25//!
26//! // Get nested values
27//! let host = yaml.try_get_path("server.host").unwrap();
28//! assert_eq!(host.to_string(), "localhost");
29//!
30//! // Set nested values (creates intermediate mappings)
31//! yaml.try_set_path("database.primary.host", "db.example.com").unwrap();
32//!
33//! // Remove nested values
34//! yaml.try_remove_path("server.port").unwrap();
35//! assert_eq!(
36//!     yaml.to_string(),
37//!     "server:\n  host: localhost\ndatabase:\n  primary:\n    host: db.example.com\n"
38//! );
39//! ```
40//!
41//! # Aliases
42//!
43//! When a path segment lands on an alias (`*name`), reads resolve it to
44//! the anchored value by default, matching
45//! [`get_resolved`](crate::anchor_resolution::DocumentResolvedExt::get_resolved)
46//! and [`merged`](crate::anchor_resolution::MappingMergedExt::merged):
47//!
48//! ```
49//! use yaml_edit::{Document, path::YamlPath};
50//! use std::str::FromStr;
51//!
52//! let doc = Document::from_str("shared: &shared\n  timeout: 30\nservice: *shared\n").unwrap();
53//! assert_eq!(doc.try_get_path("service.timeout").unwrap().to_string(), "30");
54//! ```
55//!
56//! Writes are different: writing through an alias changes the anchored
57//! node, and so changes every other alias pointing at it. That is
58//! refused by default. Pass an [`AliasPolicy`] to
59//! [`try_set_path_with`](YamlPath::try_set_path_with) to choose
60//! deliberately between updating the shared anchor
61//! ([`Follow`](AliasPolicy::Follow)) and replacing the alias with an
62//! independent copy ([`Expand`](AliasPolicy::Expand)).
63//!
64//! All operations preserve formatting, comments, and whitespace.
65
66mod alias;
67mod parse;
68mod read;
69mod write;
70
71#[allow(deprecated)]
72pub use parse::parse_path;
73pub use parse::{try_parse_path, PathParseError};
74
75use crate::yaml::Mapping;
76use parse::path_segments_required;
77use read::navigate_path;
78use write::{remove_path_impl, set_path_on_mapping};
79
80/// Trait for YAML types that support path-based access.
81///
82/// Path syntax uses dots (`.`) as separators to navigate nested mappings.
83/// For example, `"server.database.host"` accesses:
84/// ```yaml
85/// server:
86///   database:
87///     host: value
88/// ```
89///
90/// A segment names a string key, or failing that the first scalar key
91/// of another type written the same way: `m.1` reaches `1: one` and
92/// `m.true` reaches `true: t`. Setting a key that does not exist yet
93/// creates a string key.
94///
95/// The `try_` methods ([`try_get_path`](Self::try_get_path),
96/// [`try_set_path`](Self::try_set_path),
97/// [`try_remove_path`](Self::try_remove_path)) return
98/// `Result<_, PathError>` and are the recommended API. The older
99/// [`get_path`](Self::get_path) / [`set_path`](Self::set_path) /
100/// [`remove_path`](Self::remove_path) methods are deprecated wrappers
101/// that discard the error and silently no-op.
102pub trait YamlPath {
103    /// Get a value at a nested path, returning a specific [`PathError`]
104    /// on failure instead of `None`.
105    ///
106    /// A tagged collection (`key: !custom` over a block mapping, say) is
107    /// descended into as the collection it wraps; the tag stays put.
108    ///
109    /// # Errors
110    ///
111    /// - [`PathError::Parse`] for a malformed path.
112    /// - [`PathError::EmptyPath`] when the path is empty.
113    /// - [`PathError::NoRoot`] when the receiver has no value to descend into.
114    /// - [`PathError::TypeMismatch`] when a segment tries to descend into a
115    ///   value of the wrong container type.
116    /// - [`PathError::NotFound`] when a segment addresses a key/index that
117    ///   does not exist.
118    /// - [`PathError::UndefinedAlias`] or [`PathError::InvalidMergeOperand`]
119    ///   when a key is not found directly and a `<<` merge operand of the
120    ///   mapping is an undefined alias or not a mapping.
121    fn try_get_path(&self, path: &str) -> Result<crate::as_yaml::YamlNode, PathError> {
122        self.try_get_path_with(path, AliasPolicy::Follow)
123    }
124
125    /// Like [`try_get_path`](Self::try_get_path), but with an explicit
126    /// [`AliasPolicy`] for segments that land on an alias.
127    ///
128    /// # Errors
129    ///
130    /// As [`try_get_path`](Self::try_get_path), plus
131    /// [`PathError::AliasRefused`] under [`AliasPolicy::Refuse`] and
132    /// [`PathError::UndefinedAlias`] when an alias names an anchor that
133    /// is not defined in the document.
134    fn try_get_path_with(
135        &self,
136        path: &str,
137        policy: AliasPolicy,
138    ) -> Result<crate::as_yaml::YamlNode, PathError>;
139
140    /// Set a value at a nested path, creating intermediate mappings /
141    /// sequences as needed. Returns a specific [`PathError`] on failure
142    /// instead of silently no-oping.
143    ///
144    /// # Errors
145    ///
146    /// - [`PathError::Parse`] for a malformed path.
147    /// - [`PathError::EmptyPath`] when the path is empty.
148    /// - [`PathError::NoRoot`] when the receiver has no root mapping to
149    ///   write into (Document with no root, or with a scalar/sequence root).
150    /// - [`PathError::TypeMismatch`] when an intermediate segment lands on
151    ///   a scalar that cannot be turned into a container.
152    fn try_set_path(&self, path: &str, value: impl crate::AsYaml) -> Result<(), PathError> {
153        self.try_set_path_with(path, value, AliasPolicy::Refuse)
154    }
155
156    /// Like [`try_set_path`](Self::try_set_path), but with an explicit
157    /// [`AliasPolicy`] for intermediate segments that land on an alias.
158    ///
159    /// Writing through an alias changes the anchored node, and therefore
160    /// every other alias that references it; that is why the default is
161    /// [`AliasPolicy::Refuse`] and this opt-in exists.
162    ///
163    /// # Errors
164    ///
165    /// As [`try_set_path`](Self::try_set_path), plus
166    /// [`PathError::AliasRefused`] under [`AliasPolicy::Refuse`] and
167    /// [`PathError::UndefinedAlias`] when an alias names an anchor that
168    /// is not defined in the document.
169    fn try_set_path_with(
170        &self,
171        path: &str,
172        value: impl crate::AsYaml,
173        policy: AliasPolicy,
174    ) -> Result<(), PathError>;
175
176    /// Remove a value at a nested path. Returns the removed
177    /// [`YamlNode`](crate::as_yaml::YamlNode) on success, or a specific
178    /// [`PathError`] describing why the removal did not happen.
179    ///
180    /// One entry is removed. Duplicate keys are legal YAML, so a path can
181    /// still resolve afterwards; use
182    /// [`Mapping::remove_nth_occurrence`](crate::Mapping::remove_nth_occurrence)
183    /// to choose between them.
184    ///
185    /// # Errors
186    ///
187    /// Same shape as [`try_get_path`](Self::try_get_path).
188    fn try_remove_path(&self, path: &str) -> Result<crate::as_yaml::YamlNode, PathError> {
189        self.try_remove_path_with(path, AliasPolicy::Refuse)
190    }
191
192    /// Like [`try_remove_path`](Self::try_remove_path), but with an
193    /// explicit [`AliasPolicy`] for intermediate segments that land on
194    /// an alias.
195    ///
196    /// Removal is a mutation, so it defaults to
197    /// [`AliasPolicy::Refuse`] for the same reason as
198    /// [`try_set_path`](Self::try_set_path).
199    ///
200    /// # Errors
201    ///
202    /// As [`try_remove_path`](Self::try_remove_path), plus
203    /// [`PathError::AliasRefused`] and [`PathError::UndefinedAlias`].
204    fn try_remove_path_with(
205        &self,
206        path: &str,
207        policy: AliasPolicy,
208    ) -> Result<crate::as_yaml::YamlNode, PathError>;
209
210    /// Get a value at a nested path.
211    ///
212    /// This is a lossy wrapper around
213    /// [`try_get_path`](Self::try_get_path): every error becomes `None`,
214    /// so callers cannot distinguish "path parsed but nothing found"
215    /// from "path was malformed" or "descended through the wrong
216    /// container type."
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use yaml_edit::{Document, path::YamlPath};
222    /// use std::str::FromStr;
223    ///
224    /// let yaml = Document::from_str("server:\n  host: localhost\n").unwrap();
225    /// #[allow(deprecated)]
226    /// let host = yaml.get_path("server.host");
227    /// assert_eq!(host.unwrap().to_string(), "localhost");
228    /// #[allow(deprecated)]
229    /// let missing = yaml.get_path("server..host");
230    /// assert!(missing.is_none());
231    /// ```
232    #[deprecated(
233        since = "0.4.0",
234        note = "use try_get_path; get_path swallows PathError as None"
235    )]
236    fn get_path(&self, path: &str) -> Option<crate::as_yaml::YamlNode> {
237        self.try_get_path(path).ok()
238    }
239
240    /// Set a value at a nested path.
241    ///
242    /// Lossy wrapper around [`try_set_path`](Self::try_set_path): every
243    /// error is silently ignored. Callers get no signal that the write
244    /// failed (bad path, no root, descending through a scalar, ...).
245    ///
246    /// # Examples
247    ///
248    /// ```
249    /// use yaml_edit::{Document, path::YamlPath};
250    /// use std::str::FromStr;
251    ///
252    /// let yaml = Document::from_str("name: test\n").unwrap();
253    /// #[allow(deprecated)]
254    /// yaml.set_path("server.host", "localhost");
255    /// #[allow(deprecated)]
256    /// yaml.set_path("server.port", 8080);
257    /// assert_eq!(
258    ///     yaml.to_string(),
259    ///     "name: test\nserver:\n  host: localhost\n  port: 8080\n"
260    /// );
261    /// // A failed write is not reported.
262    /// #[allow(deprecated)]
263    /// yaml.set_path("name.first", "x");
264    /// assert_eq!(yaml.try_get_path("name").unwrap().to_string(), "test");
265    /// ```
266    #[deprecated(
267        since = "0.4.0",
268        note = "use try_set_path; set_path silently ignores PathError"
269    )]
270    fn set_path(&self, path: &str, value: impl crate::AsYaml) {
271        let _ = self.try_set_path(path, value);
272    }
273
274    /// Remove a value at a nested path. Returns `true` if a value was
275    /// removed.
276    ///
277    /// Lossy wrapper around [`try_remove_path`](Self::try_remove_path):
278    /// every error is reported as `false`, indistinguishable from
279    /// "path was well-formed but key not present."
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// use yaml_edit::{Document, path::YamlPath};
285    /// use std::str::FromStr;
286    ///
287    /// let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
288    /// #[allow(deprecated)]
289    /// {
290    ///     assert_eq!(yaml.remove_path("server.port"), true);
291    ///     assert_eq!(yaml.remove_path("server.missing"), false);
292    /// }
293    /// ```
294    #[deprecated(
295        since = "0.4.0",
296        note = "use try_remove_path; remove_path swallows PathError as false"
297    )]
298    fn remove_path(&self, path: &str) -> bool {
299        self.try_remove_path(path).is_ok()
300    }
301}
302
303/// Represents a segment in a YAML path.
304#[derive(Debug, Clone, PartialEq)]
305pub enum PathSegment {
306    /// A mapping key (e.g., "server" in "server.host")
307    Key(String),
308    /// An array index (e.g., `0` in "items\[0\]" or "items.0")
309    Index(usize),
310}
311
312/// The most null placeholders [`try_set_path`](YamlPath::try_set_path) will
313/// insert to reach an out-of-range sequence index.
314///
315/// Writing to `s[5]` on an empty sequence is a reasonable way to build one;
316/// writing to `s[900000]` is almost always a mistake or hostile input, and
317/// filling the gap would cost time and memory that grows faster than the
318/// index (each insert re-splices the sequence's children).
319pub const MAX_INDEX_GROWTH: usize = 256;
320
321/// Error returned by [`try_get_path`](YamlPath::try_get_path) /
322/// [`try_set_path`](YamlPath::try_set_path) /
323/// [`try_remove_path`](YamlPath::try_remove_path).
324///
325/// Distinguishes the several ways a path operation can fail so callers
326/// can react appropriately, instead of getting the previous silent
327/// no-op or ambiguous `None`.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub enum PathError {
330    /// The path string is malformed; see [`PathParseError`].
331    Parse(PathParseError),
332    /// The path is the empty string. Stray dots are a
333    /// [`PathParseError::EmptySegment`] instead.
334    EmptyPath,
335    /// The receiver has no root value to descend into: `Document` was
336    /// empty, or the requested operation needed a root mapping and the
337    /// document's root is a scalar / sequence.
338    NoRoot,
339    /// A segment tried to descend into a value of the wrong container
340    /// type -- e.g. treating a scalar as a mapping in `foo.bar` when
341    /// `foo` is a scalar. `at` is the segment index (`"foo"` in the
342    /// example above) that led to the type mismatch.
343    TypeMismatch {
344        /// The segment whose value was not the expected container type.
345        at: String,
346    },
347    /// The target segment does not exist. Applies to
348    /// [`try_get_path`](YamlPath::try_get_path) and
349    /// [`try_remove_path`](YamlPath::try_remove_path); `try_set_path`
350    /// creates missing intermediates and never reports this. `at` is
351    /// the missing segment.
352    NotFound {
353        /// The segment that could not be resolved.
354        at: String,
355    },
356    /// A segment landed on an alias (`*name`) and the active
357    /// [`AliasPolicy`] refused to go through it. `at` is the segment,
358    /// `alias` the anchor name it referenced.
359    AliasRefused {
360        /// The segment whose value is an alias.
361        at: String,
362        /// The anchor name the alias references, without the `*`.
363        alias: String,
364    },
365    /// Setting the requested index would have grown a sequence by more
366    /// than [`MAX_INDEX_GROWTH`] null placeholders.
367    ///
368    /// `try_set_path("s[900000]", v)` on a short sequence would otherwise
369    /// pad it with that many entries, so a path from untrusted input could
370    /// spend unbounded time and memory.
371    IndexTooFar {
372        /// The segment whose index was out of reach.
373        at: String,
374        /// The current length of the sequence.
375        len: usize,
376        /// The index that was asked for.
377        index: usize,
378    },
379    /// A segment landed on an alias whose anchor is not defined anywhere
380    /// in the document, so it could not be resolved. Also reported when a
381    /// `<<` merge operand consulted for the segment is such an alias.
382    UndefinedAlias {
383        /// The segment whose value is a dangling alias.
384        at: String,
385        /// The anchor name the alias references, without the `*`.
386        alias: String,
387    },
388    /// Looking up a segment needed the mapping's merge keys, and a `<<`
389    /// operand was neither a mapping nor an alias to one.
390    InvalidMergeOperand {
391        /// The segment being looked up.
392        at: String,
393        /// The source text of the offending operand.
394        operand: String,
395    },
396}
397
398/// What path traversal should do when a segment lands on an alias
399/// (`*name`) that it needs to descend through.
400///
401/// Reads and writes want different answers. Reading through an alias is
402/// just a lookup, so [`try_get_path`](YamlPath::try_get_path) resolves by
403/// default. Writing through one mutates the shared anchor and therefore
404/// every other alias pointing at it, so
405/// [`try_set_path`](YamlPath::try_set_path) refuses by default and the
406/// caller has to pick a policy deliberately.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
408pub enum AliasPolicy {
409    /// Refuse to descend, reporting [`PathError::AliasRefused`].
410    ///
411    /// The default for writes.
412    #[default]
413    Refuse,
414    /// Follow the alias to its anchored node.
415    ///
416    /// For reads this is plain resolution. For writes it mutates the
417    /// anchored node in place, so the change is visible through the
418    /// anchor and through every other alias that references it.
419    Follow,
420    /// Replace the alias with an independent copy of the anchored node,
421    /// then operate on that copy.
422    ///
423    /// Only meaningful for writes; reads treat it like
424    /// [`Follow`](Self::Follow), since a read has nothing to write back.
425    /// The copy does not carry the original `&anchor`, so other aliases
426    /// keep pointing at the untouched original.
427    Expand,
428}
429
430impl std::fmt::Display for PathError {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        match self {
433            PathError::Parse(e) => write!(f, "path parse error: {e}"),
434            PathError::EmptyPath => write!(f, "path is empty (no segments)"),
435            PathError::NoRoot => write!(f, "document has no root to descend into"),
436            PathError::TypeMismatch { at } => {
437                write!(f, "path segment {at:?} is not a container")
438            }
439            PathError::NotFound { at } => write!(f, "path segment {at:?} not found"),
440            PathError::AliasRefused { at, alias } => write!(
441                f,
442                "path segment {at:?} is an alias to {alias:?}; refusing to descend through it"
443            ),
444            PathError::IndexTooFar { at, len, index } => write!(
445                f,
446                "path segment {at:?} would grow a sequence of {len} by {} null entries to reach index {index}",
447                index + 1 - len
448            ),
449            PathError::UndefinedAlias { at, alias } => write!(
450                f,
451                "path segment {at:?} is an alias to undefined anchor {alias:?}"
452            ),
453            PathError::InvalidMergeOperand { at, operand } => write!(
454                f,
455                "looking up path segment {at:?}: merge key operand {operand:?} is not a mapping"
456            ),
457        }
458    }
459}
460
461impl std::error::Error for PathError {}
462
463impl From<PathParseError> for PathError {
464    fn from(e: PathParseError) -> Self {
465        PathError::Parse(e)
466    }
467}
468
469/// The document's root content, which a path descends from.
470fn root_node(doc: &crate::yaml::Document) -> Result<crate::as_yaml::YamlNode, PathError> {
471    doc.as_mapping()
472        .map(crate::as_yaml::YamlNode::Mapping)
473        .or_else(|| doc.as_sequence().map(crate::as_yaml::YamlNode::Sequence))
474        .or_else(|| doc.as_scalar().map(crate::as_yaml::YamlNode::Scalar))
475        .ok_or(PathError::NoRoot)
476}
477
478// Implementation for Document
479impl YamlPath for crate::yaml::Document {
480    fn try_get_path_with(
481        &self,
482        path: &str,
483        policy: AliasPolicy,
484    ) -> Result<crate::as_yaml::YamlNode, PathError> {
485        let segments = path_segments_required(path)?;
486
487        navigate_path(root_node(self)?, &segments, policy)
488    }
489
490    fn try_set_path_with(
491        &self,
492        path: &str,
493        value: impl crate::AsYaml,
494        policy: AliasPolicy,
495    ) -> Result<(), PathError> {
496        let segments = path_segments_required(path)?;
497
498        // Only a root mapping can hold new-key insertions; a scalar or
499        // sequence root has no place to graft `foo.bar` under.
500        let mapping = self.as_mapping().ok_or(PathError::NoRoot)?;
501
502        set_path_on_mapping(&mapping, &segments, value, policy)
503    }
504
505    fn try_remove_path_with(
506        &self,
507        path: &str,
508        policy: AliasPolicy,
509    ) -> Result<crate::as_yaml::YamlNode, PathError> {
510        let segments = path_segments_required(path)?;
511
512        remove_path_impl(root_node(self)?, None, &segments, policy)
513    }
514}
515
516// Implementation for Mapping
517impl YamlPath for Mapping {
518    fn try_get_path_with(
519        &self,
520        path: &str,
521        policy: AliasPolicy,
522    ) -> Result<crate::as_yaml::YamlNode, PathError> {
523        let segments = path_segments_required(path)?;
524        navigate_path(
525            crate::as_yaml::YamlNode::Mapping(self.clone()),
526            &segments,
527            policy,
528        )
529    }
530
531    fn try_set_path_with(
532        &self,
533        path: &str,
534        value: impl crate::AsYaml,
535        policy: AliasPolicy,
536    ) -> Result<(), PathError> {
537        let segments = path_segments_required(path)?;
538        set_path_on_mapping(self, &segments, value, policy)
539    }
540
541    fn try_remove_path_with(
542        &self,
543        path: &str,
544        policy: AliasPolicy,
545    ) -> Result<crate::as_yaml::YamlNode, PathError> {
546        let segments = path_segments_required(path)?;
547        remove_path_impl(
548            crate::as_yaml::YamlNode::Mapping(self.clone()),
549            None,
550            &segments,
551            policy,
552        )
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_path_error_display() {
562        for (error, text) in [
563            (
564                PathError::Parse(PathParseError::UnclosedIndex),
565                "path parse error: unclosed '[' in path",
566            ),
567            (
568                PathError::Parse(PathParseError::InvalidIndex("x".to_string())),
569                "path parse error: invalid path index [x]",
570            ),
571            (
572                PathError::Parse(PathParseError::TrailingBackslash),
573                "path parse error: path ends with an unescaped '\\'",
574            ),
575            (
576                PathError::Parse(PathParseError::EmptySegment),
577                "path parse error: empty segment in path",
578            ),
579            (
580                PathError::Parse(PathParseError::MissingSeparator),
581                "path parse error: expected '.' or '[' after ']' in path",
582            ),
583            (PathError::EmptyPath, "path is empty (no segments)"),
584            (PathError::NoRoot, "document has no root to descend into"),
585            (
586                PathError::TypeMismatch { at: "a".into() },
587                "path segment \"a\" is not a container",
588            ),
589            (
590                PathError::NotFound { at: "a".into() },
591                "path segment \"a\" not found",
592            ),
593            (
594                PathError::AliasRefused {
595                    at: "a".into(),
596                    alias: "x".into(),
597                },
598                "path segment \"a\" is an alias to \"x\"; refusing to descend through it",
599            ),
600            (
601                PathError::IndexTooFar {
602                    at: "[300]".into(),
603                    len: 2,
604                    index: 300,
605                },
606                "path segment \"[300]\" would grow a sequence of 2 by 299 null entries to reach index 300",
607            ),
608            (
609                PathError::UndefinedAlias {
610                    at: "a".into(),
611                    alias: "x".into(),
612                },
613                "path segment \"a\" is an alias to undefined anchor \"x\"",
614            ),
615            (
616                PathError::InvalidMergeOperand {
617                    at: "a".into(),
618                    operand: "1".into(),
619                },
620                "looking up path segment \"a\": merge key operand \"1\" is not a mapping",
621            ),
622        ] {
623            assert_eq!(error.to_string(), text);
624        }
625    }
626}