Skip to main content

panache_parser/syntax/
yaml.rs

1use std::ops::Range;
2
3use crate::parser::utils::yaml_regions::hashpipe_language_and_prefix;
4use crate::parser::yaml::YamlDiagnostic;
5use crate::syntax::{
6    AstNode, PanacheLanguage, SyntaxKind, SyntaxNode, YamlDocument, YamlScalarStyle,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct YamlFrontmatterRegion {
11    pub id: String,
12    pub host_range: Range<usize>,
13    pub content_range: Range<usize>,
14    pub content: String,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum YamlRegionKind {
19    Frontmatter,
20    Hashpipe,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct YamlRegion {
25    pub id: String,
26    pub kind: YamlRegionKind,
27    pub host_range: Range<usize>,
28    pub region_range: Range<usize>,
29    pub content_range: Range<usize>,
30    pub content: String,
31}
32
33#[derive(Debug, Clone)]
34pub struct ParsedYamlRegion {
35    region: YamlRegion,
36    /// The host content node (`YAML_METADATA_CONTENT` / `HASHPIPE_YAML_CONTENT`)
37    /// carrying the embedded `YAML_DOCUMENT` subtree, when the parser embedded a
38    /// valid one. `None` for malformed YAML (opaque fallback). Validity and
39    /// document shape derive from its presence — no standalone re-parse.
40    embedded: Option<SyntaxNode>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ParsedYamlRegionSnapshot {
45    region: YamlRegion,
46    parse_ok: bool,
47    document_shape_summary: Option<String>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum YamlEmbeddingHostKind {
52    FrontmatterMetadata,
53    HashpipePreamble,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub struct YamlMetadata(SyntaxNode);
58
59impl AstNode for YamlMetadata {
60    type Language = PanacheLanguage;
61
62    fn can_cast(kind: SyntaxKind) -> bool {
63        kind == SyntaxKind::YAML_METADATA
64    }
65
66    fn cast(node: SyntaxNode) -> Option<Self> {
67        Self::can_cast(node.kind()).then(|| Self(node))
68    }
69
70    fn syntax(&self) -> &SyntaxNode {
71        &self.0
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76pub struct HashpipeYamlPreamble(SyntaxNode);
77
78impl AstNode for HashpipeYamlPreamble {
79    type Language = PanacheLanguage;
80
81    fn can_cast(kind: SyntaxKind) -> bool {
82        kind == SyntaxKind::HASHPIPE_YAML_PREAMBLE
83    }
84
85    fn cast(node: SyntaxNode) -> Option<Self> {
86        Self::can_cast(node.kind()).then(|| Self(node))
87    }
88
89    fn syntax(&self) -> &SyntaxNode {
90        &self.0
91    }
92}
93
94#[derive(Debug, Clone)]
95pub enum YamlEmbeddingHost {
96    FrontmatterMetadata(YamlMetadata),
97    HashpipePreamble(HashpipeYamlPreamble),
98}
99
100#[derive(Debug, Clone)]
101pub struct YamlEmbeddedCst {
102    host: YamlEmbeddingHost,
103    parsed: ParsedYamlRegion,
104}
105
106impl YamlEmbeddedCst {
107    pub fn host_kind(&self) -> YamlEmbeddingHostKind {
108        match self.host {
109            YamlEmbeddingHost::FrontmatterMetadata(_) => YamlEmbeddingHostKind::FrontmatterMetadata,
110            YamlEmbeddingHost::HashpipePreamble(_) => YamlEmbeddingHostKind::HashpipePreamble,
111        }
112    }
113
114    pub fn host_node(&self) -> &SyntaxNode {
115        match &self.host {
116            YamlEmbeddingHost::FrontmatterMetadata(host) => host.syntax(),
117            YamlEmbeddingHost::HashpipePreamble(host) => host.syntax(),
118        }
119    }
120
121    pub fn frontmatter_host(&self) -> Option<&YamlMetadata> {
122        match &self.host {
123            YamlEmbeddingHost::FrontmatterMetadata(host) => Some(host),
124            _ => None,
125        }
126    }
127
128    pub fn hashpipe_host(&self) -> Option<&HashpipeYamlPreamble> {
129        match &self.host {
130            YamlEmbeddingHost::HashpipePreamble(host) => Some(host),
131            _ => None,
132        }
133    }
134
135    pub fn parsed(&self) -> &ParsedYamlRegion {
136        &self.parsed
137    }
138
139    pub fn yaml_content(&self) -> &str {
140        self.parsed.content()
141    }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum YamlAstRootKind {
146    Root,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum YamlDocumentKind {
151    BlockMap,
152    BlockSeq,
153    BlockScalar,
154    Flow,
155    Empty,
156}
157
158#[derive(Debug, Clone, Copy)]
159pub struct YamlAstRoot<'a> {
160    node: &'a SyntaxNode,
161}
162
163impl YamlAstRoot<'_> {
164    pub fn kind(&self) -> YamlAstRootKind {
165        YamlAstRootKind::Root
166    }
167
168    pub fn document_count(&self) -> usize {
169        self.documents().count()
170    }
171
172    pub fn first_document_kind(&self) -> Option<YamlDocumentKind> {
173        let doc = self.documents().next()?;
174        if doc.block_map().is_some() {
175            return Some(YamlDocumentKind::BlockMap);
176        }
177        if doc.block_sequence().is_some() {
178            return Some(YamlDocumentKind::BlockSeq);
179        }
180        if let Some(scalar) = doc.scalar() {
181            return Some(match scalar.style() {
182                YamlScalarStyle::Literal | YamlScalarStyle::Folded => YamlDocumentKind::BlockScalar,
183                _ => YamlDocumentKind::Flow,
184            });
185        }
186        if doc.flow_map().is_some() || doc.flow_sequence().is_some() {
187            return Some(YamlDocumentKind::Flow);
188        }
189        Some(YamlDocumentKind::Empty)
190    }
191
192    fn documents(&self) -> impl Iterator<Item = YamlDocument> + '_ {
193        self.node.descendants().filter_map(YamlDocument::cast)
194    }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct YamlParseError {
199    offset: usize,
200    message: String,
201}
202
203impl YamlParseError {
204    pub fn offset(&self) -> usize {
205        self.offset
206    }
207
208    pub fn message(&self) -> &str {
209        &self.message
210    }
211
212    fn from_diagnostic(diag: &YamlDiagnostic) -> Self {
213        Self {
214            offset: diag.byte_start,
215            message: diag.message.to_string(),
216        }
217    }
218}
219
220impl ParsedYamlRegion {
221    pub fn id(&self) -> &str {
222        &self.region.id
223    }
224
225    pub fn kind(&self) -> &YamlRegionKind {
226        &self.region.kind
227    }
228
229    pub fn is_frontmatter(&self) -> bool {
230        matches!(self.region.kind, YamlRegionKind::Frontmatter)
231    }
232
233    pub fn is_hashpipe(&self) -> bool {
234        matches!(self.region.kind, YamlRegionKind::Hashpipe)
235    }
236
237    /// The embedded YAML document root, when the parser embedded a valid
238    /// subtree. The host content node carries `YAML_DOCUMENT` children directly,
239    /// which [`YamlAstRoot`] walks.
240    pub fn root(&self) -> Option<YamlAstRoot<'_>> {
241        self.embedded.as_ref().map(|node| YamlAstRoot { node })
242    }
243
244    pub fn root_kind(&self) -> Option<YamlAstRootKind> {
245        self.root().map(|root| root.kind())
246    }
247
248    /// Whether the region's YAML is well-formed — i.e. the parser embedded a
249    /// structured subtree rather than falling back to opaque tokens. This is the
250    /// parser's own verdict, so it cannot diverge from the syntax-error channel.
251    pub fn is_valid(&self) -> bool {
252        self.embedded.is_some()
253    }
254
255    pub fn host_range(&self) -> Range<usize> {
256        self.region.host_range.clone()
257    }
258
259    pub fn content_range(&self) -> Range<usize> {
260        self.region.content_range.clone()
261    }
262
263    pub fn region_range(&self) -> Range<usize> {
264        self.region.region_range.clone()
265    }
266
267    pub fn to_region(&self) -> YamlRegion {
268        self.region.clone()
269    }
270
271    pub fn content(&self) -> &str {
272        &self.region.content
273    }
274
275    pub fn document_shape_summary(&self) -> Option<String> {
276        let root = self.root()?;
277        let doc_count = root.document_count();
278        let first_kind = root.first_document_kind();
279        Some(match first_kind {
280            Some(kind) => format!("{:?} docs={} first={:?}", root.kind(), doc_count, kind),
281            None => format!("{:?} docs={}", root.kind(), doc_count),
282        })
283    }
284
285    pub fn to_snapshot(&self) -> ParsedYamlRegionSnapshot {
286        ParsedYamlRegionSnapshot {
287            region: self.region.clone(),
288            parse_ok: self.is_valid(),
289            document_shape_summary: self.document_shape_summary(),
290        }
291    }
292}
293
294impl ParsedYamlRegionSnapshot {
295    pub fn id(&self) -> &str {
296        &self.region.id
297    }
298
299    pub fn is_frontmatter(&self) -> bool {
300        matches!(self.region.kind, YamlRegionKind::Frontmatter)
301    }
302
303    pub fn is_hashpipe(&self) -> bool {
304        matches!(self.region.kind, YamlRegionKind::Hashpipe)
305    }
306
307    pub fn is_valid(&self) -> bool {
308        self.parse_ok
309    }
310
311    pub fn host_range(&self) -> Range<usize> {
312        self.region.host_range.clone()
313    }
314
315    pub fn document_shape_summary(&self) -> Option<&str> {
316        self.document_shape_summary.as_deref()
317    }
318
319    pub fn to_region(&self) -> YamlRegion {
320        self.region.clone()
321    }
322}
323
324pub fn collect_frontmatter_region(tree: &SyntaxNode) -> Option<YamlFrontmatterRegion> {
325    let metadata = tree
326        .descendants()
327        .find(|node| node.kind() == SyntaxKind::YAML_METADATA)?;
328    let content_node = metadata
329        .children()
330        .find(|child| child.kind() == SyntaxKind::YAML_METADATA_CONTENT)?;
331
332    let host_start: usize = metadata.text_range().start().into();
333    let host_end: usize = metadata.text_range().end().into();
334    let content_start: usize = content_node.text_range().start().into();
335    let content_end: usize = content_node.text_range().end().into();
336
337    Some(YamlFrontmatterRegion {
338        id: format!("frontmatter:{}:{}", content_start, content_end),
339        host_range: host_start..host_end,
340        content_range: content_start..content_end,
341        content: content_node.text().to_string(),
342    })
343}
344
345pub fn collect_frontmatter_yaml_region(tree: &SyntaxNode) -> Option<YamlRegion> {
346    let frontmatter = collect_frontmatter_region(tree)?;
347    let content_range = frontmatter.content_range.clone();
348    Some(YamlRegion {
349        id: frontmatter.id,
350        kind: YamlRegionKind::Frontmatter,
351        host_range: frontmatter.host_range.clone(),
352        region_range: frontmatter.host_range,
353        content_range,
354        content: frontmatter.content,
355    })
356}
357
358pub fn collect_hashpipe_regions(tree: &SyntaxNode) -> Vec<YamlRegion> {
359    let mut regions = Vec::new();
360    for node in tree
361        .descendants()
362        .filter(|n| n.kind() == SyntaxKind::CODE_BLOCK)
363    {
364        let mut info_text: Option<String> = None;
365        let mut content_node: Option<SyntaxNode> = None;
366        for child in node.children() {
367            match child.kind() {
368                SyntaxKind::CODE_FENCE_OPEN => {
369                    for nested in child.children() {
370                        if nested.kind() == SyntaxKind::CODE_INFO {
371                            info_text = Some(nested.text().to_string());
372                        }
373                    }
374                }
375                SyntaxKind::CODE_CONTENT => content_node = Some(child),
376                _ => {}
377            }
378        }
379        let (Some(info_text), Some(content_node)) = (info_text, content_node) else {
380            continue;
381        };
382        let Some((language, prefix)) = hashpipe_language_and_prefix(&info_text) else {
383            continue;
384        };
385
386        let host_start: usize = node.text_range().start().into();
387        let host_end: usize = node.text_range().end().into();
388        let Some(preamble) = content_node
389            .children()
390            .find(|n| n.kind() == SyntaxKind::HASHPIPE_YAML_PREAMBLE)
391        else {
392            continue;
393        };
394        let Some(preamble_content) = preamble
395            .children()
396            .find(|n| n.kind() == SyntaxKind::HASHPIPE_YAML_CONTENT)
397        else {
398            continue;
399        };
400        let preamble_text = preamble_content.text().to_string();
401        let preamble_start: usize = preamble_content.text_range().start().into();
402        if let Some(region) = extract_hashpipe_region(
403            &preamble_text,
404            host_start,
405            host_end,
406            preamble_start,
407            prefix,
408            language.as_str(),
409        ) {
410            regions.push(region);
411        }
412    }
413    regions
414}
415
416pub fn collect_yaml_regions(tree: &SyntaxNode) -> Vec<YamlRegion> {
417    let mut regions = Vec::new();
418    if let Some(frontmatter) = collect_frontmatter_yaml_region(tree) {
419        regions.push(frontmatter);
420    }
421    regions.extend(collect_hashpipe_regions(tree));
422    regions
423}
424
425pub fn collect_parsed_yaml_regions(tree: &SyntaxNode) -> Vec<ParsedYamlRegion> {
426    let embedded_frontmatter = embedded_frontmatter_stream(tree);
427    collect_yaml_regions(tree)
428        .into_iter()
429        .map(|region| {
430            // Validity and document shape come from the host-embedded subtree the
431            // parser produced — no standalone re-parse. `None` (malformed YAML,
432            // opaque fallback) means invalid; the parser's syntax-error channel
433            // carries the diagnostic for those.
434            let embedded = match &region.kind {
435                YamlRegionKind::Frontmatter => embedded_frontmatter.clone(),
436                YamlRegionKind::Hashpipe => embedded_hashpipe_stream(tree, &region.region_range),
437            };
438            ParsedYamlRegion { embedded, region }
439        })
440        .collect()
441}
442
443/// Locate the embedded YAML subtree under the frontmatter's
444/// YAML_METADATA_CONTENT node, if the host parser embedded one (valid
445/// frontmatter). The content node plays the stream container role for the
446/// singleton-stream embedding, so we return it directly when the parser
447/// embedded YAML. Returns `None` for malformed frontmatter, where the content
448/// node holds opaque line tokens and the syntax-error channel carries the
449/// diagnostic.
450fn embedded_frontmatter_stream(tree: &SyntaxNode) -> Option<SyntaxNode> {
451    let metadata = tree
452        .descendants()
453        .find(|node| node.kind() == SyntaxKind::YAML_METADATA)?;
454    let content_node = metadata
455        .children()
456        .find(|child| child.kind() == SyntaxKind::YAML_METADATA_CONTENT)?;
457    (!is_opaque_yaml_fallback(&content_node)).then_some(content_node)
458}
459
460/// Locate the embedded YAML subtree under the hashpipe preamble's
461/// `HASHPIPE_YAML_CONTENT` node whose range matches `region_range`, when the
462/// host parser embedded one (valid hashpipe YAML). Mirrors
463/// [`embedded_frontmatter_stream`]. Returns `None` for malformed YAML (opaque
464/// fallback).
465fn embedded_hashpipe_stream(tree: &SyntaxNode, region_range: &Range<usize>) -> Option<SyntaxNode> {
466    tree.descendants()
467        .filter(|node| node.kind() == SyntaxKind::HASHPIPE_YAML_CONTENT)
468        .find(|node| {
469            let start: usize = node.text_range().start().into();
470            let end: usize = node.text_range().end().into();
471            start == region_range.start && end == region_range.end
472        })
473        .filter(|node| !is_opaque_yaml_fallback(node))
474}
475
476/// Whether a host YAML content node holds the parser's *opaque fallback* — raw
477/// `TEXT` line tokens emitted when the YAML failed to validate — rather than an
478/// embedded YAML subtree. Valid embeddings carry `YAML_*` nodes (or, for empty
479/// content, nothing) and never a raw `TEXT` token, so its presence is the
480/// reliable malformed-YAML fingerprint. Empty content (valid empty YAML) is not
481/// opaque.
482fn is_opaque_yaml_fallback(content_node: &SyntaxNode) -> bool {
483    content_node
484        .children_with_tokens()
485        .any(|element| element.kind() == SyntaxKind::TEXT)
486}
487
488pub fn collect_parsed_frontmatter_region(tree: &SyntaxNode) -> Option<ParsedYamlRegion> {
489    collect_parsed_yaml_regions(tree)
490        .into_iter()
491        .find(|region| region.is_frontmatter())
492}
493
494pub fn collect_parsed_yaml_region_snapshots(tree: &SyntaxNode) -> Vec<ParsedYamlRegionSnapshot> {
495    collect_parsed_yaml_regions(tree)
496        .iter()
497        .map(ParsedYamlRegion::to_snapshot)
498        .collect()
499}
500
501pub fn validate_yaml_text(input: &str) -> Result<(), YamlParseError> {
502    match crate::parser::yaml::parse_yaml_report(input)
503        .diagnostics
504        .first()
505    {
506        Some(diag) => Err(YamlParseError::from_diagnostic(diag)),
507        None => Ok(()),
508    }
509}
510
511pub fn collect_embedded_yaml_cst(tree: &SyntaxNode) -> Vec<YamlEmbeddedCst> {
512    let parsed_regions = collect_parsed_yaml_regions(tree);
513    let frontmatter_node = tree.descendants().find_map(YamlMetadata::cast);
514    let hashpipe_preambles: Vec<HashpipeYamlPreamble> = tree
515        .descendants()
516        .filter_map(HashpipeYamlPreamble::cast)
517        .collect();
518
519    let mut embedded = Vec::new();
520    for parsed in parsed_regions {
521        match parsed.kind() {
522            YamlRegionKind::Frontmatter => {
523                if let Some(node) = frontmatter_node.clone() {
524                    embedded.push(YamlEmbeddedCst {
525                        host: YamlEmbeddingHost::FrontmatterMetadata(node),
526                        parsed,
527                    });
528                }
529            }
530            YamlRegionKind::Hashpipe => {
531                if let Some(node) = hashpipe_preambles.iter().find(|node| {
532                    let range: Range<usize> = node.syntax().text_range().start().into()
533                        ..node.syntax().text_range().end().into();
534                    range == parsed.region_range()
535                }) {
536                    embedded.push(YamlEmbeddedCst {
537                        host: YamlEmbeddingHost::HashpipePreamble(node.clone()),
538                        parsed,
539                    });
540                }
541            }
542        }
543    }
544    embedded
545}
546
547pub fn collect_embedded_frontmatter_yaml_cst(tree: &SyntaxNode) -> Option<YamlEmbeddedCst> {
548    collect_embedded_yaml_cst(tree)
549        .into_iter()
550        .find(|embedded| embedded.frontmatter_host().is_some())
551}
552
553fn extract_hashpipe_region(
554    content: &str,
555    host_start: usize,
556    host_end: usize,
557    content_start: usize,
558    prefix: &str,
559    language: &str,
560) -> Option<YamlRegion> {
561    let lines: Vec<&str> = content.split_inclusive('\n').collect();
562    if lines.is_empty() {
563        return None;
564    }
565    // Rebuild the prefix-stripped YAML payload (used for the region's `content`
566    // shape view). Host↔stripped offset mapping is no longer needed here: the
567    // parser embeds a host-aligned YAML subtree and surfaces malformed-YAML
568    // diagnostics through its own syntax-error channel.
569    let mut collected = String::new();
570    let mut offset = 0usize;
571    for line in &lines {
572        let line = *line;
573        let line_core = line.strip_suffix('\n').unwrap_or(line);
574        let line_core = line_core.strip_suffix('\r').unwrap_or(line_core);
575        let eol = &line[line_core.len()..];
576        let indent_len = line_core
577            .chars()
578            .take_while(|ch| *ch == ' ' || *ch == '\t')
579            .map(char::len_utf8)
580            .sum::<usize>();
581        let trimmed = &line_core[indent_len..];
582        let after_prefix = trimmed.strip_prefix(prefix)?;
583        let payload = after_prefix
584            .strip_prefix(' ')
585            .or_else(|| after_prefix.strip_prefix('\t'))
586            .unwrap_or(after_prefix);
587        collected.push_str(payload);
588        collected.push_str(eol);
589        offset += line.len();
590    }
591    let start = content_start;
592    let region_end = content_start + offset;
593    let id = format!("hashpipe:{}:{}:{}", language, host_start, start);
594    Some(YamlRegion {
595        id,
596        kind: YamlRegionKind::Hashpipe,
597        host_range: host_start..host_end,
598        region_range: start..region_end,
599        content_range: start..region_end,
600        content: collected,
601    })
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn parsed_yaml_regions_include_frontmatter_and_hashpipe_cst_roots() {
610        let input = "---\ntitle: Test\n---\n\n```{r}\n#| echo: false\n1 + 1\n```\n";
611        let config = crate::options::ParserOptions {
612            flavor: crate::options::Flavor::Quarto,
613            extensions: crate::options::Extensions::for_flavor(crate::options::Flavor::Quarto),
614            ..Default::default()
615        };
616        let tree = crate::parser::parse(input, Some(config));
617        let regions = collect_parsed_yaml_regions(&tree);
618        assert_eq!(regions.len(), 2);
619        assert!(regions.iter().any(|parsed| {
620            parsed.is_frontmatter() && parsed.root_kind() == Some(YamlAstRootKind::Root)
621        }));
622        assert!(regions.iter().any(|parsed| {
623            parsed.is_hashpipe() && parsed.root_kind() == Some(YamlAstRootKind::Root)
624        }));
625    }
626
627    #[test]
628    fn parsed_hashpipe_region_validity_derives_from_embedded_subtree() {
629        let config = crate::options::ParserOptions {
630            flavor: crate::options::Flavor::Quarto,
631            extensions: crate::options::Extensions::for_flavor(crate::options::Flavor::Quarto),
632            ..Default::default()
633        };
634        // Malformed hashpipe YAML: no embedded subtree → invalid, no root.
635        let bad = crate::parser::parse("```{r}\n#| echo: [\n1 + 1\n```\n", Some(config.clone()));
636        let bad_region = collect_parsed_yaml_regions(&bad)
637            .into_iter()
638            .find(|region| region.is_hashpipe())
639            .expect("hashpipe region");
640        assert!(!bad_region.is_valid());
641        assert!(bad_region.root().is_none());
642
643        // Well-formed hashpipe YAML: embedded subtree → valid, root present.
644        let good = crate::parser::parse("```{r}\n#| echo: false\n1 + 1\n```\n", Some(config));
645        let good_region = collect_parsed_yaml_regions(&good)
646            .into_iter()
647            .find(|region| region.is_hashpipe())
648            .expect("hashpipe region");
649        assert!(good_region.is_valid());
650        assert!(good_region.root().is_some());
651    }
652
653    #[test]
654    fn empty_frontmatter_is_valid() {
655        // Valid empty YAML embeds an empty content node (no document, no opaque
656        // TEXT). It must still count as valid — not malformed.
657        let tree = crate::parser::parse("---\n---\n\nbody\n", None);
658        let parsed = collect_parsed_frontmatter_region(&tree).expect("frontmatter");
659        assert!(parsed.is_valid());
660    }
661
662    #[test]
663    fn malformed_frontmatter_is_invalid() {
664        let tree = crate::parser::parse("---\ntitle: [\n---\n", None);
665        let parsed = collect_parsed_frontmatter_region(&tree).expect("frontmatter");
666        assert!(!parsed.is_valid());
667    }
668
669    #[test]
670    fn yaml_ast_root_reports_document_shape() {
671        let input = "---\ntitle: Test\n---\n";
672        let tree = crate::parser::parse(input, None);
673        let parsed = collect_parsed_frontmatter_region(&tree).expect("frontmatter");
674        let root = parsed.root().expect("yaml root");
675        assert_eq!(root.document_count(), 1);
676        assert_eq!(root.first_document_kind(), Some(YamlDocumentKind::BlockMap));
677    }
678
679    #[test]
680    fn embedded_yaml_cst_attaches_to_frontmatter_and_hashpipe_hosts() {
681        let input = "---\ntitle: Test\n---\n\n```{r}\n#| echo: false\nx <- 1\n```\n";
682        let config = crate::options::ParserOptions {
683            flavor: crate::options::Flavor::Quarto,
684            extensions: crate::options::Extensions::for_flavor(crate::options::Flavor::Quarto),
685            ..Default::default()
686        };
687        let tree = crate::parser::parse(input, Some(config));
688        let embedded = collect_embedded_yaml_cst(&tree);
689        assert_eq!(embedded.len(), 2);
690        assert!(
691            embedded
692                .iter()
693                .any(|item| item.frontmatter_host().is_some())
694        );
695        assert!(embedded.iter().any(|item| item.hashpipe_host().is_some()));
696    }
697
698    #[test]
699    fn embedded_yaml_cst_exposes_frontmatter_and_hashpipe_payloads() {
700        let input = "---\ntitle: Test\n---\n\n```{r}\n#| fig-cap: |\n#|   A caption\nx <- 1\n```\n";
701        let config = crate::options::ParserOptions {
702            flavor: crate::options::Flavor::Quarto,
703            extensions: crate::options::Extensions::for_flavor(crate::options::Flavor::Quarto),
704            ..Default::default()
705        };
706        let tree = crate::parser::parse(input, Some(config));
707        let embedded = collect_embedded_yaml_cst(&tree);
708        assert_eq!(embedded.len(), 2);
709
710        let frontmatter = embedded
711            .iter()
712            .find(|item| item.frontmatter_host().is_some())
713            .expect("frontmatter embedding");
714        assert!(frontmatter.parsed().is_valid());
715        assert_eq!(
716            frontmatter.parsed().document_shape_summary().as_deref(),
717            Some("Root docs=1 first=BlockMap")
718        );
719
720        let hashpipe = embedded
721            .iter()
722            .find(|item| item.hashpipe_host().is_some())
723            .expect("hashpipe embedding");
724        assert!(hashpipe.parsed().is_valid());
725        assert!(hashpipe.parsed().to_region().content.contains("fig-cap: |"));
726    }
727
728    #[test]
729    fn embedded_frontmatter_query_returns_typed_host_wrapper() {
730        let input = "---\ntitle: Test\n---\n\nBody\n";
731        let tree = crate::parser::parse(input, None);
732        let embedded = collect_embedded_frontmatter_yaml_cst(&tree).expect("frontmatter embedding");
733        let _host = embedded.frontmatter_host().expect("frontmatter host");
734        assert!(embedded.hashpipe_host().is_none());
735    }
736}