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's root is not a mapping
149    ///   (Document with a scalar or sequence root). A Document with no root
150    ///   gets a root mapping, as with [`Document::set`](crate::Document::set).
151    /// - [`PathError::TypeMismatch`] when an intermediate segment lands on
152    ///   a scalar that cannot be turned into a container.
153    fn try_set_path(&self, path: &str, value: impl crate::AsYaml) -> Result<(), PathError> {
154        self.try_set_path_with(path, value, AliasPolicy::Refuse)
155    }
156
157    /// Like [`try_set_path`](Self::try_set_path), but with an explicit
158    /// [`AliasPolicy`] for intermediate segments that land on an alias.
159    ///
160    /// Writing through an alias changes the anchored node, and therefore
161    /// every other alias that references it; that is why the default is
162    /// [`AliasPolicy::Refuse`] and this opt-in exists.
163    ///
164    /// # Errors
165    ///
166    /// As [`try_set_path`](Self::try_set_path), plus
167    /// [`PathError::AliasRefused`] under [`AliasPolicy::Refuse`] and
168    /// [`PathError::UndefinedAlias`] when an alias names an anchor that
169    /// is not defined in the document.
170    fn try_set_path_with(
171        &self,
172        path: &str,
173        value: impl crate::AsYaml,
174        policy: AliasPolicy,
175    ) -> Result<(), PathError>;
176
177    /// Remove a value at a nested path. Returns the removed
178    /// [`YamlNode`](crate::as_yaml::YamlNode) on success, or a specific
179    /// [`PathError`] describing why the removal did not happen.
180    ///
181    /// One entry is removed. Duplicate keys are legal YAML, so a path can
182    /// still resolve afterwards; use
183    /// [`Mapping::remove_nth_occurrence`](crate::Mapping::remove_nth_occurrence)
184    /// to choose between them.
185    ///
186    /// # Errors
187    ///
188    /// Same shape as [`try_get_path`](Self::try_get_path).
189    fn try_remove_path(&self, path: &str) -> Result<crate::as_yaml::YamlNode, PathError> {
190        self.try_remove_path_with(path, AliasPolicy::Refuse)
191    }
192
193    /// Like [`try_remove_path`](Self::try_remove_path), but with an
194    /// explicit [`AliasPolicy`] for intermediate segments that land on
195    /// an alias.
196    ///
197    /// Removal is a mutation, so it defaults to
198    /// [`AliasPolicy::Refuse`] for the same reason as
199    /// [`try_set_path`](Self::try_set_path).
200    ///
201    /// # Errors
202    ///
203    /// As [`try_remove_path`](Self::try_remove_path), plus
204    /// [`PathError::AliasRefused`] and [`PathError::UndefinedAlias`].
205    fn try_remove_path_with(
206        &self,
207        path: &str,
208        policy: AliasPolicy,
209    ) -> Result<crate::as_yaml::YamlNode, PathError>;
210
211    /// Get a value at a nested path.
212    ///
213    /// This is a lossy wrapper around
214    /// [`try_get_path`](Self::try_get_path): every error becomes `None`,
215    /// so callers cannot distinguish "path parsed but nothing found"
216    /// from "path was malformed" or "descended through the wrong
217    /// container type."
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use yaml_edit::{Document, path::YamlPath};
223    /// use std::str::FromStr;
224    ///
225    /// let yaml = Document::from_str("server:\n  host: localhost\n").unwrap();
226    /// #[allow(deprecated)]
227    /// let host = yaml.get_path("server.host");
228    /// assert_eq!(host.unwrap().to_string(), "localhost");
229    /// #[allow(deprecated)]
230    /// let missing = yaml.get_path("server..host");
231    /// assert!(missing.is_none());
232    /// ```
233    #[deprecated(
234        since = "0.4.0",
235        note = "use try_get_path; get_path swallows PathError as None"
236    )]
237    fn get_path(&self, path: &str) -> Option<crate::as_yaml::YamlNode> {
238        self.try_get_path(path).ok()
239    }
240
241    /// Set a value at a nested path.
242    ///
243    /// Lossy wrapper around [`try_set_path`](Self::try_set_path): every
244    /// error is silently ignored. Callers get no signal that the write
245    /// failed (bad path, no root, descending through a scalar, ...).
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use yaml_edit::{Document, path::YamlPath};
251    /// use std::str::FromStr;
252    ///
253    /// let yaml = Document::from_str("name: test\n").unwrap();
254    /// #[allow(deprecated)]
255    /// yaml.set_path("server.host", "localhost");
256    /// #[allow(deprecated)]
257    /// yaml.set_path("server.port", 8080);
258    /// assert_eq!(
259    ///     yaml.to_string(),
260    ///     "name: test\nserver:\n  host: localhost\n  port: 8080\n"
261    /// );
262    /// // A failed write is not reported.
263    /// #[allow(deprecated)]
264    /// yaml.set_path("name.first", "x");
265    /// assert_eq!(yaml.try_get_path("name").unwrap().to_string(), "test");
266    /// ```
267    #[deprecated(
268        since = "0.4.0",
269        note = "use try_set_path; set_path silently ignores PathError"
270    )]
271    fn set_path(&self, path: &str, value: impl crate::AsYaml) {
272        let _ = self.try_set_path(path, value);
273    }
274
275    /// Remove a value at a nested path. Returns `true` if a value was
276    /// removed.
277    ///
278    /// Lossy wrapper around [`try_remove_path`](Self::try_remove_path):
279    /// every error is reported as `false`, indistinguishable from
280    /// "path was well-formed but key not present."
281    ///
282    /// # Examples
283    ///
284    /// ```
285    /// use yaml_edit::{Document, path::YamlPath};
286    /// use std::str::FromStr;
287    ///
288    /// let yaml = Document::from_str("server:\n  host: localhost\n  port: 8080\n").unwrap();
289    /// #[allow(deprecated)]
290    /// {
291    ///     assert_eq!(yaml.remove_path("server.port"), true);
292    ///     assert_eq!(yaml.remove_path("server.missing"), false);
293    /// }
294    /// ```
295    #[deprecated(
296        since = "0.4.0",
297        note = "use try_remove_path; remove_path swallows PathError as false"
298    )]
299    fn remove_path(&self, path: &str) -> bool {
300        self.try_remove_path(path).is_ok()
301    }
302}
303
304/// Represents a segment in a YAML path.
305#[derive(Debug, Clone, PartialEq)]
306pub enum PathSegment {
307    /// A mapping key (e.g., "server" in "server.host")
308    Key(String),
309    /// An array index (e.g., `0` in "items\[0\]" or "items.0")
310    Index(usize),
311}
312
313/// The most null placeholders [`try_set_path`](YamlPath::try_set_path) will
314/// insert to reach an out-of-range sequence index.
315///
316/// Writing to `s[5]` on an empty sequence is a reasonable way to build one;
317/// writing to `s[900000]` is almost always a mistake or hostile input, and
318/// filling the gap would cost time and memory that grows faster than the
319/// index (each insert re-splices the sequence's children).
320pub const MAX_INDEX_GROWTH: usize = 256;
321
322/// Error returned by [`try_get_path`](YamlPath::try_get_path) /
323/// [`try_set_path`](YamlPath::try_set_path) /
324/// [`try_remove_path`](YamlPath::try_remove_path).
325///
326/// Distinguishes the several ways a path operation can fail so callers
327/// can react appropriately, instead of getting the previous silent
328/// no-op or ambiguous `None`.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum PathError {
331    /// The path string is malformed; see [`PathParseError`].
332    Parse(PathParseError),
333    /// The path is the empty string. Stray dots are a
334    /// [`PathParseError::EmptySegment`] instead.
335    EmptyPath,
336    /// The receiver has no root value to descend into: `Document` was
337    /// empty, or the requested operation needed a root mapping and the
338    /// document's root is a scalar / sequence.
339    NoRoot,
340    /// A segment tried to descend into a value of the wrong container
341    /// type -- e.g. treating a scalar as a mapping in `foo.bar` when
342    /// `foo` is a scalar. `at` is the segment index (`"foo"` in the
343    /// example above) that led to the type mismatch.
344    TypeMismatch {
345        /// The segment whose value was not the expected container type.
346        at: String,
347    },
348    /// The target segment does not exist. Applies to
349    /// [`try_get_path`](YamlPath::try_get_path) and
350    /// [`try_remove_path`](YamlPath::try_remove_path); `try_set_path`
351    /// creates missing intermediates and never reports this. `at` is
352    /// the missing segment.
353    NotFound {
354        /// The segment that could not be resolved.
355        at: String,
356    },
357    /// A segment landed on an alias (`*name`) and the active
358    /// [`AliasPolicy`] refused to go through it. `at` is the segment,
359    /// `alias` the anchor name it referenced.
360    AliasRefused {
361        /// The segment whose value is an alias.
362        at: String,
363        /// The anchor name the alias references, without the `*`.
364        alias: String,
365    },
366    /// Setting the requested index would have grown a sequence by more
367    /// than [`MAX_INDEX_GROWTH`] null placeholders.
368    ///
369    /// `try_set_path("s[900000]", v)` on a short sequence would otherwise
370    /// pad it with that many entries, so a path from untrusted input could
371    /// spend unbounded time and memory.
372    IndexTooFar {
373        /// The segment whose index was out of reach.
374        at: String,
375        /// The current length of the sequence.
376        len: usize,
377        /// The index that was asked for.
378        index: usize,
379    },
380    /// A segment landed on an alias whose anchor is not defined anywhere
381    /// in the document, so it could not be resolved. Also reported when a
382    /// `<<` merge operand consulted for the segment is such an alias.
383    UndefinedAlias {
384        /// The segment whose value is a dangling alias.
385        at: String,
386        /// The anchor name the alias references, without the `*`.
387        alias: String,
388    },
389    /// Looking up a segment needed the mapping's merge keys, and a `<<`
390    /// operand was neither a mapping nor an alias to one.
391    InvalidMergeOperand {
392        /// The segment being looked up.
393        at: String,
394        /// The source text of the offending operand.
395        operand: String,
396    },
397}
398
399/// What path traversal should do when a segment lands on an alias
400/// (`*name`) that it needs to descend through.
401///
402/// Reads and writes want different answers. Reading through an alias is
403/// just a lookup, so [`try_get_path`](YamlPath::try_get_path) resolves by
404/// default. Writing through one mutates the shared anchor and therefore
405/// every other alias pointing at it, so
406/// [`try_set_path`](YamlPath::try_set_path) refuses by default and the
407/// caller has to pick a policy deliberately.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
409pub enum AliasPolicy {
410    /// Refuse to descend, reporting [`PathError::AliasRefused`].
411    ///
412    /// The default for writes.
413    #[default]
414    Refuse,
415    /// Follow the alias to its anchored node.
416    ///
417    /// For reads this is plain resolution. For writes it mutates the
418    /// anchored node in place, so the change is visible through the
419    /// anchor and through every other alias that references it.
420    Follow,
421    /// Replace the alias with an independent copy of the anchored node,
422    /// then operate on that copy.
423    ///
424    /// Only meaningful for writes; reads treat it like
425    /// [`Follow`](Self::Follow), since a read has nothing to write back.
426    /// The copy does not carry the original `&anchor`, so other aliases
427    /// keep pointing at the untouched original.
428    Expand,
429}
430
431impl std::fmt::Display for PathError {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        match self {
434            PathError::Parse(e) => write!(f, "path parse error: {e}"),
435            PathError::EmptyPath => write!(f, "path is empty (no segments)"),
436            PathError::NoRoot => write!(f, "document has no root to descend into"),
437            PathError::TypeMismatch { at } => {
438                write!(f, "path segment {at:?} is not a container")
439            }
440            PathError::NotFound { at } => write!(f, "path segment {at:?} not found"),
441            PathError::AliasRefused { at, alias } => write!(
442                f,
443                "path segment {at:?} is an alias to {alias:?}; refusing to descend through it"
444            ),
445            PathError::IndexTooFar { at, len, index } => write!(
446                f,
447                "path segment {at:?} would grow a sequence of {len} by {} null entries to reach index {index}",
448                index + 1 - len
449            ),
450            PathError::UndefinedAlias { at, alias } => write!(
451                f,
452                "path segment {at:?} is an alias to undefined anchor {alias:?}"
453            ),
454            PathError::InvalidMergeOperand { at, operand } => write!(
455                f,
456                "looking up path segment {at:?}: merge key operand {operand:?} is not a mapping"
457            ),
458        }
459    }
460}
461
462impl std::error::Error for PathError {}
463
464impl From<PathParseError> for PathError {
465    fn from(e: PathParseError) -> Self {
466        PathError::Parse(e)
467    }
468}
469
470/// The document's root content, which a path descends from.
471fn root_node(doc: &crate::yaml::Document) -> Result<crate::as_yaml::YamlNode, PathError> {
472    doc.as_mapping()
473        .map(crate::as_yaml::YamlNode::Mapping)
474        .or_else(|| doc.as_sequence().map(crate::as_yaml::YamlNode::Sequence))
475        .or_else(|| doc.as_scalar().map(crate::as_yaml::YamlNode::Scalar))
476        .ok_or(PathError::NoRoot)
477}
478
479// Implementation for Document
480impl YamlPath for crate::yaml::Document {
481    fn try_get_path_with(
482        &self,
483        path: &str,
484        policy: AliasPolicy,
485    ) -> Result<crate::as_yaml::YamlNode, PathError> {
486        let segments = path_segments_required(path)?;
487
488        navigate_path(root_node(self)?, &segments, policy)
489    }
490
491    fn try_set_path_with(
492        &self,
493        path: &str,
494        value: impl crate::AsYaml,
495        policy: AliasPolicy,
496    ) -> Result<(), PathError> {
497        let segments = path_segments_required(path)?;
498
499        // Only a root mapping can hold new-key insertions; a scalar or
500        // sequence root has no place to graft `foo.bar` under.
501        self.try_edit_root_mapping(|mapping| set_path_on_mapping(mapping, &segments, value, policy))
502            .ok_or(PathError::NoRoot)?
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}