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}