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. **`!must_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 `!must_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//! `!must_fill` is the only recognized fill tag. Every other custom tag
22//! (`!include`, `!env`, …) is treated alike: dropped with a
23//! `parse::unsupported_yaml_tag` warning, the scalar value kept.
24
25use crate::Diagnostic;
26use crate::Severity;
27
28/// One ordered hint extracted from the fence body.
29///
30/// `Field` captures only the `fill` flag; the value comes from serde_saphyr.
31/// `Comment.inline` distinguishes own-line from trailing inline comments;
32/// inline comments immediately follow their host `Field` in the item stream.
33#[derive(Debug, Clone, PartialEq)]
34pub(crate) enum PreItem {
35    Field { key: String, fill: bool },
36    Comment { text: String, inline: bool },
37}
38
39/// One segment of a path into the parsed YAML structure.
40///
41/// Aliased to the crate-wide [`crate::value::PathSegment`] so prescan,
42/// emit, and the value tree all speak one path type.
43pub use crate::value::PathSegment as CommentPathSegment;
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)]
53#[non_exhaustive]
54pub struct NestedComment {
55    pub container_path: Vec<CommentPathSegment>,
56    pub position: usize,
57    pub text: String,
58    pub inline: bool,
59}
60
61/// Output of [`prescan_fence_content`].
62#[derive(Debug, Clone, Default)]
63pub(crate) struct PreScan {
64    /// YAML with `!must_fill` tags stripped and comment lines removed; fed to serde_saphyr.
65    pub cleaned_yaml: String,
66    /// Top-level fields and comments in source order.
67    pub items: Vec<PreItem>,
68    pub nested_comments: Vec<NestedComment>,
69    /// Paths of nested fields tagged `!must_fill`, relative to the fence root
70    /// (the first segment is the owning top-level key). Applied onto the
71    /// value tree by the assembler. Top-level fills ride on `PreItem::Field`.
72    pub nested_fills: Vec<Vec<CommentPathSegment>>,
73    pub warnings: Vec<Diagnostic>,
74    /// `!must_fill` on mappings: turned into `ParseError::InvalidStructure` by the parser.
75    pub fill_target_errors: Vec<String>,
76}
77
78#[derive(Debug)]
79struct Frame {
80    indent: usize,
81    path: Vec<CommentPathSegment>,
82    kind: Option<FrameKind>,
83    child_count: usize,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum FrameKind {
88    Mapping,
89    Sequence,
90}
91
92pub(crate) fn prescan_fence_content(content: &str) -> PreScan {
93    let mut out = PreScan::default();
94
95    let lines: Vec<&str> = content.split('\n').collect();
96    let mut cleaned_lines: Vec<String> = Vec::with_capacity(lines.len());
97
98    let mut stack: Vec<Frame> = vec![Frame {
99        indent: 0,
100        path: Vec::new(),
101        kind: Some(FrameKind::Mapping),
102        child_count: 0,
103    }];
104
105    // Indent of the `key:` line that opened the current YAML block scalar
106    // (`|`/`>`), if any. While set, deeper-indented lines are literal scalar
107    // content and bypass structural prescanning.
108    let mut block_scalar_indent: Option<usize> = None;
109
110    for raw_line in &lines {
111        let line = *raw_line;
112        let indent = leading_space_count(line);
113        let trimmed = &line[indent..];
114
115        if trimmed.is_empty() {
116            cleaned_lines.push(line.to_string());
117            continue;
118        }
119
120        // Inside a block scalar: lines indented deeper than the opening key
121        // are literal text, a markdown heading (`## …`), a `- ` bullet, or a
122        // `key: value` line in the content must pass through verbatim, never
123        // parsed as a comment, sequence item, or nested key. A line at or
124        // below the key's indent ends the scalar and is reprocessed normally.
125        if let Some(key_indent) = block_scalar_indent {
126            if indent > key_indent {
127                cleaned_lines.push(line.to_string());
128                continue;
129            }
130            block_scalar_indent = None;
131        }
132
133        while let Some(frame) = stack.last() {
134            if frame.indent > indent {
135                stack.pop();
136            } else {
137                break;
138            }
139        }
140
141        // Case 1: own-line comment.
142        if trimmed.starts_with('#') {
143            let text = strip_comment_marker(trimmed);
144            let frame = stack.last().expect("root frame always present");
145
146            if frame.path.is_empty() {
147                // Top-level comment: preserve via PreItem::Comment.
148                out.items.push(PreItem::Comment {
149                    text: text.to_string(),
150                    inline: false,
151                });
152            } else {
153                out.nested_comments.push(NestedComment {
154                    container_path: frame.path.clone(),
155                    position: frame.child_count,
156                    text: text.to_string(),
157                    inline: false,
158                });
159            }
160            continue;
161        }
162
163        // Case 2: sequence item line (`- ...`).
164        if trimmed == "-" || trimmed.starts_with("- ") {
165            let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Sequence);
166            let frame = &mut stack[frame_idx];
167            let item_index = frame.child_count;
168            frame.child_count += 1;
169            let parent_path: Vec<CommentPathSegment> = frame.path.clone();
170            let item_path: Vec<CommentPathSegment> = {
171                let mut p = parent_path.clone();
172                p.push(CommentPathSegment::Index(item_index));
173                p
174            };
175            while stack.len() > frame_idx + 1 {
176                stack.pop();
177            }
178
179            // `trimmed` is either `"-"` or starts with `"- "` (case 2 guard).
180            // `strip_prefix` keeps this categorically free of byte-range
181            // slicing on user content even though `"- "` is two ASCII bytes.
182            let after_dash_full = trimmed.strip_prefix("- ").unwrap_or("");
183            let (after_dash, trailing_comment) = split_trailing_comment(after_dash_full);
184            let after_dash_trimmed = after_dash.trim_start();
185            let inline_indent_offset = indent + 2 + (after_dash.len() - after_dash_trimmed.len());
186
187            // The first key of a sequence-item mapping (`- key: value`) sits on
188            // the dash line, so Case 4 never sees it. Inspect it here for a fill
189            // marker / unsupported tag, mirroring Case 4. `dash_body_clean`, when
190            // set, is the tag-stripped `key:value` rewritten onto the dash line
191            // so serde_saphyr parses the bare value.
192            let mut dash_body_clean: Option<String> = None;
193            if after_dash_trimmed.is_empty() {
194                stack.push(Frame {
195                    indent: indent + 2,
196                    path: item_path,
197                    kind: None,
198                    child_count: 0,
199                });
200            } else if let Some((key, after_colon)) = split_key(after_dash_trimmed) {
201                let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
202                    inspect_fill_and_tags(&after_colon, &key);
203                if had_non_fill_tag {
204                    out.warnings.push(
205                        Diagnostic::new(
206                            Severity::Warning,
207                            format!(
208                                "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
209                                key
210                            ),
211                        )
212                        .with_code("parse::unsupported_yaml_tag".to_string()),
213                    );
214                }
215                if let Some(err) = fill_target_err {
216                    out.fill_target_errors.push(err);
217                }
218                if fill {
219                    let mut key_path = item_path.clone();
220                    key_path.push(CommentPathSegment::Key(key.clone()));
221                    out.nested_fills.push(key_path);
222                }
223                if fill || had_non_fill_tag {
224                    dash_body_clean = Some(format!("{}:{}", key, value_without_tag));
225                }
226                stack.push(Frame {
227                    indent: inline_indent_offset,
228                    path: item_path,
229                    kind: Some(FrameKind::Mapping),
230                    child_count: 1,
231                });
232            }
233
234            if let Some(c) = &trailing_comment {
235                out.nested_comments.push(NestedComment {
236                    container_path: parent_path,
237                    position: item_index,
238                    text: strip_comment_marker(c).to_string(),
239                    inline: true,
240                });
241            }
242            // Rewrite the dash line when a tag was stripped and/or a trailing
243            // comment was lifted off; otherwise pass the original through.
244            if dash_body_clean.is_some() || trailing_comment.is_some() {
245                let head = format!("{:width$}", "", width = indent);
246                let body = match dash_body_clean {
247                    Some(b) => format!("- {}", b),
248                    None if after_dash.trim_end().is_empty() => "-".to_string(),
249                    None => format!("- {}", after_dash.trim_end()),
250                };
251                cleaned_lines.push(format!("{}{}", head, body));
252            } else {
253                cleaned_lines.push(line.to_string());
254            }
255
256            // A sequence item whose value is itself a block scalar (`- |-`):
257            // content lines are indented past the dash, so the dash line's
258            // indent is the boundary. Without this, headings / bullets / `key:`
259            // lines inside a `richtext[]` item would be mis-parsed as structure.
260            if is_block_scalar_header(after_dash_trimmed) {
261                block_scalar_indent = Some(indent);
262            }
263            continue;
264        }
265
266        // Case 3: top-level field line.
267        let is_top_level = indent == 0;
268        if is_top_level {
269            if let Some((key, after_colon)) = split_key(line) {
270                let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
271
272                let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
273                    inspect_fill_and_tags(&value_part, &key);
274
275                if had_non_fill_tag {
276                    out.warnings.push(
277                        Diagnostic::new(
278                            Severity::Warning,
279                            format!(
280                                "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
281                                key
282                            ),
283                        )
284                        .with_code("parse::unsupported_yaml_tag".to_string()),
285                    );
286                }
287                if let Some(err) = fill_target_err {
288                    out.fill_target_errors.push(err);
289                }
290
291                out.items.push(PreItem::Field {
292                    key: key.clone(),
293                    fill,
294                });
295
296                let root = &mut stack[0];
297                root.child_count += 1;
298                let key_path = vec![CommentPathSegment::Key(key.clone())];
299
300                while stack.len() > 1 {
301                    stack.pop();
302                }
303
304                if has_empty_inline_value(&value_without_tag) {
305                    stack.push(Frame {
306                        indent: 2,
307                        path: key_path,
308                        kind: None,
309                        child_count: 0,
310                    });
311                }
312
313                let cleaned = format!("{}:{}", key, value_without_tag);
314                cleaned_lines.push(cleaned);
315
316                if let Some(c) = trailing_comment {
317                    out.items.push(PreItem::Comment {
318                        text: strip_comment_marker(&c).to_string(),
319                        inline: true,
320                    });
321                }
322
323                if is_block_scalar_header(&value_without_tag) {
324                    block_scalar_indent = Some(indent);
325                }
326
327                continue;
328            }
329        }
330
331        // Case 4: nested key line inside a block mapping.
332        if let Some((key, after_colon)) = split_key(trimmed) {
333            let frame_idx = ensure_frame_at_indent(&mut stack, indent, FrameKind::Mapping);
334            let frame = &mut stack[frame_idx];
335            let key_index = frame.child_count;
336            frame.child_count += 1;
337            let parent_path: Vec<CommentPathSegment> = frame.path.clone();
338            let key_path: Vec<CommentPathSegment> = {
339                let mut p = parent_path.clone();
340                p.push(CommentPathSegment::Key(key.clone()));
341                p
342            };
343            while stack.len() > frame_idx + 1 {
344                stack.pop();
345            }
346
347            let (value_part, trailing_comment) = split_trailing_comment(&after_colon);
348
349            let (fill, value_without_tag, had_non_fill_tag, fill_target_err) =
350                inspect_fill_and_tags(&value_part, &key);
351            if had_non_fill_tag {
352                out.warnings.push(
353                    Diagnostic::new(
354                        Severity::Warning,
355                        format!(
356                            "YAML tag on key `{}` is not supported; the tag has been dropped and the value kept",
357                            key
358                        ),
359                    )
360                    .with_code("parse::unsupported_yaml_tag".to_string()),
361                );
362            }
363            if let Some(err) = fill_target_err {
364                out.fill_target_errors.push(err);
365            }
366            if fill {
367                out.nested_fills.push(key_path.clone());
368            }
369
370            if trailing_comment.is_some() || fill {
371                if let Some(c) = trailing_comment {
372                    out.nested_comments.push(NestedComment {
373                        container_path: parent_path,
374                        position: key_index,
375                        text: strip_comment_marker(&c).to_string(),
376                        inline: true,
377                    });
378                }
379                let head = format!("{:width$}", "", width = indent);
380                cleaned_lines.push(format!("{}{}:{}", head, key, value_without_tag));
381            } else {
382                cleaned_lines.push(line.to_string());
383            }
384
385            if has_empty_inline_value(&value_without_tag) {
386                stack.push(Frame {
387                    indent: indent + 2,
388                    path: key_path,
389                    kind: None,
390                    child_count: 0,
391                });
392            }
393
394            if is_block_scalar_header(&value_without_tag) {
395                block_scalar_indent = Some(indent);
396            }
397            continue;
398        }
399
400        cleaned_lines.push(line.to_string());
401    }
402
403    // Catch-all: prescan lifts every `!must_fill` it can preserve (block-style
404    // `key: !must_fill` and `- key: !must_fill`), stripping the tag from the
405    // cleaned text. Any tag that survives here sits in a position we cannot
406    // round-trip (inside a flow collection (`{…}` / `[…]`) or on a bare
407    // sequence element) where serde_saphyr would silently drop it. Warn rather
408    // than lose the marker quietly.
409    if cleaned_lines
410        .iter()
411        .any(|l| line_has_unsupported_fill_tag(l))
412    {
413        out.warnings.push(
414            Diagnostic::new(
415                Severity::Warning,
416                "a `!must_fill` marker appears in a flow collection or on a bare \
417                 sequence element and is not preserved; use block style \
418                 (`key: !must_fill`) to mark a placeholder"
419                    .to_string(),
420            )
421            .with_code("parse::fill_marker_unsupported_position".to_string()),
422        );
423    }
424
425    out.cleaned_yaml = cleaned_lines.join("\n");
426    out
427}
428
429/// True when `line` still carries a `!must_fill` / `!fill` tag in a value or
430/// element position that prescan could not lift. Block-style markers are
431/// stripped before this runs, so a survivor means an unsupported position.
432/// The boundary checks keep a quoted scalar that merely contains the literal
433/// text (e.g. `note: "see !must_fill"`) from matching.
434fn line_has_unsupported_fill_tag(line: &str) -> bool {
435    for tag in FILL_TAGS {
436        let mut from = 0;
437        while let Some(rel) = line[from..].find(tag) {
438            let at = from + rel;
439            let after = at + tag.len();
440            // Trailing boundary: a real tag ends at whitespace, flow
441            // punctuation, or end of line, not mid-word (`!fillet`).
442            let trailing_ok = line[after..]
443                .chars()
444                .next()
445                .is_none_or(|c| c.is_whitespace() || matches!(c, ',' | '}' | ']'));
446            // Leading boundary: the tag sits in value/element position,
447            // directly after `{` / `[` / `,`, or after whitespace following
448            // `:` / `-` / `,` / `{` / `[`.
449            let before = line[..at].trim_end_matches([' ', '\t']);
450            let had_ws = before.len() != at;
451            let leading_ok = match before.chars().last() {
452                Some('{') | Some('[') | Some(',') => true,
453                Some(':') | Some('-') => had_ws,
454                _ => false,
455            };
456            if trailing_ok && leading_ok {
457                return true;
458            }
459            from = after;
460        }
461    }
462    false
463}
464
465/// Return the index of the deepest frame matching `indent` and `kind`,
466/// pushing a new frame if the current top is shallower (safety net for
467/// unusual layouts; the placeholder frame from case 3 usually covers this).
468fn ensure_frame_at_indent(stack: &mut Vec<Frame>, indent: usize, kind: FrameKind) -> usize {
469    let top_idx = stack.len() - 1;
470    let top = &mut stack[top_idx];
471
472    if top.indent == indent {
473        if top.kind.is_none() {
474            top.kind = Some(kind);
475        }
476        return top_idx;
477    }
478
479    let parent_path = top.path.clone();
480    stack.push(Frame {
481        indent,
482        path: parent_path,
483        kind: Some(kind),
484        child_count: 0,
485    });
486    stack.len() - 1
487}
488
489fn strip_comment_marker(raw: &str) -> &str {
490    let after = raw.trim_start_matches('#');
491    after.strip_prefix(' ').unwrap_or(after)
492}
493
494fn leading_space_count(line: &str) -> usize {
495    line.bytes().take_while(|b| *b == b' ').count()
496}
497
498/// `true` when a field value is a YAML block-scalar header (`|` or `>`, with
499/// optional chomping/indent indicators). Unquoted plain scalars cannot begin
500/// with these characters, so a leading `|`/`>` unambiguously opens a literal/
501/// folded block whose following content lines are text, not YAML structure.
502fn is_block_scalar_header(value: &str) -> bool {
503    let t = value.trim_start();
504    t.starts_with('|') || t.starts_with('>')
505}
506
507/// `true` when the value portion of a `key:` line is empty: real value is on
508/// subsequent indented lines.
509fn has_empty_inline_value(after_colon: &str) -> bool {
510    let (v, _) = split_trailing_comment(after_colon);
511    v.trim().is_empty()
512}
513
514/// Split a line into `(key, rest_after_colon)`, or `None` for non-key lines.
515/// Handles `[a-zA-Z_][a-zA-Z0-9_]*` and `$`-prefixed system keys.
516fn split_key(line: &str) -> Option<(String, String)> {
517    let bytes = line.as_bytes();
518    if bytes.is_empty() {
519        return None;
520    }
521    let mut i;
522    if bytes[0] == b'$' {
523        if bytes.len() < 2 || !(bytes[1].is_ascii_alphabetic() || bytes[1] == b'_') {
524            return None;
525        }
526        i = 2;
527    } else if bytes[0].is_ascii_alphabetic() || bytes[0] == b'_' {
528        i = 1;
529    } else {
530        return None;
531    }
532    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
533        i += 1;
534    }
535    if i >= bytes.len() || bytes[i] != b':' {
536        return None;
537    }
538    let key = line[..i].to_string();
539    let rest = line[i + 1..].to_string();
540    Some((key, rest))
541}
542
543/// Split `value` into `(value_without_comment, trailing_comment)` following
544/// YAML's rules. A `#` preceded by whitespace (or at value start) begins a
545/// comment, except inside a quoted scalar, and a quote opens a quoted
546/// scalar only when it is the *first* character of the scalar, or appears
547/// inside a flow collection (`[`/`{`). Inside a plain scalar, `'` and `"`
548/// are ordinary characters: `x: it's fine # note` carries a comment.
549fn split_trailing_comment(value: &str) -> (String, Option<String>) {
550    let bytes = value.as_bytes();
551    let Some(first) = bytes.iter().position(|b| !matches!(b, b' ' | b'\t')) else {
552        return (value.to_string(), None);
553    };
554    match bytes[first] {
555        // Quoted scalar: skip the quoted body, then scan for a comment. An
556        // unterminated quote means the scalar continues on the next line:
557        // no comment on this one.
558        b'"' | b'\'' => match find_quote_end(bytes, first) {
559            Some(end) => find_comment_from(value, end + 1),
560            None => (value.to_string(), None),
561        },
562        // Flow collection: quotes open quoted scalars anywhere inside, so
563        // track quote state across the whole value.
564        b'[' | b'{' => split_flow_trailing_comment(value),
565        // Plain scalar (or block-scalar header): quotes are ordinary
566        // characters; only the whitespace-then-`#` rule applies.
567        _ => find_comment_from(value, 0),
568    }
569}
570
571/// Byte index of the closing quote of the quoted scalar opening at `start`,
572/// honouring `\"` escapes in double quotes and `''` escapes in single quotes.
573fn find_quote_end(bytes: &[u8], start: usize) -> Option<usize> {
574    let quote = bytes[start];
575    let mut i = start + 1;
576    while i < bytes.len() {
577        let b = bytes[i];
578        if quote == b'"' && b == b'\\' {
579            i += 2;
580            continue;
581        }
582        if b == quote {
583            if quote == b'\'' && bytes.get(i + 1) == Some(&b'\'') {
584                i += 2; // '' is an escaped quote, not the closer
585                continue;
586            }
587            return Some(i);
588        }
589        i += 1;
590    }
591    None
592}
593
594/// Scan `value` from byte `from` for a `#` preceded by whitespace (or at the
595/// scan start) and split there. Quote characters are not interpreted.
596fn find_comment_from(value: &str, from: usize) -> (String, Option<String>) {
597    let bytes = value.as_bytes();
598    let mut prev_was_ws = true;
599    for i in from..bytes.len() {
600        let b = bytes[i];
601        if b == b'#' && prev_was_ws {
602            let v = value[..i].trim_end().to_string();
603            let c = value[i..].to_string();
604            return (v, Some(c));
605        }
606        prev_was_ws = matches!(b, b' ' | b'\t');
607    }
608    (value.to_string(), None)
609}
610
611/// Comment split for flow-collection values (`[…]` / `{…}`), where quoted
612/// scalars can open anywhere: track quote state across the value and split
613/// at the first whitespace-preceded `#` outside quotes.
614fn split_flow_trailing_comment(value: &str) -> (String, Option<String>) {
615    let bytes = value.as_bytes();
616    let mut i = 0;
617    let mut prev_was_ws = true;
618    let mut in_dq = false;
619    let mut in_sq = false;
620    while i < bytes.len() {
621        let b = bytes[i];
622        if in_dq {
623            if b == b'\\' && i + 1 < bytes.len() {
624                i += 2;
625                continue;
626            }
627            if b == b'"' {
628                in_dq = false;
629            }
630        } else if in_sq {
631            if b == b'\'' {
632                in_sq = false;
633            }
634        } else {
635            if b == b'"' {
636                in_dq = true;
637            } else if b == b'\'' {
638                in_sq = true;
639            } else if b == b'#' && prev_was_ws {
640                let v = value[..i].trim_end().to_string();
641                let c = value[i..].to_string();
642                return (v, Some(c));
643            }
644        }
645        prev_was_ws = matches!(b, b' ' | b'\t');
646        i += 1;
647    }
648    (value.to_string(), None)
649}
650
651/// The placeholder tag. `!must_fill` is the only recognized fill tag; any
652/// other custom tag is treated as a noncanonical tag: dropped with a
653/// `parse::unsupported_yaml_tag` warning.
654const FILL_TAGS: [&str; 1] = ["!must_fill"];
655
656/// If `trimmed` begins with a fill tag (either the bare tag or the tag
657/// followed by whitespace), return the remainder after the tag. A tag that
658/// is merely a prefix of a longer word (e.g. `!fillet`) does not match.
659fn strip_fill_tag(trimmed: &str) -> Option<&str> {
660    for tag in FILL_TAGS {
661        if trimmed == tag {
662            return Some("");
663        }
664        if let Some(rest) = trimmed.strip_prefix(tag) {
665            if rest.starts_with(' ') || rest.starts_with('\t') {
666                return Some(rest);
667            }
668        }
669    }
670    None
671}
672
673/// Inspect a field value for the `!must_fill` tag and other (noncanonical) tags.
674///
675/// Returns `(fill, value_without_tag, had_other_tag, fill_target_err)`.
676/// `fill_target_err` is set when the fill tag targets a mapping (rejected;
677/// scalars and sequences are allowed).
678fn inspect_fill_and_tags(value: &str, key: &str) -> (bool, String, bool, Option<String>) {
679    let trimmed = value.trim_start();
680    let leading_ws_len = value.len() - trimmed.len();
681
682    if trimmed.is_empty() {
683        return (false, value.to_string(), false, None);
684    }
685
686    if let Some(rest) = strip_fill_tag(trimmed) {
687        let rest_trim = rest.trim_start();
688        let err = if rest_trim.starts_with('{') {
689            Some(format!(
690                "`!must_fill` on key `{}` targets a mapping; `!must_fill` is supported on scalars and sequences only",
691                key
692            ))
693        } else {
694            None
695        };
696        let reconstructed = if rest_trim.is_empty() {
697            value[..leading_ws_len].to_string()
698        } else {
699            format!(" {}", rest_trim)
700        };
701        return (true, reconstructed, false, err);
702    }
703
704    if trimmed.starts_with('!') {
705        return (false, value.to_string(), true, None);
706    }
707
708    (false, value.to_string(), false, None)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn extracts_own_line_comments() {
717        let input = "# top\ntitle: foo\n# mid\nauthor: bar\n";
718        let out = prescan_fence_content(input);
719        assert_eq!(
720            out.items,
721            vec![
722                PreItem::Comment {
723                    text: "top".to_string(),
724                    inline: false,
725                },
726                PreItem::Field {
727                    key: "title".to_string(),
728                    fill: false,
729                },
730                PreItem::Comment {
731                    text: "mid".to_string(),
732                    inline: false,
733                },
734                PreItem::Field {
735                    key: "author".to_string(),
736                    fill: false,
737                },
738            ]
739        );
740        assert!(out.nested_comments.is_empty());
741    }
742
743    #[test]
744    fn splits_trailing_comments() {
745        let input = "title: foo # inline\n";
746        let out = prescan_fence_content(input);
747        assert_eq!(
748            out.items,
749            vec![
750                PreItem::Field {
751                    key: "title".to_string(),
752                    fill: false,
753                },
754                PreItem::Comment {
755                    text: "inline".to_string(),
756                    inline: true,
757                },
758            ]
759        );
760        assert!(out.cleaned_yaml.contains("title: foo"));
761        assert!(!out.cleaned_yaml.contains("inline"));
762    }
763
764    #[test]
765    fn fill_alias_is_rejected_as_noncanonical_tag() {
766        // `!fill` is an unrecognized custom tag, not a fill marker. It is dropped
767        // with an unsupported-tag warning; the value is kept.
768        let input = "dept: !fill Department\n";
769        let out = prescan_fence_content(input);
770        assert_eq!(
771            out.items,
772            vec![PreItem::Field {
773                key: "dept".to_string(),
774                fill: false,
775            }]
776        );
777        assert!(
778            out.warnings
779                .iter()
780                .any(|w| w.code.as_deref() == Some("parse::unsupported_yaml_tag")),
781            "`!fill` must warn as an unsupported tag"
782        );
783    }
784
785    #[test]
786    fn detects_must_fill_on_scalar() {
787        let input = "dept: !must_fill Department\n";
788        let out = prescan_fence_content(input);
789        assert_eq!(
790            out.items,
791            vec![PreItem::Field {
792                key: "dept".to_string(),
793                fill: true,
794            }]
795        );
796        assert!(out.cleaned_yaml.contains("dept: Department"));
797        assert!(!out.cleaned_yaml.contains("!must_fill"));
798        assert!(!out.cleaned_yaml.contains("!fill"));
799    }
800
801    #[test]
802    fn detects_bare_must_fill() {
803        let input = "dept: !must_fill\n";
804        let out = prescan_fence_content(input);
805        assert_eq!(
806            out.items,
807            vec![PreItem::Field {
808                key: "dept".to_string(),
809                fill: true,
810            }]
811        );
812        assert!(!out.cleaned_yaml.contains("!must_fill"));
813    }
814
815    #[test]
816    fn fillet_is_not_a_fill_tag() {
817        // A tag that merely starts with the fill-tag prefix must not be treated
818        // as fill (`!must_filler` shares the `!must_fill` prefix; `!fillet` is
819        // unrelated). Both are ordinary noncanonical tags.
820        let input = "x: !must_filler value\n";
821        let out = prescan_fence_content(input);
822        assert_eq!(
823            out.items,
824            vec![PreItem::Field {
825                key: "x".to_string(),
826                fill: false,
827            }]
828        );
829    }
830
831    #[test]
832    fn unknown_tag_warns() {
833        let input = "x: !custom value\n";
834        let out = prescan_fence_content(input);
835        assert!(
836            out.warnings
837                .iter()
838                .any(|w| w.code.as_deref() == Some("parse::unsupported_yaml_tag")),
839            "expected unsupported_yaml_tag warning"
840        );
841    }
842
843    #[test]
844    fn nested_comment_in_sequence_captured() {
845        let input = "arr:\n  # before-first\n  - a\n  # between\n  - b\n  # after-last\n";
846        let out = prescan_fence_content(input);
847        assert_eq!(
848            out.nested_comments,
849            vec![
850                NestedComment {
851                    container_path: vec![CommentPathSegment::Key("arr".to_string())],
852                    position: 0,
853                    text: "before-first".to_string(),
854                    inline: false,
855                },
856                NestedComment {
857                    container_path: vec![CommentPathSegment::Key("arr".to_string())],
858                    position: 1,
859                    text: "between".to_string(),
860                    inline: false,
861                },
862                NestedComment {
863                    container_path: vec![CommentPathSegment::Key("arr".to_string())],
864                    position: 2,
865                    text: "after-last".to_string(),
866                    inline: false,
867                },
868            ]
869        );
870        assert!(
871            !out.warnings
872                .iter()
873                .any(|w| w.code.as_deref() == Some("parse::comments_in_nested_yaml_dropped")),
874            "nested comments are preserved, so no dropped-comment warning is emitted"
875        );
876    }
877
878    #[test]
879    fn nested_comment_in_mapping_captured() {
880        let input = "outer:\n  # comment\n  inner: 1\n";
881        let out = prescan_fence_content(input);
882        assert_eq!(
883            out.nested_comments,
884            vec![NestedComment {
885                container_path: vec![CommentPathSegment::Key("outer".to_string())],
886                position: 0,
887                text: "comment".to_string(),
888                inline: false,
889            }]
890        );
891    }
892
893    #[test]
894    fn deep_nested_comment_path() {
895        let input = "outer:\n  inner:\n    # deep\n    leaf: 1\n";
896        let out = prescan_fence_content(input);
897        assert_eq!(
898            out.nested_comments,
899            vec![NestedComment {
900                container_path: vec![
901                    CommentPathSegment::Key("outer".to_string()),
902                    CommentPathSegment::Key("inner".to_string()),
903                ],
904                position: 0,
905                text: "deep".to_string(),
906                inline: false,
907            }]
908        );
909    }
910
911    #[test]
912    fn comment_inside_seq_of_maps() {
913        let input = "items:\n  - name: a\n    # inside-first\n    val: 1\n  - name: b\n";
914        let out = prescan_fence_content(input);
915        assert_eq!(
916            out.nested_comments,
917            vec![NestedComment {
918                container_path: vec![
919                    CommentPathSegment::Key("items".to_string()),
920                    CommentPathSegment::Index(0),
921                ],
922                position: 1,
923                text: "inside-first".to_string(),
924                inline: false,
925            }]
926        );
927    }
928
929    #[test]
930    fn nested_inline_on_sequence_item() {
931        let input = "arr:\n  - a # tail\n  - b\n";
932        let out = prescan_fence_content(input);
933        assert_eq!(
934            out.nested_comments,
935            vec![NestedComment {
936                container_path: vec![CommentPathSegment::Key("arr".to_string())],
937                position: 0,
938                text: "tail".to_string(),
939                inline: true,
940            }]
941        );
942        assert!(out.cleaned_yaml.contains("- a\n"));
943        assert!(!out.cleaned_yaml.contains("tail"));
944    }
945
946    #[test]
947    fn nested_inline_on_mapping_field() {
948        let input = "outer:\n  inner: 1 # tail\n";
949        let out = prescan_fence_content(input);
950        assert_eq!(
951            out.nested_comments,
952            vec![NestedComment {
953                container_path: vec![CommentPathSegment::Key("outer".to_string())],
954                position: 0,
955                text: "tail".to_string(),
956                inline: true,
957            }]
958        );
959    }
960
961    #[test]
962    fn fill_on_flow_sequence_allowed() {
963        let input = "x: !must_fill [1, 2]\n";
964        let out = prescan_fence_content(input);
965        assert!(
966            out.fill_target_errors.is_empty(),
967            "expected no error; !must_fill on sequences is supported"
968        );
969        assert_eq!(
970            out.items,
971            vec![PreItem::Field {
972                key: "x".to_string(),
973                fill: true,
974            }]
975        );
976    }
977
978    #[test]
979    fn sequence_with_multibyte_after_dash_does_not_panic() {
980        // En-dash (3 bytes), em-dash (3 bytes), smart quote (3 bytes), and emoji
981        // (4 bytes) appearing immediately after `- ` or as a sibling bullet
982        // marker. Earlier versions sliced `&trimmed[2..]` here; if that ever
983        // regresses to indexing inside a multi-byte codepoint, this test will
984        // panic with `"byte index 2 is not a char boundary"`.
985        let inputs = [
986            "arr:\n  - – en-dash\n  - — em-dash\n",
987            "arr:\n  - \u{2013}line\n  - \u{2014}line\n",
988            "arr:\n  - \u{201C}smart-quoted\u{201D}\n",
989            "arr:\n  - \u{1F600} emoji\n",
990            // A literal block scalar holding mixed dashes: mirrors the eval
991            // payload (`bullets: |` with `–` substituted for `-`).
992            "bullets: |\n  - (U) **A:** text\n  – (U) **B:** text\n",
993        ];
994        for input in inputs {
995            let out = prescan_fence_content(input);
996            // We don't care about the exact items; just that no panic occurred
997            // and that the cleaned YAML round-trips line count.
998            assert_eq!(out.cleaned_yaml.lines().count(), input.lines().count());
999        }
1000    }
1001
1002    #[test]
1003    fn block_scalar_content_is_not_parsed_as_structure() {
1004        // A markdown block scalar whose content contains a `#` heading, a
1005        // `- ` bullet, and a `key:` line. None of these are YAML structure:
1006        // they must survive verbatim in the cleaned YAML, and the field after
1007        // the block must still parse as a top-level field.
1008        let input =
1009            "bio: |-\n  ## About me\n\n  - point one\n  role: engineer\n  Done.\nname: jane\n";
1010        let out = prescan_fence_content(input);
1011
1012        // The heading is content, not a stripped comment.
1013        assert!(
1014            out.cleaned_yaml.contains("## About me"),
1015            "block-scalar heading must survive: {:?}",
1016            out.cleaned_yaml
1017        );
1018        assert!(out.cleaned_yaml.contains("- point one"));
1019        assert!(out.cleaned_yaml.contains("role: engineer"));
1020
1021        // Nothing from inside the block leaked into items as a comment/field.
1022        assert!(
1023            !out.items.iter().any(|i| matches!(
1024                i,
1025                PreItem::Comment { text, .. } if text.contains("About")
1026            )),
1027            "block-scalar `#` line must not become a comment"
1028        );
1029        assert!(
1030            !out.items
1031                .iter()
1032                .any(|i| matches!(i, PreItem::Field { key, .. } if key == "role")),
1033            "block-scalar `key:` line must not become a field"
1034        );
1035
1036        // The two real top-level fields are `bio` then `name`, in order.
1037        let fields: Vec<&str> = out
1038            .items
1039            .iter()
1040            .filter_map(|i| match i {
1041                PreItem::Field { key, .. } => Some(key.as_str()),
1042                _ => None,
1043            })
1044            .collect();
1045        assert_eq!(fields, vec!["bio", "name"]);
1046    }
1047
1048    #[test]
1049    fn sequence_item_block_scalar_content_is_not_parsed_as_structure() {
1050        // A `richtext[]` array authored as `- |-` block-scalar items. Content
1051        // lines (heading, bullet, `key:`) must survive verbatim, and the next
1052        // item at the dash indent must still parse as a sequence item.
1053        let input = "items:\n  - |-\n    ## Heading\n    - inner bullet\n    role: x\n  - second\n";
1054        let out = prescan_fence_content(input);
1055
1056        assert!(
1057            out.cleaned_yaml.contains("## Heading"),
1058            "block-scalar heading inside a sequence item must survive: {:?}",
1059            out.cleaned_yaml
1060        );
1061        assert!(out.cleaned_yaml.contains("- inner bullet"));
1062        assert!(out.cleaned_yaml.contains("role: x"));
1063        // The heading must not have been captured as a comment.
1064        assert!(
1065            !out.nested_comments
1066                .iter()
1067                .any(|c| c.text.contains("Heading")),
1068            "block-scalar `#` line must not become a nested comment"
1069        );
1070        // `second` is preserved (the block ended at the next dash).
1071        assert!(out.cleaned_yaml.contains("- second"));
1072    }
1073
1074    #[test]
1075    fn fill_on_flow_mapping_errors() {
1076        let input = "x: !must_fill {a: 1}\n";
1077        let out = prescan_fence_content(input);
1078        assert!(
1079            !out.fill_target_errors.is_empty(),
1080            "expected error; !must_fill on mappings is rejected"
1081        );
1082    }
1083    // ── split_trailing_comment: YAML 1.2 conformance ─────────────────────────
1084
1085    #[test]
1086    fn comment_after_plain_scalar_with_apostrophe() {
1087        // YAML: in a plain scalar, `'` is an ordinary character; the
1088        // whitespace-preceded `#` still starts a comment.
1089        let (v, c) = split_trailing_comment(" it's a test # note");
1090        assert_eq!(v, " it's a test");
1091        assert_eq!(c.as_deref(), Some("# note"));
1092    }
1093
1094    #[test]
1095    fn hash_inside_quoted_scalar_is_not_a_comment() {
1096        let (v, c) = split_trailing_comment(" 'a # b'");
1097        assert_eq!(v, " 'a # b'");
1098        assert_eq!(c, None);
1099
1100        let (v, c) = split_trailing_comment(" \"a # b\"");
1101        assert_eq!(v, " \"a # b\"");
1102        assert_eq!(c, None);
1103    }
1104
1105    #[test]
1106    fn comment_after_quoted_scalar() {
1107        let (v, c) = split_trailing_comment(" 'a # b' # real");
1108        assert_eq!(v, " 'a # b'");
1109        assert_eq!(c.as_deref(), Some("# real"));
1110
1111        // '' is an escaped quote, not the closer.
1112        let (v, c) = split_trailing_comment(" 'it''s # x' # real");
1113        assert_eq!(v, " 'it''s # x'");
1114        assert_eq!(c.as_deref(), Some("# real"));
1115
1116        // \" is an escaped quote in double-quoted scalars.
1117        let (v, c) = split_trailing_comment(" \"a \\\" # b\" # real");
1118        assert_eq!(v, " \"a \\\" # b\"");
1119        assert_eq!(c.as_deref(), Some("# real"));
1120    }
1121
1122    #[test]
1123    fn unterminated_quote_means_multiline_scalar_no_comment() {
1124        let (v, c) = split_trailing_comment(" \"starts here # not a comment");
1125        assert_eq!(v, " \"starts here # not a comment");
1126        assert_eq!(c, None);
1127    }
1128
1129    #[test]
1130    fn flow_collection_tracks_quotes_anywhere() {
1131        let (v, c) = split_trailing_comment(" [a, \"b # c\"] # real");
1132        assert_eq!(v, " [a, \"b # c\"]");
1133        assert_eq!(c.as_deref(), Some("# real"));
1134
1135        let (v, c) = split_trailing_comment(" [a, \"b # c\"]");
1136        assert_eq!(c, None);
1137        assert_eq!(v, " [a, \"b # c\"]");
1138    }
1139
1140    #[test]
1141    fn hash_without_preceding_whitespace_is_not_a_comment() {
1142        let (v, c) = split_trailing_comment(" a#b");
1143        assert_eq!(v, " a#b");
1144        assert_eq!(c, None);
1145    }
1146}