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}