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    // Indent of the `key:` line that opened the current YAML block scalar
101    // (`|`/`>`), if any. While set, deeper-indented lines are literal scalar
102    // content and bypass structural prescanning.
103    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        // Inside a block scalar: lines indented deeper than the opening key
116        // are literal text — a markdown heading (`## …`), a `- ` bullet, or a
117        // `key: value` line in the content must pass through verbatim, never
118        // parsed as a comment, sequence item, or nested key. A line at or
119        // below the key's indent ends the scalar and is reprocessed normally.
120        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        // Case 1: own-line comment.
137        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                // Top-level comment — preserve via PreItem::Comment.
143                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        // Case 2: sequence item line (`- ...`).
159        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            // `trimmed` is either `"-"` or starts with `"- "` (case 2 guard).
175            // `strip_prefix` keeps this categorically free of byte-range
176            // slicing on user content even though `"- "` is two ASCII bytes.
177            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            // A sequence item whose value is itself a block scalar (`- |-`):
217            // content lines are indented past the dash, so the dash line's
218            // indent is the boundary. Without this, headings / bullets / `key:`
219            // lines inside a `markdown[]` item would be mis-parsed as structure.
220            if is_block_scalar_header(after_dash_trimmed) {
221                block_scalar_indent = Some(indent);
222            }
223            continue;
224        }
225
226        // Case 3: top-level field line.
227        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        // Case 4: nested key line inside a block mapping.
292        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
343/// Return the index of the deepest frame matching `indent` and `kind`,
344/// pushing a new frame if the current top is shallower (safety net for
345/// unusual layouts; the placeholder frame from case 3 usually covers this).
346fn 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
376/// `true` when a field value is a YAML block-scalar header (`|` or `>`, with
377/// optional chomping/indent indicators). Unquoted plain scalars cannot begin
378/// with these characters, so a leading `|`/`>` unambiguously opens a literal/
379/// folded block whose following content lines are text, not YAML structure.
380fn is_block_scalar_header(value: &str) -> bool {
381    let t = value.trim_start();
382    t.starts_with('|') || t.starts_with('>')
383}
384
385/// `true` when the value portion of a `key:` line is empty — real value is on
386/// subsequent indented lines.
387fn has_empty_inline_value(after_colon: &str) -> bool {
388    let (v, _) = split_trailing_comment(after_colon);
389    v.trim().is_empty()
390}
391
392/// Split a line into `(key, rest_after_colon)`, or `None` for non-key lines.
393/// Handles `[a-zA-Z_][a-zA-Z0-9_]*` and `$`-prefixed system keys.
394fn 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
421/// Split `value` into `(value_without_comment, trailing_comment)` following
422/// YAML's rules. A `#` preceded by whitespace (or at value start) begins a
423/// comment, except inside a quoted scalar — and a quote opens a quoted
424/// scalar only when it is the *first* character of the scalar, or appears
425/// inside a flow collection (`[`/`{`). Inside a plain scalar, `'` and `"`
426/// are ordinary characters: `x: it's fine # note` carries a comment.
427fn 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        // Quoted scalar: skip the quoted body, then scan for a comment. An
434        // unterminated quote means the scalar continues on the next line —
435        // no comment on this one.
436        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        // Flow collection: quotes open quoted scalars anywhere inside, so
441        // track quote state across the whole value.
442        b'[' | b'{' => split_flow_trailing_comment(value),
443        // Plain scalar (or block-scalar header): quotes are ordinary
444        // characters; only the whitespace-then-`#` rule applies.
445        _ => find_comment_from(value, 0),
446    }
447}
448
449/// Byte index of the closing quote of the quoted scalar opening at `start`,
450/// honouring `\"` escapes in double quotes and `''` escapes in single quotes.
451fn 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; // '' is an escaped quote, not the closer
463                continue;
464            }
465            return Some(i);
466        }
467        i += 1;
468    }
469    None
470}
471
472/// Scan `value` from byte `from` for a `#` preceded by whitespace (or at the
473/// scan start) and split there. Quote characters are not interpreted.
474fn 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
489/// Comment split for flow-collection values (`[…]` / `{…}`), where quoted
490/// scalars can open anywhere: track quote state across the value and split
491/// at the first whitespace-preceded `#` outside quotes.
492fn 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
529/// Inspect a field value for `!fill` and other tags.
530///
531/// Returns `(fill, value_without_tag, had_other_tag, fill_target_err)`.
532/// `fill_target_err` is set when `!fill` targets a mapping (rejected;
533/// scalars and sequences are allowed).
534fn 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        // En-dash (3 bytes), em-dash (3 bytes), smart quote (3 bytes), and emoji
806        // (4 bytes) appearing immediately after `- ` or as a sibling bullet
807        // marker. Earlier versions sliced `&trimmed[2..]` here; if that ever
808        // regresses to indexing inside a multi-byte codepoint, this test will
809        // panic with `"byte index 2 is not a char boundary"`.
810        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            // A literal block scalar holding mixed dashes — mirrors the eval
816            // payload (`bullets: |` with `–` substituted for `-`).
817            "bullets: |\n  - (U) **A:** text\n  – (U) **B:** text\n",
818        ];
819        for input in inputs {
820            let out = prescan_fence_content(input);
821            // We don't care about the exact items; just that no panic occurred
822            // and that the cleaned YAML round-trips line count.
823            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        // A markdown block scalar whose content contains a `#` heading, a
830        // `- ` bullet, and a `key:` line. None of these are YAML structure —
831        // they must survive verbatim in the cleaned YAML, and the field after
832        // the block must still parse as a top-level field.
833        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        // The heading is content, not a stripped comment.
838        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        // Nothing from inside the block leaked into items as a comment/field.
847        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        // The two real top-level fields are `bio` then `name`, in order.
862        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        // A `markdown[]` array authored as `- |-` block-scalar items. Content
876        // lines (heading, bullet, `key:`) must survive verbatim, and the next
877        // item at the dash indent must still parse as a sequence item.
878        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        // The heading must not have been captured as a comment.
889        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        // `second` is preserved (the block ended at the next dash).
896        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    // ── split_trailing_comment: YAML 1.2 conformance ─────────────────────────
909
910    #[test]
911    fn comment_after_plain_scalar_with_apostrophe() {
912        // YAML: in a plain scalar, `'` is an ordinary character; the
913        // whitespace-preceded `#` still starts a comment.
914        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        // '' is an escaped quote, not the closer.
944        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        // \" is an escaped quote in double-quoted scalars.
949        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}