Skip to main content

quillmark_core/document/
prescan.rs

1//! Pre-scan of a card-yaml block's YAML payload to recover features that
2//! serde_saphyr discards.
3//!
4//! Three features are recovered here:
5//!
6//! 1. **Top-level comments.** YAML comments are dropped by the YAML parser.
7//!    To round-trip them as [`super::PayloadItem::Comment`], we extract them
8//!    before parsing.
9//!
10//! 2. **Nested comments.** Comments inside block mappings/sequences are
11//!    captured with their structural path (sequence of keys/indices) and an
12//!    ordinal indicating where in the container they sit. The emitter
13//!    re-injects them at the matching position. See [`NestedComment`].
14//!
15//! 3. **`!fill` tags.** Custom YAML tags are accepted and dropped by
16//!    serde_saphyr; the value survives but the tag annotation is lost. We
17//!    detect `!fill` on top-level scalar fields, strip the tag from the
18//!    cleaned YAML (so serde_saphyr sees a plain scalar), and record a
19//!    `fill: true` marker on the resulting `Field` item.
20//!
21//! Other custom tags (`!include`, `!env`, …) are stripped with a
22//! `parse::unsupported_yaml_tag` warning.
23
24use crate::Diagnostic;
25use crate::Severity;
26
27/// One ordered hint extracted from the fence body.
28///
29/// `Field` captures only the `fill` flag; the value comes from serde_saphyr.
30/// `Comment.inline` distinguishes own-line from trailing inline comments;
31/// inline comments immediately follow their host `Field` in the item stream.
32#[derive(Debug, Clone, PartialEq)]
33pub enum PreItem {
34    Field { key: String, fill: bool },
35    Comment { text: String, inline: bool },
36}
37
38/// One segment of a path into the parsed YAML structure.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum CommentPathSegment {
41    Key(String),
42    Index(usize),
43}
44
45/// A comment inside a nested mapping or sequence.
46///
47/// `container_path` locates the immediate parent. For own-line comments
48/// (`inline = false`), `position` is the child slot ordinal (`0..=child_count`,
49/// where `child_count` means "after all children"). For inline comments
50/// (`inline = true`), `position` is the host child's index; orphaned inlines
51/// degrade to own-line at emit time.
52#[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/// Output of [`prescan_fence_content`].
61#[derive(Debug, Clone, Default)]
62pub struct PreScan {
63    /// YAML with `!fill` tags stripped and comment lines removed; fed to serde_saphyr.
64    pub cleaned_yaml: String,
65    /// Top-level fields and comments in source order.
66    pub items: Vec<PreItem>,
67    pub nested_comments: Vec<NestedComment>,
68    pub warnings: Vec<Diagnostic>,
69    /// `!fill` on mappings — turned into `ParseError::InvalidStructure` by the parser.
70    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        // Case 1: own-line comment.
119        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                // Top-level comment — preserve via PreItem::Comment.
125                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        // Case 2: sequence item line (`- ...`).
141        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        // Case 3: top-level field line.
198        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        // Case 4: nested key line inside a block mapping.
259        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
306/// Return the index of the deepest frame matching `indent` and `kind`,
307/// pushing a new frame if the current top is shallower (safety net for
308/// unusual layouts; the placeholder frame from case 3 usually covers this).
309fn 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
339/// `true` when the value portion of a `key:` line is empty — real value is on
340/// subsequent indented lines.
341fn has_empty_inline_value(after_colon: &str) -> bool {
342    let (v, _) = split_trailing_comment(after_colon);
343    v.trim().is_empty()
344}
345
346/// Split a line into `(key, rest_after_colon)`, or `None` for non-key lines.
347/// Handles `[a-zA-Z_][a-zA-Z0-9_]*` and `$`-prefixed system keys.
348fn 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
375/// Split `value` into `(value_without_comment, trailing_comment)`.
376/// Respects `"..."` and `'...'` quoting; ` #` or `\t#` outside quotes
377/// starts a comment.
378fn 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
415/// Inspect a field value for `!fill` and other tags.
416///
417/// Returns `(fill, value_without_tag, had_other_tag, fill_target_err)`.
418/// `fill_target_err` is set when `!fill` targets a mapping (rejected;
419/// scalars and sequences are allowed).
420fn 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}