Skip to main content

panache_parser/syntax/
yaml_ast.rs

1//! Typed AST wrappers over the in-tree YAML CST.
2//!
3//! These wrappers give value-extraction consumers (metadata, bibliography,
4//! includes, hashpipe) a typed traversal API over the rowan CST produced by
5//! [`crate::parser::yaml::parse_yaml_tree`], replacing the external
6//! `yaml_parser` crate's `ast` module. They follow the house pattern
7//! ([`super::headings`], [`super::references`]): newtype wrappers with
8//! hand-written [`AstNode`] impls and `rowan::ast::support`-based accessors.
9//!
10//! Two CST facts shape the API:
11//!
12//! - YAML parses produce one or more `YAML_DOCUMENT` children under a
13//!   *stream-equivalent* container. Standalone parses
14//!   ([`crate::parser::yaml::parse_yaml_tree`]) root the tree at
15//!   `YAML_STREAM`; embedded parses nest the documents directly under
16//!   the host wrapper (`YAML_METADATA_CONTENT` for frontmatter,
17//!   `HASHPIPE_YAML_CONTENT` for hashpipe), which plays the stream
18//!   role for its singleton-stream embedding. [`parse_yaml_document`]
19//!   centralizes the descent so no consumer re-implements it.
20//! - `YAML_BLOCK_MAP_KEY` includes the trailing `:` (`YAML_COLON`) token, so
21//!   [`YamlBlockMapKey::scalar`] reads the `YAML_SCALAR` node child rather
22//!   than the whole-key text.
23//!
24//! Scalar style is not a CST kind — every style is a `YAML_SCALAR` node
25//! (wrapping `YAML_SCALAR_TEXT` content) — so [`YamlScalar`] detects the
26//! style from the leading byte and cooks via the shared
27//! [`crate::parser::yaml::cook`] primitives.
28
29use rowan::{TextRange, TextSize};
30
31use super::ast::{AstChildren, AstNode, support};
32use super::{PanacheLanguage, SyntaxKind, SyntaxNode, SyntaxToken};
33use crate::parser::yaml::{ScalarStyle, cook, parse_yaml_tree};
34
35/// Parse `input` and return the first YAML document. Descends through any
36/// stream-equivalent container ([`is_stream_equivalent`]) so it works against
37/// standalone parses (rooted at `YAML_STREAM`) and host embeddings alike.
38/// Returns `None` when the input fails the structural validator (no tree)
39/// or has no document.
40pub fn parse_yaml_document(input: &str) -> Option<YamlDocument> {
41    first_document(&parse_yaml_tree(input)?)
42}
43
44/// Parse `input` and return every YAML document in the stream. Most consumers
45/// only need the first ([`parse_yaml_document`]); this exists for multi-document
46/// completeness (`a: 1\n---\nb: 2`).
47pub fn parse_yaml_documents(input: &str) -> Vec<YamlDocument> {
48    let Some(tree) = parse_yaml_tree(input) else {
49        return Vec::new();
50    };
51    stream_container(&tree)
52        .map(|stream| stream.children().filter_map(YamlDocument::cast).collect())
53        .unwrap_or_default()
54}
55
56/// True for any node that plays the YAML stream container role: the spec
57/// `YAML_STREAM` for standalone parses, and the host embedding wrappers
58/// (`YAML_METADATA_CONTENT`, `HASHPIPE_YAML_CONTENT`) for the singleton-stream
59/// embeddings the host parser produces. Each wraps zero or more
60/// `YAML_DOCUMENT` children.
61pub(crate) fn is_stream_equivalent(kind: SyntaxKind) -> bool {
62    matches!(
63        kind,
64        SyntaxKind::YAML_STREAM
65            | SyntaxKind::YAML_METADATA_CONTENT
66            | SyntaxKind::HASHPIPE_YAML_CONTENT
67    )
68}
69
70fn stream_container(tree: &SyntaxNode) -> Option<SyntaxNode> {
71    tree.descendants().find(|n| is_stream_equivalent(n.kind()))
72}
73
74fn first_document(tree: &SyntaxNode) -> Option<YamlDocument> {
75    stream_container(tree)?
76        .children()
77        .find_map(YamlDocument::cast)
78}
79
80/// The five concrete node shapes a value, sequence item, or document body can
81/// take. `None` (i.e. an absent `YamlNode`) models an empty YAML value.
82#[derive(Debug, Clone)]
83pub enum YamlNode {
84    BlockMap(YamlBlockMap),
85    BlockSequence(YamlBlockSequence),
86    FlowMap(YamlFlowMap),
87    FlowSequence(YamlFlowSequence),
88    Scalar(YamlScalar),
89}
90
91/// Resolve the single content node held by a value / item / document wrapper.
92/// Container children take precedence; a bare scalar value resolves to the
93/// first `YAML_SCALAR` node (anchors/tags/aliases are skipped). Returns `None`
94/// for an empty value.
95fn node_child(parent: &SyntaxNode) -> Option<YamlNode> {
96    for child in parent.children() {
97        match child.kind() {
98            SyntaxKind::YAML_BLOCK_MAP => return YamlBlockMap::cast(child).map(YamlNode::BlockMap),
99            SyntaxKind::YAML_BLOCK_SEQUENCE => {
100                return YamlBlockSequence::cast(child).map(YamlNode::BlockSequence);
101            }
102            SyntaxKind::YAML_FLOW_MAP => return YamlFlowMap::cast(child).map(YamlNode::FlowMap),
103            SyntaxKind::YAML_FLOW_SEQUENCE => {
104                return YamlFlowSequence::cast(child).map(YamlNode::FlowSequence);
105            }
106            _ => {}
107        }
108    }
109    scalar_token(parent).map(YamlNode::Scalar)
110}
111
112/// First direct `YAML_SCALAR` node child of `parent`, wrapped. A scalar is a
113/// node whose leaves are the per-line `YAML_SCALAR_TEXT` fragments (and any
114/// `NEWLINE` between them); flow punctuation is `YAML_FLOW_INDICATOR`, so it
115/// never matches here.
116fn scalar_token(parent: &SyntaxNode) -> Option<YamlScalar> {
117    parent
118        .children()
119        .find(|n| n.kind() == SyntaxKind::YAML_SCALAR)
120        .and_then(YamlScalar::cast)
121}
122
123fn token_of(parent: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxToken> {
124    parent
125        .children_with_tokens()
126        .filter_map(|el| el.into_token())
127        .find(|t| t.kind() == kind)
128}
129
130/// A composite node's `text_range()` includes the trailing newline rowan
131/// attaches after its last child, so using it as a diagnostic span ends at
132/// column 1 of the following sibling line. This returns the range trimmed of
133/// trailing whitespace — ending at the last content byte. `text_range()` itself
134/// stays lossless; this is the semantic projection, mirroring the inline
135/// `content_range()` accessors.
136fn content_text_range(node: &SyntaxNode) -> TextRange {
137    let range = node.text_range();
138    let content_len = node.text().to_string().trim_end().len();
139    let end = range.start() + TextSize::from(content_len as u32);
140    TextRange::new(range.start(), end)
141}
142
143/// Projections shared by value / item / document body wrappers. Implemented as
144/// a macro so each wrapper gets the same `as_*` surface without repetition.
145macro_rules! node_projections {
146    () => {
147        /// The single content node, or `None` for an empty value.
148        pub fn as_node(&self) -> Option<YamlNode> {
149            node_child(&self.0)
150        }
151
152        /// The value as a scalar, or `None` if it is a container or empty.
153        pub fn as_scalar(&self) -> Option<YamlScalar> {
154            match self.as_node()? {
155                YamlNode::Scalar(s) => Some(s),
156                _ => None,
157            }
158        }
159
160        pub fn as_block_map(&self) -> Option<YamlBlockMap> {
161            match self.as_node()? {
162                YamlNode::BlockMap(m) => Some(m),
163                _ => None,
164            }
165        }
166
167        pub fn as_block_sequence(&self) -> Option<YamlBlockSequence> {
168            match self.as_node()? {
169                YamlNode::BlockSequence(s) => Some(s),
170                _ => None,
171            }
172        }
173
174        pub fn as_flow_map(&self) -> Option<YamlFlowMap> {
175            match self.as_node()? {
176                YamlNode::FlowMap(m) => Some(m),
177                _ => None,
178            }
179        }
180
181        pub fn as_flow_sequence(&self) -> Option<YamlFlowSequence> {
182            match self.as_node()? {
183                YamlNode::FlowSequence(s) => Some(s),
184                _ => None,
185            }
186        }
187
188        /// Whether this value is empty (no scalar and no container child).
189        pub fn is_empty(&self) -> bool {
190            self.as_node().is_none()
191        }
192
193        /// The explicit `YAML_TAG` token decorating this value (e.g. `!expr`),
194        /// if any. Used by the hashpipe formatter to preserve chunk-option tags.
195        pub fn tag(&self) -> Option<SyntaxToken> {
196            token_of(&self.0, SyntaxKind::YAML_TAG)
197        }
198    };
199}
200
201/// Declare a newtype CST-node wrapper with the standard hand-written
202/// [`AstNode`] impl for a single `SyntaxKind`.
203macro_rules! ast_node {
204    ($(#[$meta:meta])* $name:ident, $kind:ident) => {
205        $(#[$meta])*
206        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
207        pub struct $name(SyntaxNode);
208
209        impl AstNode for $name {
210            type Language = PanacheLanguage;
211
212            fn can_cast(kind: SyntaxKind) -> bool {
213                kind == SyntaxKind::$kind
214            }
215
216            fn cast(syntax: SyntaxNode) -> Option<Self> {
217                Self::can_cast(syntax.kind()).then_some(Self(syntax))
218            }
219
220            fn syntax(&self) -> &SyntaxNode {
221                &self.0
222            }
223        }
224    };
225}
226
227ast_node!(
228    /// A single YAML document inside the stream.
229    YamlDocument, YAML_DOCUMENT
230);
231
232impl YamlDocument {
233    pub fn block_map(&self) -> Option<YamlBlockMap> {
234        support::child(&self.0)
235    }
236
237    pub fn block_sequence(&self) -> Option<YamlBlockSequence> {
238        support::child(&self.0)
239    }
240
241    pub fn flow_map(&self) -> Option<YamlFlowMap> {
242        support::child(&self.0)
243    }
244
245    pub fn flow_sequence(&self) -> Option<YamlFlowSequence> {
246        support::child(&self.0)
247    }
248
249    /// A top-level bare scalar document (`"just a string"`).
250    pub fn scalar(&self) -> Option<YamlScalar> {
251        scalar_token(&self.0)
252    }
253
254    pub fn as_node(&self) -> Option<YamlNode> {
255        node_child(&self.0)
256    }
257}
258
259ast_node!(
260    /// A block mapping (`key: value` entries).
261    YamlBlockMap, YAML_BLOCK_MAP
262);
263
264impl YamlBlockMap {
265    pub fn entries(&self) -> AstChildren<YamlBlockMapEntry> {
266        support::children(&self.0)
267    }
268
269    /// The node's range trimmed of trailing trivia (see [`content_text_range`]).
270    pub fn content_range(&self) -> TextRange {
271        content_text_range(&self.0)
272    }
273
274    /// The first entry whose (cooked) key text equals `key`.
275    pub fn entry(&self, key: &str) -> Option<YamlBlockMapEntry> {
276        self.entries()
277            .find(|entry| entry.key_text().as_deref() == Some(key))
278    }
279
280    pub fn value_of(&self, key: &str) -> Option<YamlBlockMapValue> {
281        self.entry(key)?.value()
282    }
283}
284
285ast_node!(
286    /// One `key: value` pair in a block mapping.
287    YamlBlockMapEntry, YAML_BLOCK_MAP_ENTRY
288);
289
290impl YamlBlockMapEntry {
291    pub fn key(&self) -> Option<YamlBlockMapKey> {
292        support::child(&self.0)
293    }
294
295    /// The cooked key text. Reads the scalar child of `YAML_BLOCK_MAP_KEY`, so
296    /// the trailing `:` token is excluded.
297    pub fn key_text(&self) -> Option<String> {
298        self.key()?.scalar().map(|s| s.value())
299    }
300
301    pub fn value(&self) -> Option<YamlBlockMapValue> {
302        support::child(&self.0)
303    }
304}
305
306ast_node!(
307    /// The key side of a block-map entry. Holds the `YAML_SCALAR` node AND the
308    /// trailing `YAML_COLON` token.
309    YamlBlockMapKey, YAML_BLOCK_MAP_KEY
310);
311
312impl YamlBlockMapKey {
313    /// The key's scalar node (excluding the `:` colon).
314    pub fn scalar(&self) -> Option<YamlScalar> {
315        scalar_token(&self.0)
316    }
317}
318
319ast_node!(
320    /// The value side of a block-map entry: a scalar, a nested container, or
321    /// empty.
322    YamlBlockMapValue, YAML_BLOCK_MAP_VALUE
323);
324
325impl YamlBlockMapValue {
326    node_projections!();
327}
328
329ast_node!(
330    /// A block sequence (`- item` entries).
331    YamlBlockSequence, YAML_BLOCK_SEQUENCE
332);
333
334impl YamlBlockSequence {
335    pub fn items(&self) -> AstChildren<YamlBlockSequenceItem> {
336        support::children(&self.0)
337    }
338
339    /// The node's range trimmed of trailing trivia (see [`content_text_range`]).
340    pub fn content_range(&self) -> TextRange {
341        content_text_range(&self.0)
342    }
343}
344
345ast_node!(
346    /// One `- item` in a block sequence. The leading `-` is a
347    /// `YAML_BLOCK_SEQ_ENTRY` token, skipped by the content projections.
348    YamlBlockSequenceItem, YAML_BLOCK_SEQUENCE_ITEM
349);
350
351impl YamlBlockSequenceItem {
352    node_projections!();
353}
354
355ast_node!(
356    /// A flow sequence (`[a, b, c]`).
357    YamlFlowSequence, YAML_FLOW_SEQUENCE
358);
359
360impl YamlFlowSequence {
361    pub fn items(&self) -> AstChildren<YamlFlowSequenceItem> {
362        support::children(&self.0)
363    }
364
365    /// The node's range trimmed of trailing trivia (see [`content_text_range`]).
366    pub fn content_range(&self) -> TextRange {
367        content_text_range(&self.0)
368    }
369}
370
371ast_node!(
372    /// One item in a flow sequence.
373    YamlFlowSequenceItem, YAML_FLOW_SEQUENCE_ITEM
374);
375
376impl YamlFlowSequenceItem {
377    node_projections!();
378}
379
380ast_node!(
381    /// A flow mapping (`{k: v, ...}`).
382    YamlFlowMap, YAML_FLOW_MAP
383);
384
385impl YamlFlowMap {
386    pub fn entries(&self) -> AstChildren<YamlFlowMapEntry> {
387        support::children(&self.0)
388    }
389
390    /// The node's range trimmed of trailing trivia (see [`content_text_range`]).
391    pub fn content_range(&self) -> TextRange {
392        content_text_range(&self.0)
393    }
394
395    pub fn entry(&self, key: &str) -> Option<YamlFlowMapEntry> {
396        self.entries()
397            .find(|entry| entry.key_text().as_deref() == Some(key))
398    }
399
400    pub fn value_of(&self, key: &str) -> Option<YamlFlowMapValue> {
401        self.entry(key)?.value()
402    }
403}
404
405ast_node!(
406    /// One `k: v` pair in a flow mapping.
407    YamlFlowMapEntry, YAML_FLOW_MAP_ENTRY
408);
409
410impl YamlFlowMapEntry {
411    pub fn key(&self) -> Option<YamlFlowMapKey> {
412        support::child(&self.0)
413    }
414
415    pub fn key_text(&self) -> Option<String> {
416        self.key()?.scalar().map(|s| s.value())
417    }
418
419    pub fn value(&self) -> Option<YamlFlowMapValue> {
420        support::child(&self.0)
421    }
422}
423
424ast_node!(
425    /// The key side of a flow-map entry.
426    YamlFlowMapKey, YAML_FLOW_MAP_KEY
427);
428
429impl YamlFlowMapKey {
430    pub fn scalar(&self) -> Option<YamlScalar> {
431        scalar_token(&self.0)
432    }
433}
434
435ast_node!(
436    /// The value side of a flow-map entry.
437    YamlFlowMapValue, YAML_FLOW_MAP_VALUE
438);
439
440impl YamlFlowMapValue {
441    node_projections!();
442}
443
444/// The lexical style of a scalar, detected from its raw source. (The CST does
445/// not record style as a distinct kind — every style is a `YAML_SCALAR` node.)
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
447pub enum YamlScalarStyle {
448    Plain,
449    SingleQuoted,
450    DoubleQuoted,
451    Literal,
452    Folded,
453}
454
455impl YamlScalarStyle {
456    fn to_cook_style(self) -> ScalarStyle {
457        match self {
458            YamlScalarStyle::Plain => ScalarStyle::Plain,
459            YamlScalarStyle::SingleQuoted => ScalarStyle::SingleQuoted,
460            YamlScalarStyle::DoubleQuoted => ScalarStyle::DoubleQuoted,
461            YamlScalarStyle::Literal => ScalarStyle::Literal,
462            YamlScalarStyle::Folded => ScalarStyle::Folded,
463        }
464    }
465}
466
467fn detect_style(raw: &str) -> YamlScalarStyle {
468    match raw.trim_start().as_bytes().first() {
469        Some(b'\'') => YamlScalarStyle::SingleQuoted,
470        Some(b'"') => YamlScalarStyle::DoubleQuoted,
471        Some(b'|') => YamlScalarStyle::Literal,
472        Some(b'>') => YamlScalarStyle::Folded,
473        _ => YamlScalarStyle::Plain,
474    }
475}
476
477/// A scalar value node. Its leaves are the per-physical-line content fragments
478/// (`YAML_SCALAR_TEXT`) interleaved with `NEWLINE` (and, for embedded hashpipe,
479/// line-prefix) tokens; [`raw`](Self::raw) reassembles them.
480#[derive(Debug, Clone, PartialEq, Eq, Hash)]
481pub struct YamlScalar(SyntaxNode);
482
483impl YamlScalar {
484    pub fn cast(node: SyntaxNode) -> Option<Self> {
485        (node.kind() == SyntaxKind::YAML_SCALAR).then_some(Self(node))
486    }
487
488    /// The raw source bytes of the scalar (all leaves concatenated), including
489    /// any quotes / block header and embedded line breaks.
490    pub fn raw(&self) -> String {
491        self.0.text().to_string()
492    }
493
494    pub fn style(&self) -> YamlScalarStyle {
495        detect_style(&self.raw())
496    }
497
498    /// The cooked logical string: quotes stripped, escapes decoded, multi-line
499    /// scalars folded per YAML 1.2. Block scalars (`|`/`>`) are returned raw
500    /// (their cooking needs parent indent context).
501    pub fn value(&self) -> String {
502        let source = self.prefix_stripped_source();
503        cook(detect_style(&source).to_cook_style(), &source)
504    }
505
506    /// The scalar's content leaves concatenated, dropping any embedded
507    /// per-line prefix trivia (`YAML_LINE_PREFIX`, e.g. hashpipe `#|`). This
508    /// is the cooking input: prefixes are host framing, not scalar content,
509    /// so they must not fold into the value. For plain (frontmatter) scalars,
510    /// which carry no prefix leaves, this equals [`raw`](Self::raw).
511    fn prefix_stripped_source(&self) -> String {
512        self.0
513            .children_with_tokens()
514            .filter_map(|el| el.into_token())
515            .filter(|t| t.kind() != SyntaxKind::YAML_LINE_PREFIX)
516            .map(|t| t.text().to_string())
517            .collect()
518    }
519
520    pub fn text_range(&self) -> TextRange {
521        self.0.text_range()
522    }
523
524    pub fn syntax(&self) -> &SyntaxNode {
525        &self.0
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn parse_yaml_document_descends_envelope() {
535        let doc = parse_yaml_document("title: x\n").expect("document");
536        let map = doc.block_map().expect("block map");
537        assert_eq!(map.entries().count(), 1);
538    }
539
540    #[test]
541    fn content_range_excludes_trailing_trivia() {
542        // A nested block map's `text_range()` runs up to the next sibling key
543        // (it owns the trailing newline); `content_range()` ends at the last
544        // content byte so a diagnostic does not bleed onto the sibling line.
545        let input = "outer:\n  a: 1\n  b: 2\nsibling: x\n";
546        let doc = parse_yaml_document(input).expect("document");
547        let inner = doc.block_map().unwrap().value_of("outer").unwrap();
548        let map = inner.as_block_map().unwrap();
549        let slice = |r: TextRange| &input[usize::from(r.start())..usize::from(r.end())];
550        assert!(
551            slice(map.syntax().text_range()).ends_with('\n'),
552            "text_range keeps the trailing newline (lossless)"
553        );
554        assert_eq!(slice(map.content_range()), "a: 1\n  b: 2");
555    }
556
557    #[test]
558    fn key_text_strips_colon() {
559        let doc = parse_yaml_document("key: value\n").expect("document");
560        let entry = doc.block_map().unwrap().entries().next().unwrap();
561        assert_eq!(entry.key_text().as_deref(), Some("key"));
562    }
563
564    #[test]
565    fn value_is_cooked() {
566        let doc = parse_yaml_document("k: 'it''s'\n").expect("document");
567        let value = doc.block_map().unwrap().value_of("k").unwrap();
568        assert_eq!(value.as_scalar().unwrap().value(), "it's");
569
570        let doc = parse_yaml_document("k: \"a\\nb\"\n").expect("document");
571        let value = doc.block_map().unwrap().value_of("k").unwrap();
572        assert_eq!(value.as_scalar().unwrap().value(), "a\nb");
573    }
574
575    #[test]
576    fn raw_preserves_quotes() {
577        let doc = parse_yaml_document("k: 'it''s'\n").expect("document");
578        let scalar = doc
579            .block_map()
580            .unwrap()
581            .value_of("k")
582            .unwrap()
583            .as_scalar()
584            .unwrap();
585        assert_eq!(scalar.raw(), "'it''s'");
586        assert_eq!(scalar.style(), YamlScalarStyle::SingleQuoted);
587    }
588
589    #[test]
590    fn value_skips_embedded_line_prefix() {
591        use crate::parser::yaml::parse_stream_with_prefix;
592
593        // Double-quoted multi-line scalar inside hashpipe-prefixed YAML: the
594        // `#|` continuation prefix is carried as a `YAML_LINE_PREFIX` leaf for
595        // losslessness, but it must not bleed into the cooked value.
596        let tree = parse_stream_with_prefix("#| key: \"foo\n#|   bar\"\n", "#|");
597        let scalar = first_document(&tree)
598            .and_then(|d| d.block_map())
599            .and_then(|m| m.value_of("key"))
600            .and_then(|v| v.as_scalar())
601            .expect("scalar value");
602        let value = scalar.value();
603        assert!(!value.contains("#|"), "prefix leaked into value: {value:?}");
604        assert_eq!(value, "foo bar");
605        // raw() stays byte-exact (lossless contract): the prefix leaf is kept.
606        assert!(
607            scalar.raw().contains("#|"),
608            "raw() must retain the prefix leaf: {:?}",
609            scalar.raw()
610        );
611    }
612
613    #[test]
614    fn scalar_text_range_is_content_relative() {
615        let input = "k: value\n";
616        let doc = parse_yaml_document(input).expect("document");
617        let scalar = doc
618            .block_map()
619            .unwrap()
620            .value_of("k")
621            .unwrap()
622            .as_scalar()
623            .unwrap();
624        let range = scalar.text_range();
625        let start: usize = range.start().into();
626        let end: usize = range.end().into();
627        assert_eq!(&input[start..end], "value");
628    }
629
630    #[test]
631    fn empty_value_has_no_scalar() {
632        let doc = parse_yaml_document("k:\n").expect("document");
633        let value = doc.block_map().unwrap().value_of("k").unwrap();
634        assert!(value.is_empty());
635        assert!(value.as_scalar().is_none());
636    }
637
638    #[test]
639    fn block_sequence_items_yield_scalars() {
640        let doc = parse_yaml_document("k:\n  - a\n  - b\n").expect("document");
641        let seq = doc
642            .block_map()
643            .unwrap()
644            .value_of("k")
645            .unwrap()
646            .as_block_sequence()
647            .expect("block sequence");
648        let items: Vec<String> = seq
649            .items()
650            .filter_map(|item| item.as_scalar().map(|s| s.value()))
651            .collect();
652        assert_eq!(items, vec!["a".to_string(), "b".to_string()]);
653    }
654
655    #[test]
656    fn flow_sequence_items_yield_scalars() {
657        let doc = parse_yaml_document("k: [a, b]\n").expect("document");
658        let seq = doc
659            .block_map()
660            .unwrap()
661            .value_of("k")
662            .unwrap()
663            .as_flow_sequence()
664            .expect("flow sequence");
665        let items: Vec<String> = seq
666            .items()
667            .filter_map(|item| item.as_scalar().map(|s| s.value()))
668            .collect();
669        assert_eq!(items, vec!["a".to_string(), "b".to_string()]);
670    }
671
672    #[test]
673    fn tag_token_is_exposed_and_scalar_ignores_it() {
674        let doc = parse_yaml_document("k: !expr foo\n").expect("document");
675        let value = doc.block_map().unwrap().value_of("k").unwrap();
676        assert_eq!(
677            value.tag().map(|t| t.text().to_string()),
678            Some("!expr".to_string())
679        );
680        assert_eq!(value.as_scalar().unwrap().raw(), "foo");
681    }
682
683    #[test]
684    fn quoted_key_with_colon_round_trips() {
685        let doc = parse_yaml_document("\"foo:bar\": 1\n").expect("document");
686        let entry = doc.block_map().unwrap().entries().next().unwrap();
687        assert_eq!(entry.key_text().as_deref(), Some("foo:bar"));
688        assert_eq!(entry.key().unwrap().scalar().unwrap().raw(), "\"foo:bar\"");
689    }
690
691    #[test]
692    fn parse_yaml_documents_returns_all_documents() {
693        let docs = parse_yaml_documents("a: 1\n---\nb: 2\n");
694        assert_eq!(docs.len(), 2);
695    }
696
697    #[test]
698    fn invalid_yaml_yields_no_document() {
699        assert!(parse_yaml_document("k: [\n").is_none());
700    }
701}