Skip to main content

panache_parser/parser/blocks/
metadata.rs

1//! YAML metadata block parsing utilities.
2
3use crate::options::Flavor;
4use crate::parser::diagnostics::{Diagnostics, SyntaxError, SyntaxErrorSource};
5use crate::parser::utils::helpers::{emit_line_tokens, strip_newline};
6use crate::parser::utils::tree_copy::copy_green_children;
7use crate::parser::yaml::{YamlValidationContext, locate_yaml_diagnostic_ctx, parse_stream};
8use crate::syntax::{SyntaxKind, SyntaxNode};
9use rowan::{GreenNode, GreenNodeBuilder, TextRange};
10
11/// Try to parse a YAML metadata block starting at the given position.
12/// Returns the new position after the block if successful, None otherwise.
13///
14/// A YAML block:
15/// - Starts with `---` (not followed by blank line)
16/// - Ends with `---` or `...`
17/// - At document start OR preceded by blank line
18/// - Content passes [`prepare_yaml_content`]'s metadata gate
19pub(crate) fn try_parse_yaml_block(
20    lines: &[&str],
21    pos: usize,
22    builder: &mut GreenNodeBuilder<'static>,
23    at_document_start: bool,
24    diags: &Diagnostics,
25    flavor: Flavor,
26) -> Option<usize> {
27    let closing_pos = find_yaml_block_closing_pos(lines, pos, at_document_start)?;
28    let content = collect_yaml_content(lines, pos, closing_pos);
29    let outcome = prepare_yaml_content(&content, flavor)?;
30    emit_yaml_block(lines, pos, closing_pos, builder, diags, &outcome)
31}
32
33/// Reconstruct the content between the delimiters as a contiguous byte
34/// string. The lines returned by `split_lines_inclusive` are non-overlapping
35/// slices of the original input that retain their trailing LF / CRLF, so
36/// concatenating them rebuilds the source bytes exactly (including CRLF).
37pub(crate) fn collect_yaml_content(lines: &[&str], pos: usize, closing_pos: usize) -> String {
38    let mut content = String::new();
39    for content_line in lines.iter().take(closing_pos).skip(pos + 1) {
40        content.push_str(content_line);
41    }
42    content
43}
44
45/// Validation + parse outcome for YAML metadata content, computed once at
46/// detection and carried to emission (via `YamlMetadataPrepared`) so the
47/// content is never re-parsed.
48#[derive(Debug, Clone)]
49pub(crate) enum YamlContentOutcome {
50    /// Content failed validation: emit opaque line tokens plus a syntax
51    /// error at content-relative `start..end`. Pandoc hard-errors here, so
52    /// there is no alternative interpretation to fall back to.
53    Invalid {
54        message: &'static str,
55        start: usize,
56        end: usize,
57    },
58    /// Content validated: embed the parsed `YAML_STREAM`'s children.
59    Valid { stream: GreenNode },
60}
61
62/// Validate and parse metadata-block content, applying pandoc's metadata
63/// gate: pandoc accepts frontmatter whose YAML parses to a top-level mapping
64/// or null (empty/comments-only), backtracks to another block interpretation
65/// (simple table, HR + paragraph) when it parses to anything else, and
66/// hard-errors on a YAML parse exception. Returns `None` for the backtrack
67/// case so the dispatcher falls through to the other block parsers; the
68/// hard-error case maps to [`YamlContentOutcome::Invalid`].
69///
70/// The gate only applies to flavors with an asserted frontmatter YAML
71/// consumer (pandoc-family); GFM/CommonMark-family frontmatter stays lenient.
72pub(crate) fn prepare_yaml_content(content: &str, flavor: Flavor) -> Option<YamlContentOutcome> {
73    let yaml_ctx = YamlValidationContext::frontmatter(flavor);
74    if let Some((diag, start, end)) = locate_yaml_diagnostic_ctx(content, "", yaml_ctx) {
75        return Some(YamlContentOutcome::Invalid {
76            message: diag.message,
77            start,
78            end,
79        });
80    }
81    let stream = parse_stream(content);
82    if !yaml_ctx.consumers().is_empty() && !top_level_is_mapping_or_null(&stream) {
83        return None;
84    }
85    Some(YamlContentOutcome::Valid {
86        stream: stream.green().into_owned(),
87    })
88}
89
90/// Whether the first document in a parsed `YAML_STREAM` has a top-level
91/// mapping or null value (pandoc's acceptance rule for metadata blocks).
92/// A stream or document with no content node (empty, comments-only) counts
93/// as null.
94fn top_level_is_mapping_or_null(stream: &SyntaxNode) -> bool {
95    let Some(document) = stream
96        .children()
97        .find(|n| n.kind() == SyntaxKind::YAML_DOCUMENT)
98    else {
99        return true;
100    };
101    let Some(value) = document.children().find(|n| {
102        matches!(
103            n.kind(),
104            SyntaxKind::YAML_BLOCK_MAP
105                | SyntaxKind::YAML_FLOW_MAP
106                | SyntaxKind::YAML_BLOCK_SEQUENCE
107                | SyntaxKind::YAML_FLOW_SEQUENCE
108                | SyntaxKind::YAML_SCALAR
109        )
110    }) else {
111        return true;
112    };
113    match value.kind() {
114        SyntaxKind::YAML_BLOCK_MAP | SyntaxKind::YAML_FLOW_MAP => true,
115        SyntaxKind::YAML_SCALAR => scalar_is_null(&value),
116        _ => false,
117    }
118}
119
120/// Whether a `YAML_SCALAR` node is a plain null scalar (`~`, `null`, `Null`,
121/// `NULL`). Quoted forms keep their quotes in `YAML_SCALAR_TEXT`, so they
122/// correctly resolve to strings, not null.
123fn scalar_is_null(scalar: &SyntaxNode) -> bool {
124    let mut text = String::new();
125    for token in scalar.children_with_tokens().filter_map(|t| t.into_token()) {
126        if token.kind() == SyntaxKind::YAML_SCALAR_TEXT {
127            text.push_str(token.text());
128        }
129    }
130    matches!(text.trim(), "~" | "null" | "Null" | "NULL")
131}
132
133pub(crate) fn find_yaml_block_closing_pos(
134    lines: &[&str],
135    pos: usize,
136    at_document_start: bool,
137) -> Option<usize> {
138    if pos >= lines.len() {
139        return None;
140    }
141
142    let line = lines[pos];
143
144    // Must start with ---
145    if line.trim() != "---" {
146        return None;
147    }
148
149    // If not at document start, previous line must be blank
150    if !at_document_start && pos > 0 {
151        let prev_line = lines[pos - 1];
152        if !prev_line.trim().is_empty() {
153            return None;
154        }
155    }
156
157    // Check that next line (if exists) is NOT blank (this distinguishes from horizontal rule)
158    if pos + 1 < lines.len() {
159        let next_line = lines[pos + 1];
160        if next_line.trim().is_empty() {
161            // This is likely a horizontal rule, not YAML
162            return None;
163        }
164    } else {
165        // No content after ---, can't be a YAML block
166        return None;
167    }
168
169    // Find a closing delimiter before emitting; otherwise this is not a valid YAML block.
170    let mut closing_pos = None;
171    for (i, content_line) in lines.iter().enumerate().skip(pos + 1) {
172        if content_line.trim() == "---" || content_line.trim() == "..." {
173            closing_pos = Some(i);
174            break;
175        }
176    }
177    closing_pos
178}
179
180pub(crate) fn emit_yaml_block(
181    lines: &[&str],
182    pos: usize,
183    closing_pos: usize,
184    builder: &mut GreenNodeBuilder<'static>,
185    diags: &Diagnostics,
186    outcome: &YamlContentOutcome,
187) -> Option<usize> {
188    if pos >= lines.len() || closing_pos <= pos || closing_pos >= lines.len() {
189        return None;
190    }
191    // Start metadata node
192    builder.start_node(SyntaxKind::YAML_METADATA.into());
193
194    // Opening delimiter - strip newline before emitting
195    let (text, newline_str) = strip_newline(lines[pos]);
196    builder.token(SyntaxKind::YAML_METADATA_DELIM.into(), text);
197    if !newline_str.is_empty() {
198        builder.token(SyntaxKind::NEWLINE.into(), newline_str);
199    }
200
201    builder.start_node(SyntaxKind::YAML_METADATA_CONTENT.into());
202
203    // Embed the in-tree YAML CST under YAML_METADATA_CONTENT when the
204    // content validates. On validation failure, fall back to the
205    // opaque line-token shape so downstream re-parse (and the host
206    // CST snapshot of malformed YAML) keep their current behavior.
207    //
208    // The stored stream is a `YAML_STREAM` wrapping one or more
209    // `YAML_DOCUMENT` children. The wrapper is the YAML-spec stream
210    // container — but inside frontmatter the host's
211    // `YAML_METADATA_CONTENT` already plays that role (and
212    // `find_yaml_block_closing_pos` guarantees a single document by
213    // stopping at the first internal `---` / `...`). Splice the stream's
214    // children in directly to avoid the redundant wrapper.
215    match outcome {
216        YamlContentOutcome::Invalid {
217            message,
218            start,
219            end,
220        } => {
221            // Malformed frontmatter YAML: record the syntax error at its host
222            // position (the parser already has the verdict), then fall back to
223            // the opaque line-token shape. The content begins at
224            // `lines[pos + 1]`, a subslice of the host input, so its host
225            // start is the pointer offset from line 0; offsets are identity
226            // (no per-line prefix).
227            let host_start = lines[pos + 1].as_ptr() as usize - lines[0].as_ptr() as usize;
228            diags.push(SyntaxError {
229                range: TextRange::new(
230                    ((host_start + start) as u32).into(),
231                    ((host_start + end) as u32).into(),
232                ),
233                message: message.to_string(),
234                source: SyntaxErrorSource::Yaml,
235            });
236            for content_line in lines.iter().take(closing_pos).skip(pos + 1) {
237                emit_line_tokens(builder, content_line);
238            }
239        }
240        YamlContentOutcome::Valid { stream } => {
241            copy_green_children(builder, stream);
242        }
243    }
244    builder.finish_node(); // YAML_METADATA_CONTENT
245
246    let (closing_text, closing_newline) = strip_newline(lines[closing_pos]);
247    builder.token(SyntaxKind::YAML_METADATA_DELIM.into(), closing_text);
248    if !closing_newline.is_empty() {
249        builder.token(SyntaxKind::NEWLINE.into(), closing_newline);
250    }
251
252    builder.finish_node(); // YamlMetadata
253
254    Some(closing_pos + 1)
255}
256
257/// Try to parse a Pandoc title block starting at the beginning of document.
258/// Returns the new position after the block if successful, None otherwise.
259///
260/// A Pandoc title block:
261/// - Must be at document start (pos == 0)
262/// - Has 1-3 lines starting with `%`
263/// - Format: % title, % author(s), % date
264/// - Continuation lines start with leading space
265pub(crate) fn try_parse_pandoc_title_block(
266    lines: &[&str],
267    pos: usize,
268    builder: &mut GreenNodeBuilder<'static>,
269) -> Option<usize> {
270    if pos != 0 || lines.is_empty() {
271        return None;
272    }
273
274    let first_line = lines[0];
275    if !first_line.trim_start().starts_with('%') {
276        return None;
277    }
278
279    // Start title block node
280    builder.start_node(SyntaxKind::PANDOC_TITLE_BLOCK.into());
281
282    let mut current_pos = 0;
283    let mut field_count = 0;
284
285    // Parse up to 3 fields (title, author, date)
286    while current_pos < lines.len() && field_count < 3 {
287        let line = lines[current_pos];
288
289        // Check if this line starts a field (begins with %)
290        if line.trim_start().starts_with('%') {
291            emit_line_tokens(builder, line);
292            field_count += 1;
293            current_pos += 1;
294
295            // Collect continuation lines (start with leading space, not with %)
296            while current_pos < lines.len() {
297                let cont_line = lines[current_pos];
298                if cont_line.is_empty() {
299                    // Blank line ends title block
300                    break;
301                }
302                if cont_line.trim_start().starts_with('%') {
303                    // Next field
304                    break;
305                }
306                if cont_line.starts_with(' ') || cont_line.starts_with('\t') {
307                    // Continuation line
308                    emit_line_tokens(builder, cont_line);
309                    current_pos += 1;
310                } else {
311                    // Non-continuation, non-% line ends title block
312                    break;
313                }
314            }
315        } else {
316            // Line doesn't start with %, title block ends
317            break;
318        }
319    }
320
321    builder.finish_node(); // PandocTitleBlock
322
323    if field_count > 0 {
324        Some(current_pos)
325    } else {
326        None
327    }
328}
329
330fn mmd_key_value(line: &str) -> Option<(String, String)> {
331    let (key, value) = line.split_once(':')?;
332    let key_trimmed = key.trim();
333    if key_trimmed.is_empty() {
334        return None;
335    }
336    Some((key_trimmed.to_string(), value.trim().to_string()))
337}
338
339/// Try to parse a MultiMarkdown title block starting at the beginning of document.
340/// Returns the new position after the block if successful, None otherwise.
341///
342/// A MultiMarkdown title block:
343/// - Must be at document start (pos == 0)
344/// - Contains one or more `Key: Value` lines
345/// - The first field value must be non-empty
346/// - Continuation lines start with leading space or tab
347/// - Terminates with a blank line
348pub(crate) fn try_parse_mmd_title_block(
349    lines: &[&str],
350    pos: usize,
351    builder: &mut GreenNodeBuilder<'static>,
352) -> Option<usize> {
353    if pos != 0 || lines.is_empty() {
354        return None;
355    }
356
357    let mut current_pos = pos;
358
359    // First line must be a key-value pair with non-empty value.
360    let first = lines[current_pos];
361    let (_first_key, first_value) = mmd_key_value(first)?;
362    if first_value.is_empty() {
363        return None;
364    }
365
366    builder.start_node(SyntaxKind::MMD_TITLE_BLOCK.into());
367
368    while current_pos < lines.len() {
369        let line = lines[current_pos];
370
371        if line.trim().is_empty() {
372            break;
373        }
374
375        if mmd_key_value(line).is_none() {
376            builder.finish_node();
377            return None;
378        }
379
380        emit_line_tokens(builder, line);
381        current_pos += 1;
382
383        // Optional continuation lines (must be indented and not key-value starts).
384        while current_pos < lines.len() {
385            let cont_line = lines[current_pos];
386            if cont_line.trim().is_empty() {
387                break;
388            }
389
390            let trimmed = cont_line.trim_start();
391            if mmd_key_value(trimmed).is_some() {
392                break;
393            }
394
395            if cont_line.starts_with(' ') || cont_line.starts_with('\t') {
396                emit_line_tokens(builder, cont_line);
397                current_pos += 1;
398            } else {
399                builder.finish_node();
400                return None;
401            }
402        }
403    }
404
405    if current_pos >= lines.len() || !lines[current_pos].trim().is_empty() {
406        builder.finish_node();
407        return None;
408    }
409
410    emit_line_tokens(builder, lines[current_pos]);
411    current_pos += 1;
412
413    builder.finish_node(); // MMD_TITLE_BLOCK
414    Some(current_pos)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_yaml_block_at_start() {
423        let lines = vec!["---", "title: Test", "---", "Content"];
424        let mut builder = GreenNodeBuilder::new();
425        let result = try_parse_yaml_block(
426            &lines,
427            0,
428            &mut builder,
429            true,
430            &Diagnostics::default(),
431            Flavor::Pandoc,
432        );
433        assert_eq!(result, Some(3));
434    }
435
436    #[test]
437    fn test_yaml_block_not_at_start() {
438        let lines = vec!["Paragraph", "", "---", "title: Test", "---", "Content"];
439        let mut builder = GreenNodeBuilder::new();
440        let result = try_parse_yaml_block(
441            &lines,
442            2,
443            &mut builder,
444            false,
445            &Diagnostics::default(),
446            Flavor::Pandoc,
447        );
448        assert_eq!(result, Some(5));
449    }
450
451    #[test]
452    fn test_horizontal_rule_not_yaml() {
453        let lines = vec!["---", "", "Content"];
454        let mut builder = GreenNodeBuilder::new();
455        let result = try_parse_yaml_block(
456            &lines,
457            0,
458            &mut builder,
459            true,
460            &Diagnostics::default(),
461            Flavor::Pandoc,
462        );
463        assert_eq!(result, None); // Followed by blank line, so not YAML
464    }
465
466    #[test]
467    fn test_yaml_with_dots_closer() {
468        let lines = vec!["---", "title: Test", "...", "Content"];
469        let mut builder = GreenNodeBuilder::new();
470        let result = try_parse_yaml_block(
471            &lines,
472            0,
473            &mut builder,
474            true,
475            &Diagnostics::default(),
476            Flavor::Pandoc,
477        );
478        assert_eq!(result, Some(3));
479    }
480
481    #[test]
482    fn test_yaml_without_closing_delimiter_is_not_yaml_block() {
483        let lines = vec!["---", "title: Test", "Content"];
484        let mut builder = GreenNodeBuilder::new();
485        let result = try_parse_yaml_block(
486            &lines,
487            0,
488            &mut builder,
489            true,
490            &Diagnostics::default(),
491            Flavor::Pandoc,
492        );
493        assert_eq!(result, None);
494    }
495
496    #[test]
497    fn test_find_yaml_block_closing_pos() {
498        let lines = vec!["---", "title: Test", "---", "Content"];
499        let result = find_yaml_block_closing_pos(&lines, 0, true);
500        assert_eq!(result, Some(2));
501    }
502
503    #[test]
504    fn test_nonmapping_yaml_is_not_metadata() {
505        // Pandoc backtracks when the content is well-formed YAML whose top
506        // level is not a mapping (scalar, sequence): the lines reparse as
507        // ordinary blocks (simple table, or HR + paragraph).
508        for content in ["- a", "plain prose here", "42", "[a, b]"] {
509            let lines = vec!["---", content, "---"];
510            let mut builder = GreenNodeBuilder::new();
511            let result = try_parse_yaml_block(
512                &lines,
513                0,
514                &mut builder,
515                true,
516                &Diagnostics::default(),
517                Flavor::Pandoc,
518            );
519            assert_eq!(result, None, "{content:?} should not parse as metadata");
520        }
521    }
522
523    #[test]
524    fn test_mapping_and_null_yaml_is_metadata() {
525        // Pandoc accepts a top-level mapping, and null or comments-only
526        // content (empty metadata).
527        for content in ["foo: bar", "{a: 1}", "null", "~", "# comment"] {
528            let lines = vec!["---", content, "---"];
529            let mut builder = GreenNodeBuilder::new();
530            let result = try_parse_yaml_block(
531                &lines,
532                0,
533                &mut builder,
534                true,
535                &Diagnostics::default(),
536                Flavor::Pandoc,
537            );
538            assert_eq!(result, Some(3), "{content:?} should parse as metadata");
539        }
540    }
541
542    #[test]
543    fn test_nonmapping_yaml_stays_metadata_for_lenient_flavors() {
544        // GFM/CommonMark frontmatter has no asserted YAML metadata consumer,
545        // so the mapping gate does not apply there.
546        let lines = vec!["---", "42", "---"];
547        let mut builder = GreenNodeBuilder::new();
548        let result = try_parse_yaml_block(
549            &lines,
550            0,
551            &mut builder,
552            true,
553            &Diagnostics::default(),
554            Flavor::Gfm,
555        );
556        assert_eq!(result, Some(3));
557    }
558
559    #[test]
560    fn test_invalid_yaml_stays_metadata_with_diagnostic() {
561        // Pandoc hard-errors on YAML parse exceptions (no fallback
562        // interpretation exists); the lossless analog is keeping the
563        // metadata node and reporting a syntax error.
564        let lines = vec!["---", "foo: \"bar", "---"];
565        let mut builder = GreenNodeBuilder::new();
566        let diags = Diagnostics::default();
567        let result = try_parse_yaml_block(&lines, 0, &mut builder, true, &diags, Flavor::Pandoc);
568        assert_eq!(result, Some(3));
569        assert!(
570            !diags.take().is_empty(),
571            "malformed YAML should surface a syntax error"
572        );
573    }
574
575    #[test]
576    fn test_yaml_block_emits_content_node() {
577        let input = "---\ntitle: Test\nlist:\n  - a\n---\n";
578        let tree = crate::parse(input, Some(crate::ParserOptions::default()));
579        let metadata = tree
580            .descendants()
581            .find(|n| n.kind() == SyntaxKind::YAML_METADATA)
582            .expect("yaml metadata node");
583        let content = metadata
584            .children()
585            .find(|n| n.kind() == SyntaxKind::YAML_METADATA_CONTENT)
586            .expect("yaml metadata content node");
587        assert_eq!(content.text().to_string(), "title: Test\nlist:\n  - a\n");
588    }
589
590    #[test]
591    fn test_pandoc_title_simple() {
592        let lines = vec!["% My Title", "% Author", "% Date", "", "Content"];
593        let mut builder = GreenNodeBuilder::new();
594        let result = try_parse_pandoc_title_block(&lines, 0, &mut builder);
595        assert_eq!(result, Some(3));
596    }
597
598    #[test]
599    fn test_pandoc_title_with_continuation() {
600        let lines = vec![
601            "% My Title",
602            "  on multiple lines",
603            "% Author One",
604            "  Author Two",
605            "% June 15, 2006",
606            "",
607            "Content",
608        ];
609        let mut builder = GreenNodeBuilder::new();
610        let result = try_parse_pandoc_title_block(&lines, 0, &mut builder);
611        assert_eq!(result, Some(5));
612    }
613
614    #[test]
615    fn test_pandoc_title_partial() {
616        let lines = vec!["% My Title", "%", "% June 15, 2006", "", "Content"];
617        let mut builder = GreenNodeBuilder::new();
618        let result = try_parse_pandoc_title_block(&lines, 0, &mut builder);
619        assert_eq!(result, Some(3));
620    }
621
622    #[test]
623    fn test_pandoc_title_not_at_start() {
624        let lines = vec!["Content", "% Title"];
625        let mut builder = GreenNodeBuilder::new();
626        let result = try_parse_pandoc_title_block(&lines, 1, &mut builder);
627        assert_eq!(result, None);
628    }
629
630    #[test]
631    fn test_mmd_title_simple() {
632        let lines = vec!["Title: My Title", "Author: Jane Doe", "", "Content"];
633        let mut builder = GreenNodeBuilder::new();
634        let result = try_parse_mmd_title_block(&lines, 0, &mut builder);
635        assert_eq!(result, Some(3));
636    }
637
638    #[test]
639    fn test_mmd_title_with_continuation() {
640        let lines = vec![
641            "Title: My title",
642            "Author: John Doe",
643            "Comment: This is a sample mmd title block, with",
644            "  a field spanning multiple lines.",
645            "",
646            "Body",
647        ];
648        let mut builder = GreenNodeBuilder::new();
649        let result = try_parse_mmd_title_block(&lines, 0, &mut builder);
650        assert_eq!(result, Some(5));
651    }
652
653    #[test]
654    fn test_mmd_title_requires_non_empty_first_value() {
655        let lines = vec!["Title:", "Author: Jane Doe", "", "Body"];
656        let mut builder = GreenNodeBuilder::new();
657        let result = try_parse_mmd_title_block(&lines, 0, &mut builder);
658        assert_eq!(result, None);
659    }
660
661    #[test]
662    fn test_mmd_title_requires_trailing_blank_line() {
663        let lines = vec!["Title: My Title", "Author: Jane Doe"];
664        let mut builder = GreenNodeBuilder::new();
665        let result = try_parse_mmd_title_block(&lines, 0, &mut builder);
666        assert_eq!(result, None);
667    }
668
669    #[test]
670    fn test_mmd_title_not_at_start() {
671        let lines = vec!["Body", "Title: My Title", ""];
672        let mut builder = GreenNodeBuilder::new();
673        let result = try_parse_mmd_title_block(&lines, 1, &mut builder);
674        assert_eq!(result, None);
675    }
676
677    #[test]
678    fn test_indented_yaml_delimiters_are_lossless() {
679        let input = "    ---\n    title: Test\n    ...\n";
680        let tree = crate::parse(input, Some(crate::ParserOptions::default()));
681        assert_eq!(tree.text().to_string(), input);
682    }
683
684    #[test]
685    fn test_valid_yaml_content_embeds_yaml_document_subtree() {
686        let input = "---\ntitle: Test\nlist:\n  - a\n---\n";
687        let tree = crate::parse(input, Some(crate::ParserOptions::default()));
688        assert_eq!(tree.text().to_string(), input);
689        let content = tree
690            .descendants()
691            .find(|n| n.kind() == SyntaxKind::YAML_METADATA)
692            .and_then(|m| {
693                m.children()
694                    .find(|c| c.kind() == SyntaxKind::YAML_METADATA_CONTENT)
695            })
696            .expect("yaml metadata content node");
697        // YAML_METADATA_CONTENT plays the singleton-stream role; the
698        // YAML_STREAM wrapper is dropped during embedding. The direct
699        // child is the YAML_DOCUMENT covering the full content range.
700        let first_child = content
701            .children()
702            .next()
703            .expect("embedded yaml subtree child");
704        assert_eq!(first_child.kind(), SyntaxKind::YAML_DOCUMENT);
705        assert_eq!(first_child.text_range(), content.text_range());
706        assert!(
707            content
708                .descendants()
709                .all(|n| n.kind() != SyntaxKind::YAML_STREAM),
710            "host embed should not carry the redundant YAML_STREAM wrapper"
711        );
712    }
713
714    #[test]
715    fn test_invalid_yaml_content_falls_back_to_line_tokens() {
716        // Unterminated single-quoted scalar is rejected by the YAML
717        // validator. The host parser must keep the legacy line-token
718        // shape so losslessness holds and the downstream re-parse still
719        // reports the diagnostic.
720        let input = "---\ntitle: 'unterminated\n---\n";
721        let tree = crate::parse(input, Some(crate::ParserOptions::default()));
722        assert_eq!(tree.text().to_string(), input);
723        let content = tree
724            .descendants()
725            .find(|n| n.kind() == SyntaxKind::YAML_METADATA)
726            .and_then(|m| {
727                m.children()
728                    .find(|c| c.kind() == SyntaxKind::YAML_METADATA_CONTENT)
729            })
730            .expect("yaml metadata content node");
731        assert!(
732            content
733                .children()
734                .all(|c| c.kind() != SyntaxKind::YAML_DOCUMENT),
735            "invalid YAML must not embed a YAML_DOCUMENT subtree"
736        );
737    }
738}