Skip to main content

granit_parser/input/
str.rs

1use crate::{
2    char_traits::is_breakz,
3    error::ErrorKind,
4    input::{BorrowedInput, Input, SkipTabs, WhitespaceResult},
5};
6use alloc::string::String;
7
8/// A parser input backed by a `&str`.
9#[allow(clippy::module_name_repetitions)]
10pub struct StrInput<'a> {
11    /// The full, original input string.
12    ///
13    /// This is kept to support O(1) byte-offset capture and zero-copy slicing via the optional
14    /// [`Input::byte_offset`] / [`Input::slice_bytes`] APIs.
15    original: &'a str,
16    /// The remaining input slice.
17    ///
18    /// This is a moving window into [`Self::original`]. All consuming operations advance this
19    /// slice.
20    buffer: &'a str,
21    /// The number of characters we have looked ahead.
22    ///
23    /// This tracks how many characters the parser asked us to look ahead for so we can return the
24    /// correct value in [`Self::buflen`].
25    lookahead: usize,
26}
27
28impl<'a> StrInput<'a> {
29    /// Create a new [`StrInput`] over the given string slice.
30    #[must_use]
31    pub fn new(input: &'a str) -> Self {
32        Self {
33            original: input,
34            buffer: input,
35            lookahead: 0,
36        }
37    }
38
39    /// Return the number of bytes consumed from the original input.
40    ///
41    /// This is an O(1) operation derived from the invariant that [`Self::buffer`] is always a
42    /// suffix of [`Self::original`].
43    #[inline]
44    #[must_use]
45    fn consumed_bytes(&self) -> usize {
46        self.original.len() - self.buffer.len()
47    }
48
49    /// Find the end of the next plain-scalar chunk in the current input buffer.
50    ///
51    /// Returns its byte length and character count. This keeps the existing batched scanner in one
52    /// place so callers can either materialize the chunk or retain it as a borrowed source slice.
53    fn plain_scalar_chunk_len(&self, flow_level_gt_0: bool) -> (usize, usize) {
54        let bytes = self.buffer.as_bytes();
55        let mut byte_pos = 0;
56        let mut chars_consumed = 0;
57
58        while byte_pos < bytes.len() {
59            let byte = bytes[byte_pos];
60            if byte < 0x80 {
61                let character = byte as char;
62                if crate::char_traits::is_blank_or_breakz(character)
63                    || flow_level_gt_0 && crate::char_traits::is_flow(character)
64                {
65                    break;
66                }
67                if character == ':' {
68                    let next_byte = bytes.get(byte_pos + 1).copied().unwrap_or(0);
69                    // A non-ASCII character cannot be blank, breakz, or a flow indicator.
70                    let stops_scalar = next_byte < 0x80
71                        && (crate::char_traits::is_blank_or_breakz(next_byte as char)
72                            || flow_level_gt_0 && crate::char_traits::is_flow(next_byte as char));
73                    if stops_scalar {
74                        break;
75                    }
76                }
77                byte_pos += 1;
78            } else {
79                let character = self.buffer[byte_pos..].chars().next().unwrap();
80                byte_pos += character.len_utf8();
81            }
82            chars_consumed += 1;
83        }
84
85        (byte_pos, chars_consumed)
86    }
87}
88
89impl Input for StrInput<'_> {
90    #[inline]
91    fn lookahead(&mut self, x: usize) {
92        // We already have all characters that we need.
93        // We cannot add '\0's to the buffer when we reach EOF.
94        // Character-retrieving functions return '\0' when they read past EOF.
95        self.lookahead = self.lookahead.max(x);
96    }
97
98    #[inline]
99    fn buflen(&self) -> usize {
100        self.lookahead
101    }
102
103    #[inline]
104    fn bufmaxlen(&self) -> usize {
105        BUFFER_LEN
106    }
107
108    #[inline]
109    fn raw_read_ch(&mut self) -> char {
110        let mut chars = self.buffer.chars();
111        if let Some(c) = chars.next() {
112            self.buffer = chars.as_str();
113            c
114        } else {
115            '\0'
116        }
117    }
118
119    #[inline]
120    fn raw_read_non_breakz_ch(&mut self) -> Option<char> {
121        if let Some((c, sub_str)) = split_first_char(self.buffer) {
122            if is_breakz(c) {
123                None
124            } else {
125                self.buffer = sub_str;
126                Some(c)
127            }
128        } else {
129            None
130        }
131    }
132
133    #[inline]
134    fn skip(&mut self) {
135        if !self.buffer.is_empty() {
136            let b = self.buffer.as_bytes()[0];
137            if b < 0x80 {
138                self.buffer = &self.buffer[1..];
139            } else {
140                let mut chars = self.buffer.chars();
141                chars.next();
142                self.buffer = chars.as_str();
143            }
144        }
145    }
146
147    #[inline]
148    fn skip_n(&mut self, count: usize) {
149        let mut chars = self.buffer.chars();
150        for _ in 0..count {
151            if chars.next().is_none() {
152                break;
153            }
154        }
155        self.buffer = chars.as_str();
156    }
157
158    #[inline]
159    fn peek(&self) -> char {
160        if self.buffer.is_empty() {
161            return '\0';
162        }
163        let b = self.buffer.as_bytes()[0];
164        if b < 0x80 {
165            b as char
166        } else {
167            self.buffer.chars().next().unwrap()
168        }
169    }
170
171    #[inline]
172    fn peek_nth(&self, n: usize) -> char {
173        if n == 0 {
174            return self.peek();
175        }
176        let bytes = self.buffer.as_bytes();
177        if n == 1 && bytes.len() >= 2 && bytes[0] < 0x80 && bytes[1] < 0x80 {
178            return bytes[1] as char;
179        }
180        let mut chars = self.buffer.chars();
181        for _ in 0..n {
182            if chars.next().is_none() {
183                return '\0';
184            }
185        }
186        chars.next().unwrap_or('\0')
187    }
188
189    #[inline]
190    fn byte_offset(&self) -> Option<usize> {
191        Some(self.consumed_bytes())
192    }
193
194    #[inline]
195    fn slice_bytes(&self, start: usize, end: usize) -> Option<&str> {
196        debug_assert!(start <= end);
197        debug_assert!(end <= self.original.len());
198        self.original.get(start..end)
199    }
200
201    #[cfg(feature = "comments")]
202    #[inline]
203    fn may_contain_comments(&self) -> bool {
204        self.original.as_bytes().contains(&b'#')
205    }
206
207    #[inline]
208    fn next_2_are(&self, c1: char, c2: char) -> bool {
209        let mut chars = self.buffer.chars();
210        chars.next() == Some(c1) && chars.next() == Some(c2)
211    }
212
213    #[inline]
214    fn next_3_are(&self, c1: char, c2: char, c3: char) -> bool {
215        let mut chars = self.buffer.chars();
216        chars.next() == Some(c1) && chars.next() == Some(c2) && chars.next() == Some(c3)
217    }
218
219    #[inline]
220    fn next_is_document_indicator(&self) -> bool {
221        if self.buffer.len() < 3 {
222            false
223        } else {
224            // Since all characters we look for are ASCII, we can directly use the byte API of str.
225            let bytes = self.buffer.as_bytes();
226            (bytes.len() == 3 || matches!(bytes[3], b' ' | b'\t' | 0 | b'\n' | b'\r'))
227                && (bytes[0] == b'.' || bytes[0] == b'-')
228                && bytes[0] == bytes[1]
229                && bytes[1] == bytes[2]
230        }
231    }
232
233    #[inline]
234    fn next_is_document_start(&self) -> bool {
235        if self.buffer.len() < 3 {
236            false
237        } else {
238            // Since all characters we look for are ASCII, we can directly use the byte API of str.
239            let bytes = self.buffer.as_bytes();
240            (bytes.len() == 3 || matches!(bytes[3], b' ' | b'\t' | 0 | b'\n' | b'\r'))
241                && bytes[0] == b'-'
242                && bytes[1] == b'-'
243                && bytes[2] == b'-'
244        }
245    }
246
247    #[inline]
248    fn next_is_document_end(&self) -> bool {
249        if self.buffer.len() < 3 {
250            false
251        } else {
252            // Since all characters we look for are ASCII, we can directly use the byte API of str.
253            let bytes = self.buffer.as_bytes();
254            (bytes.len() == 3 || matches!(bytes[3], b' ' | b'\t' | 0 | b'\n' | b'\r'))
255                && bytes[0] == b'.'
256                && bytes[1] == b'.'
257                && bytes[2] == b'.'
258        }
259    }
260
261    fn skip_ws_to_eol(
262        &mut self,
263        skip_tabs: SkipTabs,
264    ) -> (usize, Result<WhitespaceResult, ErrorKind>) {
265        let mut new_str = self.buffer;
266        let mut has_yaml_ws = false;
267        let mut encountered_tab = false;
268
269        // Separate loops keep the fast space-only path while still tracking whether tabs were seen.
270        if skip_tabs == SkipTabs::Yes {
271            loop {
272                if let Some(sub_str) = new_str.strip_prefix(' ') {
273                    has_yaml_ws = true;
274                    new_str = sub_str;
275                } else if let Some(sub_str) = new_str.strip_prefix('\t') {
276                    encountered_tab = true;
277                    new_str = sub_str;
278                } else {
279                    break;
280                }
281            }
282        } else {
283            while let Some(sub_str) = new_str.strip_prefix(' ') {
284                has_yaml_ws = true;
285                new_str = sub_str;
286            }
287        }
288
289        // All characters consumed were ASCII. We can use the byte length difference to count the
290        // number of whitespace ignored.
291        let mut chars_consumed = self.buffer.len() - new_str.len();
292
293        if !new_str.is_empty() && new_str.as_bytes()[0] == b'#' {
294            if !encountered_tab && !has_yaml_ws {
295                return (chars_consumed, Err(ErrorKind::CommentNotSeparated));
296            }
297
298            // Skip remaining characters until we hit a breakz.
299            while let Some((c, sub_str)) = split_first_char(new_str) {
300                if is_breakz(c) {
301                    break;
302                }
303                new_str = sub_str;
304                chars_consumed += 1;
305            }
306        }
307
308        self.buffer = new_str;
309
310        (
311            chars_consumed,
312            Ok(WhitespaceResult::new(encountered_tab, has_yaml_ws)),
313        )
314    }
315
316    fn skip_ws_to_eol_blanks(&mut self, skip_tabs: SkipTabs) -> (usize, WhitespaceResult) {
317        let bytes = self.buffer.as_bytes();
318        let mut i = 0;
319        let mut encountered_tab = false;
320        let mut has_yaml_ws = false;
321
322        if skip_tabs == SkipTabs::Yes {
323            while i < bytes.len() {
324                match bytes[i] {
325                    b' ' => {
326                        has_yaml_ws = true;
327                        i += 1;
328                    }
329                    b'\t' => {
330                        encountered_tab = true;
331                        i += 1;
332                    }
333                    _ => break,
334                }
335            }
336        } else {
337            while i < bytes.len() && bytes[i] == b' ' {
338                has_yaml_ws = true;
339                i += 1;
340            }
341        }
342
343        self.buffer = &self.buffer[i..];
344
345        (i, WhitespaceResult::new(encountered_tab, has_yaml_ws))
346    }
347
348    #[inline]
349    fn next_is_blank_or_break(&self) -> bool {
350        !self.buffer.is_empty() && matches!(self.buffer.as_bytes()[0], b' ' | b'\t' | b'\n' | b'\r')
351    }
352
353    #[inline]
354    fn next_is_blank_or_breakz(&self) -> bool {
355        self.buffer.is_empty()
356            || matches!(self.buffer.as_bytes()[0], b' ' | b'\t' | 0 | b'\n' | b'\r')
357    }
358
359    #[inline]
360    fn next_is_blank(&self) -> bool {
361        !self.buffer.is_empty() && matches!(self.buffer.as_bytes()[0], b' ' | b'\t')
362    }
363
364    #[inline]
365    fn next_is_break(&self) -> bool {
366        !self.buffer.is_empty() && matches!(self.buffer.as_bytes()[0], b'\n' | b'\r')
367    }
368
369    #[inline]
370    fn next_is_breakz(&self) -> bool {
371        self.buffer.is_empty() || matches!(self.buffer.as_bytes()[0], 0 | b'\n' | b'\r')
372    }
373
374    #[inline]
375    fn next_is_z(&self) -> bool {
376        self.buffer.is_empty()
377    }
378
379    #[inline]
380    fn next_is_flow(&self) -> bool {
381        !self.buffer.is_empty()
382            && matches!(self.buffer.as_bytes()[0], b',' | b'[' | b']' | b'{' | b'}')
383    }
384
385    #[inline]
386    fn next_is_digit(&self) -> bool {
387        !self.buffer.is_empty() && self.buffer.as_bytes()[0].is_ascii_digit()
388    }
389
390    /// Check if the next character is an ASCII alphanumeric, `_`, or `-`.
391    ///
392    /// This is used as a heuristic for error detection (e.g., when `:` is followed
393    /// by tab and then a potential value character). The ASCII-only check is intentional:
394    /// it catches common cases like `key:\tvalue` while avoiding false positives for
395    /// valid YAML constructs. Unicode value starters (e.g., `äöü`) are not detected,
396    /// but such cases will still fail to parse (with a less specific error message).
397    #[inline]
398    fn next_is_alpha(&self) -> bool {
399        !self.buffer.is_empty()
400            && matches!(self.buffer.as_bytes()[0], b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'-')
401    }
402
403    fn skip_while_non_breakz(&mut self) -> usize {
404        let mut byte_pos = 0;
405        let mut chars_consumed = 0;
406
407        for (i, c) in self.buffer.char_indices() {
408            if is_breakz(c) || !crate::char_traits::is_printable(c) {
409                break;
410            }
411            byte_pos = i + c.len_utf8();
412            chars_consumed += 1;
413        }
414
415        self.buffer = &self.buffer[byte_pos..];
416        chars_consumed
417    }
418
419    #[inline]
420    fn skip_while_blank(&mut self) -> usize {
421        let bytes = self.buffer.as_bytes();
422
423        let mut i = 0;
424        while i < bytes.len() {
425            match bytes[i] {
426                b' ' | b'\t' => i += 1,
427                _ => break,
428            }
429        }
430
431        self.buffer = &self.buffer[i..];
432        i
433    }
434
435    /// Fetch characters matching `is_alpha` (ASCII alphanumeric, `_`, `-`).
436    ///
437    /// This is used for scanning tag handles (e.g., `!foo!`). Per YAML 1.2 spec,
438    /// tag handles use `ns-word-char` which is `[0-9a-zA-Z-]`. Our implementation
439    /// is slightly more permissive by also accepting `_`, but this is harmless
440    /// and matches common practice. Unicode characters like `ä` or `π` are NOT
441    /// valid in tag handles per spec, so the ASCII-only byte-based scanning here
442    /// is both correct and efficient.
443    fn fetch_while_is_alpha(&mut self, out: &mut String) -> usize {
444        let bytes = self.buffer.as_bytes();
445        let mut i = 0;
446
447        // All target characters are ASCII, so we can scan bytes directly.
448        while i < bytes.len() {
449            match bytes[i] {
450                b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'-' => i += 1,
451                _ => break,
452            }
453        }
454
455        // All matched characters are ASCII, so we can safely slice and convert.
456        out.push_str(&self.buffer[..i]);
457        self.buffer = &self.buffer[i..];
458
459        i
460    }
461
462    fn fetch_while_is_yaml_non_space(&mut self, out: &mut String) -> usize {
463        let mut byte_pos = 0;
464        let mut chars_consumed = 0;
465
466        for (i, c) in self.buffer.char_indices() {
467            if !crate::char_traits::is_yaml_non_space(c) || crate::char_traits::is_z(c) {
468                break;
469            }
470
471            byte_pos = i + c.len_utf8();
472            chars_consumed += 1;
473        }
474
475        out.push_str(&self.buffer[..byte_pos]);
476        self.buffer = &self.buffer[byte_pos..];
477
478        chars_consumed
479    }
480
481    #[inline]
482    fn fetch_block_scalar_line(&mut self, out: &mut String) -> usize {
483        let end = self
484            .buffer
485            .as_bytes()
486            .iter()
487            .position(|&byte| matches!(byte, b'\r' | b'\n' | 0))
488            .unwrap_or(self.buffer.len());
489
490        // All terminators are ASCII, so the line ends on a UTF-8 boundary.
491        let (line, remaining) = self.buffer.split_at(end);
492        out.push_str(line);
493        self.buffer = remaining;
494        line.chars().count()
495    }
496
497    #[inline]
498    fn take_quoted_scalar_ascii_chunk(&mut self, single: bool) -> &str {
499        let quote = if single { b'\'' } else { b'"' };
500        let end = self
501            .buffer
502            .as_bytes()
503            .iter()
504            .position(|&byte| {
505                !(b'!'..=b'~').contains(&byte) || byte == quote || (!single && byte == b'\\')
506            })
507            .unwrap_or(self.buffer.len());
508
509        // The run is entirely ASCII, so its end is a UTF-8 boundary.
510        let (chunk, remaining) = self.buffer.split_at(end);
511        self.buffer = remaining;
512        chunk
513    }
514
515    #[inline]
516    fn take_quoted_scalar_chunk(&mut self, single: bool) -> (&str, usize) {
517        // Keep the byte-only fast path for ASCII runs. Unicode runs can also include the
518        // following ASCII text, avoiding repeated scanner and marker updates at boundaries.
519        if self.buffer.as_bytes().first().map_or(true, u8::is_ascii) {
520            let chunk = self.take_quoted_scalar_ascii_chunk(single);
521            return (chunk, chunk.len());
522        }
523
524        let quote = if single { b'\'' } else { b'"' };
525        let bytes = self.buffer.as_bytes();
526        let mut end = 0;
527        let mut count = 0;
528        while end < bytes.len() {
529            let byte = bytes[end];
530            if byte.is_ascii() {
531                // ASCII tails use the same byte predicate as the ASCII-led fast path.
532                if !(b'!'..=b'~').contains(&byte) || byte == quote || (!single && byte == b'\\') {
533                    break;
534                }
535                end += 1;
536            } else {
537                // `end` stays on a UTF-8 boundary. YAML blanks, breaks, quotes and
538                // escapes are ASCII, so only printability needs checking here.
539                let character = self.buffer[end..].chars().next().unwrap();
540                if !crate::char_traits::is_printable(character) {
541                    break;
542                }
543                end += character.len_utf8();
544            }
545            count += 1;
546        }
547        let (chunk, remaining) = self.buffer.split_at(end);
548        self.buffer = remaining;
549        (chunk, count)
550    }
551
552    fn fetch_plain_scalar_chunk(
553        &mut self,
554        out: &mut String,
555        _count: usize,
556        flow_level_gt_0: bool,
557    ) -> (bool, usize) {
558        let (byte_pos, chars_consumed) = self.plain_scalar_chunk_len(flow_level_gt_0);
559        out.push_str(&self.buffer[..byte_pos]);
560        self.buffer = &self.buffer[byte_pos..];
561        (true, chars_consumed)
562    }
563
564    fn skip_plain_scalar_chunk(&mut self, _count: usize, flow_level_gt_0: bool) -> (bool, usize) {
565        let (byte_pos, chars_consumed) = self.plain_scalar_chunk_len(flow_level_gt_0);
566        self.buffer = &self.buffer[byte_pos..];
567        (true, chars_consumed)
568    }
569}
570
571impl<'a> BorrowedInput<'a> for StrInput<'a> {
572    #[inline]
573    fn slice_borrowed(&self, start: usize, end: usize) -> Option<&'a str> {
574        debug_assert!(start <= end);
575        debug_assert!(end <= self.original.len());
576        self.original.get(start..end)
577    }
578}
579
580/// The buffer size we return to the scanner.
581///
582/// This does not correspond to any allocated buffer size. In practice, the scanner may request any
583/// character in the virtual buffer: characters inside the input are returned as-is, and positions
584/// past EOF return `\0`.
585///
586/// The number of characters we are asked to retrieve in [`lookahead`] depends on the buffer size
587/// of the input. Our buffer here is virtually unlimited, but the scanner cannot work with that. It
588/// may allocate buffers of its own of the size we return in [`bufmaxlen`] (so we can't return
589/// [`usize::MAX`]). We can't always return the number of characters left either, as the scanner
590/// expects [`buflen`] to return the same value that was given to [`lookahead`] right after its
591/// call.
592///
593/// This creates a complex situation where [`bufmaxlen`] influences what value [`lookahead`] is
594/// called with, which in turn dictates what [`buflen`] returns. In order to avoid breaking any
595/// function, we return this constant in [`bufmaxlen`] which, since the input is processed one line
596/// at a time, should fit what we expect to be a good balance between memory consumption and what
597/// we expect the maximum line length to be.
598///
599/// [`lookahead`]: `StrInput::lookahead`
600/// [`bufmaxlen`]: `StrInput::bufmaxlen`
601/// [`buflen`]: `StrInput::buflen`
602const BUFFER_LEN: usize = 128;
603
604/// Splits the first character of the given string and returns it along with the rest of the
605/// string.
606#[inline]
607fn split_first_char(s: &str) -> Option<(char, &str)> {
608    let mut chars = s.chars();
609    let c = chars.next()?;
610    Some((c, chars.as_str()))
611}
612
613#[cfg(test)]
614mod test {
615    use alloc::string::String;
616
617    use crate::{
618        error::ErrorKind,
619        input::{BorrowedInput, Input, SkipTabs, WhitespaceResult},
620    };
621
622    use super::StrInput;
623
624    #[test]
625    pub fn is_document_start() {
626        let input = StrInput::new("---\n");
627        assert!(input.next_is_document_start());
628        assert!(input.next_is_document_indicator());
629        let input = StrInput::new("---");
630        assert!(input.next_is_document_start());
631        assert!(input.next_is_document_indicator());
632        let input = StrInput::new("...\n");
633        assert!(!input.next_is_document_start());
634        assert!(input.next_is_document_indicator());
635        let input = StrInput::new("--- ");
636        assert!(input.next_is_document_start());
637        assert!(input.next_is_document_indicator());
638    }
639
640    #[test]
641    pub fn is_document_end() {
642        let input = StrInput::new("...\n");
643        assert!(input.next_is_document_end());
644        assert!(input.next_is_document_indicator());
645        let input = StrInput::new("...");
646        assert!(input.next_is_document_end());
647        assert!(input.next_is_document_indicator());
648        let input = StrInput::new("---\n");
649        assert!(!input.next_is_document_end());
650        assert!(input.next_is_document_indicator());
651        let input = StrInput::new("... ");
652        assert!(input.next_is_document_end());
653        assert!(input.next_is_document_indicator());
654    }
655
656    #[test]
657    fn raw_reads_track_byte_offsets_and_eof() {
658        let mut input = StrInput::new("aé");
659
660        assert_eq!(input.raw_read_ch(), 'a');
661        assert_eq!(input.byte_offset(), Some(1));
662        assert_eq!(input.raw_read_ch(), 'é');
663        assert_eq!(input.byte_offset(), Some(3));
664        assert_eq!(input.raw_read_ch(), '\0');
665        assert_eq!(input.byte_offset(), Some(3));
666    }
667
668    #[test]
669    fn raw_read_non_breakz_stops_before_breakz() {
670        let mut input = StrInput::new("a\n");
671
672        assert_eq!(input.raw_read_non_breakz_ch(), Some('a'));
673        assert_eq!(input.raw_read_non_breakz_ch(), None);
674        assert_eq!(input.peek(), '\n');
675
676        let mut empty = StrInput::new("");
677        assert_eq!(empty.raw_read_non_breakz_ch(), None);
678    }
679
680    #[test]
681    fn skip_handles_ascii_unicode_and_eof() {
682        let mut input = StrInput::new("éab");
683
684        input.skip();
685        assert_eq!(input.peek(), 'a');
686
687        input.skip_n(8);
688        assert_eq!(input.peek(), '\0');
689
690        input.skip();
691        assert_eq!(input.peek(), '\0');
692    }
693
694    #[test]
695    fn peeking_past_end_returns_nul() {
696        let ascii = StrInput::new("ab");
697        assert_eq!(ascii.peek_nth(1), 'b');
698        assert_eq!(ascii.peek_nth(3), '\0');
699
700        let unicode = StrInput::new("éab");
701        assert!(unicode.next_3_are('é', 'a', 'b'));
702        assert!(!unicode.next_3_are('é', 'a', 'c'));
703    }
704
705    #[test]
706    fn skip_ws_to_eol_without_tabs_stops_before_tab() {
707        let mut input = StrInput::new("  \t# comment\n");
708
709        let (consumed, result) = input.skip_ws_to_eol(SkipTabs::No);
710
711        assert_eq!(consumed, 2);
712        let result = result.unwrap();
713        assert!(!result.found_tabs());
714        assert!(result.has_valid_yaml_ws());
715        assert_eq!(input.peek(), '\t');
716    }
717
718    #[test]
719    fn skip_ws_to_eol_skips_comments_after_whitespace() {
720        let mut input = StrInput::new("  # comment\nnext");
721
722        let (consumed, result) = input.skip_ws_to_eol(SkipTabs::Yes);
723
724        assert_eq!(consumed, 11);
725        let result = result.unwrap();
726        assert!(!result.found_tabs());
727        assert!(result.has_valid_yaml_ws());
728        assert_eq!(input.peek(), '\n');
729    }
730
731    #[test]
732    fn skip_ws_to_eol_rejects_unseparated_comment() {
733        for policy in [SkipTabs::Yes, SkipTabs::No] {
734            let mut input = StrInput::new("#é中\n");
735
736            let (consumed, result) = input.skip_ws_to_eol(policy);
737
738            assert_eq!(consumed, 0);
739            assert_eq!(result, Err(ErrorKind::CommentNotSeparated));
740            assert_eq!(input.byte_offset(), Some(0));
741            assert_eq!(input.peek(), '#');
742        }
743    }
744
745    #[test]
746    fn skip_ws_to_eol_counts_unicode_comments_and_tracks_whitespace_outside_them() {
747        for (source, consumed, expected, byte_offset, next) in [
748            ("", 0, WhitespaceResult::new(false, false), 0, '\0'),
749            (" \t#é中\r\n", 5, WhitespaceResult::new(true, true), 8, '\r'),
750            ("\t# é 中\n", 6, WhitespaceResult::new(true, false), 9, '\n'),
751            (" #é中", 4, WhitespaceResult::new(false, true), 7, '\0'),
752        ] {
753            let mut input = StrInput::new(source);
754            assert_eq!(
755                input.skip_ws_to_eol(SkipTabs::Yes),
756                (consumed, Ok(expected))
757            );
758            assert_eq!(input.byte_offset(), Some(byte_offset));
759            assert_eq!(input.peek(), next);
760        }
761    }
762
763    #[test]
764    fn skip_ws_to_eol_blanks_preserves_comments_and_unskipped_tabs() {
765        for (source, policy, consumed, found_tabs, found_spaces, next) in [
766            ("", SkipTabs::Yes, 0, false, false, '\0'),
767            ("  #é中\n", SkipTabs::Yes, 2, false, true, '#'),
768            ("\t#é中\n", SkipTabs::Yes, 1, true, false, '#'),
769            (" \t#é中\n", SkipTabs::Yes, 2, true, true, '#'),
770            (" \t#é中\n", SkipTabs::No, 1, false, true, '\t'),
771            ("\t#é中\n", SkipTabs::No, 0, false, false, '\t'),
772        ] {
773            let mut input = StrInput::new(source);
774            assert_eq!(
775                input.skip_ws_to_eol_blanks(policy),
776                (consumed, WhitespaceResult::new(found_tabs, found_spaces)),
777                "source: {source:?}",
778            );
779            assert_eq!(input.byte_offset(), Some(consumed));
780            assert_eq!(input.peek(), next, "source: {source:?}");
781        }
782    }
783
784    #[test]
785    fn fetch_while_is_alpha_is_ascii_only() {
786        let mut input = StrInput::new("abc_123-é");
787        let mut out = String::new();
788
789        assert_eq!(input.fetch_while_is_alpha(&mut out), 8);
790        assert_eq!(out, "abc_123-");
791        assert_eq!(input.peek(), 'é');
792    }
793
794    #[test]
795    fn fetch_plain_scalar_chunk_handles_non_ascii_after_colon() {
796        let mut input = StrInput::new("a:é ");
797        let mut out = String::new();
798
799        assert_eq!(
800            input.fetch_plain_scalar_chunk(&mut out, 16, false),
801            (true, 3)
802        );
803        assert_eq!(out, "a:é");
804        assert_eq!(input.peek(), ' ');
805    }
806
807    #[test]
808    fn fetch_plain_scalar_chunk_stops_at_flow_indicator() {
809        let mut input = StrInput::new("abc,def");
810        let mut out = String::new();
811
812        assert_eq!(
813            input.fetch_plain_scalar_chunk(&mut out, 16, true),
814            (true, 3)
815        );
816        assert_eq!(out, "abc");
817        assert_eq!(input.peek(), ',');
818    }
819
820    #[test]
821    fn borrowed_slices_use_original_input_lifetime() {
822        let input = StrInput::new("aéz");
823
824        assert_eq!(BorrowedInput::slice_borrowed(&input, 1, 3), Some("é"));
825        assert_eq!(input.slice_bytes(3, 4), Some("z"));
826    }
827}