1use 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
11pub(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
33pub(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#[derive(Debug, Clone)]
49pub(crate) enum YamlContentOutcome {
50 Invalid {
54 message: &'static str,
55 start: usize,
56 end: usize,
57 },
58 Valid { stream: GreenNode },
60}
61
62pub(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
90fn 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
120fn 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 if line.trim() != "---" {
146 return None;
147 }
148
149 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 if pos + 1 < lines.len() {
159 let next_line = lines[pos + 1];
160 if next_line.trim().is_empty() {
161 return None;
163 }
164 } else {
165 return None;
167 }
168
169 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 builder.start_node(SyntaxKind::YAML_METADATA.into());
193
194 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 match outcome {
216 YamlContentOutcome::Invalid {
217 message,
218 start,
219 end,
220 } => {
221 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(); 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(); Some(closing_pos + 1)
255}
256
257pub(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 builder.start_node(SyntaxKind::PANDOC_TITLE_BLOCK.into());
281
282 let mut current_pos = 0;
283 let mut field_count = 0;
284
285 while current_pos < lines.len() && field_count < 3 {
287 let line = lines[current_pos];
288
289 if line.trim_start().starts_with('%') {
291 emit_line_tokens(builder, line);
292 field_count += 1;
293 current_pos += 1;
294
295 while current_pos < lines.len() {
297 let cont_line = lines[current_pos];
298 if cont_line.is_empty() {
299 break;
301 }
302 if cont_line.trim_start().starts_with('%') {
303 break;
305 }
306 if cont_line.starts_with(' ') || cont_line.starts_with('\t') {
307 emit_line_tokens(builder, cont_line);
309 current_pos += 1;
310 } else {
311 break;
313 }
314 }
315 } else {
316 break;
318 }
319 }
320
321 builder.finish_node(); 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
339pub(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 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 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(); 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); }
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 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 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 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 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 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 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}