Skip to main content

rlsp_yaml_parser/
node.rs

1// SPDX-License-Identifier: MIT
2
3//! YAML AST node types.
4//!
5//! [`Node<Loc>`] is the core type — a YAML value parameterized by its
6//! location type.  For most uses `Loc = Span`.  The loader produces
7//! `Vec<Document<Span>>`.
8
9use std::borrow::Cow;
10use std::sync::Arc;
11
12use crate::event::{CollectionStyle, ScalarStyle};
13use crate::pos::{LineIndex, Span};
14
15// ---------------------------------------------------------------------------
16// Public types
17// ---------------------------------------------------------------------------
18
19/// A YAML document: a root node plus directive metadata.
20#[derive(Debug, Clone)]
21pub struct Document<Loc = Span> {
22    /// The root node of the document.
23    pub root: Node<Loc>,
24    /// YAML version declared by a `%YAML` directive, if present (e.g. `(1, 2)`).
25    pub version: Option<(u8, u8)>,
26    /// Tag handle/prefix pairs declared by `%TAG` directives (handle, prefix).
27    pub tags: Vec<(String, String)>,
28    /// Comments that appear at document level (before or between nodes).
29    pub comments: Vec<String>,
30    /// Whether the document was introduced with an explicit `---` marker.
31    pub explicit_start: bool,
32    /// Whether the document was closed with an explicit `...` marker.
33    pub explicit_end: bool,
34    /// Line index for resolving byte offsets to `(line, column)` pairs.
35    /// Shared via `Arc` across all documents in a multi-doc stream so that
36    /// only one copy of the source string and newline table is kept in memory.
37    /// Only populated when `Loc = Span` (i.e., when constructed by the loader).
38    pub(crate) line_index: Option<Arc<LineIndex>>,
39}
40
41impl<Loc: PartialEq> PartialEq for Document<Loc> {
42    fn eq(&self, other: &Self) -> bool {
43        self.root == other.root
44            && self.version == other.version
45            && self.tags == other.tags
46            && self.comments == other.comments
47            && self.explicit_start == other.explicit_start
48            && self.explicit_end == other.explicit_end
49        // line_index is excluded: it is derived from source and does not
50        // represent document content.
51    }
52}
53
54impl<Loc> Document<Loc> {
55    /// Construct a document with the given root node and all metadata set to
56    /// their default values.
57    ///
58    /// This constructor is intended for unit tests that synthesise AST nodes
59    /// without going through the loader.  The `line_index` is set to `None`;
60    /// calling `line_index()` on the resulting document will panic.
61    pub const fn with_root(root: Node<Loc>) -> Self {
62        Self {
63            root,
64            version: None,
65            tags: Vec::new(),
66            comments: Vec::new(),
67            explicit_start: false,
68            explicit_end: false,
69            line_index: None,
70        }
71    }
72}
73
74impl Document<Span> {
75    /// Return the `LineIndex` for this document, used to resolve byte offsets
76    /// from `Span` values to `(line, column)` pairs.
77    ///
78    /// # Panics
79    ///
80    /// Panics if the document was not created by the loader (i.e., `line_index`
81    /// is `None`). All documents returned by `load()` or `Loader::load()` have a
82    /// `LineIndex` set.
83    #[must_use]
84    #[expect(
85        clippy::expect_used,
86        reason = "documents from load() always have line_index set; None only for manually constructed test documents"
87    )]
88    pub fn line_index(&self) -> &LineIndex {
89        self.line_index
90            .as_deref()
91            .expect("Document<Span> must have a LineIndex from the loader")
92    }
93}
94
95/// Rare per-node fields that are absent on most nodes in typical documents.
96///
97/// Bundled behind `Option<Box<NodeMeta>>` on `Node::Scalar`, `Node::Mapping`,
98/// and `Node::Sequence` so that the common case (no anchor, no user-authored
99/// tag location, no comments) pays only one 8-byte pointer instead of ~200
100/// bytes of inline storage.  When `meta` is `None` all five fields read as
101/// their zero/empty defaults via the `Node` accessor methods.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct NodeMeta<Loc = Span> {
104    /// Anchor name defined on this node (e.g. `&anchor`), if any.
105    pub anchor: Option<String>,
106    /// Source span of the `&name` anchor token — from `&` through the last byte of the
107    /// name.  `Some` when `anchor` is `Some`; `None` otherwise.
108    pub anchor_loc: Option<Loc>,
109    /// Source span of the tag token — from `!` through the last byte of the tag.
110    /// `Some` when a user-authored tag is present; `None` for resolver-injected tags.
111    pub tag_loc: Option<Loc>,
112    /// Comment lines that appear before this node (e.g. `# note`).
113    /// Populated only for non-first entries in a mapping or sequence.
114    /// Document-prefix leading comments are discarded by the tokenizer
115    /// per YAML §9.2 and cannot be recovered here.
116    pub leading_comments: Option<Vec<String>>,
117    /// Inline comment on the same line as this node (e.g. `# note`).
118    pub trailing_comment: Option<String>,
119}
120
121impl<Loc> NodeMeta<Loc> {
122    /// Return `true` if all fields are `None` / empty — used to decide whether
123    /// to store `None` or `Some(Box::new(self))`.
124    #[inline]
125    pub(crate) const fn is_all_none(&self) -> bool {
126        self.anchor.is_none()
127            && self.anchor_loc.is_none()
128            && self.tag_loc.is_none()
129            && self.leading_comments.is_none()
130            && self.trailing_comment.is_none()
131    }
132
133    /// Wrap into `Option<Box<NodeMeta>>`, returning `None` when all fields are absent.
134    #[inline]
135    pub fn into_option(self) -> Option<Box<Self>> {
136        if self.is_all_none() {
137            None
138        } else {
139            Some(Box::new(self))
140        }
141    }
142}
143
144/// A YAML node parameterized by its location type.
145#[derive(Debug, Clone, PartialEq)]
146pub enum Node<Loc = Span> {
147    /// A scalar value.
148    Scalar {
149        /// The scalar content as a UTF-8 string (after block/flow unfolding).
150        value: String,
151        /// The presentation style used in the source (plain, single-quoted, etc.).
152        style: ScalarStyle,
153        /// Tag applied to this node (e.g. `!!str`), if any.
154        tag: Option<Cow<'static, str>>,
155        /// Source span covering this scalar in the input.
156        loc: Loc,
157        /// Rare fields: `anchor`, `anchor_loc`, `tag_loc`, `leading_comments`, `trailing_comment`.
158        /// `None` for the common case where none of these are set.
159        meta: Option<Box<NodeMeta<Loc>>>,
160    },
161    /// A mapping (sequence of key–value pairs preserving declaration order).
162    Mapping {
163        /// Key–value pairs in declaration order.
164        entries: Vec<(Self, Self)>,
165        /// The presentation style used in the source (block or flow).
166        style: CollectionStyle,
167        /// Tag applied to this mapping (e.g. `!!map`), if any.
168        tag: Option<Cow<'static, str>>,
169        /// Source span from the opening indicator to the last entry.
170        loc: Loc,
171        /// Rare fields: `anchor`, `anchor_loc`, `tag_loc`, `leading_comments`, `trailing_comment`.
172        /// `None` for the common case where none of these are set.
173        meta: Option<Box<NodeMeta<Loc>>>,
174    },
175    /// A sequence (ordered list of nodes).
176    Sequence {
177        /// Ordered list of child nodes.
178        items: Vec<Self>,
179        /// The presentation style used in the source (block or flow).
180        style: CollectionStyle,
181        /// Tag applied to this sequence (e.g. `!!seq`), if any.
182        tag: Option<Cow<'static, str>>,
183        /// Source span from the opening indicator to the last item.
184        loc: Loc,
185        /// Rare fields: `anchor`, `anchor_loc`, `tag_loc`, `leading_comments`, `trailing_comment`.
186        /// `None` for the common case where none of these are set.
187        meta: Option<Box<NodeMeta<Loc>>>,
188    },
189    /// An alias reference (lossless mode only — resolved mode expands these).
190    Alias {
191        /// The anchor name this alias refers to (without the `*` sigil).
192        name: String,
193        /// Source span covering the `*name` alias token.
194        loc: Loc,
195        /// Comment lines that appear before this node.
196        leading_comments: Option<Vec<String>>,
197        /// Inline comment on the same line as this node.
198        trailing_comment: Option<String>,
199    },
200}
201
202impl<Loc> Node<Loc> {
203    /// Returns the anchor name if this node defines one.
204    #[inline]
205    pub fn anchor(&self) -> Option<&str> {
206        match self {
207            Self::Scalar { meta, .. }
208            | Self::Mapping { meta, .. }
209            | Self::Sequence { meta, .. } => meta.as_ref().and_then(|m| m.anchor.as_deref()),
210            Self::Alias { .. } => None,
211        }
212    }
213
214    /// Returns the source span of the `&name` anchor token, if any.
215    ///
216    /// `Some(span)` when `anchor()` is `Some`; `None` otherwise.
217    /// Always `None` for [`Node::Alias`] — the alias span is in `loc`.
218    #[inline]
219    pub fn anchor_loc(&self) -> Option<Loc>
220    where
221        Loc: Copy,
222    {
223        match self {
224            Self::Scalar { meta, .. }
225            | Self::Mapping { meta, .. }
226            | Self::Sequence { meta, .. } => meta.as_ref().and_then(|m| m.anchor_loc),
227            Self::Alias { .. } => None,
228        }
229    }
230
231    /// Returns the source span of the tag token, if any.
232    ///
233    /// `Some(span)` when a user-authored tag is present; `None` for resolver-injected tags.
234    /// Always `None` for [`Node::Alias`].
235    #[inline]
236    pub fn tag_loc(&self) -> Option<Loc>
237    where
238        Loc: Copy,
239    {
240        match self {
241            Self::Scalar { meta, .. }
242            | Self::Mapping { meta, .. }
243            | Self::Sequence { meta, .. } => meta.as_ref().and_then(|m| m.tag_loc),
244            Self::Alias { .. } => None,
245        }
246    }
247
248    /// Returns the leading comments for this node.
249    #[inline]
250    pub fn leading_comments(&self) -> &[String] {
251        match self {
252            Self::Scalar { meta, .. }
253            | Self::Mapping { meta, .. }
254            | Self::Sequence { meta, .. } => meta
255                .as_ref()
256                .and_then(|m| m.leading_comments.as_deref())
257                .unwrap_or(&[]),
258            Self::Alias {
259                leading_comments, ..
260            } => leading_comments.as_deref().unwrap_or(&[]),
261        }
262    }
263
264    /// Returns the trailing comment for this node, if any.
265    #[inline]
266    pub fn trailing_comment(&self) -> Option<&str> {
267        match self {
268            Self::Scalar { meta, .. }
269            | Self::Mapping { meta, .. }
270            | Self::Sequence { meta, .. } => {
271                meta.as_ref().and_then(|m| m.trailing_comment.as_deref())
272            }
273            Self::Alias {
274                trailing_comment, ..
275            } => trailing_comment.as_deref(),
276        }
277    }
278
279    /// Clear the anchor and `anchor_loc` on this node (used by code actions that
280    /// remove unused anchors).  No-op on `Node::Alias`.
281    pub fn clear_anchor(&mut self) {
282        match self {
283            Self::Scalar { meta, .. }
284            | Self::Mapping { meta, .. }
285            | Self::Sequence { meta, .. } => {
286                if let Some(m) = meta.as_mut() {
287                    m.anchor = None;
288                    m.anchor_loc = None;
289                    // Collapse box to None if no other fields remain set.
290                    if m.is_all_none() {
291                        *meta = None;
292                    }
293                }
294            }
295            Self::Alias { .. } => {}
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use std::borrow::Cow;
303
304    use super::*;
305    use crate::event::{CollectionStyle, ScalarStyle};
306    use crate::pos::Span;
307
308    fn zero_span() -> Span {
309        Span { start: 0, end: 0 }
310    }
311
312    fn plain_scalar(value: &str) -> Node<Span> {
313        Node::Scalar {
314            value: value.to_owned(),
315            style: ScalarStyle::Plain,
316            tag: None,
317            loc: zero_span(),
318            meta: None,
319        }
320    }
321
322    // -----------------------------------------------------------------------
323    // META-*: NodeMeta None/Some gating
324    // -----------------------------------------------------------------------
325
326    // META-1: scalar_all_none_meta_fields_produces_meta_none
327    #[test]
328    fn scalar_all_none_meta_fields_produces_meta_none() {
329        let node = Node::Scalar {
330            value: "v".to_owned(),
331            style: ScalarStyle::Plain,
332            tag: None,
333            loc: zero_span(),
334            meta: NodeMeta {
335                anchor: None,
336                anchor_loc: None,
337                tag_loc: None,
338                leading_comments: None,
339                trailing_comment: None,
340            }
341            .into_option(),
342        };
343        assert!(
344            matches!(node, Node::Scalar { meta: None, .. }),
345            "all-None meta fields must produce meta: None"
346        );
347    }
348
349    // META-2: scalar_with_anchor_only_produces_meta_some
350    #[test]
351    fn scalar_with_anchor_only_produces_meta_some() {
352        let node = Node::Scalar {
353            value: "v".to_owned(),
354            style: ScalarStyle::Plain,
355            tag: None,
356            loc: zero_span(),
357            meta: NodeMeta {
358                anchor: Some("a".to_owned()),
359                anchor_loc: None,
360                tag_loc: None,
361                leading_comments: None,
362                trailing_comment: None,
363            }
364            .into_option(),
365        };
366        assert!(
367            matches!(node, Node::Scalar { meta: Some(_), .. }),
368            "anchor-only meta must produce meta: Some"
369        );
370    }
371
372    // META-3: scalar_with_leading_comment_only_produces_meta_some
373    #[test]
374    fn scalar_with_leading_comment_only_produces_meta_some() {
375        let node = Node::Scalar {
376            value: "v".to_owned(),
377            style: ScalarStyle::Plain,
378            tag: None,
379            loc: zero_span(),
380            meta: NodeMeta {
381                anchor: None,
382                anchor_loc: None,
383                tag_loc: None,
384                leading_comments: Some(vec!["# x".to_owned()]),
385                trailing_comment: None,
386            }
387            .into_option(),
388        };
389        assert!(
390            matches!(node, Node::Scalar { meta: Some(_), .. }),
391            "leading-comment-only meta must produce meta: Some"
392        );
393    }
394
395    // META-4: scalar_with_trailing_comment_only_produces_meta_some
396    #[test]
397    fn scalar_with_trailing_comment_only_produces_meta_some() {
398        let node = Node::Scalar {
399            value: "v".to_owned(),
400            style: ScalarStyle::Plain,
401            tag: None,
402            loc: zero_span(),
403            meta: NodeMeta {
404                anchor: None,
405                anchor_loc: None,
406                tag_loc: None,
407                leading_comments: None,
408                trailing_comment: Some("# y".to_owned()),
409            }
410            .into_option(),
411        };
412        assert!(
413            matches!(node, Node::Scalar { meta: Some(_), .. }),
414            "trailing-comment-only meta must produce meta: Some"
415        );
416    }
417
418    // META-5: scalar_with_tag_loc_only_produces_meta_some
419    #[test]
420    fn scalar_with_tag_loc_only_produces_meta_some() {
421        let node = Node::Scalar {
422            value: "v".to_owned(),
423            style: ScalarStyle::Plain,
424            tag: None,
425            loc: zero_span(),
426            meta: NodeMeta {
427                anchor: None,
428                anchor_loc: None,
429                tag_loc: Some(zero_span()),
430                leading_comments: None,
431                trailing_comment: None,
432            }
433            .into_option(),
434        };
435        assert!(
436            matches!(node, Node::Scalar { meta: Some(_), .. }),
437            "tag-loc-only meta must produce meta: Some"
438        );
439    }
440
441    // META-6: mapping_all_none_meta_fields_produces_meta_none
442    #[test]
443    fn mapping_all_none_meta_fields_produces_meta_none() {
444        let node = Node::Mapping {
445            entries: vec![],
446            style: CollectionStyle::Block,
447            tag: None,
448            loc: zero_span(),
449            meta: NodeMeta {
450                anchor: None,
451                anchor_loc: None,
452                tag_loc: None,
453                leading_comments: None,
454                trailing_comment: None,
455            }
456            .into_option(),
457        };
458        assert!(
459            matches!(node, Node::Mapping { meta: None, .. }),
460            "all-None mapping meta must produce meta: None"
461        );
462    }
463
464    // META-7: sequence_all_none_meta_fields_produces_meta_none
465    #[test]
466    fn sequence_all_none_meta_fields_produces_meta_none() {
467        let node = Node::Sequence {
468            items: vec![],
469            style: CollectionStyle::Block,
470            tag: None,
471            loc: zero_span(),
472            meta: NodeMeta {
473                anchor: None,
474                anchor_loc: None,
475                tag_loc: None,
476                leading_comments: None,
477                trailing_comment: None,
478            }
479            .into_option(),
480        };
481        assert!(
482            matches!(node, Node::Sequence { meta: None, .. }),
483            "all-None sequence meta must produce meta: None"
484        );
485    }
486
487    // -----------------------------------------------------------------------
488    // ACC-*: accessor behavior
489    // -----------------------------------------------------------------------
490
491    // ACC-1: accessor_anchor_returns_none_when_meta_is_none
492    #[test]
493    fn accessor_anchor_returns_none_when_meta_is_none() {
494        let node = plain_scalar("v");
495        assert_eq!(node.anchor(), None);
496    }
497
498    // ACC-2: accessor_anchor_returns_some_when_meta_is_some
499    #[test]
500    fn accessor_anchor_returns_some_when_meta_is_some() {
501        let node = Node::Scalar {
502            value: "v".to_owned(),
503            style: ScalarStyle::Plain,
504            tag: None,
505            loc: zero_span(),
506            meta: NodeMeta {
507                anchor: Some("a".to_owned()),
508                anchor_loc: None,
509                tag_loc: None,
510                leading_comments: None,
511                trailing_comment: None,
512            }
513            .into_option(),
514        };
515        assert_eq!(node.anchor(), Some("a"));
516    }
517
518    // ACC-3: accessor_anchor_loc_returns_none_when_meta_is_none
519    #[test]
520    fn accessor_anchor_loc_returns_none_when_meta_is_none() {
521        let node = plain_scalar("v");
522        assert_eq!(node.anchor_loc(), None);
523    }
524
525    // ACC-4: accessor_anchor_loc_returns_some_when_set
526    #[test]
527    fn accessor_anchor_loc_returns_some_when_set() {
528        let span = zero_span();
529        let node = Node::Scalar {
530            value: "v".to_owned(),
531            style: ScalarStyle::Plain,
532            tag: None,
533            loc: zero_span(),
534            meta: NodeMeta {
535                anchor: Some("a".to_owned()),
536                anchor_loc: Some(span),
537                tag_loc: None,
538                leading_comments: None,
539                trailing_comment: None,
540            }
541            .into_option(),
542        };
543        assert_eq!(node.anchor_loc(), Some(span));
544    }
545
546    // ACC-5: accessor_tag_loc_returns_none_when_meta_is_none
547    #[test]
548    fn accessor_tag_loc_returns_none_when_meta_is_none() {
549        let node = plain_scalar("v");
550        assert_eq!(node.tag_loc(), None);
551    }
552
553    // ACC-6: accessor_tag_loc_returns_some_when_set
554    #[test]
555    fn accessor_tag_loc_returns_some_when_set() {
556        let span = zero_span();
557        let node = Node::Scalar {
558            value: "v".to_owned(),
559            style: ScalarStyle::Plain,
560            tag: None,
561            loc: zero_span(),
562            meta: NodeMeta {
563                anchor: None,
564                anchor_loc: None,
565                tag_loc: Some(span),
566                leading_comments: None,
567                trailing_comment: None,
568            }
569            .into_option(),
570        };
571        assert_eq!(node.tag_loc(), Some(span));
572    }
573
574    // ACC-7: accessor_leading_comments_returns_empty_slice_when_meta_is_none
575    #[test]
576    fn accessor_leading_comments_returns_empty_slice_when_meta_is_none() {
577        let node = plain_scalar("v");
578        assert_eq!(node.leading_comments(), &[] as &[String]);
579    }
580
581    // ACC-8: accessor_leading_comments_returns_slice_when_set
582    #[test]
583    fn accessor_leading_comments_returns_slice_when_set() {
584        let node = Node::Scalar {
585            value: "v".to_owned(),
586            style: ScalarStyle::Plain,
587            tag: None,
588            loc: zero_span(),
589            meta: NodeMeta {
590                anchor: None,
591                anchor_loc: None,
592                tag_loc: None,
593                leading_comments: Some(vec!["# x".to_owned()]),
594                trailing_comment: None,
595            }
596            .into_option(),
597        };
598        assert_eq!(node.leading_comments(), &["# x"]);
599    }
600
601    // ACC-9: accessor_trailing_comment_returns_none_when_meta_is_none
602    #[test]
603    fn accessor_trailing_comment_returns_none_when_meta_is_none() {
604        let node = plain_scalar("v");
605        assert_eq!(node.trailing_comment(), None);
606    }
607
608    // ACC-10: accessor_trailing_comment_returns_some_when_set
609    #[test]
610    fn accessor_trailing_comment_returns_some_when_set() {
611        let node = Node::Scalar {
612            value: "v".to_owned(),
613            style: ScalarStyle::Plain,
614            tag: None,
615            loc: zero_span(),
616            meta: NodeMeta {
617                anchor: None,
618                anchor_loc: None,
619                tag_loc: None,
620                leading_comments: None,
621                trailing_comment: Some("# y".to_owned()),
622            }
623            .into_option(),
624        };
625        assert_eq!(node.trailing_comment(), Some("# y"));
626    }
627
628    // ACC-ALIAS-1: alias_anchor_returns_none
629    #[test]
630    fn alias_anchor_returns_none() {
631        let node = Node::Alias {
632            name: "x".to_owned(),
633            loc: zero_span(),
634            leading_comments: None,
635            trailing_comment: None,
636        };
637        assert_eq!(node.anchor(), None);
638        assert_eq!(node.anchor_loc(), None);
639        assert_eq!(node.tag_loc(), None);
640    }
641
642    // SIZE-1: node_span_size_fits_target
643    const _: () = assert!(
644        std::mem::size_of::<Node<Span>>() <= 120,
645        "Node<Span> must be <= 120 bytes"
646    );
647    #[test]
648    fn node_span_size_fits_target() {
649        let size = std::mem::size_of::<Node<Span>>();
650        assert!(
651            size <= 120,
652            "Node<Span> size {size} exceeds 120-byte target"
653        );
654    }
655
656    // CROSS-1: clear_anchor_sets_anchor_and_anchor_loc_to_none
657    #[test]
658    fn clear_anchor_sets_anchor_and_anchor_loc_to_none() {
659        let mut node = Node::Scalar {
660            value: "v".to_owned(),
661            style: ScalarStyle::Plain,
662            tag: None,
663            loc: zero_span(),
664            meta: NodeMeta {
665                anchor: Some("a".to_owned()),
666                anchor_loc: Some(zero_span()),
667                tag_loc: None,
668                leading_comments: None,
669                trailing_comment: None,
670            }
671            .into_option(),
672        };
673        node.clear_anchor();
674        assert_eq!(
675            node.anchor(),
676            None,
677            "anchor must be None after clear_anchor"
678        );
679        assert_eq!(
680            node.anchor_loc(),
681            None,
682            "anchor_loc must be None after clear_anchor"
683        );
684        // Only anchor was set, so meta should collapse to None.
685        assert!(
686            matches!(node, Node::Scalar { meta: None, .. }),
687            "meta must collapse to None when all fields become None"
688        );
689    }
690
691    // NF-1: node_debug_includes_leading_comments
692    #[test]
693    fn node_debug_includes_leading_comments() {
694        let node = Node::Scalar {
695            value: "val".to_owned(),
696            style: ScalarStyle::Plain,
697            tag: None,
698            loc: zero_span(),
699            meta: NodeMeta {
700                anchor: None,
701                anchor_loc: None,
702                tag_loc: None,
703                leading_comments: Some(vec!["# note".to_owned()]),
704                trailing_comment: None,
705            }
706            .into_option(),
707        };
708        let debug = format!("{node:?}");
709        assert!(debug.contains("# note"), "debug output: {debug}");
710    }
711
712    // NF-2: node_partial_eq_considers_leading_comments
713    #[test]
714    fn node_partial_eq_considers_leading_comments() {
715        let a = Node::Scalar {
716            value: "val".to_owned(),
717            style: ScalarStyle::Plain,
718            tag: None,
719            loc: zero_span(),
720            meta: NodeMeta {
721                anchor: None,
722                anchor_loc: None,
723                tag_loc: None,
724                leading_comments: Some(vec!["# a".to_owned()]),
725                trailing_comment: None,
726            }
727            .into_option(),
728        };
729        let b = Node::Scalar {
730            value: "val".to_owned(),
731            style: ScalarStyle::Plain,
732            tag: None,
733            loc: zero_span(),
734            meta: NodeMeta {
735                anchor: None,
736                anchor_loc: None,
737                tag_loc: None,
738                leading_comments: Some(vec!["# b".to_owned()]),
739                trailing_comment: None,
740            }
741            .into_option(),
742        };
743        assert_ne!(a, b);
744    }
745
746    // NF-3: node_clone_preserves_comments
747    #[test]
748    fn node_clone_preserves_comments() {
749        let node = Node::Scalar {
750            value: "val".to_owned(),
751            style: ScalarStyle::Plain,
752            tag: None,
753            loc: zero_span(),
754            meta: NodeMeta {
755                anchor: None,
756                anchor_loc: None,
757                tag_loc: None,
758                leading_comments: Some(vec!["# x".to_owned()]),
759                trailing_comment: Some("# y".to_owned()),
760            }
761            .into_option(),
762        };
763        let cloned = node.clone();
764        assert_eq!(node, cloned);
765        assert_eq!(cloned.leading_comments(), &["# x"]);
766        assert_eq!(cloned.trailing_comment(), Some("# y"));
767    }
768
769    // Sanity: plain_scalar helper produces empty comment fields.
770    #[test]
771    fn plain_scalar_has_empty_comments() {
772        let n = plain_scalar("hello");
773        assert!(n.leading_comments().is_empty());
774        assert!(n.trailing_comment().is_none());
775    }
776
777    fn bare_document(explicit_start: bool, explicit_end: bool) -> Document<Span> {
778        Document {
779            root: plain_scalar("val"),
780            version: None,
781            tags: Vec::new(),
782            comments: Vec::new(),
783            explicit_start,
784            explicit_end,
785            line_index: None,
786        }
787    }
788
789    // NF-DOC-1: explicit_start and explicit_end default to false
790    #[test]
791    fn document_explicit_flags_in_equality() {
792        let a = bare_document(false, false);
793        let b = bare_document(false, false);
794        assert_eq!(a, b);
795    }
796
797    // NF-DOC-2: PartialEq distinguishes differing explicit_start
798    #[test]
799    fn document_partial_eq_distinguishes_explicit_start() {
800        let a = bare_document(true, false);
801        let b = bare_document(false, false);
802        assert_ne!(a, b);
803    }
804
805    // NF-DOC-3: PartialEq distinguishes differing explicit_end
806    #[test]
807    fn document_partial_eq_distinguishes_explicit_end() {
808        let a = bare_document(false, true);
809        let b = bare_document(false, false);
810        assert_ne!(a, b);
811    }
812
813    // NF-DOC-4: Clone preserves both flags
814    #[test]
815    fn document_clone_preserves_explicit_flags() {
816        let doc = bare_document(true, true);
817        let cloned = doc.clone();
818        assert_eq!(doc, cloned);
819        assert!(cloned.explicit_start);
820        assert!(cloned.explicit_end);
821    }
822
823    // -----------------------------------------------------------------------
824    // AL-NODE: anchor_loc() accessor
825    // -----------------------------------------------------------------------
826
827    // AL-NODE-1: anchor_loc_accessor_returns_some_for_anchored_scalar
828    #[test]
829    fn anchor_loc_accessor_returns_some_for_anchored_scalar() {
830        let span = zero_span();
831        let node = Node::Scalar {
832            value: "v".to_owned(),
833            style: ScalarStyle::Plain,
834            tag: None,
835            loc: zero_span(),
836            meta: NodeMeta {
837                anchor: Some("a".to_owned()),
838                anchor_loc: Some(span),
839                tag_loc: None,
840                leading_comments: None,
841                trailing_comment: None,
842            }
843            .into_option(),
844        };
845        assert_eq!(node.anchor_loc(), Some(span));
846    }
847
848    // AL-NODE-2: anchor_loc_accessor_returns_none_for_unanchored_scalar
849    #[test]
850    fn anchor_loc_accessor_returns_none_for_unanchored_scalar() {
851        let node = plain_scalar("v");
852        assert_eq!(node.anchor_loc(), None);
853    }
854
855    // AL-NODE-3: anchor_loc_accessor_returns_none_for_alias
856    #[test]
857    fn anchor_loc_accessor_returns_none_for_alias() {
858        let node = Node::Alias {
859            name: "x".to_owned(),
860            loc: zero_span(),
861            leading_comments: None,
862            trailing_comment: None,
863        };
864        assert_eq!(node.anchor_loc(), None);
865    }
866
867    // AL-NODE-4: anchor_loc_accessor_returns_some_for_anchored_mapping
868    #[test]
869    fn anchor_loc_accessor_returns_some_for_anchored_mapping() {
870        let span = zero_span();
871        let node = Node::Mapping {
872            entries: vec![],
873            style: CollectionStyle::Block,
874            tag: None,
875            loc: zero_span(),
876            meta: NodeMeta {
877                anchor: Some("m".to_owned()),
878                anchor_loc: Some(span),
879                tag_loc: None,
880                leading_comments: None,
881                trailing_comment: None,
882            }
883            .into_option(),
884        };
885        assert_eq!(node.anchor_loc(), Some(span));
886    }
887
888    // AL-NODE-5: anchor_loc_accessor_returns_some_for_anchored_sequence
889    #[test]
890    fn anchor_loc_accessor_returns_some_for_anchored_sequence() {
891        let span = zero_span();
892        let node = Node::Sequence {
893            items: vec![],
894            style: CollectionStyle::Block,
895            tag: None,
896            loc: zero_span(),
897            meta: NodeMeta {
898                anchor: Some("s".to_owned()),
899                anchor_loc: Some(span),
900                tag_loc: None,
901                leading_comments: None,
902                trailing_comment: None,
903            }
904            .into_option(),
905        };
906        assert_eq!(node.anchor_loc(), Some(span));
907    }
908
909    // -----------------------------------------------------------------------
910    // TL-NODE: tag_loc() accessor
911    // -----------------------------------------------------------------------
912
913    // TL-NODE-1: tag_loc_accessor_returns_some_for_tagged_scalar
914    #[test]
915    fn tag_loc_accessor_returns_some_for_tagged_scalar() {
916        let span = zero_span();
917        let node = Node::Scalar {
918            value: "v".to_owned(),
919            style: ScalarStyle::Plain,
920            tag: Some(Cow::Owned("!t".to_owned())),
921            loc: zero_span(),
922            meta: NodeMeta {
923                anchor: None,
924                anchor_loc: None,
925                tag_loc: Some(span),
926                leading_comments: None,
927                trailing_comment: None,
928            }
929            .into_option(),
930        };
931        assert_eq!(node.tag_loc(), Some(span));
932    }
933
934    // TL-NODE-2: tag_loc_accessor_returns_none_for_untagged_scalar
935    #[test]
936    fn tag_loc_accessor_returns_none_for_untagged_scalar() {
937        let node = plain_scalar("v");
938        assert_eq!(node.tag_loc(), None);
939    }
940
941    // TL-NODE-3: tag_loc_accessor_returns_none_for_alias
942    #[test]
943    fn tag_loc_accessor_returns_none_for_alias() {
944        let node = Node::Alias {
945            name: "x".to_owned(),
946            loc: zero_span(),
947            leading_comments: None,
948            trailing_comment: None,
949        };
950        assert_eq!(node.tag_loc(), None);
951    }
952
953    // TL-NODE-4: tag_loc_accessor_returns_some_for_tagged_mapping
954    #[test]
955    fn tag_loc_accessor_returns_some_for_tagged_mapping() {
956        let span = zero_span();
957        let node = Node::Mapping {
958            entries: vec![],
959            style: CollectionStyle::Block,
960            tag: Some(Cow::Owned("!!map".to_owned())),
961            loc: zero_span(),
962            meta: NodeMeta {
963                anchor: None,
964                anchor_loc: None,
965                tag_loc: Some(span),
966                leading_comments: None,
967                trailing_comment: None,
968            }
969            .into_option(),
970        };
971        assert_eq!(node.tag_loc(), Some(span));
972    }
973
974    // TL-NODE-5: tag_loc_accessor_returns_some_for_tagged_sequence
975    #[test]
976    fn tag_loc_accessor_returns_some_for_tagged_sequence() {
977        let span = zero_span();
978        let node = Node::Sequence {
979            items: vec![],
980            style: CollectionStyle::Block,
981            tag: Some(Cow::Owned("!!seq".to_owned())),
982            loc: zero_span(),
983            meta: NodeMeta {
984                anchor: None,
985                anchor_loc: None,
986                tag_loc: Some(span),
987                leading_comments: None,
988                trailing_comment: None,
989            }
990            .into_option(),
991        };
992        assert_eq!(node.tag_loc(), Some(span));
993    }
994
995    // -----------------------------------------------------------------------
996    // COW-NODE: Cow variant construction, equality, and clone
997    // -----------------------------------------------------------------------
998
999    // COW-NODE-1: node construction with Cow::Borrowed tag compiles and round-trips
1000    #[test]
1001    fn node_construction_with_borrowed_tag() {
1002        let node = Node::Scalar {
1003            value: "v".to_owned(),
1004            style: ScalarStyle::Plain,
1005            tag: Some(Cow::Borrowed("tag:yaml.org,2002:str")),
1006            loc: zero_span(),
1007            meta: None,
1008        };
1009        assert_eq!(node.tag_loc(), None);
1010        if let Node::Scalar { tag, .. } = &node {
1011            assert!(matches!(tag, Some(Cow::Borrowed(_))));
1012        }
1013    }
1014
1015    // COW-NODE-2: node construction with Cow::Owned tag compiles and round-trips
1016    #[test]
1017    fn node_construction_with_owned_tag() {
1018        let node = Node::Scalar {
1019            value: "v".to_owned(),
1020            style: ScalarStyle::Plain,
1021            tag: Some(Cow::Owned("!custom".to_owned())),
1022            loc: zero_span(),
1023            meta: None,
1024        };
1025        assert_eq!(node.tag_loc(), None);
1026        if let Node::Scalar { tag, .. } = &node {
1027            assert_eq!(tag.as_deref(), Some("!custom"));
1028        }
1029    }
1030
1031    // COW-NODE-3: Borrowed and Owned with the same content compare equal
1032    #[test]
1033    fn node_partial_eq_borrowed_vs_owned_same_content() {
1034        let borrowed = Node::Scalar {
1035            value: "v".to_owned(),
1036            style: ScalarStyle::Plain,
1037            tag: Some(Cow::Borrowed("tag:yaml.org,2002:str")),
1038            loc: zero_span(),
1039            meta: None,
1040        };
1041        let owned = Node::Scalar {
1042            value: "v".to_owned(),
1043            style: ScalarStyle::Plain,
1044            tag: Some(Cow::Owned("tag:yaml.org,2002:str".to_owned())),
1045            loc: zero_span(),
1046            meta: None,
1047        };
1048        assert_eq!(borrowed, owned);
1049    }
1050
1051    // COW-NODE-4: clone of Cow::Borrowed tag stays Borrowed
1052    #[test]
1053    fn node_clone_preserves_cow_variant() {
1054        let node = Node::Scalar {
1055            value: "v".to_owned(),
1056            style: ScalarStyle::Plain,
1057            tag: Some(Cow::Borrowed("tag:yaml.org,2002:str")),
1058            loc: zero_span(),
1059            meta: None,
1060        };
1061        let cloned_tag = if let Node::Scalar { tag, .. } = &node {
1062            tag.clone()
1063        } else {
1064            unreachable!()
1065        };
1066        assert!(
1067            matches!(cloned_tag, Some(Cow::Borrowed(_))),
1068            "cloned Borrowed tag must remain Borrowed"
1069        );
1070    }
1071}