1use crate::Diagnostic;
26use crate::Severity;
27
28#[derive(Debug, Clone, PartialEq)]
34pub(crate) enum PreItem {
35 Field { key: String, fill: bool },
36 Comment { text: String, inline: bool },
37}
38
39pub use crate::value::PathSegment as CommentPathSegment;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct NestedComment {
55 pub container_path: Vec<CommentPathSegment>,
56 pub position: usize,
57 pub text: String,
58 pub inline: bool,
59}
60
61#[derive(Debug, Clone, Default)]
63pub(crate) struct PreScan {
64 pub cleaned_yaml: String,
66 pub items: Vec<PreItem>,
68 pub nested_comments: Vec<NestedComment>,
69 pub nested_fills: Vec<Vec<CommentPathSegment>>,
73 pub warnings: Vec<Diagnostic>,
74 pub fill_target_errors: Vec<String>,
76}
77
78#[derive(Debug)]
79struct Frame {
80 indent: usize,
81 path: Vec<CommentPathSegment>,
82 kind: Option<FrameKind>,
83 child_count: usize,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum FrameKind {
88 Mapping,
89 Sequence,
90}
91
92pub(crate) fn prescan_fence_content(content: &str) -> PreScan {
93 let mut out = PreScan::default();
94
95 let lines: Vec<&str> = content.split('\n').collect();
96 let mut cleaned_lines: Vec<String> = Vec::with_capacity(lines.len());
97
98 let mut stack: Vec<Frame> = vec![Frame {
99 indent: 0,
100 path: Vec::new(),
101 kind: Some(FrameKind::Mapping),
102 child_count: 0,
103 }];
104
105 let mut block_scalar_indent: Option<usize> = None;
109
110 for raw_line in &lines {
111 let line = *raw_line;
112 let indent = leading_space_count(line);
113 let trimmed = &line[indent..];
114
115 if trimmed.is_empty() {
116 cleaned_lines.push(line.to_string());
117 continue;
118 }
119
120 if let Some(key_indent) = block_scalar_indent {
126 if indent > key_indent {
127 cleaned_lines.push(line.to_string());
128 continue;
129 }
130 block_scalar_indent = None;
131 }
132
133 while let Some(frame) = stack.last() {
134 if frame.indent > indent {
135 stack.pop();
136 } else {
137 break;
138 }
139 }
140
141 if trimmed.starts_with('#') {
143 let text = strip_comment_marker(trimmed);
144 let frame = stack.last().expect("root frame always present");
145
146 if frame.path.is_empty() {
147 out.items.push(PreItem::Comment {
149 text: text.to_string(),
150 inline: false,
151 });
152 } else {
153 out.nested_comments.push(NestedComment {
154 container_path: frame.path.clone(),
155 position: frame.child_count,
156 text: text.to_string(),
157 inline: false,
158 });
159 }
160 continue;
161 }
162
163 if trimmed == "-" || trimmed.starts_with("- ") {
165 let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Sequence);
166 let frame = &mut stack[frame_idx];
167 let item_index = frame.child_count;
168 frame.child_count += 1;
169 let parent_path: Vec<CommentPathSegment> = frame.path.clone();
170 let item_path: Vec<CommentPathSegment> = {
171 let mut p = parent_path.clone();
172 p.push(CommentPathSegment::Index(item_index));
173 p
174 };
175 while stack.len() > frame_idx + 1 {
176 stack.pop();
177 }
178
179 let after_dash_full = trimmed.strip_prefix("- ").unwrap_or("");
183 let (after_dash, trailing_comment) = split_trailing_comment(after_dash_full);
184 let after_dash_trimmed = after_dash.trim_start();
185 let inline_indent_offset = indent + 2 + (after_dash.len() - after_dash_trimmed.len());
186
187 let mut dash_body_clean: Option<String> = None;
193 if after_dash_trimmed.is_empty() {
194 stack.push(Frame {
195 indent: indent + 2,
196 path: item_path,
197 kind: None,
198 child_count: 0,
199 });
200 } else if let Some((key, after_colon)) = split_key(after_dash_trimmed) {
201 let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
202 inspect_fill_and_tags(&after_colon, &key);
203 if had_non_fill_tag {
204 out.warnings.push(
205 Diagnostic::new(
206 Severity::Warning,
207 format!(
208 "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
209 key
210 ),
211 )
212 .with_code("parse::unsupported_yaml_tag".to_string()),
213 );
214 }
215 if let Some(err) = fill_target_err {
216 out.fill_target_errors.push(err);
217 }
218 if fill {
219 let mut key_path = item_path.clone();
220 key_path.push(CommentPathSegment::Key(key.clone()));
221 out.nested_fills.push(key_path);
222 }
223 if fill || had_non_fill_tag {
224 dash_body_clean = Some(format!("{}:{}", key, value_without_tag));
225 }
226 stack.push(Frame {
227 indent: inline_indent_offset,
228 path: item_path,
229 kind: Some(FrameKind::Mapping),
230 child_count: 1,
231 });
232 }
233
234 if let Some(c) = &trailing_comment {
235 out.nested_comments.push(NestedComment {
236 container_path: parent_path,
237 position: item_index,
238 text: strip_comment_marker(c).to_string(),
239 inline: true,
240 });
241 }
242 if dash_body_clean.is_some() || trailing_comment.is_some() {
245 let head = format!("{:width$}", "", width = indent);
246 let body = match dash_body_clean {
247 Some(b) => format!("- {}", b),
248 None if after_dash.trim_end().is_empty() => "-".to_string(),
249 None => format!("- {}", after_dash.trim_end()),
250 };
251 cleaned_lines.push(format!("{}{}", head, body));
252 } else {
253 cleaned_lines.push(line.to_string());
254 }
255
256 if is_block_scalar_header(after_dash_trimmed) {
261 block_scalar_indent = Some(indent);
262 }
263 continue;
264 }
265
266 let is_top_level = indent == 0;
268 if is_top_level {
269 if let Some((key, after_colon)) = split_key(line) {
270 let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
271
272 let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
273 inspect_fill_and_tags(&value_part, &key);
274
275 if had_non_fill_tag {
276 out.warnings.push(
277 Diagnostic::new(
278 Severity::Warning,
279 format!(
280 "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
281 key
282 ),
283 )
284 .with_code("parse::unsupported_yaml_tag".to_string()),
285 );
286 }
287 if let Some(err) = fill_target_err {
288 out.fill_target_errors.push(err);
289 }
290
291 out.items.push(PreItem::Field {
292 key: key.clone(),
293 fill,
294 });
295
296 let root = &mut stack[0];
297 root.child_count += 1;
298 let key_path = vec![CommentPathSegment::Key(key.clone())];
299
300 while stack.len() > 1 {
301 stack.pop();
302 }
303
304 if has_empty_inline_value(&value_without_tag) {
305 stack.push(Frame {
306 indent: 2,
307 path: key_path,
308 kind: None,
309 child_count: 0,
310 });
311 }
312
313 let cleaned = format!("{}:{}", key, value_without_tag);
314 cleaned_lines.push(cleaned);
315
316 if let Some(c) = trailing_comment {
317 out.items.push(PreItem::Comment {
318 text: strip_comment_marker(&c).to_string(),
319 inline: true,
320 });
321 }
322
323 if is_block_scalar_header(&value_without_tag) {
324 block_scalar_indent = Some(indent);
325 }
326
327 continue;
328 }
329 }
330
331 if let Some((key, after_colon)) = split_key(trimmed) {
333 let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Mapping);
334 let frame = &mut stack[frame_idx];
335 let key_index = frame.child_count;
336 frame.child_count += 1;
337 let parent_path: Vec<CommentPathSegment> = frame.path.clone();
338 let key_path: Vec<CommentPathSegment> = {
339 let mut p = parent_path.clone();
340 p.push(CommentPathSegment::Key(key.clone()));
341 p
342 };
343 while stack.len() > frame_idx + 1 {
344 stack.pop();
345 }
346
347 let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
348
349 let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
350 inspect_fill_and_tags(&value_part, &key);
351 if had_non_fill_tag {
352 out.warnings.push(
353 Diagnostic::new(
354 Severity::Warning,
355 format!(
356 "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
357 key
358 ),
359 )
360 .with_code("parse::unsupported_yaml_tag".to_string()),
361 );
362 }
363 if let Some(err) = fill_target_err {
364 out.fill_target_errors.push(err);
365 }
366 if fill {
367 out.nested_fills.push(key_path.clone());
368 }
369
370 if trailing_comment.is_some() || fill {
371 if let Some(c) = trailing_comment {
372 out.nested_comments.push(NestedComment {
373 container_path: parent_path,
374 position: key_index,
375 text: strip_comment_marker(&c).to_string(),
376 inline: true,
377 });
378 }
379 let head = format!("{:width$}", "", width = indent);
380 cleaned_lines.push(format!("{}{}:{}", head, key, value_without_tag));
381 } else {
382 cleaned_lines.push(line.to_string());
383 }
384
385 if has_empty_inline_value(&value_without_tag) {
386 stack.push(Frame {
387 indent: indent + 2,
388 path: key_path,
389 kind: None,
390 child_count: 0,
391 });
392 }
393
394 if is_block_scalar_header(&value_without_tag) {
395 block_scalar_indent = Some(indent);
396 }
397 continue;
398 }
399
400 cleaned_lines.push(line.to_string());
401 }
402
403 if cleaned_lines
410 .iter()
411 .any(|l| line_has_unsupported_fill_tag(l))
412 {
413 out.warnings.push(
414 Diagnostic::new(
415 Severity::Warning,
416 "a `!must_fill` marker appears in a flow collection or on a bare \
417 sequence element and is not preserved; use block style \
418 (`key: !must_fill`) to mark a placeholder"
419 .to_string(),
420 )
421 .with_code("parse::fill_marker_unsupported_position".to_string()),
422 );
423 }
424
425 out.cleaned_yaml = cleaned_lines.join("\n");
426 out
427}
428
429fn line_has_unsupported_fill_tag(line: &str) -> bool {
435 for tag in FILL_TAGS {
436 let mut from = 0;
437 while let Some(rel) = line[from..].find(tag) {
438 let at = from + rel;
439 let after = at + tag.len();
440 let trailing_ok = line[after..]
443 .chars()
444 .next()
445 .is_none_or(|c| c.is_whitespace() || matches!(c, ',' | '}' | ']'));
446 let before = line[..at].trim_end_matches([' ', '\t']);
450 let had_ws = before.len() != at;
451 let leading_ok = match before.chars().last() {
452 Some('{') | Some('[') | Some(',') => true,
453 Some(':') | Some('-') => had_ws,
454 _ => false,
455 };
456 if trailing_ok && leading_ok {
457 return true;
458 }
459 from = after;
460 }
461 }
462 false
463}
464
465fn ensure_frame_at_indent(stack: &mut Vec<Frame>, indent: usize, kind: FrameKind) -> usize {
469 let top_idx = stack.len() - 1;
470 let top = &mut stack[top_idx];
471
472 if top.indent == indent {
473 if top.kind.is_none() {
474 top.kind = Some(kind);
475 }
476 return top_idx;
477 }
478
479 let parent_path = top.path.clone();
480 stack.push(Frame {
481 indent,
482 path: parent_path,
483 kind: Some(kind),
484 child_count: 0,
485 });
486 stack.len() - 1
487}
488
489fn strip_comment_marker(raw: &str) -> &str {
490 let after = raw.trim_start_matches('#');
491 after.strip_prefix(' ').unwrap_or(after)
492}
493
494fn leading_space_count(line: &str) -> usize {
495 line.bytes().take_while(|b| *b == b' ').count()
496}
497
498fn is_block_scalar_header(value: &str) -> bool {
503 let t = value.trim_start();
504 t.starts_with('|') || t.starts_with('>')
505}
506
507fn has_empty_inline_value(after_colon: &str) -> bool {
510 let (v, _) = split_trailing_comment(after_colon);
511 v.trim().is_empty()
512}
513
514fn split_key(line: &str) -> Option<(String, String)> {
517 let bytes = line.as_bytes();
518 if bytes.is_empty() {
519 return None;
520 }
521 let mut i;
522 if bytes[0] == b'$' {
523 if bytes.len() < 2 || !(bytes[1].is_ascii_alphabetic() || bytes[1] == b'_') {
524 return None;
525 }
526 i = 2;
527 } else if bytes[0].is_ascii_alphabetic() || bytes[0] == b'_' {
528 i = 1;
529 } else {
530 return None;
531 }
532 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
533 i += 1;
534 }
535 if i >= bytes.len() || bytes[i] != b':' {
536 return None;
537 }
538 let key = line[..i].to_string();
539 let rest = line[i + 1..].to_string();
540 Some((key, rest))
541}
542
543fn split_trailing_comment(value: &str) -> (String, Option<String>) {
550 let bytes = value.as_bytes();
551 let Some(first) = bytes.iter().position(|b| !matches!(b, b' ' | b'\t')) else {
552 return (value.to_string(), None);
553 };
554 match bytes[first] {
555 b'"' | b'\'' => match find_quote_end(bytes, first) {
559 Some(end) => find_comment_from(value, end + 1),
560 None => (value.to_string(), None),
561 },
562 b'[' | b'{' => split_flow_trailing_comment(value),
565 _ => find_comment_from(value, 0),
568 }
569}
570
571fn find_quote_end(bytes: &[u8], start: usize) -> Option<usize> {
574 let quote = bytes[start];
575 let mut i = start + 1;
576 while i < bytes.len() {
577 let b = bytes[i];
578 if quote == b'"' && b == b'\\' {
579 i += 2;
580 continue;
581 }
582 if b == quote {
583 if quote == b'\'' && bytes.get(i + 1) == Some(&b'\'') {
584 i += 2; continue;
586 }
587 return Some(i);
588 }
589 i += 1;
590 }
591 None
592}
593
594fn find_comment_from(value: &str, from: usize) -> (String, Option<String>) {
597 let bytes = value.as_bytes();
598 let mut prev_was_ws = true;
599 for i in from..bytes.len() {
600 let b = bytes[i];
601 if b == b'#' && prev_was_ws {
602 let v = value[..i].trim_end().to_string();
603 let c = value[i..].to_string();
604 return (v, Some(c));
605 }
606 prev_was_ws = matches!(b, b' ' | b'\t');
607 }
608 (value.to_string(), None)
609}
610
611fn split_flow_trailing_comment(value: &str) -> (String, Option<String>) {
615 let bytes = value.as_bytes();
616 let mut i = 0;
617 let mut prev_was_ws = true;
618 let mut in_dq = false;
619 let mut in_sq = false;
620 while i < bytes.len() {
621 let b = bytes[i];
622 if in_dq {
623 if b == b'\\' && i + 1 < bytes.len() {
624 i += 2;
625 continue;
626 }
627 if b == b'"' {
628 in_dq = false;
629 }
630 } else if in_sq {
631 if b == b'\'' {
632 in_sq = false;
633 }
634 } else {
635 if b == b'"' {
636 in_dq = true;
637 } else if b == b'\'' {
638 in_sq = true;
639 } else if b == b'#' && prev_was_ws {
640 let v = value[..i].trim_end().to_string();
641 let c = value[i..].to_string();
642 return (v, Some(c));
643 }
644 }
645 prev_was_ws = matches!(b, b' ' | b'\t');
646 i += 1;
647 }
648 (value.to_string(), None)
649}
650
651const FILL_TAGS: [&str; 1] = ["!must_fill"];
655
656fn strip_fill_tag(trimmed: &str) -> Option<&str> {
660 for tag in FILL_TAGS {
661 if trimmed == tag {
662 return Some("");
663 }
664 if let Some(rest) = trimmed.strip_prefix(tag) {
665 if rest.starts_with(' ') || rest.starts_with('\t') {
666 return Some(rest);
667 }
668 }
669 }
670 None
671}
672
673fn inspect_fill_and_tags(value: &str, key: &str) -> (bool, String, bool, Option<String>) {
679 let trimmed = value.trim_start();
680 let leading_ws_len = value.len() - trimmed.len();
681
682 if trimmed.is_empty() {
683 return (false, value.to_string(), false, None);
684 }
685
686 if let Some(rest) = strip_fill_tag(trimmed) {
687 let rest_trim = rest.trim_start();
688 let err = if rest_trim.starts_with('{') {
689 Some(format!(
690 "`!must_fill` on key `{}` targets a mapping; `!must_fill` is supported on scalars and sequences only",
691 key
692 ))
693 } else {
694 None
695 };
696 let reconstructed = if rest_trim.is_empty() {
697 value[..leading_ws_len].to_string()
698 } else {
699 format!(" {}", rest_trim)
700 };
701 return (true, reconstructed, false, err);
702 }
703
704 if trimmed.starts_with('!') {
705 return (false, value.to_string(), true, None);
706 }
707
708 (false, value.to_string(), false, None)
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn extracts_own_line_comments() {
717 let input = "# top\ntitle: foo\n# mid\nauthor: bar\n";
718 let out = prescan_fence_content(input);
719 assert_eq!(
720 out.items,
721 vec![
722 PreItem::Comment {
723 text: "top".to_string(),
724 inline: false,
725 },
726 PreItem::Field {
727 key: "title".to_string(),
728 fill: false,
729 },
730 PreItem::Comment {
731 text: "mid".to_string(),
732 inline: false,
733 },
734 PreItem::Field {
735 key: "author".to_string(),
736 fill: false,
737 },
738 ]
739 );
740 assert!(out.nested_comments.is_empty());
741 }
742
743 #[test]
744 fn splits_trailing_comments() {
745 let input = "title: foo # inline\n";
746 let out = prescan_fence_content(input);
747 assert_eq!(
748 out.items,
749 vec![
750 PreItem::Field {
751 key: "title".to_string(),
752 fill: false,
753 },
754 PreItem::Comment {
755 text: "inline".to_string(),
756 inline: true,
757 },
758 ]
759 );
760 assert!(out.cleaned_yaml.contains("title: foo"));
761 assert!(!out.cleaned_yaml.contains("inline"));
762 }
763
764 #[test]
765 fn fill_alias_is_rejected_as_noncanonical_tag() {
766 let input = "dept: !fill Department\n";
769 let out = prescan_fence_content(input);
770 assert_eq!(
771 out.items,
772 vec![PreItem::Field {
773 key: "dept".to_string(),
774 fill: false,
775 }]
776 );
777 assert!(
778 out.warnings
779 .iter()
780 .any(|w| w.code.as_deref() == Some("parse::unsupported_yaml_tag")),
781 "`!fill` must warn as an unsupported tag"
782 );
783 }
784
785 #[test]
786 fn detects_must_fill_on_scalar() {
787 let input = "dept: !must_fill Department\n";
788 let out = prescan_fence_content(input);
789 assert_eq!(
790 out.items,
791 vec![PreItem::Field {
792 key: "dept".to_string(),
793 fill: true,
794 }]
795 );
796 assert!(out.cleaned_yaml.contains("dept: Department"));
797 assert!(!out.cleaned_yaml.contains("!must_fill"));
798 assert!(!out.cleaned_yaml.contains("!fill"));
799 }
800
801 #[test]
802 fn detects_bare_must_fill() {
803 let input = "dept: !must_fill\n";
804 let out = prescan_fence_content(input);
805 assert_eq!(
806 out.items,
807 vec![PreItem::Field {
808 key: "dept".to_string(),
809 fill: true,
810 }]
811 );
812 assert!(!out.cleaned_yaml.contains("!must_fill"));
813 }
814
815 #[test]
816 fn fillet_is_not_a_fill_tag() {
817 let input = "x: !must_filler value\n";
821 let out = prescan_fence_content(input);
822 assert_eq!(
823 out.items,
824 vec![PreItem::Field {
825 key: "x".to_string(),
826 fill: false,
827 }]
828 );
829 }
830
831 #[test]
832 fn unknown_tag_warns() {
833 let input = "x: !custom value\n";
834 let out = prescan_fence_content(input);
835 assert!(
836 out.warnings
837 .iter()
838 .any(|w| w.code.as_deref() == Some("parse::unsupported_yaml_tag")),
839 "expected unsupported_yaml_tag warning"
840 );
841 }
842
843 #[test]
844 fn nested_comment_in_sequence_captured() {
845 let input = "arr:\n # before-first\n - a\n # between\n - b\n # after-last\n";
846 let out = prescan_fence_content(input);
847 assert_eq!(
848 out.nested_comments,
849 vec![
850 NestedComment {
851 container_path: vec![CommentPathSegment::Key("arr".to_string())],
852 position: 0,
853 text: "before-first".to_string(),
854 inline: false,
855 },
856 NestedComment {
857 container_path: vec![CommentPathSegment::Key("arr".to_string())],
858 position: 1,
859 text: "between".to_string(),
860 inline: false,
861 },
862 NestedComment {
863 container_path: vec![CommentPathSegment::Key("arr".to_string())],
864 position: 2,
865 text: "after-last".to_string(),
866 inline: false,
867 },
868 ]
869 );
870 assert!(
871 !out.warnings
872 .iter()
873 .any(|w| w.code.as_deref() == Some("parse::comments_in_nested_yaml_dropped")),
874 "nested comments are preserved, so no dropped-comment warning is emitted"
875 );
876 }
877
878 #[test]
879 fn nested_comment_in_mapping_captured() {
880 let input = "outer:\n # comment\n inner: 1\n";
881 let out = prescan_fence_content(input);
882 assert_eq!(
883 out.nested_comments,
884 vec![NestedComment {
885 container_path: vec![CommentPathSegment::Key("outer".to_string())],
886 position: 0,
887 text: "comment".to_string(),
888 inline: false,
889 }]
890 );
891 }
892
893 #[test]
894 fn deep_nested_comment_path() {
895 let input = "outer:\n inner:\n # deep\n leaf: 1\n";
896 let out = prescan_fence_content(input);
897 assert_eq!(
898 out.nested_comments,
899 vec![NestedComment {
900 container_path: vec![
901 CommentPathSegment::Key("outer".to_string()),
902 CommentPathSegment::Key("inner".to_string()),
903 ],
904 position: 0,
905 text: "deep".to_string(),
906 inline: false,
907 }]
908 );
909 }
910
911 #[test]
912 fn comment_inside_seq_of_maps() {
913 let input = "items:\n - name: a\n # inside-first\n val: 1\n - name: b\n";
914 let out = prescan_fence_content(input);
915 assert_eq!(
916 out.nested_comments,
917 vec![NestedComment {
918 container_path: vec![
919 CommentPathSegment::Key("items".to_string()),
920 CommentPathSegment::Index(0),
921 ],
922 position: 1,
923 text: "inside-first".to_string(),
924 inline: false,
925 }]
926 );
927 }
928
929 #[test]
930 fn nested_inline_on_sequence_item() {
931 let input = "arr:\n - a # tail\n - b\n";
932 let out = prescan_fence_content(input);
933 assert_eq!(
934 out.nested_comments,
935 vec![NestedComment {
936 container_path: vec![CommentPathSegment::Key("arr".to_string())],
937 position: 0,
938 text: "tail".to_string(),
939 inline: true,
940 }]
941 );
942 assert!(out.cleaned_yaml.contains("- a\n"));
943 assert!(!out.cleaned_yaml.contains("tail"));
944 }
945
946 #[test]
947 fn nested_inline_on_mapping_field() {
948 let input = "outer:\n inner: 1 # tail\n";
949 let out = prescan_fence_content(input);
950 assert_eq!(
951 out.nested_comments,
952 vec![NestedComment {
953 container_path: vec![CommentPathSegment::Key("outer".to_string())],
954 position: 0,
955 text: "tail".to_string(),
956 inline: true,
957 }]
958 );
959 }
960
961 #[test]
962 fn fill_on_flow_sequence_allowed() {
963 let input = "x: !must_fill [1, 2]\n";
964 let out = prescan_fence_content(input);
965 assert!(
966 out.fill_target_errors.is_empty(),
967 "expected no error; !must_fill on sequences is supported"
968 );
969 assert_eq!(
970 out.items,
971 vec![PreItem::Field {
972 key: "x".to_string(),
973 fill: true,
974 }]
975 );
976 }
977
978 #[test]
979 fn sequence_with_multibyte_after_dash_does_not_panic() {
980 let inputs = [
986 "arr:\n - – en-dash\n - — em-dash\n",
987 "arr:\n - \u{2013}line\n - \u{2014}line\n",
988 "arr:\n - \u{201C}smart-quoted\u{201D}\n",
989 "arr:\n - \u{1F600} emoji\n",
990 "bullets: |\n - (U) **A:** text\n – (U) **B:** text\n",
993 ];
994 for input in inputs {
995 let out = prescan_fence_content(input);
996 assert_eq!(out.cleaned_yaml.lines().count(), input.lines().count());
999 }
1000 }
1001
1002 #[test]
1003 fn block_scalar_content_is_not_parsed_as_structure() {
1004 let input =
1009 "bio: |-\n ## About me\n\n - point one\n role: engineer\n Done.\nname: jane\n";
1010 let out = prescan_fence_content(input);
1011
1012 assert!(
1014 out.cleaned_yaml.contains("## About me"),
1015 "block-scalar heading must survive: {:?}",
1016 out.cleaned_yaml
1017 );
1018 assert!(out.cleaned_yaml.contains("- point one"));
1019 assert!(out.cleaned_yaml.contains("role: engineer"));
1020
1021 assert!(
1023 !out.items.iter().any(|i| matches!(
1024 i,
1025 PreItem::Comment { text, .. } if text.contains("About")
1026 )),
1027 "block-scalar `#` line must not become a comment"
1028 );
1029 assert!(
1030 !out.items
1031 .iter()
1032 .any(|i| matches!(i, PreItem::Field { key, .. } if key == "role")),
1033 "block-scalar `key:` line must not become a field"
1034 );
1035
1036 let fields: Vec<&str> = out
1038 .items
1039 .iter()
1040 .filter_map(|i| match i {
1041 PreItem::Field { key, .. } => Some(key.as_str()),
1042 _ => None,
1043 })
1044 .collect();
1045 assert_eq!(fields, vec!["bio", "name"]);
1046 }
1047
1048 #[test]
1049 fn sequence_item_block_scalar_content_is_not_parsed_as_structure() {
1050 let input = "items:\n - |-\n ## Heading\n - inner bullet\n role: x\n - second\n";
1054 let out = prescan_fence_content(input);
1055
1056 assert!(
1057 out.cleaned_yaml.contains("## Heading"),
1058 "block-scalar heading inside a sequence item must survive: {:?}",
1059 out.cleaned_yaml
1060 );
1061 assert!(out.cleaned_yaml.contains("- inner bullet"));
1062 assert!(out.cleaned_yaml.contains("role: x"));
1063 assert!(
1065 !out.nested_comments
1066 .iter()
1067 .any(|c| c.text.contains("Heading")),
1068 "block-scalar `#` line must not become a nested comment"
1069 );
1070 assert!(out.cleaned_yaml.contains("- second"));
1072 }
1073
1074 #[test]
1075 fn fill_on_flow_mapping_errors() {
1076 let input = "x: !must_fill {a: 1}\n";
1077 let out = prescan_fence_content(input);
1078 assert!(
1079 !out.fill_target_errors.is_empty(),
1080 "expected error; !must_fill on mappings is rejected"
1081 );
1082 }
1083 #[test]
1086 fn comment_after_plain_scalar_with_apostrophe() {
1087 let (v, c) = split_trailing_comment(" it's a test # note");
1090 assert_eq!(v, " it's a test");
1091 assert_eq!(c.as_deref(), Some("# note"));
1092 }
1093
1094 #[test]
1095 fn hash_inside_quoted_scalar_is_not_a_comment() {
1096 let (v, c) = split_trailing_comment(" 'a # b'");
1097 assert_eq!(v, " 'a # b'");
1098 assert_eq!(c, None);
1099
1100 let (v, c) = split_trailing_comment(" \"a # b\"");
1101 assert_eq!(v, " \"a # b\"");
1102 assert_eq!(c, None);
1103 }
1104
1105 #[test]
1106 fn comment_after_quoted_scalar() {
1107 let (v, c) = split_trailing_comment(" 'a # b' # real");
1108 assert_eq!(v, " 'a # b'");
1109 assert_eq!(c.as_deref(), Some("# real"));
1110
1111 let (v, c) = split_trailing_comment(" 'it''s # x' # real");
1113 assert_eq!(v, " 'it''s # x'");
1114 assert_eq!(c.as_deref(), Some("# real"));
1115
1116 let (v, c) = split_trailing_comment(" \"a \\\" # b\" # real");
1118 assert_eq!(v, " \"a \\\" # b\"");
1119 assert_eq!(c.as_deref(), Some("# real"));
1120 }
1121
1122 #[test]
1123 fn unterminated_quote_means_multiline_scalar_no_comment() {
1124 let (v, c) = split_trailing_comment(" \"starts here # not a comment");
1125 assert_eq!(v, " \"starts here # not a comment");
1126 assert_eq!(c, None);
1127 }
1128
1129 #[test]
1130 fn flow_collection_tracks_quotes_anywhere() {
1131 let (v, c) = split_trailing_comment(" [a, \"b # c\"] # real");
1132 assert_eq!(v, " [a, \"b # c\"]");
1133 assert_eq!(c.as_deref(), Some("# real"));
1134
1135 let (v, c) = split_trailing_comment(" [a, \"b # c\"]");
1136 assert_eq!(c, None);
1137 assert_eq!(v, " [a, \"b # c\"]");
1138 }
1139
1140 #[test]
1141 fn hash_without_preceding_whitespace_is_not_a_comment() {
1142 let (v, c) = split_trailing_comment(" a#b");
1143 assert_eq!(v, " a#b");
1144 assert_eq!(c, None);
1145 }
1146}