1use crate::Diagnostic;
25use crate::Severity;
26
27#[derive(Debug, Clone, PartialEq)]
33pub enum PreItem {
34 Field { key: String, fill: bool },
35 Comment { text: String, inline: bool },
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum CommentPathSegment {
41 Key(String),
42 Index(usize),
43}
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 warnings: Vec<Diagnostic>,
69 pub fill_target_errors: Vec<String>,
71}
72
73#[derive(Debug)]
74struct Frame {
75 indent: usize,
76 path: Vec<CommentPathSegment>,
77 kind: Option<FrameKind>,
78 child_count: usize,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum FrameKind {
83 Mapping,
84 Sequence,
85}
86
87pub fn prescan_fence_content(content: &str) -> PreScan {
88 let mut out = PreScan::default();
89
90 let lines: Vec<&str> = content.split('\n').collect();
91 let mut cleaned_lines: Vec<String> = Vec::with_capacity(lines.len());
92
93 let mut stack: Vec<Frame> = vec![Frame {
94 indent: 0,
95 path: Vec::new(),
96 kind: Some(FrameKind::Mapping),
97 child_count: 0,
98 }];
99
100 let mut block_scalar_indent: Option<usize> = None;
104
105 for raw_line in &lines {
106 let line = *raw_line;
107 let indent = leading_space_count(line);
108 let trimmed = &line[indent..];
109
110 if trimmed.is_empty() {
111 cleaned_lines.push(line.to_string());
112 continue;
113 }
114
115 if let Some(key_indent) = block_scalar_indent {
121 if indent > key_indent {
122 cleaned_lines.push(line.to_string());
123 continue;
124 }
125 block_scalar_indent = None;
126 }
127
128 while let Some(frame) = stack.last() {
129 if frame.indent > indent {
130 stack.pop();
131 } else {
132 break;
133 }
134 }
135
136 if trimmed.starts_with('#') {
138 let text = strip_comment_marker(trimmed);
139 let frame = stack.last().expect("root frame always present");
140
141 if frame.path.is_empty() {
142 out.items.push(PreItem::Comment {
144 text: text.to_string(),
145 inline: false,
146 });
147 } else {
148 out.nested_comments.push(NestedComment {
149 container_path: frame.path.clone(),
150 position: frame.child_count,
151 text: text.to_string(),
152 inline: false,
153 });
154 }
155 continue;
156 }
157
158 if trimmed == "-" || trimmed.starts_with("- ") {
160 let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Sequence);
161 let frame = &mut stack[frame_idx];
162 let item_index = frame.child_count;
163 frame.child_count += 1;
164 let parent_path: Vec<CommentPathSegment> = frame.path.clone();
165 let item_path: Vec<CommentPathSegment> = {
166 let mut p = parent_path.clone();
167 p.push(CommentPathSegment::Index(item_index));
168 p
169 };
170 while stack.len() > frame_idx + 1 {
171 stack.pop();
172 }
173
174 let after_dash_full = trimmed.strip_prefix("- ").unwrap_or("");
178 let (after_dash, trailing_comment) = split_trailing_comment(after_dash_full);
179 let after_dash_trimmed = after_dash.trim_start();
180 let inline_indent_offset = indent + 2 + (after_dash.len() - after_dash_trimmed.len());
181
182 if after_dash_trimmed.is_empty() {
183 stack.push(Frame {
184 indent: indent + 2,
185 path: item_path,
186 kind: None,
187 child_count: 0,
188 });
189 } else if split_key(after_dash_trimmed).is_some() {
190 stack.push(Frame {
191 indent: inline_indent_offset,
192 path: item_path,
193 kind: Some(FrameKind::Mapping),
194 child_count: 1,
195 });
196 }
197
198 if let Some(c) = trailing_comment {
199 out.nested_comments.push(NestedComment {
200 container_path: parent_path,
201 position: item_index,
202 text: strip_comment_marker(&c).to_string(),
203 inline: true,
204 });
205 let head = format!("{:width$}", "", width = indent);
206 let body = if after_dash.trim_end().is_empty() {
207 "-".to_string()
208 } else {
209 format!("- {}", after_dash.trim_end())
210 };
211 cleaned_lines.push(format!("{}{}", head, body));
212 } else {
213 cleaned_lines.push(line.to_string());
214 }
215
216 if is_block_scalar_header(after_dash_trimmed) {
221 block_scalar_indent = Some(indent);
222 }
223 continue;
224 }
225
226 let is_top_level = indent == 0;
228 if is_top_level {
229 if let Some((key, after_colon)) = split_key(line) {
230 let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
231
232 let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
233 inspect_fill_and_tags(&value_part, &key);
234
235 if had_non_fill_tag {
236 out.warnings.push(
237 Diagnostic::new(
238 Severity::Warning,
239 format!(
240 "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
241 key
242 ),
243 )
244 .with_code("parse::unsupported_yaml_tag".to_string()),
245 );
246 }
247 if let Some(err) = fill_target_err {
248 out.fill_target_errors.push(err);
249 }
250
251 out.items.push(PreItem::Field {
252 key: key.clone(),
253 fill,
254 });
255
256 let root = &mut stack[0];
257 root.child_count += 1;
258 let key_path = vec![CommentPathSegment::Key(key.clone())];
259
260 while stack.len() > 1 {
261 stack.pop();
262 }
263
264 if has_empty_inline_value(&value_without_tag) {
265 stack.push(Frame {
266 indent: 2,
267 path: key_path,
268 kind: None,
269 child_count: 0,
270 });
271 }
272
273 let cleaned = format!("{}:{}", key, value_without_tag);
274 cleaned_lines.push(cleaned);
275
276 if let Some(c) = trailing_comment {
277 out.items.push(PreItem::Comment {
278 text: strip_comment_marker(&c).to_string(),
279 inline: true,
280 });
281 }
282
283 if is_block_scalar_header(&value_without_tag) {
284 block_scalar_indent = Some(indent);
285 }
286
287 continue;
288 }
289 }
290
291 if let Some((key, after_colon)) = split_key(trimmed) {
293 let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Mapping);
294 let frame = &mut stack[frame_idx];
295 let key_index = frame.child_count;
296 frame.child_count += 1;
297 let parent_path: Vec<CommentPathSegment> = frame.path.clone();
298 let key_path: Vec<CommentPathSegment> = {
299 let mut p = parent_path.clone();
300 p.push(CommentPathSegment::Key(key.clone()));
301 p
302 };
303 while stack.len() > frame_idx + 1 {
304 stack.pop();
305 }
306
307 let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
308 if let Some(c) = trailing_comment {
309 out.nested_comments.push(NestedComment {
310 container_path: parent_path,
311 position: key_index,
312 text: strip_comment_marker(&c).to_string(),
313 inline: true,
314 });
315 let head = format!("{:width$}", "", width = indent);
316 cleaned_lines.push(format!("{}{}:{}", head, key, value_part));
317 } else {
318 cleaned_lines.push(line.to_string());
319 }
320
321 if has_empty_inline_value(&after_colon) {
322 stack.push(Frame {
323 indent: indent + 2,
324 path: key_path,
325 kind: None,
326 child_count: 0,
327 });
328 }
329
330 if is_block_scalar_header(&value_part) {
331 block_scalar_indent = Some(indent);
332 }
333 continue;
334 }
335
336 cleaned_lines.push(line.to_string());
337 }
338
339 out.cleaned_yaml = cleaned_lines.join("\n");
340 out
341}
342
343fn ensure_frame_at_indent(stack: &mut Vec<Frame>, indent: usize, kind: FrameKind) -> usize {
347 let top_idx = stack.len() - 1;
348 let top = &mut stack[top_idx];
349
350 if top.indent == indent {
351 if top.kind.is_none() {
352 top.kind = Some(kind);
353 }
354 return top_idx;
355 }
356
357 let parent_path = top.path.clone();
358 stack.push(Frame {
359 indent,
360 path: parent_path,
361 kind: Some(kind),
362 child_count: 0,
363 });
364 stack.len() - 1
365}
366
367fn strip_comment_marker(raw: &str) -> &str {
368 let after = raw.trim_start_matches('#');
369 after.strip_prefix(' ').unwrap_or(after)
370}
371
372fn leading_space_count(line: &str) -> usize {
373 line.bytes().take_while(|b| *b == b' ').count()
374}
375
376fn is_block_scalar_header(value: &str) -> bool {
381 let t = value.trim_start();
382 t.starts_with('|') || t.starts_with('>')
383}
384
385fn has_empty_inline_value(after_colon: &str) -> bool {
388 let (v, _) = split_trailing_comment(after_colon);
389 v.trim().is_empty()
390}
391
392fn split_key(line: &str) -> Option<(String, String)> {
395 let bytes = line.as_bytes();
396 if bytes.is_empty() {
397 return None;
398 }
399 let mut i;
400 if bytes[0] == b'$' {
401 if bytes.len() < 2 || !(bytes[1].is_ascii_alphabetic() || bytes[1] == b'_') {
402 return None;
403 }
404 i = 2;
405 } else if bytes[0].is_ascii_alphabetic() || bytes[0] == b'_' {
406 i = 1;
407 } else {
408 return None;
409 }
410 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
411 i += 1;
412 }
413 if i >= bytes.len() || bytes[i] != b':' {
414 return None;
415 }
416 let key = line[..i].to_string();
417 let rest = line[i + 1..].to_string();
418 Some((key, rest))
419}
420
421fn split_trailing_comment(value: &str) -> (String, Option<String>) {
428 let bytes = value.as_bytes();
429 let Some(first) = bytes.iter().position(|b| !matches!(b, b' ' | b'\t')) else {
430 return (value.to_string(), None);
431 };
432 match bytes[first] {
433 b'"' | b'\'' => match find_quote_end(bytes, first) {
437 Some(end) => find_comment_from(value, end + 1),
438 None => (value.to_string(), None),
439 },
440 b'[' | b'{' => split_flow_trailing_comment(value),
443 _ => find_comment_from(value, 0),
446 }
447}
448
449fn find_quote_end(bytes: &[u8], start: usize) -> Option<usize> {
452 let quote = bytes[start];
453 let mut i = start + 1;
454 while i < bytes.len() {
455 let b = bytes[i];
456 if quote == b'"' && b == b'\\' {
457 i += 2;
458 continue;
459 }
460 if b == quote {
461 if quote == b'\'' && bytes.get(i + 1) == Some(&b'\'') {
462 i += 2; continue;
464 }
465 return Some(i);
466 }
467 i += 1;
468 }
469 None
470}
471
472fn find_comment_from(value: &str, from: usize) -> (String, Option<String>) {
475 let bytes = value.as_bytes();
476 let mut prev_was_ws = true;
477 for i in from..bytes.len() {
478 let b = bytes[i];
479 if b == b'#' && prev_was_ws {
480 let v = value[..i].trim_end().to_string();
481 let c = value[i..].to_string();
482 return (v, Some(c));
483 }
484 prev_was_ws = matches!(b, b' ' | b'\t');
485 }
486 (value.to_string(), None)
487}
488
489fn split_flow_trailing_comment(value: &str) -> (String, Option<String>) {
493 let bytes = value.as_bytes();
494 let mut i = 0;
495 let mut prev_was_ws = true;
496 let mut in_dq = false;
497 let mut in_sq = false;
498 while i < bytes.len() {
499 let b = bytes[i];
500 if in_dq {
501 if b == b'\\' && i + 1 < bytes.len() {
502 i += 2;
503 continue;
504 }
505 if b == b'"' {
506 in_dq = false;
507 }
508 } else if in_sq {
509 if b == b'\'' {
510 in_sq = false;
511 }
512 } else {
513 if b == b'"' {
514 in_dq = true;
515 } else if b == b'\'' {
516 in_sq = true;
517 } else if b == b'#' && prev_was_ws {
518 let v = value[..i].trim_end().to_string();
519 let c = value[i..].to_string();
520 return (v, Some(c));
521 }
522 }
523 prev_was_ws = matches!(b, b' ' | b'\t');
524 i += 1;
525 }
526 (value.to_string(), None)
527}
528
529fn inspect_fill_and_tags(value: &str, key: &str) -> (bool, String, bool, Option<String>) {
535 let trimmed = value.trim_start();
536 let leading_ws_len = value.len() - trimmed.len();
537
538 if trimmed.is_empty() {
539 return (false, value.to_string(), false, None);
540 }
541
542 if trimmed == "!fill" {
543 let reconstructed = value[..leading_ws_len].to_string();
544 return (true, reconstructed, false, None);
545 }
546
547 if let Some(rest) = trimmed.strip_prefix("!fill") {
548 if rest.starts_with(' ') || rest.starts_with('\t') || rest.is_empty() {
549 let rest_trim = rest.trim_start();
550 let err = if rest_trim.starts_with('{') {
551 Some(format!(
552 "`!fill` on key `{}` targets a mapping; `!fill` is supported on scalars and sequences only",
553 key
554 ))
555 } else {
556 None
557 };
558 let reconstructed = if rest_trim.is_empty() {
559 value[..leading_ws_len].to_string()
560 } else {
561 format!(" {}", rest_trim)
562 };
563 return (true, reconstructed, false, err);
564 }
565 }
566
567 if trimmed.starts_with('!') {
568 return (false, value.to_string(), true, None);
569 }
570
571 (false, value.to_string(), false, None)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn extracts_own_line_comments() {
580 let input = "# top\ntitle: foo\n# mid\nauthor: bar\n";
581 let out = prescan_fence_content(input);
582 assert_eq!(
583 out.items,
584 vec![
585 PreItem::Comment {
586 text: "top".to_string(),
587 inline: false,
588 },
589 PreItem::Field {
590 key: "title".to_string(),
591 fill: false,
592 },
593 PreItem::Comment {
594 text: "mid".to_string(),
595 inline: false,
596 },
597 PreItem::Field {
598 key: "author".to_string(),
599 fill: false,
600 },
601 ]
602 );
603 assert!(out.nested_comments.is_empty());
604 }
605
606 #[test]
607 fn splits_trailing_comments() {
608 let input = "title: foo # inline\n";
609 let out = prescan_fence_content(input);
610 assert_eq!(
611 out.items,
612 vec![
613 PreItem::Field {
614 key: "title".to_string(),
615 fill: false,
616 },
617 PreItem::Comment {
618 text: "inline".to_string(),
619 inline: true,
620 },
621 ]
622 );
623 assert!(out.cleaned_yaml.contains("title: foo"));
624 assert!(!out.cleaned_yaml.contains("inline"));
625 }
626
627 #[test]
628 fn detects_fill_on_scalar() {
629 let input = "dept: !fill Department\n";
630 let out = prescan_fence_content(input);
631 assert_eq!(
632 out.items,
633 vec![PreItem::Field {
634 key: "dept".to_string(),
635 fill: true,
636 }]
637 );
638 assert!(out.cleaned_yaml.contains("dept: Department"));
639 assert!(!out.cleaned_yaml.contains("!fill"));
640 }
641
642 #[test]
643 fn detects_bare_fill() {
644 let input = "dept: !fill\n";
645 let out = prescan_fence_content(input);
646 assert_eq!(
647 out.items,
648 vec![PreItem::Field {
649 key: "dept".to_string(),
650 fill: true,
651 }]
652 );
653 assert!(!out.cleaned_yaml.contains("!fill"));
654 }
655
656 #[test]
657 fn unknown_tag_warns() {
658 let input = "x: !custom value\n";
659 let out = prescan_fence_content(input);
660 assert!(
661 out.warnings
662 .iter()
663 .any(|w| w.code.as_deref() == Some("parse::unsupported_yaml_tag")),
664 "expected unsupported_yaml_tag warning"
665 );
666 }
667
668 #[test]
669 fn nested_comment_in_sequence_captured() {
670 let input = "arr:\n # before-first\n - a\n # between\n - b\n # after-last\n";
671 let out = prescan_fence_content(input);
672 assert_eq!(
673 out.nested_comments,
674 vec![
675 NestedComment {
676 container_path: vec![CommentPathSegment::Key("arr".to_string())],
677 position: 0,
678 text: "before-first".to_string(),
679 inline: false,
680 },
681 NestedComment {
682 container_path: vec![CommentPathSegment::Key("arr".to_string())],
683 position: 1,
684 text: "between".to_string(),
685 inline: false,
686 },
687 NestedComment {
688 container_path: vec![CommentPathSegment::Key("arr".to_string())],
689 position: 2,
690 text: "after-last".to_string(),
691 inline: false,
692 },
693 ]
694 );
695 assert!(
696 !out.warnings
697 .iter()
698 .any(|w| w.code.as_deref() == Some("parse::comments_in_nested_yaml_dropped")),
699 "nested comments are preserved, so no dropped-comment warning is emitted"
700 );
701 }
702
703 #[test]
704 fn nested_comment_in_mapping_captured() {
705 let input = "outer:\n # comment\n inner: 1\n";
706 let out = prescan_fence_content(input);
707 assert_eq!(
708 out.nested_comments,
709 vec![NestedComment {
710 container_path: vec![CommentPathSegment::Key("outer".to_string())],
711 position: 0,
712 text: "comment".to_string(),
713 inline: false,
714 }]
715 );
716 }
717
718 #[test]
719 fn deep_nested_comment_path() {
720 let input = "outer:\n inner:\n # deep\n leaf: 1\n";
721 let out = prescan_fence_content(input);
722 assert_eq!(
723 out.nested_comments,
724 vec![NestedComment {
725 container_path: vec![
726 CommentPathSegment::Key("outer".to_string()),
727 CommentPathSegment::Key("inner".to_string()),
728 ],
729 position: 0,
730 text: "deep".to_string(),
731 inline: false,
732 }]
733 );
734 }
735
736 #[test]
737 fn comment_inside_seq_of_maps() {
738 let input = "items:\n - name: a\n # inside-first\n val: 1\n - name: b\n";
739 let out = prescan_fence_content(input);
740 assert_eq!(
741 out.nested_comments,
742 vec![NestedComment {
743 container_path: vec![
744 CommentPathSegment::Key("items".to_string()),
745 CommentPathSegment::Index(0),
746 ],
747 position: 1,
748 text: "inside-first".to_string(),
749 inline: false,
750 }]
751 );
752 }
753
754 #[test]
755 fn nested_inline_on_sequence_item() {
756 let input = "arr:\n - a # tail\n - b\n";
757 let out = prescan_fence_content(input);
758 assert_eq!(
759 out.nested_comments,
760 vec![NestedComment {
761 container_path: vec![CommentPathSegment::Key("arr".to_string())],
762 position: 0,
763 text: "tail".to_string(),
764 inline: true,
765 }]
766 );
767 assert!(out.cleaned_yaml.contains("- a\n"));
768 assert!(!out.cleaned_yaml.contains("tail"));
769 }
770
771 #[test]
772 fn nested_inline_on_mapping_field() {
773 let input = "outer:\n inner: 1 # tail\n";
774 let out = prescan_fence_content(input);
775 assert_eq!(
776 out.nested_comments,
777 vec![NestedComment {
778 container_path: vec![CommentPathSegment::Key("outer".to_string())],
779 position: 0,
780 text: "tail".to_string(),
781 inline: true,
782 }]
783 );
784 }
785
786 #[test]
787 fn fill_on_flow_sequence_allowed() {
788 let input = "x: !fill [1, 2]\n";
789 let out = prescan_fence_content(input);
790 assert!(
791 out.fill_target_errors.is_empty(),
792 "expected no error; !fill on sequences is supported"
793 );
794 assert_eq!(
795 out.items,
796 vec![PreItem::Field {
797 key: "x".to_string(),
798 fill: true,
799 }]
800 );
801 }
802
803 #[test]
804 fn sequence_with_multibyte_after_dash_does_not_panic() {
805 let inputs = [
811 "arr:\n - – en-dash\n - — em-dash\n",
812 "arr:\n - \u{2013}line\n - \u{2014}line\n",
813 "arr:\n - \u{201C}smart-quoted\u{201D}\n",
814 "arr:\n - \u{1F600} emoji\n",
815 "bullets: |\n - (U) **A:** text\n – (U) **B:** text\n",
818 ];
819 for input in inputs {
820 let out = prescan_fence_content(input);
821 assert_eq!(out.cleaned_yaml.lines().count(), input.lines().count());
824 }
825 }
826
827 #[test]
828 fn block_scalar_content_is_not_parsed_as_structure() {
829 let input =
834 "bio: |-\n ## About me\n\n - point one\n role: engineer\n Done.\nname: jane\n";
835 let out = prescan_fence_content(input);
836
837 assert!(
839 out.cleaned_yaml.contains("## About me"),
840 "block-scalar heading must survive: {:?}",
841 out.cleaned_yaml
842 );
843 assert!(out.cleaned_yaml.contains("- point one"));
844 assert!(out.cleaned_yaml.contains("role: engineer"));
845
846 assert!(
848 !out.items.iter().any(|i| matches!(
849 i,
850 PreItem::Comment { text, .. } if text.contains("About")
851 )),
852 "block-scalar `#` line must not become a comment"
853 );
854 assert!(
855 !out.items
856 .iter()
857 .any(|i| matches!(i, PreItem::Field { key, .. } if key == "role")),
858 "block-scalar `key:` line must not become a field"
859 );
860
861 let fields: Vec<&str> = out
863 .items
864 .iter()
865 .filter_map(|i| match i {
866 PreItem::Field { key, .. } => Some(key.as_str()),
867 _ => None,
868 })
869 .collect();
870 assert_eq!(fields, vec!["bio", "name"]);
871 }
872
873 #[test]
874 fn sequence_item_block_scalar_content_is_not_parsed_as_structure() {
875 let input = "items:\n - |-\n ## Heading\n - inner bullet\n role: x\n - second\n";
879 let out = prescan_fence_content(input);
880
881 assert!(
882 out.cleaned_yaml.contains("## Heading"),
883 "block-scalar heading inside a sequence item must survive: {:?}",
884 out.cleaned_yaml
885 );
886 assert!(out.cleaned_yaml.contains("- inner bullet"));
887 assert!(out.cleaned_yaml.contains("role: x"));
888 assert!(
890 !out.nested_comments
891 .iter()
892 .any(|c| c.text.contains("Heading")),
893 "block-scalar `#` line must not become a nested comment"
894 );
895 assert!(out.cleaned_yaml.contains("- second"));
897 }
898
899 #[test]
900 fn fill_on_flow_mapping_errors() {
901 let input = "x: !fill {a: 1}\n";
902 let out = prescan_fence_content(input);
903 assert!(
904 !out.fill_target_errors.is_empty(),
905 "expected error; !fill on mappings is rejected"
906 );
907 }
908 #[test]
911 fn comment_after_plain_scalar_with_apostrophe() {
912 let (v, c) = split_trailing_comment(" it's a test # note");
915 assert_eq!(v, " it's a test");
916 assert_eq!(c.as_deref(), Some("# note"));
917 }
918
919 #[test]
920 fn comment_after_plain_scalar_with_double_quote() {
921 let (v, c) = split_trailing_comment(" don\"t # note");
922 assert_eq!(v, " don\"t");
923 assert_eq!(c.as_deref(), Some("# note"));
924 }
925
926 #[test]
927 fn hash_inside_quoted_scalar_is_not_a_comment() {
928 let (v, c) = split_trailing_comment(" 'a # b'");
929 assert_eq!(v, " 'a # b'");
930 assert_eq!(c, None);
931
932 let (v, c) = split_trailing_comment(" \"a # b\"");
933 assert_eq!(v, " \"a # b\"");
934 assert_eq!(c, None);
935 }
936
937 #[test]
938 fn comment_after_quoted_scalar() {
939 let (v, c) = split_trailing_comment(" 'a # b' # real");
940 assert_eq!(v, " 'a # b'");
941 assert_eq!(c.as_deref(), Some("# real"));
942
943 let (v, c) = split_trailing_comment(" 'it''s # x' # real");
945 assert_eq!(v, " 'it''s # x'");
946 assert_eq!(c.as_deref(), Some("# real"));
947
948 let (v, c) = split_trailing_comment(" \"a \\\" # b\" # real");
950 assert_eq!(v, " \"a \\\" # b\"");
951 assert_eq!(c.as_deref(), Some("# real"));
952 }
953
954 #[test]
955 fn unterminated_quote_means_multiline_scalar_no_comment() {
956 let (v, c) = split_trailing_comment(" \"starts here # not a comment");
957 assert_eq!(v, " \"starts here # not a comment");
958 assert_eq!(c, None);
959 }
960
961 #[test]
962 fn flow_collection_tracks_quotes_anywhere() {
963 let (v, c) = split_trailing_comment(" [a, \"b # c\"] # real");
964 assert_eq!(v, " [a, \"b # c\"]");
965 assert_eq!(c.as_deref(), Some("# real"));
966
967 let (v, c) = split_trailing_comment(" [a, \"b # c\"]");
968 assert_eq!(c, None);
969 assert_eq!(v, " [a, \"b # c\"]");
970 }
971
972 #[test]
973 fn hash_without_preceding_whitespace_is_not_a_comment() {
974 let (v, c) = split_trailing_comment(" a#b");
975 assert_eq!(v, " a#b");
976 assert_eq!(c, None);
977 }
978
979}