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 for raw_line in &lines {
101 let line = *raw_line;
102 let indent = leading_space_count(line);
103 let trimmed = &line[indent..];
104
105 if trimmed.is_empty() {
106 cleaned_lines.push(line.to_string());
107 continue;
108 }
109
110 while let Some(frame) = stack.last() {
111 if frame.indent > indent {
112 stack.pop();
113 } else {
114 break;
115 }
116 }
117
118 if trimmed.starts_with('#') {
120 let text = strip_comment_marker(trimmed);
121 let frame = stack.last().expect("root frame always present");
122
123 if frame.path.is_empty() {
124 out.items.push(PreItem::Comment {
126 text: text.to_string(),
127 inline: false,
128 });
129 } else {
130 out.nested_comments.push(NestedComment {
131 container_path: frame.path.clone(),
132 position: frame.child_count,
133 text: text.to_string(),
134 inline: false,
135 });
136 }
137 continue;
138 }
139
140 if trimmed == "-" || trimmed.starts_with("- ") {
142 let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Sequence);
143 let frame = &mut stack[frame_idx];
144 let item_index = frame.child_count;
145 frame.child_count += 1;
146 let parent_path: Vec<CommentPathSegment> = frame.path.clone();
147 let item_path: Vec<CommentPathSegment> = {
148 let mut p = parent_path.clone();
149 p.push(CommentPathSegment::Index(item_index));
150 p
151 };
152 while stack.len() > frame_idx + 1 {
153 stack.pop();
154 }
155
156 let after_dash_full = trimmed.strip_prefix("- ").unwrap_or("");
160 let (after_dash, trailing_comment) = split_trailing_comment(after_dash_full);
161 let after_dash_trimmed = after_dash.trim_start();
162 let inline_indent_offset = indent + 2 + (after_dash.len() - after_dash_trimmed.len());
163
164 if after_dash_trimmed.is_empty() {
165 stack.push(Frame {
166 indent: indent + 2,
167 path: item_path,
168 kind: None,
169 child_count: 0,
170 });
171 } else if split_key(after_dash_trimmed).is_some() {
172 stack.push(Frame {
173 indent: inline_indent_offset,
174 path: item_path,
175 kind: Some(FrameKind::Mapping),
176 child_count: 1,
177 });
178 }
179
180 if let Some(c) = trailing_comment {
181 out.nested_comments.push(NestedComment {
182 container_path: parent_path,
183 position: item_index,
184 text: strip_comment_marker(&c).to_string(),
185 inline: true,
186 });
187 let head = format!("{:width$}", "", width = indent);
188 let body = if after_dash.trim_end().is_empty() {
189 "-".to_string()
190 } else {
191 format!("- {}", after_dash.trim_end())
192 };
193 cleaned_lines.push(format!("{}{}", head, body));
194 } else {
195 cleaned_lines.push(line.to_string());
196 }
197 continue;
198 }
199
200 let is_top_level = indent == 0;
202 if is_top_level {
203 if let Some((key, after_colon)) = split_key(line) {
204 let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
205
206 let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
207 inspect_fill_and_tags(&value_part, &key);
208
209 if had_non_fill_tag {
210 out.warnings.push(
211 Diagnostic::new(
212 Severity::Warning,
213 format!(
214 "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
215 key
216 ),
217 )
218 .with_code("parse::unsupported_yaml_tag".to_string()),
219 );
220 }
221 if let Some(err) = fill_target_err {
222 out.fill_target_errors.push(err);
223 }
224
225 out.items.push(PreItem::Field {
226 key: key.clone(),
227 fill,
228 });
229
230 let root = &mut stack[0];
231 root.child_count += 1;
232 let key_path = vec![CommentPathSegment::Key(key.clone())];
233
234 while stack.len() > 1 {
235 stack.pop();
236 }
237
238 if has_empty_inline_value(&value_without_tag) {
239 stack.push(Frame {
240 indent: 2,
241 path: key_path,
242 kind: None,
243 child_count: 0,
244 });
245 }
246
247 let cleaned = format!("{}:{}", key, value_without_tag);
248 cleaned_lines.push(cleaned);
249
250 if let Some(c) = trailing_comment {
251 out.items.push(PreItem::Comment {
252 text: strip_comment_marker(&c).to_string(),
253 inline: true,
254 });
255 }
256
257 continue;
258 }
259 }
260
261 if let Some((key, after_colon)) = split_key(trimmed) {
263 let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Mapping);
264 let frame = &mut stack[frame_idx];
265 let key_index = frame.child_count;
266 frame.child_count += 1;
267 let parent_path: Vec<CommentPathSegment> = frame.path.clone();
268 let key_path: Vec<CommentPathSegment> = {
269 let mut p = parent_path.clone();
270 p.push(CommentPathSegment::Key(key.clone()));
271 p
272 };
273 while stack.len() > frame_idx + 1 {
274 stack.pop();
275 }
276
277 let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
278 if let Some(c) = trailing_comment {
279 out.nested_comments.push(NestedComment {
280 container_path: parent_path,
281 position: key_index,
282 text: strip_comment_marker(&c).to_string(),
283 inline: true,
284 });
285 let head = format!("{:width$}", "", width = indent);
286 cleaned_lines.push(format!("{}{}:{}", head, key, value_part));
287 } else {
288 cleaned_lines.push(line.to_string());
289 }
290
291 if has_empty_inline_value(&after_colon) {
292 stack.push(Frame {
293 indent: indent + 2,
294 path: key_path,
295 kind: None,
296 child_count: 0,
297 });
298 }
299 continue;
300 }
301
302 cleaned_lines.push(line.to_string());
303 }
304
305 out.cleaned_yaml = cleaned_lines.join("\n");
306 out
307}
308
309fn ensure_frame_at_indent(stack: &mut Vec<Frame>, indent: usize, kind: FrameKind) -> usize {
313 let top_idx = stack.len() - 1;
314 let top = &mut stack[top_idx];
315
316 if top.indent == indent {
317 if top.kind.is_none() {
318 top.kind = Some(kind);
319 }
320 return top_idx;
321 }
322
323 let parent_path = top.path.clone();
324 stack.push(Frame {
325 indent,
326 path: parent_path,
327 kind: Some(kind),
328 child_count: 0,
329 });
330 stack.len() - 1
331}
332
333fn strip_comment_marker(raw: &str) -> &str {
334 let after = raw.trim_start_matches('#');
335 after.strip_prefix(' ').unwrap_or(after)
336}
337
338fn leading_space_count(line: &str) -> usize {
339 line.bytes().take_while(|b| *b == b' ').count()
340}
341
342fn has_empty_inline_value(after_colon: &str) -> bool {
345 let (v, _) = split_trailing_comment(after_colon);
346 v.trim().is_empty()
347}
348
349fn split_key(line: &str) -> Option<(String, String)> {
352 let bytes = line.as_bytes();
353 if bytes.is_empty() {
354 return None;
355 }
356 let mut i;
357 if bytes[0] == b'$' {
358 if bytes.len() < 2 || !(bytes[1].is_ascii_alphabetic() || bytes[1] == b'_') {
359 return None;
360 }
361 i = 2;
362 } else if bytes[0].is_ascii_alphabetic() || bytes[0] == b'_' {
363 i = 1;
364 } else {
365 return None;
366 }
367 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
368 i += 1;
369 }
370 if i >= bytes.len() || bytes[i] != b':' {
371 return None;
372 }
373 let key = line[..i].to_string();
374 let rest = line[i + 1..].to_string();
375 Some((key, rest))
376}
377
378fn split_trailing_comment(value: &str) -> (String, Option<String>) {
382 let bytes = value.as_bytes();
383 let mut i = 0;
384 let mut prev_was_ws = true;
385 let mut in_dq = false;
386 let mut in_sq = false;
387 while i < bytes.len() {
388 let b = bytes[i];
389 if in_dq {
390 if b == b'\\' && i + 1 < bytes.len() {
391 i += 2;
392 continue;
393 }
394 if b == b'"' {
395 in_dq = false;
396 }
397 } else if in_sq {
398 if b == b'\'' {
399 in_sq = false;
400 }
401 } else {
402 if b == b'"' {
403 in_dq = true;
404 } else if b == b'\'' {
405 in_sq = true;
406 } else if b == b'#' && prev_was_ws {
407 let v = value[..i].trim_end().to_string();
408 let c = value[i..].to_string();
409 return (v, Some(c));
410 }
411 }
412 prev_was_ws = matches!(b, b' ' | b'\t');
413 i += 1;
414 }
415 (value.to_string(), None)
416}
417
418fn inspect_fill_and_tags(value: &str, key: &str) -> (bool, String, bool, Option<String>) {
424 let trimmed = value.trim_start();
425 let leading_ws_len = value.len() - trimmed.len();
426
427 if trimmed.is_empty() {
428 return (false, value.to_string(), false, None);
429 }
430
431 if trimmed == "!fill" {
432 let reconstructed = value[..leading_ws_len].to_string();
433 return (true, reconstructed, false, None);
434 }
435
436 if let Some(rest) = trimmed.strip_prefix("!fill") {
437 if rest.starts_with(' ') || rest.starts_with('\t') || rest.is_empty() {
438 let rest_trim = rest.trim_start();
439 let err = if rest_trim.starts_with('{') {
440 Some(format!(
441 "`!fill` on key `{}` targets a mapping; `!fill` is supported on scalars and sequences only",
442 key
443 ))
444 } else {
445 None
446 };
447 let reconstructed = if rest_trim.is_empty() {
448 value[..leading_ws_len].to_string()
449 } else {
450 format!(" {}", rest_trim)
451 };
452 return (true, reconstructed, false, err);
453 }
454 }
455
456 if trimmed.starts_with('!') {
457 return (false, value.to_string(), true, None);
458 }
459
460 (false, value.to_string(), false, None)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn extracts_own_line_comments() {
469 let input = "# top\ntitle: foo\n# mid\nauthor: bar\n";
470 let out = prescan_fence_content(input);
471 assert_eq!(
472 out.items,
473 vec![
474 PreItem::Comment {
475 text: "top".to_string(),
476 inline: false,
477 },
478 PreItem::Field {
479 key: "title".to_string(),
480 fill: false,
481 },
482 PreItem::Comment {
483 text: "mid".to_string(),
484 inline: false,
485 },
486 PreItem::Field {
487 key: "author".to_string(),
488 fill: false,
489 },
490 ]
491 );
492 assert!(out.nested_comments.is_empty());
493 }
494
495 #[test]
496 fn splits_trailing_comments() {
497 let input = "title: foo # inline\n";
498 let out = prescan_fence_content(input);
499 assert_eq!(
500 out.items,
501 vec![
502 PreItem::Field {
503 key: "title".to_string(),
504 fill: false,
505 },
506 PreItem::Comment {
507 text: "inline".to_string(),
508 inline: true,
509 },
510 ]
511 );
512 assert!(out.cleaned_yaml.contains("title: foo"));
513 assert!(!out.cleaned_yaml.contains("inline"));
514 }
515
516 #[test]
517 fn detects_fill_on_scalar() {
518 let input = "dept: !fill Department\n";
519 let out = prescan_fence_content(input);
520 assert_eq!(
521 out.items,
522 vec![PreItem::Field {
523 key: "dept".to_string(),
524 fill: true,
525 }]
526 );
527 assert!(out.cleaned_yaml.contains("dept: Department"));
528 assert!(!out.cleaned_yaml.contains("!fill"));
529 }
530
531 #[test]
532 fn detects_bare_fill() {
533 let input = "dept: !fill\n";
534 let out = prescan_fence_content(input);
535 assert_eq!(
536 out.items,
537 vec![PreItem::Field {
538 key: "dept".to_string(),
539 fill: true,
540 }]
541 );
542 assert!(!out.cleaned_yaml.contains("!fill"));
543 }
544
545 #[test]
546 fn unknown_tag_warns() {
547 let input = "x: !custom value\n";
548 let out = prescan_fence_content(input);
549 assert!(
550 out.warnings
551 .iter()
552 .any(|w| w.code.as_deref() == Some("parse::unsupported_yaml_tag")),
553 "expected unsupported_yaml_tag warning"
554 );
555 }
556
557 #[test]
558 fn nested_comment_in_sequence_captured() {
559 let input = "arr:\n # before-first\n - a\n # between\n - b\n # after-last\n";
560 let out = prescan_fence_content(input);
561 assert_eq!(
562 out.nested_comments,
563 vec![
564 NestedComment {
565 container_path: vec![CommentPathSegment::Key("arr".to_string())],
566 position: 0,
567 text: "before-first".to_string(),
568 inline: false,
569 },
570 NestedComment {
571 container_path: vec![CommentPathSegment::Key("arr".to_string())],
572 position: 1,
573 text: "between".to_string(),
574 inline: false,
575 },
576 NestedComment {
577 container_path: vec![CommentPathSegment::Key("arr".to_string())],
578 position: 2,
579 text: "after-last".to_string(),
580 inline: false,
581 },
582 ]
583 );
584 assert!(
585 !out.warnings
586 .iter()
587 .any(|w| w.code.as_deref() == Some("parse::comments_in_nested_yaml_dropped")),
588 "no dropped-comment warning expected; nested comments are now preserved"
589 );
590 }
591
592 #[test]
593 fn nested_comment_in_mapping_captured() {
594 let input = "outer:\n # comment\n inner: 1\n";
595 let out = prescan_fence_content(input);
596 assert_eq!(
597 out.nested_comments,
598 vec![NestedComment {
599 container_path: vec![CommentPathSegment::Key("outer".to_string())],
600 position: 0,
601 text: "comment".to_string(),
602 inline: false,
603 }]
604 );
605 }
606
607 #[test]
608 fn deep_nested_comment_path() {
609 let input = "outer:\n inner:\n # deep\n leaf: 1\n";
610 let out = prescan_fence_content(input);
611 assert_eq!(
612 out.nested_comments,
613 vec![NestedComment {
614 container_path: vec![
615 CommentPathSegment::Key("outer".to_string()),
616 CommentPathSegment::Key("inner".to_string()),
617 ],
618 position: 0,
619 text: "deep".to_string(),
620 inline: false,
621 }]
622 );
623 }
624
625 #[test]
626 fn comment_inside_seq_of_maps() {
627 let input = "items:\n - name: a\n # inside-first\n val: 1\n - name: b\n";
628 let out = prescan_fence_content(input);
629 assert_eq!(
630 out.nested_comments,
631 vec![NestedComment {
632 container_path: vec![
633 CommentPathSegment::Key("items".to_string()),
634 CommentPathSegment::Index(0),
635 ],
636 position: 1,
637 text: "inside-first".to_string(),
638 inline: false,
639 }]
640 );
641 }
642
643 #[test]
644 fn nested_inline_on_sequence_item() {
645 let input = "arr:\n - a # tail\n - b\n";
646 let out = prescan_fence_content(input);
647 assert_eq!(
648 out.nested_comments,
649 vec![NestedComment {
650 container_path: vec![CommentPathSegment::Key("arr".to_string())],
651 position: 0,
652 text: "tail".to_string(),
653 inline: true,
654 }]
655 );
656 assert!(out.cleaned_yaml.contains("- a\n"));
657 assert!(!out.cleaned_yaml.contains("tail"));
658 }
659
660 #[test]
661 fn nested_inline_on_mapping_field() {
662 let input = "outer:\n inner: 1 # tail\n";
663 let out = prescan_fence_content(input);
664 assert_eq!(
665 out.nested_comments,
666 vec![NestedComment {
667 container_path: vec![CommentPathSegment::Key("outer".to_string())],
668 position: 0,
669 text: "tail".to_string(),
670 inline: true,
671 }]
672 );
673 }
674
675 #[test]
676 fn fill_on_flow_sequence_allowed() {
677 let input = "x: !fill [1, 2]\n";
678 let out = prescan_fence_content(input);
679 assert!(
680 out.fill_target_errors.is_empty(),
681 "expected no error; !fill on sequences is supported"
682 );
683 assert_eq!(
684 out.items,
685 vec![PreItem::Field {
686 key: "x".to_string(),
687 fill: true,
688 }]
689 );
690 }
691
692 #[test]
693 fn sequence_with_multibyte_after_dash_does_not_panic() {
694 let inputs = [
700 "arr:\n - – en-dash\n - — em-dash\n",
701 "arr:\n - \u{2013}line\n - \u{2014}line\n",
702 "arr:\n - \u{201C}smart-quoted\u{201D}\n",
703 "arr:\n - \u{1F600} emoji\n",
704 "bullets: |\n - (U) **A:** text\n – (U) **B:** text\n",
707 ];
708 for input in inputs {
709 let out = prescan_fence_content(input);
710 assert_eq!(out.cleaned_yaml.lines().count(), input.lines().count());
713 }
714 }
715
716 #[test]
717 fn fill_on_flow_mapping_errors() {
718 let input = "x: !fill {a: 1}\n";
719 let out = prescan_fence_content(input);
720 assert!(
721 !out.fill_target_errors.is_empty(),
722 "expected error; !fill on mappings is rejected"
723 );
724 }
725}