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