Skip to main content

yaml_edit/
anchor_resolution.rs

1//! Anchor and alias resolution for semantic YAML operations
2//!
3//! This module provides functionality to resolve YAML anchors and aliases,
4//! enabling semantic lookups while preserving the lossless nature of the
5//! syntax tree.
6//!
7//! # Design Philosophy
8//!
9//! - **Opt-in**: Resolution is explicit via `get_resolved()` methods
10//! - **Preserves structure**: Original syntax tree remains unchanged
11//! - **Lazy evaluation**: Anchors are resolved on-demand, not eagerly
12//! - **Error handling**: Undefined aliases return None instead of panicking
13//!
14//! # Examples
15//!
16//! ```
17//! use yaml_edit::Document;
18//! use yaml_edit::anchor_resolution::DocumentResolvedExt;
19//! use std::str::FromStr;
20//!
21//! let yaml = r#"
22//! defaults: &defaults
23//!   timeout: 30
24//!   retries: 3
25//!
26//! production:
27//!   <<: *defaults
28//!   host: prod.example.com
29//! "#;
30//!
31//! let doc = Document::from_str(yaml).unwrap();
32//!
33//! // Regular get() doesn't resolve - returns the alias syntax
34//! // get_resolved() expands aliases and merge keys
35//! if let Some(resolved_value) = doc.get_resolved("production") {
36//!     if let Some(prod) = resolved_value.as_mapping() {
37//!         // This will find 'timeout: 30' from the merged defaults
38//!         if let Some(timeout) = prod.get("timeout") {
39//!             assert_eq!(timeout.to_i64(), Some(30));
40//!         }
41//!         // This will find the overridden value
42//!         if let Some(host) = prod.get("host") {
43//!             assert_eq!(host.as_scalar().map(|s| s.to_string()).as_deref(), Some("prod.example.com"));
44//!         }
45//!     }
46//! }
47//! ```
48
49use crate::as_yaml::AsYaml;
50use crate::lex::SyntaxKind;
51use crate::value::YamlValue;
52use crate::yaml::SyntaxNode;
53use std::collections::HashMap;
54
55/// A registry of anchors defined in a YAML document
56#[derive(Debug, Clone)]
57pub struct AnchorRegistry {
58    /// Map from anchor name to the syntax node it refers to
59    anchors: HashMap<String, SyntaxNode>,
60}
61
62impl AnchorRegistry {
63    /// Create a new empty anchor registry
64    pub fn new() -> Self {
65        Self {
66            anchors: HashMap::new(),
67        }
68    }
69
70    /// Build a registry from a Document
71    pub fn from_document(doc: &crate::yaml::Document) -> Self {
72        if let Some(node) = doc.as_node() {
73            Self::from_tree(node)
74        } else {
75            Self::new()
76        }
77    }
78
79    /// Build a registry by scanning a syntax tree
80    pub fn from_tree(root: &SyntaxNode) -> Self {
81        let mut registry = Self::new();
82        registry.collect_anchors_from_tree(root);
83        registry
84    }
85
86    /// Recursively collect all anchors from a syntax tree
87    fn collect_anchors_from_tree(&mut self, node: &SyntaxNode) {
88        // Look for ANCHOR tokens in this node
89        for child in node.children_with_tokens() {
90            if let Some(token) = child.as_token() {
91                if token.kind() == SyntaxKind::ANCHOR {
92                    // Extract anchor name (remove '&' prefix)
93                    let text = token.text();
94                    if let Some(name) = text.strip_prefix('&') {
95                        // Find the associated value node
96                        if let Some(value_node) = self.find_anchored_value(node) {
97                            self.anchors.insert(name.to_string(), value_node);
98                        }
99                    }
100                }
101            } else if let Some(child_node) = child.as_node() {
102                // Recurse into child nodes
103                self.collect_anchors_from_tree(child_node);
104            }
105        }
106    }
107
108    /// Find the value node that an anchor refers to (low-level helper)
109    fn find_anchored_value(&self, node: &SyntaxNode) -> Option<SyntaxNode> {
110        // The anchored value is typically a sibling MAPPING/SEQUENCE/SCALAR node
111        for child in node.children() {
112            if matches!(
113                child.kind(),
114                SyntaxKind::VALUE
115                    | SyntaxKind::SCALAR
116                    | SyntaxKind::MAPPING
117                    | SyntaxKind::SEQUENCE
118                    | SyntaxKind::TAGGED_NODE
119            ) {
120                return Some(child);
121            }
122        }
123        None
124    }
125
126    /// Look up an anchor by name
127    pub fn resolve(&self, name: &str) -> Option<&SyntaxNode> {
128        self.anchors.get(name)
129    }
130
131    /// Check if an anchor is defined
132    pub fn contains(&self, name: &str) -> bool {
133        self.anchors.contains_key(name)
134    }
135
136    /// Get all anchor names
137    pub fn anchor_names(&self) -> impl Iterator<Item = &str> {
138        self.anchors.keys().map(|s| s.as_str())
139    }
140}
141
142impl Default for AnchorRegistry {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148/// Extension trait for Document to support resolved lookups
149pub trait DocumentResolvedExt {
150    /// Get a value with anchor/alias resolution
151    ///
152    /// This method resolves aliases (*alias) to their anchored values (&anchor),
153    /// and supports merge keys (<<) for combining mappings.
154    ///
155    /// Resolution is read-only. To write a merge or alias back, pass a mapping
156    /// that already contains `<<: *name` (or an alias node) to
157    /// [`Mapping::set`](crate::Mapping::set). `set` does not infer a merge
158    /// from a resolved object.
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// use yaml_edit::Document;
164    /// use yaml_edit::anchor_resolution::DocumentResolvedExt;
165    /// use std::str::FromStr;
166    ///
167    /// let yaml = r#"
168    /// config: &cfg
169    ///   port: 8080
170    /// server: *cfg
171    /// "#;
172    /// let doc = Document::from_str(yaml).unwrap();
173    ///
174    /// // Get with resolution - expands the *cfg alias
175    /// if let Some(resolved_value) = doc.get_resolved("server") {
176    ///     if let Some(server) = resolved_value.as_mapping() {
177    ///         if let Some(port) = server.get("port") {
178    ///             assert_eq!(port.to_i64(), Some(8080));
179    ///         }
180    ///     }
181    /// }
182    /// ```
183    fn get_resolved(&self, key: impl crate::AsYaml) -> Option<YamlValue>;
184
185    /// Build the anchor registry for this document
186    fn build_anchor_registry(&self) -> AnchorRegistry;
187}
188
189impl DocumentResolvedExt for crate::yaml::Document {
190    fn get_resolved(&self, key: impl crate::AsYaml) -> Option<YamlValue> {
191        use rowan::ast::AstNode;
192
193        // Build the anchor registry from this document
194        let registry = self.build_anchor_registry();
195
196        // Get the value from the document (assuming it's a mapping)
197        let mapping = self.as_mapping()?;
198        let value = mapping
199            .get_node(&key)
200            .and_then(crate::value::YamlValue::cast)?;
201
202        // Check if we need to resolve an alias
203        // Get the syntax node to check for REFERENCE tokens
204        if let Some(node) = mapping.get_node(&key) {
205            if let Some(alias_name) = find_alias_reference(&node) {
206                // Resolve the alias
207                if let Some(resolved_node) = registry.resolve(&alias_name) {
208                    // Convert the resolved node back to YamlValue
209                    return YamlValue::cast(resolved_node.clone());
210                }
211            }
212        }
213
214        // Check for merge keys - must use CST Mapping, not YamlValue::Mapping (BTreeMap)
215        if let Some(node) = mapping.get_node(&key) {
216            if let Some(result_mapping) = crate::yaml::Mapping::cast(node) {
217                if has_merge_keys(&result_mapping) {
218                    return Some(YamlValue::Mapping(apply_merge_keys(
219                        &result_mapping,
220                        &registry,
221                    )));
222                }
223            }
224        }
225
226        Some(value)
227    }
228
229    fn build_anchor_registry(&self) -> AnchorRegistry {
230        AnchorRegistry::from_document(self)
231    }
232}
233
234/// Find if a node is an alias reference (contains a REFERENCE token)
235fn find_alias_reference(node: &SyntaxNode) -> Option<String> {
236    // Check this node and its children for REFERENCE tokens
237    for child in node.children_with_tokens() {
238        if let Some(token) = child.as_token() {
239            if token.kind() == SyntaxKind::REFERENCE {
240                let text = token.text();
241                // Remove the '*' prefix
242                return text.strip_prefix('*').map(|s| s.to_string());
243            }
244        }
245    }
246
247    // Check parent nodes too
248    if let Some(parent) = node.parent() {
249        for child in parent.children_with_tokens() {
250            if let Some(token) = child.as_token() {
251                if token.kind() == SyntaxKind::REFERENCE {
252                    let text = token.text();
253                    return text.strip_prefix('*').map(|s| s.to_string());
254                }
255            }
256        }
257    }
258
259    None
260}
261
262/// Extract a string value from a YamlNode scalar.
263fn node_as_string(node: &crate::as_yaml::YamlNode) -> Option<String> {
264    node.as_scalar().map(|s| s.as_string())
265}
266
267/// Check if a mapping contains merge keys (<<)
268fn has_merge_keys(mapping: &crate::yaml::Mapping) -> bool {
269    for (key, _) in mapping.iter() {
270        if node_as_string(&key).as_deref() == Some("<<") {
271            return true;
272        }
273    }
274    false
275}
276
277/// Apply merge keys to create a new merged mapping (returns BTreeMap for YamlValue::Mapping)
278fn apply_merge_keys(
279    mapping: &crate::yaml::Mapping,
280    registry: &AnchorRegistry,
281) -> std::collections::BTreeMap<String, YamlValue> {
282    use crate::as_yaml::YamlNode;
283    use std::collections::BTreeMap;
284
285    let mut merged_pairs: BTreeMap<String, YamlValue> = BTreeMap::new();
286
287    // First pass: process merge keys and collect merged values
288    for (key, value) in mapping.iter() {
289        let Some(key_str) = node_as_string(&key) else {
290            continue;
291        };
292        if key_str != "<<" {
293            continue;
294        }
295
296        // Handle both single alias and sequence of aliases
297        match &value {
298            // Single alias: `<<: *alias`
299            YamlNode::Alias(_) | YamlNode::Scalar(_) => {
300                let Some(alias_name) = alias_target(&value) else {
301                    continue;
302                };
303                merge_from_alias(&mut merged_pairs, &alias_name, registry);
304            }
305            // Multiple aliases: `<<: [*alias1, *alias2]`
306            YamlNode::Sequence(seq) => {
307                // Process aliases in order - later aliases override earlier ones
308                for alias_node in seq.values() {
309                    let Some(alias_name) = alias_target(&alias_node) else {
310                        continue;
311                    };
312                    merge_from_alias(&mut merged_pairs, &alias_name, registry);
313                }
314            }
315            _ => continue,
316        }
317    }
318
319    // Second pass: add direct keys (override merged keys)
320    for (key, value) in mapping.iter() {
321        let Some(key_str) = node_as_string(&key) else {
322            continue;
323        };
324        if key_str == "<<" {
325            continue;
326        }
327        if let Some(yaml_value) = resolved_value(value, registry) {
328            merged_pairs.insert(key_str, yaml_value);
329        }
330    }
331
332    merged_pairs
333}
334
335/// Turn a mapping value into a [`YamlValue`] for the resolved map.
336///
337/// An alias is followed to the node it names. One that resolves to nothing
338/// (the anchor was edited away, say) is kept as its literal `*name` text
339/// rather than dropped, so the key still appears in the result.
340fn resolved_value(value: crate::as_yaml::YamlNode, registry: &AnchorRegistry) -> Option<YamlValue> {
341    let resolved = resolve_alias_node(value, registry);
342    if let crate::as_yaml::YamlNode::Alias(alias) = &resolved {
343        return Some(YamlValue::Scalar(crate::ScalarValue::string(alias.value())));
344    }
345    YamlValue::cast(resolved.syntax().clone())
346}
347
348/// The anchor a merge-key operand refers to.
349///
350/// `*name` parses as an ALIAS node, but a merge value can also reach here as
351/// a plain scalar whose text starts with `*`, so accept both.
352fn alias_target(node: &crate::as_yaml::YamlNode) -> Option<String> {
353    match node {
354        crate::as_yaml::YamlNode::Alias(alias) => Some(alias.name()),
355        _ => node_as_string(node)?.strip_prefix('*').map(str::to_string),
356    }
357}
358
359/// Helper function to merge keys from a single alias
360fn merge_from_alias(
361    merged_pairs: &mut std::collections::BTreeMap<String, YamlValue>,
362    alias_name: &str,
363    registry: &AnchorRegistry,
364) {
365    use rowan::ast::AstNode;
366
367    let Some(resolved_node) = registry.resolve(alias_name) else {
368        return;
369    };
370
371    // Get the resolved mapping and merge its keys
372    if let Some(resolved_mapping) = crate::yaml::Mapping::cast(resolved_node.clone()) {
373        for (src_key, src_value) in resolved_mapping.iter() {
374            let Some(k_str) = node_as_string(&src_key) else {
375                continue;
376            };
377            if let Some(yaml_value) = resolved_value(src_value, registry) {
378                merged_pairs.insert(k_str, yaml_value);
379            }
380        }
381    }
382}
383
384/// A view over a [`Mapping`](crate::yaml::Mapping) that transparently expands
385/// alias references (`*name`) and merge keys (`<<:`).
386///
387/// `MergedMapping` is a lightweight, zero-copy view: it borrows the underlying
388/// mapping and an [`AnchorRegistry`]. The underlying syntax tree is not
389/// modified - every lookup walks the CST on demand and resolves aliases via
390/// the registry.
391///
392/// Direct keys in the base mapping always shadow keys contributed by merge
393/// sources, matching the YAML 1.1 merge-key semantics. When a merge value is
394/// a sequence of aliases (`<<: [*a, *b]`), earlier aliases take precedence
395/// over later ones, again per the YAML 1.1 spec.
396///
397/// Returned values are [`YamlNode`](crate::as_yaml::YamlNode)s backed by real
398/// CST nodes, so they preserve the original formatting and quoting style.
399///
400/// This view is read-only. [`Mapping::set`](crate::Mapping::set) replaces the
401/// value node you pass; it does not turn a resolved map back into `<<: *name`.
402///
403/// # Examples
404///
405/// ```
406/// use yaml_edit::{Document, anchor_resolution::{DocumentResolvedExt, MappingMergedExt}};
407/// use std::str::FromStr;
408///
409/// let yaml = r#"
410/// defaults: &defaults
411///   timeout: 30
412///   retries: 3
413///
414/// production:
415///   <<: *defaults
416///   host: prod.example.com
417///   timeout: 60
418/// "#;
419///
420/// let doc = Document::from_str(yaml).unwrap();
421/// let root = doc.as_mapping().unwrap();
422/// let registry = doc.build_anchor_registry();
423///
424/// let prod = root.get_mapping("production").unwrap();
425/// let merged = prod.merged(&registry);
426///
427/// // Direct key wins over merged value.
428/// assert_eq!(merged.get("timeout").unwrap().to_i64(), Some(60));
429/// // Merged key from defaults is visible.
430/// assert_eq!(merged.get("retries").unwrap().to_i64(), Some(3));
431/// // Direct-only key is visible.
432/// assert!(merged.get("host").is_some());
433/// // `<<` itself is hidden.
434/// assert!(merged.get("<<").is_none());
435/// ```
436#[derive(Clone)]
437pub struct MergedMapping<'a> {
438    base: crate::yaml::Mapping,
439    registry: &'a AnchorRegistry,
440}
441
442impl<'a> MergedMapping<'a> {
443    /// Create a new merged view over `base`, resolving aliases against `registry`.
444    pub fn new(base: crate::yaml::Mapping, registry: &'a AnchorRegistry) -> Self {
445        Self { base, registry }
446    }
447
448    /// Return the underlying (un-merged) [`Mapping`](crate::yaml::Mapping).
449    ///
450    /// Useful when you want to mutate the original mapping or read its raw
451    /// (non-resolved) contents.
452    pub fn base(&self) -> &crate::yaml::Mapping {
453        &self.base
454    }
455
456    /// Return the [`AnchorRegistry`] this view resolves against.
457    pub fn registry(&self) -> &AnchorRegistry {
458        self.registry
459    }
460
461    /// Get the value associated with `key`, resolving aliases and merge keys.
462    ///
463    /// Direct entries in the base mapping take precedence over keys
464    /// contributed by `<<:` merge sources. The synthetic `<<` key itself is
465    /// hidden - looking it up returns `None`.
466    ///
467    /// If the matched value is an alias (`*name`), the resolved target node
468    /// is returned. If no such anchor is defined, the alias is left
469    /// unresolved and returned as-is.
470    pub fn get(&self, key: impl crate::AsYaml) -> Option<crate::as_yaml::YamlNode> {
471        if is_merge_key(&key) {
472            return None;
473        }
474
475        if let Some(node) = self.base.get(&key) {
476            return Some(resolve_alias_node(node, self.registry));
477        }
478
479        for source in merge_sources(&self.base, self.registry) {
480            if let Some(node) = source.get(&key) {
481                return Some(resolve_alias_node(node, self.registry));
482            }
483        }
484        None
485    }
486
487    /// Returns `true` if a value would be returned by [`get`](Self::get) for
488    /// `key`.
489    pub fn contains_key(&self, key: impl crate::AsYaml) -> bool {
490        self.get(key).is_some()
491    }
492
493    /// Iterate over `(key, value)` pairs in the merged view.
494    ///
495    /// Direct entries appear first, in their original document order.
496    /// Merged-in entries follow, with duplicates (and any direct-key matches
497    /// already yielded) filtered out. The `<<` merge key itself is never
498    /// yielded.
499    pub fn iter(
500        &self,
501    ) -> impl Iterator<Item = (crate::as_yaml::YamlNode, crate::as_yaml::YamlNode)> + '_ {
502        let mut seen: Vec<String> = Vec::new();
503        let mut out: Vec<(crate::as_yaml::YamlNode, crate::as_yaml::YamlNode)> = Vec::new();
504
505        for (key, value) in self.base.iter() {
506            let Some(key_str) = node_as_string(&key) else {
507                continue;
508            };
509            if key_str == "<<" {
510                continue;
511            }
512            seen.push(key_str);
513            out.push((key, resolve_alias_node(value, self.registry)));
514        }
515
516        for source in merge_sources(&self.base, self.registry) {
517            for (key, value) in source.iter() {
518                let Some(key_str) = node_as_string(&key) else {
519                    continue;
520                };
521                if key_str == "<<" || seen.contains(&key_str) {
522                    continue;
523                }
524                seen.push(key_str);
525                out.push((key, resolve_alias_node(value, self.registry)));
526            }
527        }
528
529        out.into_iter()
530    }
531
532    /// Iterate over the keys of the merged view.
533    pub fn keys(&self) -> impl Iterator<Item = crate::as_yaml::YamlNode> + '_ {
534        self.iter().map(|(k, _)| k)
535    }
536
537    /// Iterate over the values of the merged view.
538    pub fn values(&self) -> impl Iterator<Item = crate::as_yaml::YamlNode> + '_ {
539        self.iter().map(|(_, v)| v)
540    }
541
542    /// Number of distinct keys visible through the merged view.
543    pub fn len(&self) -> usize {
544        self.iter().count()
545    }
546
547    /// Returns `true` if the merged view has no entries.
548    pub fn is_empty(&self) -> bool {
549        self.iter().next().is_none()
550    }
551
552    /// Get a nested mapping by `key` and return it as another `MergedMapping`
553    /// that shares the same registry.
554    ///
555    /// Returns `None` if the key does not exist or the value is not a
556    /// mapping.
557    pub fn get_merged(&self, key: impl crate::AsYaml) -> Option<MergedMapping<'a>> {
558        let node = self.get(key)?;
559        let mapping = node.as_mapping().cloned()?;
560        Some(MergedMapping::new(mapping, self.registry))
561    }
562}
563
564impl std::fmt::Debug for MergedMapping<'_> {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        f.debug_struct("MergedMapping")
567            .field("len", &self.len())
568            .finish()
569    }
570}
571
572/// Returns `true` if `key` is the YAML merge key `<<`.
573fn is_merge_key(key: &impl crate::AsYaml) -> bool {
574    crate::as_yaml::yaml_eq(key, &"<<")
575}
576
577/// Walk the `<<:` entries of `base` and return the resolved source mappings,
578/// in precedence order (highest first).
579fn merge_sources(
580    base: &crate::yaml::Mapping,
581    registry: &AnchorRegistry,
582) -> Vec<crate::yaml::Mapping> {
583    use crate::as_yaml::YamlNode;
584    use rowan::ast::AstNode;
585
586    let mut sources = Vec::new();
587
588    for (key, value) in base.iter() {
589        if node_as_string(&key).as_deref() != Some("<<") {
590            continue;
591        }
592
593        match &value {
594            YamlNode::Scalar(_) => {
595                if let Some(alias_text) = node_as_string(&value) {
596                    if let Some(alias_name) = alias_text.strip_prefix('*') {
597                        if let Some(target) = registry.resolve(alias_name) {
598                            if let Some(m) = crate::yaml::Mapping::cast(target.clone()) {
599                                sources.push(m);
600                            }
601                        }
602                    }
603                }
604            }
605            YamlNode::Sequence(seq) => {
606                for item in seq.values() {
607                    let alias_name = match &item {
608                        YamlNode::Alias(a) => a.name(),
609                        YamlNode::Scalar(_) => {
610                            let Some(text) = node_as_string(&item) else {
611                                continue;
612                            };
613                            let Some(name) = text.strip_prefix('*') else {
614                                continue;
615                            };
616                            name.to_string()
617                        }
618                        _ => continue,
619                    };
620                    let Some(target) = registry.resolve(&alias_name) else {
621                        continue;
622                    };
623                    if let Some(m) = crate::yaml::Mapping::cast(target.clone()) {
624                        sources.push(m);
625                    }
626                }
627            }
628            YamlNode::Alias(alias) => {
629                if let Some(target) = registry.resolve(&alias.name()) {
630                    if let Some(m) = crate::yaml::Mapping::cast(target.clone()) {
631                        sources.push(m);
632                    }
633                }
634            }
635            _ => continue,
636        }
637    }
638
639    sources
640}
641
642/// If `node` is an alias, resolve it via `registry`; otherwise return it as-is.
643fn resolve_alias_node(
644    node: crate::as_yaml::YamlNode,
645    registry: &AnchorRegistry,
646) -> crate::as_yaml::YamlNode {
647    use crate::as_yaml::YamlNode;
648
649    if let YamlNode::Alias(alias) = &node {
650        if let Some(target) = registry.resolve(&alias.name()) {
651            if let Some(resolved) = YamlNode::from_syntax_peeled(target.clone()) {
652                return resolved;
653            }
654        }
655    }
656    node
657}
658
659/// Extension trait that adds [`merged`](Self::merged) to [`Mapping`](crate::yaml::Mapping).
660pub trait MappingMergedExt {
661    /// Return a [`MergedMapping`] view over this mapping that resolves
662    /// aliases and merge keys against `registry`.
663    ///
664    /// The view is read-only and does not modify the underlying CST.
665    fn merged<'a>(&self, registry: &'a AnchorRegistry) -> MergedMapping<'a>;
666}
667
668impl MappingMergedExt for crate::yaml::Mapping {
669    fn merged<'a>(&self, registry: &'a AnchorRegistry) -> MergedMapping<'a> {
670        MergedMapping::new(self.clone(), registry)
671    }
672}
673
674/// Extension trait that adds [`merged`](Self::merged) to [`Document`](crate::yaml::Document).
675pub trait DocumentMergedExt {
676    /// Return a [`MergedMapping`] view over the document's root mapping.
677    ///
678    /// The anchor registry is built from this document automatically.
679    /// Returns `None` if the document's root is not a mapping.
680    ///
681    /// Because the registry is owned by the returned view, this method
682    /// returns an owned [`MergedView`] container rather than a borrowing
683    /// `MergedMapping<'_>`. Use [`MergedView::as_mapping`] to obtain the
684    /// borrowing view for lookups.
685    fn merged(&self) -> Option<MergedView>;
686}
687
688impl DocumentMergedExt for crate::yaml::Document {
689    fn merged(&self) -> Option<MergedView> {
690        let mapping = self.as_mapping()?;
691        let registry = self.build_anchor_registry();
692        Some(MergedView { mapping, registry })
693    }
694}
695
696/// An owning container for a [`MergedMapping`] view of a document's root.
697///
698/// Holds both the root [`Mapping`](crate::yaml::Mapping) and the
699/// [`AnchorRegistry`] built from the document, so the view can be returned
700/// from a function without lifetime gymnastics. Call
701/// [`as_mapping`](Self::as_mapping) (or use the convenience methods
702/// directly) to query the view.
703pub struct MergedView {
704    mapping: crate::yaml::Mapping,
705    registry: AnchorRegistry,
706}
707
708impl MergedView {
709    /// Borrow this view as a [`MergedMapping`] for lookups.
710    pub fn as_mapping(&self) -> MergedMapping<'_> {
711        MergedMapping::new(self.mapping.clone(), &self.registry)
712    }
713
714    /// Get a value through the merged view.
715    pub fn get(&self, key: impl crate::AsYaml) -> Option<crate::as_yaml::YamlNode> {
716        self.as_mapping().get(key)
717    }
718
719    /// Check whether `key` is reachable through the merged view.
720    pub fn contains_key(&self, key: impl crate::AsYaml) -> bool {
721        self.as_mapping().contains_key(key)
722    }
723
724    /// Return the registry built from the document.
725    pub fn registry(&self) -> &AnchorRegistry {
726        &self.registry
727    }
728}
729
730impl std::fmt::Debug for MergedView {
731    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
732        f.debug_struct("MergedView").finish()
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use crate::Document;
740    use std::str::FromStr;
741
742    fn doc(text: &str) -> Document {
743        Document::from_str(text).expect("parse")
744    }
745
746    #[test]
747    fn get_resolved_keys_are_decoded() {
748        // Mapping::pairs() yields the KEY wrapper, so casting it straight to
749        // Scalar failed and the key fell back to raw text. A quoted key kept
750        // its quotes and became unreachable by its own name -- but only
751        // without a merge key, since apply_merge_keys decodes separately.
752        for (yaml, expected) in [
753            ("a:\n  '0': z\n  x: 1\n", vec!["0", "x"]),
754            ("a:\n  \"k\": z\n", vec!["k"]),
755            ("a:\n  plain: z\n", vec!["plain"]),
756            // With a merge key, which takes the other code path.
757            ("s: &s\n  q: r\na:\n  <<: *s\n  '0': z\n", vec!["0", "q"]),
758        ] {
759            let doc = Document::from_str(yaml).unwrap();
760            let resolved = doc.get_resolved("a").expect("a resolves");
761            let mapping = resolved.as_mapping().expect("a is a mapping");
762            let keys: Vec<&str> = mapping.keys().map(String::as_str).collect();
763            assert_eq!(keys, expected, "{yaml:?}");
764            for key in expected {
765                assert!(
766                    mapping.contains_key(key),
767                    "{yaml:?}: {key:?} not reachable by name"
768                );
769            }
770        }
771    }
772
773    #[test]
774    fn get_resolved_expands_merge_keys() {
775        // `*a` reaches apply_merge_keys as an ALIAS node, not a scalar, so
776        // the merge used to fall through and drop every inherited key. Check
777        // get_resolved against the MergedMapping view, which is the same
778        // question asked of the newer code path.
779        for (yaml, present, absent) in [
780            (
781                "a: &a\n  x: 1\nb: &b\n  y: 2\nm:\n  <<: [*a, *b]\n  z: 3\n",
782                vec!["x", "y", "z"],
783                vec!["w"],
784            ),
785            (
786                "a: &a\n  x: 1\nm:\n  <<: *a\n  z: 3\n",
787                vec!["x", "z"],
788                vec!["y"],
789            ),
790        ] {
791            let doc = Document::from_str(yaml).unwrap();
792            let registry = doc.build_anchor_registry();
793            let root = doc.as_mapping().unwrap();
794            let m_node = root.get("m").unwrap();
795            let m = m_node.as_mapping().unwrap();
796            let merged = m.merged(&registry);
797            let resolved = doc.get_resolved("m").expect("m resolves");
798
799            for key in present {
800                assert!(merged.get(key).is_some(), "MergedMapping lost {key:?}");
801                assert!(
802                    resolved.as_mapping().and_then(|mm| mm.get(key)).is_some(),
803                    "get_resolved lost {key:?} in {yaml:?}"
804                );
805            }
806            for key in absent {
807                assert!(merged.get(key).is_none(), "MergedMapping invented {key:?}");
808                assert!(
809                    resolved.as_mapping().and_then(|mm| mm.get(key)).is_none(),
810                    "get_resolved invented {key:?} in {yaml:?}"
811                );
812            }
813        }
814    }
815
816    #[test]
817    fn merged_basic_merge_key() {
818        let yaml = "\
819defaults: &d
820  timeout: 30
821  retries: 3
822
823prod:
824  <<: *d
825  host: prod.example.com
826";
827        let d = doc(yaml);
828        let root = d.as_mapping().unwrap();
829        let reg = d.build_anchor_registry();
830        let prod = root.get_mapping("prod").unwrap();
831        let m = prod.merged(&reg);
832
833        assert_eq!(m.get("timeout").unwrap().to_i64(), Some(30));
834        assert_eq!(m.get("retries").unwrap().to_i64(), Some(3));
835        assert_eq!(
836            m.get("host").unwrap().as_scalar().unwrap().as_string(),
837            "prod.example.com"
838        );
839    }
840
841    #[test]
842    fn merged_direct_key_wins() {
843        let yaml = "\
844defaults: &d
845  timeout: 30
846
847prod:
848  <<: *d
849  timeout: 60
850";
851        let d = doc(yaml);
852        let reg = d.build_anchor_registry();
853        let prod = d.as_mapping().unwrap().get_mapping("prod").unwrap();
854        let m = prod.merged(&reg);
855
856        assert_eq!(m.get("timeout").unwrap().to_i64(), Some(60));
857    }
858
859    #[test]
860    fn merged_merge_key_itself_is_hidden() {
861        let yaml = "\
862d: &d
863  a: 1
864m:
865  <<: *d
866";
867        let d = doc(yaml);
868        let reg = d.build_anchor_registry();
869        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
870        let m = inner.merged(&reg);
871
872        assert!(m.get("<<").is_none());
873        let keys: Vec<String> = m
874            .keys()
875            .map(|k| k.as_scalar().unwrap().as_string())
876            .collect();
877        assert_eq!(keys, vec!["a"]);
878    }
879
880    #[test]
881    fn merged_sequence_of_aliases() {
882        // Per YAML 1.1: earlier aliases take precedence over later ones.
883        let yaml = "\
884a: &a
885  x: 1
886  y: 1
887b: &b
888  y: 2
889  z: 2
890m:
891  <<: [*a, *b]
892";
893        let d = doc(yaml);
894        let reg = d.build_anchor_registry();
895        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
896        let m = inner.merged(&reg);
897
898        // x only in a
899        assert_eq!(m.get("x").unwrap().to_i64(), Some(1));
900        // y in both - a wins (first listed)
901        assert_eq!(m.get("y").unwrap().to_i64(), Some(1));
902        // z only in b
903        assert_eq!(m.get("z").unwrap().to_i64(), Some(2));
904    }
905
906    #[test]
907    fn merged_missing_alias_returns_none() {
908        let yaml = "\
909m:
910  <<: *nope
911  a: 1
912";
913        let d = doc(yaml);
914        let reg = d.build_anchor_registry();
915        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
916        let m = inner.merged(&reg);
917
918        // Direct key still works
919        assert_eq!(m.get("a").unwrap().to_i64(), Some(1));
920        // Missing alias contributes nothing
921        assert!(m.get("b").is_none());
922    }
923
924    #[test]
925    fn merged_iter_order_direct_then_merged() {
926        let yaml = "\
927d: &d
928  shared: from_d
929  only_in_d: 1
930m:
931  direct: hi
932  <<: *d
933";
934        let d = doc(yaml);
935        let reg = d.build_anchor_registry();
936        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
937        let m = inner.merged(&reg);
938
939        let keys: Vec<String> = m
940            .iter()
941            .map(|(k, _)| k.as_scalar().unwrap().as_string())
942            .collect();
943        assert_eq!(keys, vec!["direct", "shared", "only_in_d"]);
944    }
945
946    #[test]
947    fn merged_len_and_empty() {
948        let yaml = "\
949d: &d
950  a: 1
951  b: 2
952m:
953  <<: *d
954  c: 3
955";
956        let d = doc(yaml);
957        let reg = d.build_anchor_registry();
958        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
959        let m = inner.merged(&reg);
960
961        assert_eq!(m.len(), 3);
962        assert!(!m.is_empty());
963    }
964
965    #[test]
966    fn merged_alias_value_is_resolved() {
967        let yaml = "\
968target: &t
969  k: 42
970m:
971  ref: *t
972";
973        let d = doc(yaml);
974        let reg = d.build_anchor_registry();
975        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
976        let m = inner.merged(&reg);
977
978        let r = m.get("ref").unwrap();
979        let nested = r.as_mapping().unwrap();
980        assert_eq!(nested.get("k").unwrap().to_i64(), Some(42));
981    }
982
983    #[test]
984    fn merged_get_merged_nested() {
985        let yaml = "\
986defaults: &d
987  port: 80
988prod:
989  <<: *d
990  inner:
991    a: 1
992";
993        let d = doc(yaml);
994        let reg = d.build_anchor_registry();
995        let prod = d.as_mapping().unwrap().get_mapping("prod").unwrap();
996        let m = prod.merged(&reg);
997
998        let inner = m.get_merged("inner").unwrap();
999        assert_eq!(inner.get("a").unwrap().to_i64(), Some(1));
1000    }
1001
1002    #[test]
1003    fn document_merged_view() {
1004        let yaml = "\
1005defaults: &d
1006  timeout: 30
1007shared:
1008  <<: *d
1009  retries: 5
1010";
1011        let d = doc(yaml);
1012        let view = d.merged().unwrap();
1013
1014        // Top-level keys visible
1015        assert!(view.contains_key("defaults"));
1016        assert!(view.contains_key("shared"));
1017
1018        // Drill into merged child via as_mapping()
1019        let m = view.as_mapping();
1020        let shared = m.get_merged("shared").unwrap();
1021        assert_eq!(shared.get("timeout").unwrap().to_i64(), Some(30));
1022        assert_eq!(shared.get("retries").unwrap().to_i64(), Some(5));
1023    }
1024
1025    #[test]
1026    fn merged_does_not_mutate_underlying_cst() {
1027        let yaml = "\
1028d: &d
1029  a: 1
1030m:
1031  <<: *d
1032  b: 2
1033";
1034        let d = doc(yaml);
1035        let reg = d.build_anchor_registry();
1036        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
1037        let _ = inner.merged(&reg).get("a");
1038        // Original document text should round-trip unchanged.
1039        assert_eq!(d.to_string(), yaml);
1040    }
1041}