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