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    /// # Examples
156    ///
157    /// ```
158    /// use yaml_edit::Document;
159    /// use yaml_edit::anchor_resolution::DocumentResolvedExt;
160    /// use std::str::FromStr;
161    ///
162    /// let yaml = r#"
163    /// config: &cfg
164    ///   port: 8080
165    /// server: *cfg
166    /// "#;
167    /// let doc = Document::from_str(yaml).unwrap();
168    ///
169    /// // Get with resolution - expands the *cfg alias
170    /// if let Some(resolved_value) = doc.get_resolved("server") {
171    ///     if let Some(server) = resolved_value.as_mapping() {
172    ///         if let Some(port) = server.get("port") {
173    ///             assert_eq!(port.to_i64(), Some(8080));
174    ///         }
175    ///     }
176    /// }
177    /// ```
178    fn get_resolved(&self, key: impl crate::AsYaml) -> Option<YamlValue>;
179
180    /// Build the anchor registry for this document
181    fn build_anchor_registry(&self) -> AnchorRegistry;
182}
183
184impl DocumentResolvedExt for crate::yaml::Document {
185    fn get_resolved(&self, key: impl crate::AsYaml) -> Option<YamlValue> {
186        use rowan::ast::AstNode;
187
188        // Build the anchor registry from this document
189        let registry = self.build_anchor_registry();
190
191        // Get the value from the document (assuming it's a mapping)
192        let mapping = self.as_mapping()?;
193        let value = mapping
194            .get_node(&key)
195            .and_then(crate::value::YamlValue::cast)?;
196
197        // Check if we need to resolve an alias
198        // Get the syntax node to check for REFERENCE tokens
199        if let Some(node) = mapping.get_node(&key) {
200            if let Some(alias_name) = find_alias_reference(&node) {
201                // Resolve the alias
202                if let Some(resolved_node) = registry.resolve(&alias_name) {
203                    // Convert the resolved node back to YamlValue
204                    return YamlValue::cast(resolved_node.clone());
205                }
206            }
207        }
208
209        // Check for merge keys — must use CST Mapping, not YamlValue::Mapping (BTreeMap)
210        if let Some(node) = mapping.get_node(&key) {
211            if let Some(result_mapping) = crate::yaml::Mapping::cast(node) {
212                if has_merge_keys(&result_mapping) {
213                    return Some(YamlValue::Mapping(apply_merge_keys(
214                        &result_mapping,
215                        &registry,
216                    )));
217                }
218            }
219        }
220
221        Some(value)
222    }
223
224    fn build_anchor_registry(&self) -> AnchorRegistry {
225        AnchorRegistry::from_document(self)
226    }
227}
228
229/// Find if a node is an alias reference (contains a REFERENCE token)
230fn find_alias_reference(node: &SyntaxNode) -> Option<String> {
231    // Check this node and its children for REFERENCE tokens
232    for child in node.children_with_tokens() {
233        if let Some(token) = child.as_token() {
234            if token.kind() == SyntaxKind::REFERENCE {
235                let text = token.text();
236                // Remove the '*' prefix
237                return text.strip_prefix('*').map(|s| s.to_string());
238            }
239        }
240    }
241
242    // Check parent nodes too
243    if let Some(parent) = node.parent() {
244        for child in parent.children_with_tokens() {
245            if let Some(token) = child.as_token() {
246                if token.kind() == SyntaxKind::REFERENCE {
247                    let text = token.text();
248                    return text.strip_prefix('*').map(|s| s.to_string());
249                }
250            }
251        }
252    }
253
254    None
255}
256
257/// Extract a string value from a YamlNode scalar.
258fn node_as_string(node: &crate::as_yaml::YamlNode) -> Option<String> {
259    node.as_scalar().map(|s| s.as_string())
260}
261
262/// Check if a mapping contains merge keys (<<)
263fn has_merge_keys(mapping: &crate::yaml::Mapping) -> bool {
264    for (key, _) in mapping.iter() {
265        if node_as_string(&key).as_deref() == Some("<<") {
266            return true;
267        }
268    }
269    false
270}
271
272/// Apply merge keys to create a new merged mapping (returns BTreeMap for YamlValue::Mapping)
273fn apply_merge_keys(
274    mapping: &crate::yaml::Mapping,
275    registry: &AnchorRegistry,
276) -> std::collections::BTreeMap<String, YamlValue> {
277    use crate::as_yaml::YamlNode;
278    use std::collections::BTreeMap;
279
280    let mut merged_pairs: BTreeMap<String, YamlValue> = BTreeMap::new();
281
282    // First pass: process merge keys and collect merged values
283    for (key, value) in mapping.iter() {
284        let Some(key_str) = node_as_string(&key) else {
285            continue;
286        };
287        if key_str != "<<" {
288            continue;
289        }
290
291        // Handle both single alias and sequence of aliases
292        match &value {
293            // Single alias: <<: *alias
294            YamlNode::Scalar(_) => {
295                let Some(alias_text) = node_as_string(&value) else {
296                    continue;
297                };
298                let Some(alias_name) = alias_text.strip_prefix('*') else {
299                    continue;
300                };
301
302                merge_from_alias(&mut merged_pairs, alias_name, registry);
303            }
304            // Multiple aliases: <<: [*alias1, *alias2]
305            YamlNode::Sequence(seq) => {
306                // Process aliases in order - later aliases override earlier ones
307                for alias_node in seq.values() {
308                    let Some(alias_text) = node_as_string(&alias_node) else {
309                        continue;
310                    };
311                    let Some(alias_name) = alias_text.strip_prefix('*') else {
312                        continue;
313                    };
314
315                    merge_from_alias(&mut merged_pairs, alias_name, registry);
316                }
317            }
318            _ => continue,
319        }
320    }
321
322    // Second pass: add direct keys (override merged keys)
323    for (key, value) in mapping.iter() {
324        let Some(key_str) = node_as_string(&key) else {
325            continue;
326        };
327        if key_str == "<<" {
328            continue;
329        }
330        // Convert YamlNode back to YamlValue for storage
331        if let Some(yaml_value) = YamlValue::cast(value.syntax().clone()) {
332            merged_pairs.insert(key_str, yaml_value);
333        }
334    }
335
336    merged_pairs
337}
338
339/// Helper function to merge keys from a single alias
340fn merge_from_alias(
341    merged_pairs: &mut std::collections::BTreeMap<String, YamlValue>,
342    alias_name: &str,
343    registry: &AnchorRegistry,
344) {
345    use rowan::ast::AstNode;
346
347    let Some(resolved_node) = registry.resolve(alias_name) else {
348        return;
349    };
350
351    // Get the resolved mapping and merge its keys
352    if let Some(resolved_mapping) = crate::yaml::Mapping::cast(resolved_node.clone()) {
353        for (src_key, src_value) in resolved_mapping.iter() {
354            let Some(k_str) = node_as_string(&src_key) else {
355                continue;
356            };
357            // Convert YamlNode back to YamlValue for storage; insert or override
358            if let Some(yaml_value) = YamlValue::cast(src_value.syntax().clone()) {
359                merged_pairs.insert(k_str, yaml_value);
360            }
361        }
362    }
363}
364
365/// A view over a [`Mapping`](crate::yaml::Mapping) that transparently expands
366/// alias references (`*name`) and merge keys (`<<:`).
367///
368/// `MergedMapping` is a lightweight, zero-copy view: it borrows the underlying
369/// mapping and an [`AnchorRegistry`]. The underlying syntax tree is not
370/// modified — every lookup walks the CST on demand and resolves aliases via
371/// the registry.
372///
373/// Direct keys in the base mapping always shadow keys contributed by merge
374/// sources, matching the YAML 1.1 merge-key semantics. When a merge value is
375/// a sequence of aliases (`<<: [*a, *b]`), earlier aliases take precedence
376/// over later ones, again per the YAML 1.1 spec.
377///
378/// Returned values are [`YamlNode`](crate::as_yaml::YamlNode)s backed by real
379/// CST nodes, so they preserve the original formatting and quoting style.
380///
381/// # Examples
382///
383/// ```
384/// use yaml_edit::{Document, anchor_resolution::{DocumentResolvedExt, MappingMergedExt}};
385/// use std::str::FromStr;
386///
387/// let yaml = r#"
388/// defaults: &defaults
389///   timeout: 30
390///   retries: 3
391///
392/// production:
393///   <<: *defaults
394///   host: prod.example.com
395///   timeout: 60
396/// "#;
397///
398/// let doc = Document::from_str(yaml).unwrap();
399/// let root = doc.as_mapping().unwrap();
400/// let registry = doc.build_anchor_registry();
401///
402/// let prod = root.get_mapping("production").unwrap();
403/// let merged = prod.merged(&registry);
404///
405/// // Direct key wins over merged value.
406/// assert_eq!(merged.get("timeout").unwrap().to_i64(), Some(60));
407/// // Merged key from defaults is visible.
408/// assert_eq!(merged.get("retries").unwrap().to_i64(), Some(3));
409/// // Direct-only key is visible.
410/// assert!(merged.get("host").is_some());
411/// // `<<` itself is hidden.
412/// assert!(merged.get("<<").is_none());
413/// ```
414#[derive(Clone)]
415pub struct MergedMapping<'a> {
416    base: crate::yaml::Mapping,
417    registry: &'a AnchorRegistry,
418}
419
420impl<'a> MergedMapping<'a> {
421    /// Create a new merged view over `base`, resolving aliases against `registry`.
422    pub fn new(base: crate::yaml::Mapping, registry: &'a AnchorRegistry) -> Self {
423        Self { base, registry }
424    }
425
426    /// Return the underlying (un-merged) [`Mapping`](crate::yaml::Mapping).
427    ///
428    /// Useful when you want to mutate the original mapping or read its raw
429    /// (non-resolved) contents.
430    pub fn base(&self) -> &crate::yaml::Mapping {
431        &self.base
432    }
433
434    /// Return the [`AnchorRegistry`] this view resolves against.
435    pub fn registry(&self) -> &AnchorRegistry {
436        self.registry
437    }
438
439    /// Get the value associated with `key`, resolving aliases and merge keys.
440    ///
441    /// Direct entries in the base mapping take precedence over keys
442    /// contributed by `<<:` merge sources. The synthetic `<<` key itself is
443    /// hidden — looking it up returns `None`.
444    ///
445    /// If the matched value is an alias (`*name`), the resolved target node
446    /// is returned. If no such anchor is defined, the alias is left
447    /// unresolved and returned as-is.
448    pub fn get(&self, key: impl crate::AsYaml) -> Option<crate::as_yaml::YamlNode> {
449        if is_merge_key(&key) {
450            return None;
451        }
452
453        if let Some(node) = self.base.get(&key) {
454            return Some(resolve_alias_node(node, self.registry));
455        }
456
457        for source in merge_sources(&self.base, self.registry) {
458            if let Some(node) = source.get(&key) {
459                return Some(resolve_alias_node(node, self.registry));
460            }
461        }
462        None
463    }
464
465    /// Returns `true` if a value would be returned by [`get`](Self::get) for
466    /// `key`.
467    pub fn contains_key(&self, key: impl crate::AsYaml) -> bool {
468        self.get(key).is_some()
469    }
470
471    /// Iterate over `(key, value)` pairs in the merged view.
472    ///
473    /// Direct entries appear first, in their original document order.
474    /// Merged-in entries follow, with duplicates (and any direct-key matches
475    /// already yielded) filtered out. The `<<` merge key itself is never
476    /// yielded.
477    pub fn iter(
478        &self,
479    ) -> impl Iterator<Item = (crate::as_yaml::YamlNode, crate::as_yaml::YamlNode)> + '_ {
480        let mut seen: Vec<String> = Vec::new();
481        let mut out: Vec<(crate::as_yaml::YamlNode, crate::as_yaml::YamlNode)> = Vec::new();
482
483        for (key, value) in self.base.iter() {
484            let Some(key_str) = node_as_string(&key) else {
485                continue;
486            };
487            if key_str == "<<" {
488                continue;
489            }
490            seen.push(key_str);
491            out.push((key, resolve_alias_node(value, self.registry)));
492        }
493
494        for source in merge_sources(&self.base, self.registry) {
495            for (key, value) in source.iter() {
496                let Some(key_str) = node_as_string(&key) else {
497                    continue;
498                };
499                if key_str == "<<" || seen.contains(&key_str) {
500                    continue;
501                }
502                seen.push(key_str);
503                out.push((key, resolve_alias_node(value, self.registry)));
504            }
505        }
506
507        out.into_iter()
508    }
509
510    /// Iterate over the keys of the merged view.
511    pub fn keys(&self) -> impl Iterator<Item = crate::as_yaml::YamlNode> + '_ {
512        self.iter().map(|(k, _)| k)
513    }
514
515    /// Iterate over the values of the merged view.
516    pub fn values(&self) -> impl Iterator<Item = crate::as_yaml::YamlNode> + '_ {
517        self.iter().map(|(_, v)| v)
518    }
519
520    /// Number of distinct keys visible through the merged view.
521    pub fn len(&self) -> usize {
522        self.iter().count()
523    }
524
525    /// Returns `true` if the merged view has no entries.
526    pub fn is_empty(&self) -> bool {
527        self.iter().next().is_none()
528    }
529
530    /// Get a nested mapping by `key` and return it as another `MergedMapping`
531    /// that shares the same registry.
532    ///
533    /// Returns `None` if the key does not exist or the value is not a
534    /// mapping.
535    pub fn get_merged(&self, key: impl crate::AsYaml) -> Option<MergedMapping<'a>> {
536        let node = self.get(key)?;
537        let mapping = node.as_mapping().cloned()?;
538        Some(MergedMapping::new(mapping, self.registry))
539    }
540}
541
542impl std::fmt::Debug for MergedMapping<'_> {
543    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544        f.debug_struct("MergedMapping")
545            .field("len", &self.len())
546            .finish()
547    }
548}
549
550/// Returns `true` if `key` is the YAML merge key `<<`.
551fn is_merge_key(key: &impl crate::AsYaml) -> bool {
552    use crate::as_yaml::YamlNode;
553
554    if let Some(node) = key.as_node() {
555        if let Some(yaml) = YamlNode::from_syntax_peeled(node.clone()) {
556            return node_as_string(&yaml).as_deref() == Some("<<");
557        }
558    }
559
560    // Fallback: build a green node and stringify it. This handles raw `&str`
561    // and similar input types.
562    let mut builder = rowan::GreenNodeBuilder::new();
563    builder.start_node(crate::lex::SyntaxKind::ROOT.into());
564    key.build_content(&mut builder, 0, true);
565    builder.finish_node();
566    let root = crate::yaml::SyntaxNode::new_root(builder.finish());
567    root.text().to_string().trim() == "<<"
568}
569
570/// Walk the `<<:` entries of `base` and return the resolved source mappings,
571/// in precedence order (highest first).
572fn merge_sources(
573    base: &crate::yaml::Mapping,
574    registry: &AnchorRegistry,
575) -> Vec<crate::yaml::Mapping> {
576    use crate::as_yaml::YamlNode;
577    use rowan::ast::AstNode;
578
579    let mut sources = Vec::new();
580
581    for (key, value) in base.iter() {
582        if node_as_string(&key).as_deref() != Some("<<") {
583            continue;
584        }
585
586        match &value {
587            YamlNode::Scalar(_) => {
588                if let Some(alias_text) = node_as_string(&value) {
589                    if let Some(alias_name) = alias_text.strip_prefix('*') {
590                        if let Some(target) = registry.resolve(alias_name) {
591                            if let Some(m) = crate::yaml::Mapping::cast(target.clone()) {
592                                sources.push(m);
593                            }
594                        }
595                    }
596                }
597            }
598            YamlNode::Sequence(seq) => {
599                for item in seq.values() {
600                    let alias_name = match &item {
601                        YamlNode::Alias(a) => a.name(),
602                        YamlNode::Scalar(_) => {
603                            let Some(text) = node_as_string(&item) else {
604                                continue;
605                            };
606                            let Some(name) = text.strip_prefix('*') else {
607                                continue;
608                            };
609                            name.to_string()
610                        }
611                        _ => continue,
612                    };
613                    let Some(target) = registry.resolve(&alias_name) else {
614                        continue;
615                    };
616                    if let Some(m) = crate::yaml::Mapping::cast(target.clone()) {
617                        sources.push(m);
618                    }
619                }
620            }
621            YamlNode::Alias(alias) => {
622                if let Some(target) = registry.resolve(&alias.name()) {
623                    if let Some(m) = crate::yaml::Mapping::cast(target.clone()) {
624                        sources.push(m);
625                    }
626                }
627            }
628            _ => continue,
629        }
630    }
631
632    sources
633}
634
635/// If `node` is an alias, resolve it via `registry`; otherwise return it as-is.
636fn resolve_alias_node(
637    node: crate::as_yaml::YamlNode,
638    registry: &AnchorRegistry,
639) -> crate::as_yaml::YamlNode {
640    use crate::as_yaml::YamlNode;
641
642    if let YamlNode::Alias(alias) = &node {
643        if let Some(target) = registry.resolve(&alias.name()) {
644            if let Some(resolved) = YamlNode::from_syntax_peeled(target.clone()) {
645                return resolved;
646            }
647        }
648    }
649    node
650}
651
652/// Extension trait that adds [`merged`](Self::merged) to [`Mapping`](crate::yaml::Mapping).
653pub trait MappingMergedExt {
654    /// Return a [`MergedMapping`] view over this mapping that resolves
655    /// aliases and merge keys against `registry`.
656    ///
657    /// The view is read-only and does not modify the underlying CST.
658    fn merged<'a>(&self, registry: &'a AnchorRegistry) -> MergedMapping<'a>;
659}
660
661impl MappingMergedExt for crate::yaml::Mapping {
662    fn merged<'a>(&self, registry: &'a AnchorRegistry) -> MergedMapping<'a> {
663        MergedMapping::new(self.clone(), registry)
664    }
665}
666
667/// Extension trait that adds [`merged`](Self::merged) to [`Document`](crate::yaml::Document).
668pub trait DocumentMergedExt {
669    /// Return a [`MergedMapping`] view over the document's root mapping.
670    ///
671    /// The anchor registry is built from this document automatically.
672    /// Returns `None` if the document's root is not a mapping.
673    ///
674    /// Because the registry is owned by the returned view, this method
675    /// returns an owned [`MergedView`] container rather than a borrowing
676    /// `MergedMapping<'_>`. Use [`MergedView::as_mapping`] to obtain the
677    /// borrowing view for lookups.
678    fn merged(&self) -> Option<MergedView>;
679}
680
681impl DocumentMergedExt for crate::yaml::Document {
682    fn merged(&self) -> Option<MergedView> {
683        let mapping = self.as_mapping()?;
684        let registry = self.build_anchor_registry();
685        Some(MergedView { mapping, registry })
686    }
687}
688
689/// An owning container for a [`MergedMapping`] view of a document's root.
690///
691/// Holds both the root [`Mapping`](crate::yaml::Mapping) and the
692/// [`AnchorRegistry`] built from the document, so the view can be returned
693/// from a function without lifetime gymnastics. Call
694/// [`as_mapping`](Self::as_mapping) (or use the convenience methods
695/// directly) to query the view.
696pub struct MergedView {
697    mapping: crate::yaml::Mapping,
698    registry: AnchorRegistry,
699}
700
701impl MergedView {
702    /// Borrow this view as a [`MergedMapping`] for lookups.
703    pub fn as_mapping(&self) -> MergedMapping<'_> {
704        MergedMapping::new(self.mapping.clone(), &self.registry)
705    }
706
707    /// Get a value through the merged view.
708    pub fn get(&self, key: impl crate::AsYaml) -> Option<crate::as_yaml::YamlNode> {
709        self.as_mapping().get(key)
710    }
711
712    /// Check whether `key` is reachable through the merged view.
713    pub fn contains_key(&self, key: impl crate::AsYaml) -> bool {
714        self.as_mapping().contains_key(key)
715    }
716
717    /// Return the registry built from the document.
718    pub fn registry(&self) -> &AnchorRegistry {
719        &self.registry
720    }
721}
722
723impl std::fmt::Debug for MergedView {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        f.debug_struct("MergedView").finish()
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::Document;
733    use std::str::FromStr;
734
735    fn doc(text: &str) -> Document {
736        Document::from_str(text).expect("parse")
737    }
738
739    #[test]
740    fn merged_basic_merge_key() {
741        let yaml = "\
742defaults: &d
743  timeout: 30
744  retries: 3
745
746prod:
747  <<: *d
748  host: prod.example.com
749";
750        let d = doc(yaml);
751        let root = d.as_mapping().unwrap();
752        let reg = d.build_anchor_registry();
753        let prod = root.get_mapping("prod").unwrap();
754        let m = prod.merged(&reg);
755
756        assert_eq!(m.get("timeout").unwrap().to_i64(), Some(30));
757        assert_eq!(m.get("retries").unwrap().to_i64(), Some(3));
758        assert_eq!(
759            m.get("host").unwrap().as_scalar().unwrap().as_string(),
760            "prod.example.com"
761        );
762    }
763
764    #[test]
765    fn merged_direct_key_wins() {
766        let yaml = "\
767defaults: &d
768  timeout: 30
769
770prod:
771  <<: *d
772  timeout: 60
773";
774        let d = doc(yaml);
775        let reg = d.build_anchor_registry();
776        let prod = d.as_mapping().unwrap().get_mapping("prod").unwrap();
777        let m = prod.merged(&reg);
778
779        assert_eq!(m.get("timeout").unwrap().to_i64(), Some(60));
780    }
781
782    #[test]
783    fn merged_merge_key_itself_is_hidden() {
784        let yaml = "\
785d: &d
786  a: 1
787m:
788  <<: *d
789";
790        let d = doc(yaml);
791        let reg = d.build_anchor_registry();
792        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
793        let m = inner.merged(&reg);
794
795        assert!(m.get("<<").is_none());
796        let keys: Vec<String> = m
797            .keys()
798            .map(|k| k.as_scalar().unwrap().as_string())
799            .collect();
800        assert_eq!(keys, vec!["a"]);
801    }
802
803    #[test]
804    fn merged_sequence_of_aliases() {
805        // Per YAML 1.1: earlier aliases take precedence over later ones.
806        let yaml = "\
807a: &a
808  x: 1
809  y: 1
810b: &b
811  y: 2
812  z: 2
813m:
814  <<: [*a, *b]
815";
816        let d = doc(yaml);
817        let reg = d.build_anchor_registry();
818        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
819        let m = inner.merged(&reg);
820
821        // x only in a
822        assert_eq!(m.get("x").unwrap().to_i64(), Some(1));
823        // y in both — a wins (first listed)
824        assert_eq!(m.get("y").unwrap().to_i64(), Some(1));
825        // z only in b
826        assert_eq!(m.get("z").unwrap().to_i64(), Some(2));
827    }
828
829    #[test]
830    fn merged_missing_alias_returns_none() {
831        let yaml = "\
832m:
833  <<: *nope
834  a: 1
835";
836        let d = doc(yaml);
837        let reg = d.build_anchor_registry();
838        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
839        let m = inner.merged(&reg);
840
841        // Direct key still works
842        assert_eq!(m.get("a").unwrap().to_i64(), Some(1));
843        // Missing alias contributes nothing
844        assert!(m.get("b").is_none());
845    }
846
847    #[test]
848    fn merged_iter_order_direct_then_merged() {
849        let yaml = "\
850d: &d
851  shared: from_d
852  only_in_d: 1
853m:
854  direct: hi
855  <<: *d
856";
857        let d = doc(yaml);
858        let reg = d.build_anchor_registry();
859        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
860        let m = inner.merged(&reg);
861
862        let keys: Vec<String> = m
863            .iter()
864            .map(|(k, _)| k.as_scalar().unwrap().as_string())
865            .collect();
866        assert_eq!(keys, vec!["direct", "shared", "only_in_d"]);
867    }
868
869    #[test]
870    fn merged_len_and_empty() {
871        let yaml = "\
872d: &d
873  a: 1
874  b: 2
875m:
876  <<: *d
877  c: 3
878";
879        let d = doc(yaml);
880        let reg = d.build_anchor_registry();
881        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
882        let m = inner.merged(&reg);
883
884        assert_eq!(m.len(), 3);
885        assert!(!m.is_empty());
886    }
887
888    #[test]
889    fn merged_alias_value_is_resolved() {
890        let yaml = "\
891target: &t
892  k: 42
893m:
894  ref: *t
895";
896        let d = doc(yaml);
897        let reg = d.build_anchor_registry();
898        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
899        let m = inner.merged(&reg);
900
901        let r = m.get("ref").unwrap();
902        let nested = r.as_mapping().unwrap();
903        assert_eq!(nested.get("k").unwrap().to_i64(), Some(42));
904    }
905
906    #[test]
907    fn merged_get_merged_nested() {
908        let yaml = "\
909defaults: &d
910  port: 80
911prod:
912  <<: *d
913  inner:
914    a: 1
915";
916        let d = doc(yaml);
917        let reg = d.build_anchor_registry();
918        let prod = d.as_mapping().unwrap().get_mapping("prod").unwrap();
919        let m = prod.merged(&reg);
920
921        let inner = m.get_merged("inner").unwrap();
922        assert_eq!(inner.get("a").unwrap().to_i64(), Some(1));
923    }
924
925    #[test]
926    fn document_merged_view() {
927        let yaml = "\
928defaults: &d
929  timeout: 30
930shared:
931  <<: *d
932  retries: 5
933";
934        let d = doc(yaml);
935        let view = d.merged().unwrap();
936
937        // Top-level keys visible
938        assert!(view.contains_key("defaults"));
939        assert!(view.contains_key("shared"));
940
941        // Drill into merged child via as_mapping()
942        let m = view.as_mapping();
943        let shared = m.get_merged("shared").unwrap();
944        assert_eq!(shared.get("timeout").unwrap().to_i64(), Some(30));
945        assert_eq!(shared.get("retries").unwrap().to_i64(), Some(5));
946    }
947
948    #[test]
949    fn merged_does_not_mutate_underlying_cst() {
950        let yaml = "\
951d: &d
952  a: 1
953m:
954  <<: *d
955  b: 2
956";
957        let d = doc(yaml);
958        let reg = d.build_anchor_registry();
959        let inner = d.as_mapping().unwrap().get_mapping("m").unwrap();
960        let _ = inner.merged(&reg).get("a");
961        // Original document text should round-trip unchanged.
962        assert_eq!(d.to_string(), yaml);
963    }
964}