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