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            // `trimmed` is either `"-"` or starts with `"- "` (case 2 guard).
157            // `strip_prefix` keeps this categorically free of byte-range
158            // slicing on user content even though `"- "` is two ASCII bytes.
159            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        // Case 3: top-level field line.
201        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        // Case 4: nested key line inside a block mapping.
262        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
309/// Return the index of the deepest frame matching `indent` and `kind`,
310/// pushing a new frame if the current top is shallower (safety net for
311/// unusual layouts; the placeholder frame from case 3 usually covers this).
312fn 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
342/// `true` when the value portion of a `key:` line is empty — real value is on
343/// subsequent indented lines.
344fn has_empty_inline_value(after_colon: &str) -> bool {
345    let (v, _) = split_trailing_comment(after_colon);
346    v.trim().is_empty()
347}
348
349/// Split a line into `(key, rest_after_colon)`, or `None` for non-key lines.
350/// Handles `[a-zA-Z_][a-zA-Z0-9_]*` and `$`-prefixed system keys.
351fn 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
378/// Split `value` into `(value_without_comment, trailing_comment)`.
379/// Respects `"..."` and `'...'` quoting; ` #` or `\t#` outside quotes
380/// starts a comment.
381fn 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
418/// Inspect a field value for `!fill` and other tags.
419///
420/// Returns `(fill, value_without_tag, had_other_tag, fill_target_err)`.
421/// `fill_target_err` is set when `!fill` targets a mapping (rejected;
422/// scalars and sequences are allowed).
423fn 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        // En-dash (3 bytes), em-dash (3 bytes), smart quote (3 bytes), and emoji
695        // (4 bytes) appearing immediately after `- ` or as a sibling bullet
696        // marker. Earlier versions sliced `&trimmed[2..]` here; if that ever
697        // regresses to indexing inside a multi-byte codepoint, this test will
698        // panic with `"byte index 2 is not a char boundary"`.
699        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            // A literal block scalar holding mixed dashes — mirrors the eval
705            // payload (`bullets: |` with `–` substituted for `-`).
706            "bullets: |\n  - (U) **A:** text\n  – (U) **B:** text\n",
707        ];
708        for input in inputs {
709            let out = prescan_fence_content(input);
710            // We don't care about the exact items; just that no panic occurred
711            // and that the cleaned YAML round-trips line count.
712            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}