Skip to main content

mago_syntax_core/
input.rs

1use memchr::memchr;
2use memchr::memmem::find;
3
4use mago_allocator::prelude::*;
5
6use mago_database::file::File;
7use mago_database::file::FileId;
8use mago_database::file::HasFileId;
9use mago_span::Position;
10
11/// Lookup table for ASCII whitespace (space, tab, newline, carriage return, form feed, vertical tab)
12const WHITESPACE_TABLE: [bool; 256] = {
13    let mut table = [false; 256];
14    table[b' ' as usize] = true;
15    table[b'\t' as usize] = true;
16    table[b'\n' as usize] = true;
17    table[b'\r' as usize] = true;
18    table[0x0C] = true;
19    table[0x0B] = true;
20    table
21};
22
23/// Lookup table for identifier continuation characters (a-z, A-Z, 0-9, _, 0x80-0xFF)
24const IDENT_PART_TABLE: [bool; 256] = {
25    let mut table = [false; 256];
26    let mut i = 0usize;
27    while i < 256 {
28        table[i] = matches!(i as u8, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | 0x80..=0xFF);
29        i += 1;
30    }
31    table
32};
33
34/// Lookup table for identifier start characters (a-z, A-Z, _, 0x80-0xFF)
35const IDENT_START_TABLE: [bool; 256] = {
36    let mut table = [false; 256];
37    let mut i = 0usize;
38    while i < 256 {
39        table[i] = matches!(i as u8, b'a'..=b'z' | b'A'..=b'Z' | b'_' | 0x80..=0xFF);
40        i += 1;
41    }
42    table
43};
44
45/// A struct representing the input code being lexed.
46///
47/// The `Input` struct provides methods to read, peek, consume, and skip characters
48/// from the bytes input code while keeping track of the current position (line, column, offset).
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
50#[allow(clippy::field_scoped_visibility_modifiers)]
51pub struct Input<'src> {
52    pub(crate) bytes: &'src [u8],
53    pub(crate) length: usize,
54    pub(crate) offset: usize,
55    pub(crate) starting_position: Position,
56    pub(crate) file_id: FileId,
57}
58
59impl<'src> Input<'src> {
60    /// Creates a new `Input` instance from the given input.
61    ///
62    /// # Arguments
63    ///
64    /// * `file_id` - The unique identifier for the source file this input belongs to.
65    /// * `bytes` - A byte slice representing the input code to be lexed.
66    ///
67    /// # Returns
68    ///
69    /// A new `Input` instance initialized at the beginning of the input.
70    #[must_use]
71    pub fn new(file_id: FileId, bytes: &'src [u8]) -> Self {
72        let length = bytes.len();
73
74        Self { bytes, length, offset: 0, file_id, starting_position: Position::new(0) }
75    }
76
77    /// Creates a new `Input` instance from the contents of a `File`.
78    ///
79    /// # Arguments
80    ///
81    /// * `file` - A reference to the `File` containing the source code.
82    ///
83    /// # Returns
84    ///
85    /// A new `Input` instance initialized with the file's ID and contents.
86    #[must_use]
87    pub fn from_file(file: &'src File) -> Self {
88        Self::new(file.id, file.contents.as_ref())
89    }
90
91    /// Creates a new `Input` instance from the contents of a `File`.
92    ///
93    /// # Arguments
94    ///
95    /// * `file` - A reference to the `File` containing the source code.
96    ///
97    /// # Returns
98    ///
99    /// A new `Input` instance initialized with the file's ID and contents.
100    pub fn from_file_in<A>(arena: &'src A, file: &File) -> Self
101    where
102        A: Arena,
103    {
104        Self::new(file.id, arena.alloc_slice_clone(file.contents.as_ref()))
105    }
106
107    /// Creates a new `Input` instance representing a byte slice that is
108    /// "anchored" at a specific absolute position within a larger source file.
109    ///
110    /// This is useful when lexing a subset (slice) of a source file, as it allows
111    /// generated tokens to retain accurate absolute positions and spans relative
112    /// to the original file.
113    ///
114    /// The internal cursor (`offset`) starts at 0 relative to the `bytes` slice,
115    /// but the absolute position is calculated relative to the `anchor_position`.
116    ///
117    /// # Arguments
118    ///
119    /// * `file_id` - The unique identifier for the source file this input belongs to.
120    /// * `bytes` - A byte slice representing the input code subset to be lexed.
121    /// * `anchor_position` - The absolute `Position` in the original source file where the provided `bytes` slice begins.
122    ///
123    /// # Returns
124    ///
125    /// A new `Input` instance ready to lex the `bytes`, maintaining positions
126    /// relative to `anchor_position`.
127    #[must_use]
128    pub fn anchored_at(file_id: FileId, bytes: &'src [u8], anchor_position: Position) -> Self {
129        let length = bytes.len();
130
131        Self { bytes, length, offset: 0, file_id, starting_position: anchor_position }
132    }
133
134    /// Returns the source file identifier of the input code.
135    #[inline]
136    #[must_use]
137    pub const fn file_id(&self) -> FileId {
138        self.file_id
139    }
140
141    /// Returns the absolute current `Position` of the lexer within the original source file.
142    ///
143    /// It calculates this by adding the internal offset (progress within the current byte slice)
144    /// to the `starting_position` the `Input` was initialized with.
145    #[inline]
146    #[must_use]
147    pub const fn current_position(&self) -> Position {
148        // Calculate absolute position by adding internal offset to the starting base
149        self.starting_position.forward(self.offset as u32)
150    }
151
152    /// Returns the current internal byte offset relative to the start of the input slice.
153    ///
154    /// This indicates how many bytes have been consumed from the current `bytes` slice.
155    /// To get the absolute position in the original source file, use `current_position()`.
156    #[inline]
157    #[must_use]
158    pub const fn current_offset(&self) -> usize {
159        self.offset
160    }
161
162    /// Returns `true` if the input slice is empty (length is zero).
163    #[inline]
164    #[must_use]
165    pub const fn is_empty(&self) -> bool {
166        self.length == 0
167    }
168
169    /// Returns the total length in bytes of the input slice being processed.
170    #[inline]
171    #[must_use]
172    pub const fn len(&self) -> usize {
173        self.length
174    }
175
176    /// Checks if the current position is at the end of the input.
177    ///
178    /// # Returns
179    ///
180    /// `true` if the current offset is greater than or equal to the input length; `false` otherwise.
181    #[inline(always)]
182    #[must_use]
183    pub const fn has_reached_eof(&self) -> bool {
184        self.offset >= self.length
185    }
186
187    /// Returns a byte slice within a specified absolute range.
188    ///
189    /// The `from` and `to` arguments are absolute byte offsets from the beginning
190    /// of the original source file. The method calculates the correct slice
191    /// relative to the `starting_position` of this `Input`.
192    ///
193    /// This is useful for retrieving the raw text of a `Span` or `Token` whose
194    /// positions are absolute, even when the `Input` only contains a subsection
195    /// of the source file.
196    ///
197    /// The returned slice is defensively clamped to the bounds of the current
198    /// `Input`'s byte slice to prevent panics.
199    ///
200    /// # Arguments
201    ///
202    /// * `from` - The absolute starting byte offset.
203    /// * `to` - The absolute ending byte offset (exclusive).
204    ///
205    /// # Returns
206    ///
207    /// A byte slice `&[u8]` corresponding to the requested range.
208    #[inline]
209    #[must_use]
210    pub fn slice_in_range(&self, from: u32, to: u32) -> &'src [u8] {
211        let base_offset = self.starting_position.offset;
212
213        // Calculate the start and end positions relative to the local `bytes` slice.
214        // `saturating_sub` prevents underflow if `from`/`to` are smaller than `base_offset`.
215        let local_from = from.saturating_sub(base_offset) as usize;
216        let local_to = to.saturating_sub(base_offset) as usize;
217
218        // Clamp the local indices to the actual length of the `bytes` slice to prevent panics.
219        let start = local_from.min(self.length);
220        let end = local_to.min(self.length);
221
222        // Ensure the start index is not greater than the end index.
223        if start >= end {
224            return &[];
225        }
226
227        // If the start index is beyond the length of the input, return an empty slice.
228        if start >= self.length {
229            return &[];
230        }
231
232        &self.bytes[start..end]
233    }
234
235    /// Advances the current position by one character, updating line and column numbers.
236    ///
237    /// Handles different line endings (`\n`, `\r`, `\r\n`) and updates line and column counters accordingly.
238    ///
239    /// If the end of input is reached, no action is taken.
240    #[inline(always)]
241    pub fn next(&mut self) {
242        if self.offset < self.length {
243            self.offset += 1;
244        }
245    }
246
247    /// Skips the next `count` characters, advancing the position accordingly.
248    ///
249    /// Updates offset by `count`, clamping to the input length.
250    ///
251    /// # Arguments
252    ///
253    /// * `count` - The number of characters to skip.
254    #[inline]
255    pub fn skip(&mut self, count: usize) {
256        self.offset = (self.offset + count).min(self.length);
257    }
258
259    /// Consumes the next `count` characters and returns them as a slice.
260    ///
261    /// Advances the position by `count` characters.
262    ///
263    /// # Arguments
264    ///
265    /// * `count` - The number of characters to consume.
266    ///
267    /// # Returns
268    ///
269    /// A byte slice containing the consumed characters.
270    #[inline(always)]
271    pub fn consume(&mut self, count: usize) -> &'src [u8] {
272        let from = self.offset;
273        let until = (from + count).min(self.length);
274        self.offset = until;
275        // SAFETY: from <= until <= self.length is guaranteed
276        unsafe { self.bytes.get_unchecked(from..until) }
277    }
278
279    /// Consumes all remaining characters from the current position to the end of input.
280    ///
281    /// Advances the position to EOF.
282    ///
283    /// # Returns
284    ///
285    /// A byte slice containing the remaining characters.
286    #[inline]
287    pub fn consume_remaining(&mut self) -> &'src [u8] {
288        if self.has_reached_eof() {
289            return &[];
290        }
291
292        let from = self.offset;
293        self.offset = self.length;
294
295        &self.bytes[from..]
296    }
297
298    /// Consumes characters until the given byte slice is found.
299    ///
300    /// Advances the position to the start of the search slice if found,
301    /// or to EOF if not found.
302    ///
303    /// # Arguments
304    ///
305    /// * `search` - The byte slice to search for.
306    /// * `ignore_ascii_case` - Whether to ignore ASCII case when comparing characters.
307    ///
308    /// # Returns
309    ///
310    /// A byte slice containing the consumed characters.
311    #[inline]
312    pub fn consume_until(&mut self, search: &[u8], ignore_ascii_case: bool) -> &'src [u8] {
313        let start = self.offset;
314        if ignore_ascii_case {
315            while !self.has_reached_eof() && !self.is_at(search, ignore_ascii_case) {
316                self.offset += 1;
317            }
318
319            &self.bytes[start..self.offset]
320        } else {
321            // For a single-byte search, use memchr.
322            if search.len() == 1 {
323                if let Some(pos) = memchr(search[0], &self.bytes[self.offset..]) {
324                    self.offset += pos;
325                    &self.bytes[start..self.offset]
326                } else {
327                    self.offset = self.length;
328                    &self.bytes[start..self.length]
329                }
330            } else if let Some(pos) = find(&self.bytes[self.offset..], search) {
331                self.offset += pos;
332                &self.bytes[start..self.offset]
333            } else {
334                self.offset = self.length;
335                &self.bytes[start..self.length]
336            }
337        }
338    }
339
340    #[inline]
341    pub fn consume_through(&mut self, search: u8) -> &'src [u8] {
342        let start = self.offset;
343        if let Some(pos) = memchr::memchr(search, &self.bytes[self.offset..]) {
344            self.offset += pos + 1;
345
346            &self.bytes[start..self.offset]
347        } else {
348            self.offset = self.length;
349
350            &self.bytes[start..self.length]
351        }
352    }
353
354    /// Consumes whitespaces until a non-whitespace character is found.
355    ///
356    /// # Returns
357    ///
358    /// A byte slice containing the consumed whitespaces.
359    #[inline(always)]
360    pub fn consume_whitespaces(&mut self) -> &'src [u8] {
361        let start = self.offset;
362        let bytes = self.bytes;
363        let len = self.length;
364
365        while self.offset < len {
366            // SAFETY: `self.offset < len` was just checked, so the index is in bounds.
367            let b = unsafe { *bytes.get_unchecked(self.offset) };
368            if !WHITESPACE_TABLE[b as usize] {
369                break;
370            }
371            self.offset += 1;
372        }
373
374        // SAFETY: `start` and `self.offset` are both in `0..=len`, and `start <= self.offset` because
375        // the loop only ever increments `self.offset` from its initial `start` value.
376        unsafe { bytes.get_unchecked(start..self.offset) }
377    }
378
379    /// Scans identifier characters starting at `offset_from_current` without consuming them.
380    /// Returns the length of identifier characters found (not including any trailing `\`).
381    /// Also returns whether the identifier ends with `\` followed by an identifier start character.
382    ///
383    /// This is optimized for the common case of scanning simple identifiers.
384    #[inline(always)]
385    #[must_use]
386    pub fn scan_identifier(&self, offset_from_current: usize) -> (usize, bool) {
387        let start = self.offset + offset_from_current;
388        if start >= self.length {
389            return (0, false);
390        }
391
392        let bytes = self.bytes;
393        let len = self.length;
394        let mut pos = start + 1; // Skip first byte (already validated by caller)
395
396        while pos < len {
397            // SAFETY: `pos < len` was just checked.
398            let b = unsafe { *bytes.get_unchecked(pos) };
399            if IDENT_PART_TABLE[b as usize] {
400                pos += 1;
401            } else if b == b'\\' && pos + 1 < len {
402                // SAFETY: `pos + 1 < len` was just checked in the `else if` guard.
403                let next = unsafe { *bytes.get_unchecked(pos + 1) };
404                if IDENT_START_TABLE[next as usize] {
405                    // Found \ followed by identifier start
406                    return (pos - start, true);
407                }
408                break;
409            } else {
410                break;
411            }
412        }
413
414        (pos - start, false)
415    }
416
417    /// Scans bytes starting at `offset_from_current` while they are members of `table`, without consuming.
418    /// Returns the length of the matching run.
419    #[inline(always)]
420    #[must_use]
421    pub fn scan_while(&self, offset_from_current: usize, table: &[bool; 256]) -> usize {
422        let bytes = self.bytes;
423        let len = self.length;
424        let start = self.offset + offset_from_current;
425        let mut pos = start;
426
427        while pos < len {
428            // SAFETY: `pos < len` was just checked.
429            let b = unsafe { *bytes.get_unchecked(pos) };
430            if !table[b as usize] {
431                break;
432            }
433
434            pos += 1;
435        }
436
437        pos.saturating_sub(start)
438    }
439
440    /// Scans bytes starting at `offset_from_current` until a whitespace byte is found, without consuming.
441    /// Returns the length of the non-whitespace run.
442    #[inline(always)]
443    #[must_use]
444    pub fn scan_until_whitespace(&self, offset_from_current: usize) -> usize {
445        let bytes = self.bytes;
446        let len = self.length;
447        let start = self.offset + offset_from_current;
448        let mut pos = start;
449
450        while pos < len {
451            // SAFETY: `pos < len` was just checked.
452            let b = unsafe { *bytes.get_unchecked(pos) };
453            if WHITESPACE_TABLE[b as usize] {
454                break;
455            }
456
457            pos += 1;
458        }
459
460        pos.saturating_sub(start)
461    }
462
463    /// Reads the next `n` characters without advancing the position.
464    ///
465    /// # Arguments
466    ///
467    /// * `n` - The number of characters to read.
468    ///
469    /// # Returns
470    ///
471    /// A byte slice containing the next `n` characters.
472    #[inline(always)]
473    #[must_use]
474    pub fn read(&self, n: usize) -> &'src [u8] {
475        let from = self.offset;
476        let until = (from + n).min(self.length);
477        // SAFETY: from <= until <= self.length is guaranteed by min()
478        unsafe { self.bytes.get_unchecked(from..until) }
479    }
480
481    /// Reads all remaining characters from the current position to the end of input,
482    /// without advancing the position.
483    #[inline(always)]
484    #[must_use]
485    pub fn read_remaining(&self) -> &'src [u8] {
486        let from = self.offset;
487
488        // SAFETY: `from = self.offset` is always in `0..=self.length`, so the open-ended range is in bounds.
489        unsafe { self.bytes.get_unchecked(from..) }
490    }
491
492    /// Reads a single byte at a specific byte offset within the input slice,
493    /// without advancing the internal cursor.
494    ///
495    /// This provides direct, low-level access to the underlying byte data.
496    ///
497    /// # Arguments
498    ///
499    /// * `at` - The zero-based byte offset within the input slice (`self.bytes`)
500    ///   from which to read the byte.
501    ///
502    /// # Returns
503    ///
504    /// A reference to the byte located at the specified offset `at`.
505    ///
506    /// # Panics
507    ///
508    /// This method **panics** if the provided `at` offset is out of bounds
509    /// for the input byte slice (i.e., if `at >= self.bytes.len()`).
510    #[must_use]
511    pub fn read_at(&self, at: usize) -> &'src u8 {
512        &self.bytes[at]
513    }
514
515    /// Reads a single byte at a specific byte offset within the input slice,
516    /// without advancing the internal cursor.
517    ///
518    /// # Safety
519    ///
520    /// The caller must ensure that `at` is a valid index within the bounds of the input slice
521    /// (i.e., `at < self.bytes.len()`). Failing to do so results in undefined behavior.
522    #[inline(always)]
523    #[must_use]
524    pub unsafe fn read_at_unchecked(&self, at: usize) -> &'src u8 {
525        // SAFETY: Caller must ensure at < self.length
526        unsafe { self.bytes.get_unchecked(at) }
527    }
528
529    /// Checks if the input at the current position matches the given byte slice.
530    ///
531    /// # Arguments
532    ///
533    /// * `search` - The byte slice to compare against the input.
534    /// * `ignore_ascii_case` - Whether to ignore ASCII case when comparing.
535    ///
536    /// # Returns
537    ///
538    /// `true` if the next bytes match `search`; `false` otherwise.
539    #[inline(always)]
540    #[must_use]
541    pub fn is_at(&self, search: &[u8], ignore_ascii_case: bool) -> bool {
542        let len = search.len();
543        let from = self.offset;
544        let until = (from + len).min(self.length);
545
546        if until - from != len {
547            return false;
548        }
549
550        let slice = &self.bytes[from..until];
551        if ignore_ascii_case { slice.eq_ignore_ascii_case(search) } else { slice == search }
552    }
553
554    /// Attempts to match the given byte sequence at the current position, ignoring whitespace in the input.
555    ///
556    /// This method tries to match the provided byte slice `search` against the input starting
557    /// from the current position, possibly ignoring ASCII case. Whitespace characters in the input
558    /// are skipped during matching, but their length is included in the returned length.
559    ///
560    /// Importantly, the method **does not include** any trailing whitespace **after** the matched sequence
561    /// in the returned length.
562    ///
563    /// For example, to match the sequence `(string)`, the input could be `(string)`, `( string )`, `(  string )`, etc.,
564    /// and this method would return the total length of the input consumed to match `(string)`,
565    /// including any whitespace within the matched sequence, but **excluding** any whitespace after it.
566    ///
567    /// # Arguments
568    ///
569    /// * `search` - The byte slice to match against the input.
570    /// * `ignore_ascii_case` - If `true`, ASCII case is ignored during comparison.
571    ///
572    /// # Returns
573    ///
574    /// * `Some(length)` - If the input matches `search` (ignoring whitespace within the sequence), returns the total length
575    ///   of the input consumed to match `search`, including any skipped whitespace **within** the matched sequence.
576    /// * `None` - If the input does not match `search`.
577    #[inline]
578    #[must_use]
579    pub const fn match_sequence_ignore_whitespace(&self, search: &[u8], ignore_ascii_case: bool) -> Option<usize> {
580        let mut offset = self.offset;
581        let mut search_offset = 0;
582        let mut length = 0;
583        let bytes = self.bytes;
584        let total = self.length;
585        while search_offset < search.len() {
586            // Skip whitespace in the input.
587            while offset < total && bytes[offset].is_ascii_whitespace() {
588                offset += 1;
589                length += 1;
590            }
591
592            if offset >= total {
593                return None;
594            }
595
596            let input_byte = bytes[offset];
597            let search_byte = search[search_offset];
598            let matched = if ignore_ascii_case {
599                input_byte.eq_ignore_ascii_case(&search_byte)
600            } else {
601                input_byte == search_byte
602            };
603
604            if matched {
605                offset += 1;
606                length += 1;
607                search_offset += 1;
608            } else {
609                return None;
610            }
611        }
612
613        Some(length)
614    }
615
616    /// Peeks ahead `i` characters and reads the next `n` characters without advancing the position.
617    ///
618    /// # Arguments
619    ///
620    /// * `offset` - The number of characters to skip before reading.
621    /// * `n` - The number of characters to read after skipping.
622    ///
623    /// # Returns
624    ///
625    /// A byte slice containing the peeked characters.
626    #[inline(always)]
627    #[must_use]
628    pub fn peek(&self, offset: usize, n: usize) -> &'src [u8] {
629        let from = self.offset + offset;
630        let len = self.length;
631        if from >= len {
632            return &[];
633        }
634
635        let until = (from + n).min(len);
636        // SAFETY: We verified from < len and until <= len above
637        unsafe { self.bytes.get_unchecked(from..until) }
638    }
639}
640
641impl HasFileId for Input<'_> {
642    fn file_id(&self) -> FileId {
643        self.file_id
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use mago_span::Position;
650
651    use super::*;
652
653    #[test]
654    fn test_new() {
655        let bytes = b"Hello, world!";
656        let input = Input::new(FileId::zero(), bytes);
657
658        assert_eq!(input.current_position(), Position::new(0));
659        assert_eq!(input.length, bytes.len());
660        assert_eq!(input.bytes, bytes);
661    }
662
663    #[test]
664    fn test_is_eof() {
665        let bytes = b"";
666        let input = Input::new(FileId::zero(), bytes);
667
668        assert!(input.has_reached_eof());
669
670        let bytes = b"data";
671        let mut input = Input::new(FileId::zero(), bytes);
672
673        assert!(!input.has_reached_eof());
674
675        input.skip(4);
676
677        assert!(input.has_reached_eof());
678    }
679
680    #[test]
681    fn test_next() {
682        let bytes = b"a\nb\r\nc\rd";
683        let mut input = Input::new(FileId::zero(), bytes);
684
685        // 'a'
686        input.next();
687        assert_eq!(input.current_position(), Position::new(1));
688
689        // '\n'
690        input.next();
691        assert_eq!(input.current_position(), Position::new(2));
692
693        // 'b'
694        input.next();
695        assert_eq!(input.current_position(), Position::new(3));
696
697        // '\r\n' should be treated as one newline
698        input.next();
699        assert_eq!(input.current_position(), Position::new(4));
700
701        // 'c'
702        input.next();
703        assert_eq!(input.current_position(), Position::new(5));
704
705        // '\r'
706        input.next();
707        assert_eq!(input.current_position(), Position::new(6));
708
709        // 'd'
710        input.next();
711        assert_eq!(input.current_position(), Position::new(7));
712    }
713
714    #[test]
715    fn test_consume() {
716        let bytes = b"abcdef";
717        let mut input = Input::new(FileId::zero(), bytes);
718
719        let consumed = input.consume(3);
720        assert_eq!(consumed, b"abc");
721        assert_eq!(input.current_position(), Position::new(3));
722
723        let consumed = input.consume(3);
724        assert_eq!(consumed, b"def");
725        assert_eq!(input.current_position(), Position::new(6));
726
727        let consumed = input.consume(1); // Should return empty slice at EOF
728        assert_eq!(consumed, b"");
729        assert!(input.has_reached_eof());
730    }
731
732    #[test]
733    fn test_consume_remaining() {
734        let bytes = b"abcdef";
735        let mut input = Input::new(FileId::zero(), bytes);
736
737        input.skip(2);
738        let remaining = input.consume_remaining();
739        assert_eq!(remaining, b"cdef");
740        assert!(input.has_reached_eof());
741    }
742
743    #[test]
744    fn test_read() {
745        let bytes = b"abcdef";
746        let input = Input::new(FileId::zero(), bytes);
747
748        let read = input.read(3);
749        assert_eq!(read, b"abc");
750        assert_eq!(input.current_position(), Position::new(0));
751        // Position should not change
752    }
753
754    #[test]
755    fn test_is_at() {
756        let bytes = b"abcdef";
757        let mut input = Input::new(FileId::zero(), bytes);
758
759        assert!(input.is_at(b"abc", false));
760        input.skip(2);
761        assert!(input.is_at(b"cde", false));
762        assert!(!input.is_at(b"xyz", false));
763    }
764
765    #[test]
766    fn test_is_at_ignore_ascii_case() {
767        let bytes = b"AbCdEf";
768        let mut input = Input::new(FileId::zero(), bytes);
769
770        assert!(input.is_at(b"abc", true));
771        input.skip(2);
772        assert!(input.is_at(b"cde", true));
773        assert!(!input.is_at(b"xyz", true));
774    }
775
776    #[test]
777    fn test_peek() {
778        let bytes = b"abcdef";
779        let input = Input::new(FileId::zero(), bytes);
780
781        let peeked = input.peek(2, 3);
782        assert_eq!(peeked, b"cde");
783        assert_eq!(input.current_position(), Position::new(0));
784        // Position should not change
785    }
786}