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}