Skip to main content

yaml_rt_core/
source.rs

1use crate::{Diagnostic, DiagnosticKind, YamlError};
2
3/// YAML version targeted by this workspace.
4pub const TARGET_YAML_VERSION: &str = "1.2.2";
5
6/// Identifier for a node stored inside a [`crate::YamlDoc`].
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct NodeId(pub u32);
9
10impl NodeId {
11    /// Creates a node ID from a vector index.
12    ///
13    /// # Panics
14    ///
15    /// Panics when `index` cannot fit in the u32-backed node ID.
16    #[must_use]
17    pub fn from_usize(index: usize) -> Self {
18        Self(u32::try_from(index).expect("node arena is too large for u32-based node IDs"))
19    }
20
21    /// Returns this node ID as a vector index.
22    #[must_use]
23    pub const fn as_usize(self) -> usize {
24        self.0 as usize
25    }
26}
27
28/// A byte span inside a [`Source`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct Span {
31    /// Inclusive start byte offset.
32    pub start: u32,
33    /// Exclusive end byte offset.
34    pub end: u32,
35}
36
37impl Span {
38    /// Creates a new byte span.
39    #[must_use]
40    pub const fn new(start: u32, end: u32) -> Self {
41        Self { start, end }
42    }
43
44    /// Creates a span from usize byte offsets.
45    ///
46    /// # Panics
47    ///
48    /// Panics when either offset cannot fit in the u32-backed span.
49    #[must_use]
50    pub fn from_usize(start: usize, end: usize) -> Self {
51        Self::try_from((start, end)).expect("YAML source is too large for u32-based spans")
52    }
53
54    /// Returns an empty span at `offset`.
55    #[must_use]
56    pub const fn empty(offset: u32) -> Self {
57        Self {
58            start: offset,
59            end: offset,
60        }
61    }
62
63    pub(crate) fn usize_to_u32(offset: usize) -> u32 {
64        u32::try_from(offset).expect("YAML source is too large for u32-based spans")
65    }
66
67    pub(crate) fn offset_from_usize(base: u32, offset: usize) -> u32 {
68        base.checked_add(Self::usize_to_u32(offset))
69            .expect("YAML source is too large for u32-based spans")
70    }
71
72    /// Returns an empty span at `offset`.
73    #[must_use]
74    pub fn empty_from_usize(offset: usize) -> Self {
75        Self::empty(Self::usize_to_u32(offset))
76    }
77
78    /// Returns the span length in bytes.
79    #[must_use]
80    pub const fn len(self) -> u32 {
81        self.end.saturating_sub(self.start)
82    }
83
84    /// Returns true when this span covers no bytes.
85    #[must_use]
86    pub const fn is_empty(self) -> bool {
87        self.start == self.end
88    }
89
90    /// Returns true when `offset` is inside this span.
91    #[must_use]
92    pub const fn contains(self, offset: u32) -> bool {
93        self.start <= offset && offset < self.end
94    }
95}
96
97impl TryFrom<(usize, usize)> for Span {
98    type Error = std::num::TryFromIntError;
99
100    fn try_from((start, end): (usize, usize)) -> Result<Self, Self::Error> {
101        Ok(Self {
102            start: Self::usize_to_u32(start),
103            end: Self::usize_to_u32(end),
104        })
105    }
106}
107/// One-based line and column location.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct LineCol {
110    /// One-based line number.
111    pub line: usize,
112    /// One-based column number in bytes for the current bootstrap model.
113    pub column: usize,
114}
115
116/// Original YAML input plus line-start metadata.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Source {
119    text: String,
120    line_starts: Vec<u32>,
121    line_facts: Vec<LineFacts>,
122}
123
124const NO_LINE_OFFSET: u16 = u16::MAX;
125const NO_LINE_INDEX: u32 = u32::MAX;
126const LINE_BLANK: u16 = 1 << 0;
127const LINE_SIMPLE_MAPPING: u16 = 1 << 1;
128const LINE_OFFSET_OVERFLOW: u16 = 1 << 2;
129const LINE_COMMENT: u16 = 1 << 3;
130const LINE_SCALAR_PLAIN: u16 = 1 << 4;
131const LINE_SCALAR_SINGLE_QUOTED: u16 = 1 << 5;
132const LINE_SCALAR_DOUBLE_QUOTED: u16 = 1 << 6;
133const LINE_FACTS_MIN_SOURCE_BYTES: usize = 1024;
134
135/// Compact facts for the common block-line parser path.
136///
137/// Offsets are relative to the start of the line. Exceptionally long lines use
138/// the existing full scanners instead of retaining wider offsets for every
139/// ordinary line.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub(crate) struct LineFacts {
142    indent: u16,
143    mapping_colon: u16,
144    value_start: u16,
145    scalar_end: u16,
146    flags: u16,
147    next_significant: u32,
148}
149
150impl LineFacts {
151    const FALLBACK: Self = Self {
152        indent: NO_LINE_OFFSET,
153        mapping_colon: NO_LINE_OFFSET,
154        value_start: NO_LINE_OFFSET,
155        scalar_end: NO_LINE_OFFSET,
156        flags: LINE_OFFSET_OVERFLOW,
157        next_significant: NO_LINE_INDEX,
158    };
159
160    pub(crate) fn indent(self) -> Option<usize> {
161        (!self.has(LINE_OFFSET_OVERFLOW)).then_some(self.indent as usize)
162    }
163
164    pub(crate) fn simple_mapping(self) -> Option<(usize, usize)> {
165        self.has(LINE_SIMPLE_MAPPING)
166            .then_some((self.mapping_colon as usize, self.value_start as usize))
167    }
168
169    pub(crate) fn mapping_colon(self) -> Option<usize> {
170        (self.mapping_colon != NO_LINE_OFFSET).then_some(self.mapping_colon as usize)
171    }
172
173    pub(crate) fn scalar_mapping(self) -> Option<(usize, usize, usize, CachedScalarStyle)> {
174        let style = if self.has(LINE_SCALAR_PLAIN) {
175            CachedScalarStyle::Plain
176        } else if self.has(LINE_SCALAR_SINGLE_QUOTED) {
177            CachedScalarStyle::SingleQuoted
178        } else if self.has(LINE_SCALAR_DOUBLE_QUOTED) {
179            CachedScalarStyle::DoubleQuoted
180        } else {
181            return None;
182        };
183        Some((
184            self.mapping_colon as usize,
185            self.value_start as usize,
186            self.scalar_end as usize,
187            style,
188        ))
189    }
190
191    pub(crate) const fn is_blank(self) -> bool {
192        self.has(LINE_BLANK)
193    }
194
195    fn next_significant(self) -> Option<usize> {
196        (self.next_significant != NO_LINE_INDEX).then_some(self.next_significant as usize)
197    }
198
199    const fn has(self, flag: u16) -> bool {
200        self.flags & flag != 0
201    }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub(crate) enum CachedScalarStyle {
206    Plain,
207    SingleQuoted,
208    DoubleQuoted,
209}
210
211impl Source {
212    /// Builds a source buffer, validates YAML 1.2.2 printable characters, and
213    /// records all line starts.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error when `text` contains characters that are not valid in a
218    /// YAML 1.2.2 stream.
219    pub fn new(text: String) -> Result<Self, YamlError> {
220        let bytes = text.as_bytes();
221        let mut line_starts = Vec::with_capacity(text.len() / 32 + 1);
222        line_starts.push(0);
223        const SOURCE_SCAN_CHUNK: usize = 32;
224        let (chunks, remainder) = bytes.as_chunks::<SOURCE_SCAN_CHUNK>();
225        for (chunk_index, chunk) in chunks.iter().enumerate() {
226            validate_source_chunk(
227                &text,
228                bytes,
229                chunk_index * SOURCE_SCAN_CHUNK,
230                chunk,
231                &mut line_starts,
232            )?;
233        }
234        validate_source_chunk(
235            &text,
236            bytes,
237            chunks.len() * SOURCE_SCAN_CHUNK,
238            remainder,
239            &mut line_starts,
240        )?;
241        let line_facts = if text.len() >= LINE_FACTS_MIN_SOURCE_BYTES {
242            build_line_facts(&text, &line_starts)
243        } else {
244            Vec::new()
245        };
246
247        Ok(Self {
248            text,
249            line_starts,
250            line_facts,
251        })
252    }
253
254    /// Returns the original input text.
255    #[must_use]
256    pub fn as_str(&self) -> &str {
257        &self.text
258    }
259
260    /// Returns the source length in bytes.
261    #[must_use]
262    pub fn len(&self) -> usize {
263        self.text.len()
264    }
265
266    /// Returns true when the source is empty.
267    #[must_use]
268    pub fn is_empty(&self) -> bool {
269        self.text.is_empty()
270    }
271
272    /// Returns the recorded line-start byte offsets.
273    #[must_use]
274    pub fn line_starts(&self) -> &[u32] {
275        &self.line_starts
276    }
277
278    pub(crate) fn line_facts(&self, index: usize) -> LineFacts {
279        self.line_facts
280            .get(index)
281            .copied()
282            .unwrap_or(LineFacts::FALLBACK)
283    }
284
285    pub(crate) fn cached_next_significant_line(&self, index: usize) -> Option<Option<usize>> {
286        self.line_facts
287            .get(index)
288            .copied()
289            .map(LineFacts::next_significant)
290    }
291
292    pub(crate) fn has_line_facts(&self) -> bool {
293        !self.line_facts.is_empty()
294    }
295
296    pub(crate) fn validated_line_slice(&self, start: usize, end: usize) -> &str {
297        debug_assert!(start <= end);
298        debug_assert!(end <= self.text.len());
299        debug_assert!(self.text.is_char_boundary(start));
300        debug_assert!(self.text.is_char_boundary(end));
301        // SAFETY: line offsets are produced while scanning this owned, valid
302        // UTF-8 string. The assertions retain those invariants in debug builds.
303        unsafe { self.text.get_unchecked(start..end) }
304    }
305
306    /// Returns the source slice for `span`.
307    ///
308    /// # Panics
309    ///
310    /// Panics if `span` is outside the source or does not fall on UTF-8
311    /// boundaries. Use [`Source::try_slice`] when handling user-provided spans.
312    #[must_use]
313    pub fn slice(&self, span: Span) -> &str {
314        self.try_slice(span)
315            .expect("span must be in bounds and on UTF-8 boundaries")
316    }
317
318    /// Returns the source slice for `span`, or a span-rich error when invalid.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error when `span` is outside the source text or does not fall
323    /// on UTF-8 boundaries.
324    pub fn try_slice(&self, span: Span) -> Result<&str, YamlError> {
325        let start = span.start as usize;
326        let end = span.end as usize;
327
328        if start > end || end > self.text.len() {
329            return Err(YamlError::new(Diagnostic::new(
330                DiagnosticKind::Source,
331                "span is outside the source text",
332                span,
333            )));
334        }
335
336        self.text.get(start..end).ok_or_else(|| {
337            YamlError::new(Diagnostic::new(
338                DiagnosticKind::Source,
339                "span does not align with UTF-8 character boundaries",
340                span,
341            ))
342        })
343    }
344
345    /// Converts a byte offset into a one-based line/column pair.
346    #[must_use]
347    pub fn line_col(&self, offset: usize) -> LineCol {
348        let offset = Span::usize_to_u32(offset.min(self.text.len()));
349        let line_index = match self.line_starts.binary_search(&offset) {
350            Ok(index) => index,
351            Err(index) => index.saturating_sub(1),
352        };
353        let line_start = self.line_starts[line_index];
354
355        LineCol {
356            line: line_index + 1,
357            column: (offset - line_start) as usize + 1,
358        }
359    }
360
361    /// Returns the line/column pair for a diagnostic's primary span.
362    #[must_use]
363    pub fn diagnostic_position(&self, diagnostic: &Diagnostic) -> LineCol {
364        self.line_col(diagnostic.span.start as usize)
365    }
366}
367
368fn validate_source_chunk(
369    text: &str,
370    bytes: &[u8],
371    base: usize,
372    chunk: &[u8],
373    line_starts: &mut Vec<u32>,
374) -> Result<(), YamlError> {
375    let mut cursor = 0;
376    while let Some(relative) = chunk[cursor..]
377        .iter()
378        .position(|byte| *byte < b' ' || matches!(*byte, 0x7F | 0xC2 | 0xEF))
379    {
380        let relative = cursor + relative;
381        let byte = chunk[relative];
382        let offset = base + relative;
383        if byte < 0x80 {
384            if !matches!(byte, b'\t' | b'\n' | b'\r' | b' '..=b'~') {
385                return Err(invalid_yaml_character(offset, char::from(byte)));
386            }
387            if byte == b'\n' {
388                line_starts.push(Span::usize_to_u32(offset + 1));
389            }
390        } else if unicode_sequence_may_be_non_printable(bytes, offset) {
391            let character = text[offset..]
392                .chars()
393                .next()
394                .expect("offset starts a valid UTF-8 character");
395            if !is_yaml_printable(character) {
396                return Err(invalid_yaml_character(offset, character));
397            }
398        }
399        cursor = relative + 1;
400    }
401    Ok(())
402}
403
404fn unicode_sequence_may_be_non_printable(bytes: &[u8], offset: usize) -> bool {
405    match bytes[offset..] {
406        [0xC2, continuation, ..] => (0x80..=0x9F).contains(&continuation) && continuation != 0x85,
407        [0xEF, 0xBF, 0xBE | 0xBF, ..] => true,
408        _ => false,
409    }
410}
411
412fn build_line_facts(text: &str, line_starts: &[u32]) -> Vec<LineFacts> {
413    let mut facts = Vec::with_capacity(line_starts.len());
414    for (index, &start) in line_starts.iter().enumerate() {
415        let start = start as usize;
416        let mut end = line_starts
417            .get(index + 1)
418            .map_or(text.len(), |next| *next as usize);
419        if end > start && text.as_bytes()[end - 1] == b'\n' {
420            end -= 1;
421            if end > start && text.as_bytes()[end - 1] == b'\r' {
422                end -= 1;
423            }
424        } else if end > start && text.as_bytes()[end - 1] == b'\r' {
425            end -= 1;
426        }
427        facts.push(analyze_line(&text.as_bytes()[start..end]));
428    }
429    populate_next_significant_lines(&mut facts);
430    facts
431}
432
433fn populate_next_significant_lines(facts: &mut [LineFacts]) {
434    let mut next = NO_LINE_INDEX;
435    for (index, fact) in facts.iter_mut().enumerate().rev() {
436        fact.next_significant = next;
437        if !fact.has(LINE_BLANK | LINE_COMMENT) {
438            next = u32::try_from(index).expect("line index exceeds u32 capacity");
439        }
440    }
441}
442
443fn analyze_line(line: &[u8]) -> LineFacts {
444    let indent = line.iter().take_while(|byte| **byte == b' ').count();
445    let mut flags = 0;
446    if line[indent..].is_empty() {
447        flags |= LINE_BLANK;
448    } else if line[indent] == b'#' {
449        flags |= LINE_COMMENT;
450    }
451
452    if line.len() >= NO_LINE_OFFSET as usize {
453        return LineFacts {
454            flags: flags | LINE_OFFSET_OVERFLOW,
455            ..LineFacts::FALLBACK
456        };
457    }
458
459    let Some(indent) = u16::try_from(indent)
460        .ok()
461        .filter(|value| *value != NO_LINE_OFFSET)
462    else {
463        return LineFacts {
464            indent: NO_LINE_OFFSET,
465            mapping_colon: NO_LINE_OFFSET,
466            value_start: NO_LINE_OFFSET,
467            scalar_end: NO_LINE_OFFSET,
468            flags: flags | LINE_OFFSET_OVERFLOW,
469            next_significant: NO_LINE_INDEX,
470        };
471    };
472
473    let body = &line[indent as usize..];
474    if let Some((colon, value_start)) = plain_key_mapping_offsets(body)
475        && let Ok(colon) = u16::try_from(colon)
476        && colon != NO_LINE_OFFSET
477    {
478        let scalar_value_start = value_start;
479        let value_start = scalar_value_start
480            .and_then(|offset| u16::try_from(offset).ok())
481            .filter(|offset| *offset != NO_LINE_OFFSET);
482        let mut scalar_end = NO_LINE_OFFSET;
483        if let Some(start) = scalar_value_start
484            && let Some((end, scalar_flag)) = cached_scalar_offsets(&body[start..])
485            && let Ok(end) = u16::try_from(start + end)
486            && end != NO_LINE_OFFSET
487        {
488            scalar_end = end;
489            flags |= scalar_flag;
490            if scalar_flag == LINE_SCALAR_PLAIN {
491                flags |= LINE_SIMPLE_MAPPING;
492            }
493        }
494        return LineFacts {
495            indent,
496            mapping_colon: colon,
497            value_start: value_start.unwrap_or(NO_LINE_OFFSET),
498            scalar_end,
499            flags,
500            next_significant: NO_LINE_INDEX,
501        };
502    }
503
504    LineFacts {
505        indent,
506        mapping_colon: NO_LINE_OFFSET,
507        value_start: NO_LINE_OFFSET,
508        scalar_end: NO_LINE_OFFSET,
509        flags,
510        next_significant: NO_LINE_INDEX,
511    }
512}
513
514fn plain_key_mapping_offsets(body: &[u8]) -> Option<(usize, Option<usize>)> {
515    let mut colon = 0;
516    while colon < body.len() && body[colon] != b':' {
517        if !is_simple_plain_byte(body[colon]) {
518            return None;
519        }
520        colon += 1;
521    }
522    if colon == 0 || colon == body.len() {
523        return None;
524    }
525
526    let mut value_start = colon + 1;
527    if value_start == body.len() {
528        return Some((colon, None));
529    }
530    if body.get(value_start) != Some(&b' ') {
531        return None;
532    }
533    while body.get(value_start) == Some(&b' ') {
534        value_start += 1;
535    }
536    if value_start == body.len() || &body[value_start..] == b"-" {
537        return Some((colon, None));
538    }
539    Some((colon, Some(value_start)))
540}
541
542fn cached_scalar_offsets(text: &[u8]) -> Option<(usize, u16)> {
543    match text.first().copied()? {
544        b'"' => {
545            let end = text[1..]
546                .iter()
547                .position(|byte| matches!(*byte, b'"' | b'\\'))?
548                + 1;
549            if text[end] == b'\\' || !valid_cached_quoted_trailing_text(&text[end + 1..]) {
550                return None;
551            }
552            Some((end + 1, LINE_SCALAR_DOUBLE_QUOTED))
553        }
554        b'\'' => {
555            let mut position = 1;
556            loop {
557                let quote = text[position..].iter().position(|byte| *byte == b'\'')? + position;
558                if text.get(quote + 1) == Some(&b'\'') {
559                    position = quote + 2;
560                    continue;
561                }
562                if !valid_cached_quoted_trailing_text(&text[quote + 1..]) {
563                    return None;
564                }
565                return Some((quote + 1, LINE_SCALAR_SINGLE_QUOTED));
566            }
567        }
568        _ if text.iter().all(|byte| is_simple_plain_byte(*byte)) => {
569            Some((text.len(), LINE_SCALAR_PLAIN))
570        }
571        _ => None,
572    }
573}
574
575fn valid_cached_quoted_trailing_text(trailing: &[u8]) -> bool {
576    if trailing.iter().all(|byte| *byte == b' ') {
577        return true;
578    }
579    let whitespace = trailing.iter().take_while(|byte| **byte == b' ').count();
580    whitespace > 0 && trailing.get(whitespace) == Some(&b'#')
581}
582
583const fn is_simple_plain_byte(byte: u8) -> bool {
584    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')
585}
586
587fn invalid_yaml_character(offset: usize, character: char) -> YamlError {
588    let span = Span::from_usize(offset, offset + character.len_utf8());
589    YamlError::new(
590        Diagnostic::new(
591            DiagnosticKind::Source,
592            format!("invalid YAML 1.2.2 character U+{:04X}", character as u32),
593            span,
594        )
595        .with_note(
596            "YAML streams may contain tab, line feeds, carriage returns, printable Unicode characters, and non-breaking spaces",
597        ),
598    )
599}
600
601pub(crate) fn validate_yaml_chars(text: &str) -> Result<(), YamlError> {
602    for (offset, character) in text.char_indices() {
603        if !is_yaml_printable(character) {
604            let span = Span::from_usize(offset, offset + character.len_utf8());
605            return Err(YamlError::new(
606                Diagnostic::new(
607                    DiagnosticKind::Source,
608                    format!(
609                        "invalid YAML 1.2.2 character U+{:04X}",
610                        character as u32
611                    ),
612                    span,
613                )
614                .with_note(
615                    "YAML streams may contain tab, line feeds, carriage returns, printable Unicode characters, and non-breaking spaces",
616                ),
617            ));
618        }
619    }
620
621    Ok(())
622}
623
624const fn is_yaml_printable(character: char) -> bool {
625    matches!(
626        character as u32,
627        0x09 | 0x0A | 0x0D | 0x20..=0x7E | 0x85 | 0xA0..=0xD7FF | 0xE000..=0xFFFD | 0x001_0000..=0x0010_FFFF
628    )
629}
630
631#[cfg(test)]
632mod line_facts_tests {
633    use std::fmt::Write;
634
635    use super::*;
636    use crate::parser::Parser;
637    use crate::semantic::SemanticStore;
638    use crate::{Node, ParsedYaml, YamlDoc, YamlEvent};
639
640    #[derive(Debug, PartialEq, Eq)]
641    struct ParseFingerprint {
642        nodes: Vec<Node>,
643        semantics: SemanticStore,
644        events: Vec<YamlEvent>,
645        rendering: String,
646    }
647
648    fn parse_fingerprint(source: Source, parsed: ParsedYaml) -> ParseFingerprint {
649        let document = YamlDoc {
650            source,
651            nodes: parsed.nodes.clone(),
652            semantics: parsed.semantics.clone(),
653            root_override: None,
654            edits: Vec::new(),
655        };
656        ParseFingerprint {
657            nodes: parsed.nodes,
658            semantics: parsed.semantics,
659            events: document.events().collect(),
660            rendering: document.to_string(),
661        }
662    }
663
664    #[test]
665    fn caches_common_lines_and_leaves_complex_lines_on_the_fallback_path() {
666        let source = Source::new(
667            "alpha: beta\r\nunicode: café\n\tbad: tab\n\"quoted\": value\n&anchor key: value\nflow: [one, two]\nkey: value # comment\n# comment\nliteral: |\n  text\n"
668                .to_owned(),
669        )
670        .expect("fixture is printable YAML");
671
672        let facts = build_line_facts(source.as_str(), source.line_starts());
673        assert_eq!(facts[0].simple_mapping(), Some((5, 7)));
674        assert_eq!(facts[1].mapping_colon(), Some(7));
675        assert_eq!(facts[1].simple_mapping(), None);
676        assert_eq!(facts[2].mapping_colon(), None);
677        assert_eq!(facts[3].mapping_colon(), None);
678        assert_eq!(facts[4].mapping_colon(), None);
679        assert_eq!(facts[5].mapping_colon(), Some(4));
680        assert_eq!(facts[5].simple_mapping(), None);
681        assert_eq!(facts[6].mapping_colon(), Some(3));
682        assert_eq!(facts[6].simple_mapping(), None);
683        assert_eq!(facts[7].mapping_colon(), None);
684        assert_eq!(facts[8].mapping_colon(), Some(7));
685        assert_eq!(facts[9].indent(), Some(2));
686        assert_eq!(facts[5].next_significant(), Some(6));
687        assert_eq!(facts[6].next_significant(), Some(8));
688        assert_eq!(facts[9].next_significant(), None);
689        assert_eq!(std::mem::size_of::<LineFacts>(), 16);
690    }
691
692    #[test]
693    fn cached_scalar_facts_match_the_general_parser() {
694        let mut input = String::from("root:\n");
695        for index in 0..40 {
696            writeln!(input, "  plain_{index}: value_{index}")
697                .expect("writing to a String cannot fail");
698            writeln!(input, "  single_{index}: 'quoted # {index}' # trailing")
699                .expect("writing to a String cannot fail");
700            writeln!(
701                input,
702                "  double_{index}: \"Unicode café {index}\" # trailing"
703            )
704            .expect("writing to a String cannot fail");
705        }
706
707        let optimized = Source::new(input.clone()).expect("fixture is printable YAML");
708        assert!(optimized.line_facts(2).scalar_mapping().is_some());
709        assert!(optimized.line_facts(3).scalar_mapping().is_some());
710        let optimized_parse = Parser::new(&optimized)
711            .parse()
712            .expect("optimized fixture should parse");
713
714        let mut general = Source::new(input).expect("fixture is printable YAML");
715        general.line_facts.clear();
716        let general_parse = Parser::new(&general)
717            .parse()
718            .expect("general fixture should parse");
719        assert_eq!(
720            parse_fingerprint(optimized, optimized_parse),
721            parse_fingerprint(general, general_parse)
722        );
723    }
724
725    #[test]
726    fn cached_line_path_matches_fallback_diagnostics() {
727        let input = format!(
728            "{}---\nquoted: \"a\nb\nc\"\n",
729            "# oracle padding\n".repeat(80)
730        );
731        let optimized = Source::new(input.clone()).expect("fixture is printable YAML");
732        let optimized_error = Parser::new(&optimized)
733            .parse()
734            .expect_err("unindented quoted continuation is invalid")
735            .with_position_from(&optimized);
736
737        let mut general = Source::new(input).expect("fixture is printable YAML");
738        general.line_facts.clear();
739        let general_error = Parser::new(&general)
740            .parse()
741            .expect_err("fallback path must reject the same input")
742            .with_position_from(&general);
743
744        assert_eq!(optimized_error.diagnostic, general_error.diagnostic);
745    }
746
747    #[test]
748    fn cached_common_mapping_path_preserves_the_complete_source() {
749        let mut input = String::new();
750        for index in 0..100 {
751            writeln!(input, "key_{index:04}: value_{index:04}")
752                .expect("writing to a String cannot fail");
753        }
754        let source = Source::new(input.clone()).expect("generated mapping is printable YAML");
755        assert_eq!(source.line_facts(0).simple_mapping(), Some((8, 10)));
756
757        let doc = YamlDoc::parse(&input).expect("cached mapping should parse");
758        assert_eq!(doc.to_string(), input);
759    }
760
761    #[test]
762    fn long_line_offsets_fall_back_without_changing_parse_behavior() {
763        let key = "k".repeat(u16::MAX as usize);
764        let input = format!("{key}: value\n");
765        let source = Source::new(input.clone()).expect("long fixture is printable YAML");
766        assert_eq!(source.line_facts(0).mapping_colon(), None);
767
768        let doc = YamlDoc::parse(&input).expect("long mapping key should use the full scanner");
769        assert_eq!(doc.to_string(), input);
770    }
771}