Skip to main content

php_lexer/
lexer.rs

1use memchr::{memchr2, memmem};
2use php_ast::Span;
3
4use crate::token::{resolve_keyword, TokenKind};
5
6// ---------------------------------------------------------------------------
7// Byte-classification lookup tables
8//
9// Replacing multi-comparison chains with a single indexed load per byte.
10// The tables are computed at compile time and fit in a single cache line (256 bytes).
11// ---------------------------------------------------------------------------
12
13const fn make_whitespace_table() -> [bool; 256] {
14    let mut t = [false; 256];
15    t[b' ' as usize] = true;
16    t[b'\t' as usize] = true;
17    t[b'\r' as usize] = true;
18    t[b'\n' as usize] = true;
19    t[0x0C] = true; // form feed (\f)
20    t
21}
22
23const fn make_ident_start_table() -> [bool; 256] {
24    let mut t = [false; 256];
25    let mut i = 0usize;
26    while i < 256 {
27        let b = i as u8;
28        t[i] = (b >= b'a' && b <= b'z') || (b >= b'A' && b <= b'Z') || b == b'_' || b >= 0x80;
29        i += 1;
30    }
31    t
32}
33
34const fn make_ident_continue_table() -> [bool; 256] {
35    let mut t = [false; 256];
36    let mut i = 0usize;
37    while i < 256 {
38        let b = i as u8;
39        t[i] = (b >= b'a' && b <= b'z')
40            || (b >= b'A' && b <= b'Z')
41            || (b >= b'0' && b <= b'9')
42            || b == b'_'
43            || b >= 0x80;
44        i += 1;
45    }
46    t
47}
48
49static IS_PHP_WHITESPACE: [bool; 256] = make_whitespace_table();
50static IS_IDENT_START: [bool; 256] = make_ident_start_table();
51static IS_IDENT_CONTINUE: [bool; 256] = make_ident_continue_table();
52
53/// Discriminant for [`LexerError`] — describes what kind of lexer failure occurred.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum LexerErrorKind {
57    /// The lexer reached end-of-file inside a string literal without finding the closing delimiter.
58    UnterminatedString,
59    /// The source file exceeds the maximum supported size (2^32 bytes).
60    FileTooLarge,
61    /// Any other lexer-level error (e.g. unterminated block comment, invalid numeric literal).
62    Other,
63}
64
65/// A recoverable error found while lexing.
66#[derive(Debug, Clone, PartialEq)]
67pub struct LexerError {
68    /// Error category.
69    pub kind: LexerErrorKind,
70    /// Human-readable description.
71    pub message: String,
72    /// Source range of the error.
73    pub span: Span,
74}
75
76/// A lexed token with its source span.
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct Token {
79    /// Token type.
80    pub kind: TokenKind,
81    /// Source range of the token.
82    pub span: Span,
83}
84
85impl Token {
86    /// Creates a token.
87    pub fn new(kind: TokenKind, span: Span) -> Self {
88        Self { kind, span }
89    }
90
91    /// Creates an end-of-file token at `offset`.
92    pub fn eof(offset: u32) -> Self {
93        Self {
94            kind: TokenKind::Eof,
95            span: Span::new(offset, offset),
96        }
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum LexerMode {
102    InlineHtml,
103    Php,
104}
105
106/// Streaming PHP lexer with two-token lookahead.
107pub struct Lexer<'src> {
108    source: &'src str,
109    mode: LexerMode,
110    pos: usize,
111    peeked: Option<Token>,
112    peeked2: Option<Token>,
113    /// Start offsets, in source order, of quoted-string tokens whose contents
114    /// cannot be used verbatim (see [`Lexer::is_verbatim_string`]).
115    non_verbatim_strings: Vec<u32>,
116    /// Index into `non_verbatim_strings` of the last lookup; strings are
117    /// usually queried in source order, which makes lookups O(1).
118    verbatim_cursor: usize,
119    /// Errors collected so far.
120    pub errors: Vec<LexerError>,
121}
122
123#[inline(always)]
124fn is_ident_start(b: u8) -> bool {
125    IS_IDENT_START[b as usize]
126}
127
128#[inline(always)]
129fn is_ident_continue(b: u8) -> bool {
130    IS_IDENT_CONTINUE[b as usize]
131}
132
133/// Scan past a balanced `{ ... }` that starts at `p` (pointing at `{`).
134/// Used to skip `{$...}` complex interpolation inside double-quoted strings and
135/// heredocs, where the expression body may itself contain nested strings.
136/// Returns the byte index immediately after the matching `}`; on EOF returns
137/// `bytes.len()` so the caller's unterminated-string branch fires.
138///
139/// Nested strings and interpolations (`"{$a["{$b}"]}"`) are tracked with an
140/// explicit stack rather than recursion, because their depth is controlled by
141/// the input and would otherwise overflow the native stack.
142fn skip_complex_interp(bytes: &[u8], mut p: usize) -> usize {
143    debug_assert!(bytes.get(p) == Some(&b'{'));
144    // Interpolations and double-quoted strings strictly alternate, so the state
145    // is the innermost frame plus the brace depths of the suspended
146    // interpolations, one per enclosing nested string.
147    let mut in_dquote = false;
148    let mut depth = 0i32;
149    let mut suspended: Vec<i32> = Vec::new();
150    while p < bytes.len() {
151        match bytes[p] {
152            b'\\' => {
153                p += 1;
154                if p < bytes.len() {
155                    p += 1;
156                }
157            }
158            b'"' if in_dquote => {
159                p += 1;
160                depth = suspended
161                    .pop()
162                    .expect("a nested string is entered from an interpolation");
163                in_dquote = false;
164            }
165            b'{' if in_dquote => {
166                if bytes.get(p + 1) == Some(&b'$') {
167                    // Re-scanned below as the opening brace of the new interpolation.
168                    in_dquote = false;
169                    depth = 0;
170                } else {
171                    p += 1;
172                }
173            }
174            _ if in_dquote => p += 1,
175            b'{' => {
176                depth += 1;
177                p += 1;
178            }
179            b'}' => {
180                depth -= 1;
181                p += 1;
182                if depth == 0 {
183                    if suspended.is_empty() {
184                        return p;
185                    }
186                    in_dquote = true;
187                }
188            }
189            b'"' => {
190                suspended.push(depth);
191                in_dquote = true;
192                p += 1;
193            }
194            b'\'' => p = skip_nested_squoted(bytes, p),
195            _ => p += 1,
196        }
197    }
198    p
199}
200
201fn skip_nested_squoted(bytes: &[u8], mut p: usize) -> usize {
202    debug_assert!(bytes.get(p) == Some(&b'\''));
203    p += 1;
204    while p < bytes.len() {
205        match bytes[p] {
206            b'\\' => {
207                p += 1;
208                if p < bytes.len() {
209                    p += 1;
210                }
211            }
212            b'\'' => return p + 1,
213            _ => p += 1,
214        }
215    }
216    p
217}
218
219impl<'src> Lexer<'src> {
220    /// Creates a lexer for `source`, starting in inline-HTML mode.
221    ///
222    /// Spans hold `u32` offsets, so a source over 4 GiB is not lexed at all: the
223    /// lexer yields only `Eof` and records a [`LexerErrorKind::FileTooLarge`] error.
224    pub fn new(source: &'src str) -> Self {
225        if let Some(lexer) = Self::too_large(source) {
226            return lexer;
227        }
228
229        // Skip shebang line if present (e.g., #!/usr/bin/env php)
230        let pos = if source.starts_with("#!") {
231            source.find('\n').map(|p| p + 1).unwrap_or(source.len())
232        } else {
233            0
234        };
235
236        // Determine initial mode: if remaining source starts with `<?php` (case-insensitive) or `<?=`, start in PHP mode
237        let remaining = &source[pos..];
238        let rem_bytes = remaining.as_bytes();
239        let mode = if (rem_bytes.len() >= 5
240            && rem_bytes[0] == b'<'
241            && rem_bytes[1] == b'?'
242            && rem_bytes[2..5].eq_ignore_ascii_case(b"php"))
243            || remaining.starts_with("<?=")
244        {
245            LexerMode::Php
246        } else {
247            LexerMode::InlineHtml
248        };
249
250        Self {
251            source,
252            mode,
253            pos,
254            peeked: None,
255            peeked2: None,
256            non_verbatim_strings: Vec::new(),
257            verbatim_cursor: 0,
258            errors: Vec::new(),
259        }
260    }
261
262    /// Create a lexer starting in PHP mode at a given byte offset within `source`.
263    /// The caller guarantees that `source[offset..]` contains valid PHP expression
264    /// content (no `<?php` tag needed — the lexer is pre-set to PHP mode).
265    /// Spans produced will be correct absolute offsets into `source`.
266    ///
267    /// Like [`Lexer::new`], a source over 4 GiB yields only `Eof` and an error.
268    pub fn new_at(source: &'src str, offset: usize) -> Self {
269        if let Some(lexer) = Self::too_large(source) {
270            return lexer;
271        }
272
273        Self {
274            source,
275            mode: LexerMode::Php,
276            pos: offset,
277            peeked: None,
278            peeked2: None,
279            non_verbatim_strings: Vec::new(),
280            verbatim_cursor: 0,
281            errors: Vec::new(),
282        }
283    }
284
285    /// A lexer over an empty source when `source` does not fit `u32` spans;
286    /// checked in release builds too, since the `as u32` casts would wrap.
287    fn too_large(source: &str) -> Option<Self> {
288        if source.len() <= u32::MAX as usize {
289            return None;
290        }
291        Some(Self {
292            source: "",
293            mode: LexerMode::Php,
294            pos: 0,
295            peeked: None,
296            peeked2: None,
297            non_verbatim_strings: Vec::new(),
298            verbatim_cursor: 0,
299            errors: vec![LexerError {
300                kind: LexerErrorKind::FileTooLarge,
301                message: format!(
302                    "source is {} bytes, which exceeds the maximum supported size of {} bytes",
303                    source.len(),
304                    u32::MAX
305                ),
306                span: Span::new(0, 0),
307            }],
308        })
309    }
310
311    /// Whether the single- or double-quoted string token starting at `start`
312    /// contains no escape sequence and, for a double-quoted string, no `$`, so
313    /// its contents between the quotes can be used verbatim without decoding.
314    ///
315    /// Only meaningful for string tokens this lexer has already produced.
316    pub fn is_verbatim_string(&mut self, start: u32) -> bool {
317        let list = &self.non_verbatim_strings;
318        let mut i = self.verbatim_cursor;
319        if i > 0 && list.get(i - 1).is_some_and(|&prev| prev >= start) {
320            // Queried out of order: fall back to a search.
321            return list.binary_search(&start).is_err();
322        }
323        while list.get(i).is_some_and(|&next| next < start) {
324            i += 1;
325        }
326        self.verbatim_cursor = i;
327        list.get(i) != Some(&start)
328    }
329
330    /// The source text being lexed.
331    pub fn source(&self) -> &'src str {
332        self.source
333    }
334
335    /// Returns the next token without consuming it.
336    pub fn peek(&mut self) -> &Token {
337        if self.peeked.is_none() {
338            self.peeked = Some(self.read_next_token());
339        }
340        self.peeked.as_ref().expect("peeked is Some: set above")
341    }
342
343    /// Peek two tokens ahead (past the next token).
344    pub fn peek2(&mut self) -> &Token {
345        // Ensure peeked is filled
346        if self.peeked.is_none() {
347            self.peeked = Some(self.read_next_token());
348        }
349        if self.peeked2.is_none() {
350            self.peeked2 = Some(self.read_next_token());
351        }
352        self.peeked2.as_ref().expect("peeked2 is Some: set above")
353    }
354
355    /// Consumes and returns the next token.
356    pub fn next_token(&mut self) -> Token {
357        if let Some(token) = self.peeked.take() {
358            self.peeked = self.peeked2.take();
359            return token;
360        }
361        self.read_next_token()
362    }
363
364    /// Get the text slice corresponding to a token
365    pub fn token_text(&self, token: &Token) -> &'src str {
366        &self.source[token.span.start as usize..token.span.end as usize]
367    }
368
369    fn read_next_token(&mut self) -> Token {
370        if self.pos >= self.source.len() {
371            return Token::eof(self.source.len() as u32);
372        }
373
374        match self.mode {
375            LexerMode::InlineHtml => self.lex_inline_html(),
376            LexerMode::Php => self.lex_php(),
377        }
378    }
379
380    fn lex_inline_html(&mut self) -> Token {
381        let start = self.pos;
382        let bytes = self.source.as_bytes();
383
384        // Search for <?php or <?= with a single SIMD-accelerated scan:
385        // find the first '<', then check if it's followed by ?php or ?=.
386        // This avoids the two independent full-string scans of the original approach.
387        let mut search = self.pos;
388        let tag_pos = loop {
389            match memchr::memchr(b'<', &bytes[search..]) {
390                None => break None,
391                Some(offset) => {
392                    let p = search + offset;
393                    let rest = &bytes[p..];
394                    if (rest.len() >= 5
395                        && rest[0] == b'<'
396                        && rest[1] == b'?'
397                        && rest[2..5].eq_ignore_ascii_case(b"php"))
398                        || rest.starts_with(b"<?=")
399                    {
400                        break Some(p - self.pos);
401                    }
402                    search = p + 1;
403                }
404            }
405        };
406
407        if let Some(tag_pos) = tag_pos {
408            if tag_pos == 0 {
409                // We're right at the open tag, switch to PHP mode
410                self.mode = LexerMode::Php;
411                return self.lex_php();
412            }
413            // Emit inline HTML up to the tag
414            let end = self.pos + tag_pos;
415            self.pos = end;
416            self.mode = LexerMode::Php;
417            Token::new(TokenKind::InlineHtml, Span::new(start as u32, end as u32))
418        } else {
419            // Rest of file is inline HTML
420            let end = self.source.len();
421            self.pos = end;
422            Token::new(TokenKind::InlineHtml, Span::new(start as u32, end as u32))
423        }
424    }
425
426    fn lex_php(&mut self) -> Token {
427        // Skip whitespace only (comments are yielded as tokens below)
428        self.skip_whitespace();
429
430        if self.pos >= self.source.len() {
431            return Token::eof(self.source.len() as u32);
432        }
433
434        let bytes = self.source.as_bytes();
435        let start = self.pos;
436
437        // Yield `//` line comments as tokens.
438        // Note: in PHP, ?> terminates a line comment just like \n does.
439        if bytes[self.pos] == b'/' && self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'/' {
440            self.pos += 2;
441            Self::skip_line_comment_body(bytes, &mut self.pos);
442            return self.tok(TokenKind::LineComment, start);
443        }
444
445        // Yield `/* */` block comments and `/** */` doc comments as tokens.
446        if bytes[self.pos] == b'/' && self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'*' {
447            self.pos += 2;
448            // A doc comment starts with `/**` where the third char is `*` and not immediately
449            // followed by `/` (which would make it the empty comment `/**/`).
450            let kind = if self.pos < bytes.len()
451                && bytes[self.pos] == b'*'
452                && !(self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'/')
453            {
454                TokenKind::DocComment
455            } else {
456                TokenKind::BlockComment
457            };
458            match memmem::find(&bytes[self.pos..], b"*/") {
459                Some(end) => self.pos += end + 2,
460                None => {
461                    let span = Span::new(start as u32, self.source.len() as u32);
462                    self.errors.push(LexerError {
463                        kind: LexerErrorKind::Other,
464                        message: "unterminated block comment".to_string(),
465                        span,
466                    });
467                    self.pos = bytes.len();
468                }
469            }
470            return self.tok(kind, start);
471        }
472
473        // Yield `#` hash comments as tokens (but not `#[` which starts an attribute).
474        // Note: in PHP, ?> terminates a hash comment just like \n does.
475        if bytes[self.pos] == b'#' && !(self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'[') {
476            self.pos += 1;
477            Self::skip_line_comment_body(bytes, &mut self.pos);
478            return self.tok(TokenKind::HashComment, start);
479        }
480
481        self.scan_token()
482    }
483
484    /// Skip PHP whitespace (space, tab, CR, LF, form-feed) at the current position.
485    fn skip_whitespace(&mut self) {
486        let bytes = self.source.as_bytes();
487        while self.pos < bytes.len() && IS_PHP_WHITESPACE[bytes[self.pos] as usize] {
488            self.pos += 1;
489        }
490    }
491
492    /// Scan a single PHP token starting at the current position.
493    fn scan_token(&mut self) -> Token {
494        let start = self.pos;
495        let bytes = self.source.as_bytes();
496        let b = bytes[start];
497
498        match b {
499            // --- Operators ---
500            b'+' => {
501                if self.check_at(1, b'+') {
502                    self.pos = start + 2;
503                    self.tok(TokenKind::PlusPlus, start)
504                } else if self.check_at(1, b'=') {
505                    self.pos = start + 2;
506                    self.tok(TokenKind::PlusEquals, start)
507                } else {
508                    self.pos = start + 1;
509                    self.tok(TokenKind::Plus, start)
510                }
511            }
512            b'-' => {
513                if self.check_at(1, b'-') {
514                    self.pos = start + 2;
515                    self.tok(TokenKind::MinusMinus, start)
516                } else if self.check_at(1, b'=') {
517                    self.pos = start + 2;
518                    self.tok(TokenKind::MinusEquals, start)
519                } else if self.check_at(1, b'>') {
520                    self.pos = start + 2;
521                    self.tok(TokenKind::Arrow, start)
522                } else {
523                    self.pos = start + 1;
524                    self.tok(TokenKind::Minus, start)
525                }
526            }
527            b'*' => {
528                if self.check_at(1, b'*') {
529                    if self.check_at(2, b'=') {
530                        self.pos = start + 3;
531                        self.tok(TokenKind::StarStarEquals, start)
532                    } else {
533                        self.pos = start + 2;
534                        self.tok(TokenKind::StarStar, start)
535                    }
536                } else if self.check_at(1, b'=') {
537                    self.pos = start + 2;
538                    self.tok(TokenKind::StarEquals, start)
539                } else {
540                    self.pos = start + 1;
541                    self.tok(TokenKind::Star, start)
542                }
543            }
544            b'/' => {
545                // Comments already handled by skip_whitespace_and_comments
546                if self.check_at(1, b'=') {
547                    self.pos = start + 2;
548                    self.tok(TokenKind::SlashEquals, start)
549                } else {
550                    self.pos = start + 1;
551                    self.tok(TokenKind::Slash, start)
552                }
553            }
554            b'%' => {
555                if self.check_at(1, b'=') {
556                    self.pos = start + 2;
557                    self.tok(TokenKind::PercentEquals, start)
558                } else {
559                    self.pos = start + 1;
560                    self.tok(TokenKind::Percent, start)
561                }
562            }
563            b'.' => {
564                // FloatLiteralLeadingDot: .5, .5e3, etc.
565                if start + 1 < bytes.len() && bytes[start + 1].is_ascii_digit() {
566                    self.pos = start + 1;
567                    self.scan_digits(u8::is_ascii_digit);
568                    // Check for exponent
569                    if self.pos < bytes.len() && matches!(bytes[self.pos], b'e' | b'E') {
570                        self.try_scan_exponent();
571                    }
572                    // Check for trailing underscore
573                    if self.pos < bytes.len() && bytes[self.pos] == b'_' {
574                        self.consume_invalid_numeric_rest();
575                        return self.invalid_numeric(start);
576                    }
577                    return self.tok(TokenKind::FloatLiteralLeadingDot, start);
578                }
579                if self.check_at(1, b'.') && self.check_at(2, b'.') {
580                    self.pos = start + 3;
581                    self.tok(TokenKind::Ellipsis, start)
582                } else if self.check_at(1, b'=') {
583                    self.pos = start + 2;
584                    self.tok(TokenKind::DotEquals, start)
585                } else {
586                    self.pos = start + 1;
587                    self.tok(TokenKind::Dot, start)
588                }
589            }
590            b'=' => {
591                if self.check_at(1, b'=') {
592                    if self.check_at(2, b'=') {
593                        self.pos = start + 3;
594                        self.tok(TokenKind::EqualsEqualsEquals, start)
595                    } else {
596                        self.pos = start + 2;
597                        self.tok(TokenKind::EqualsEquals, start)
598                    }
599                } else if self.check_at(1, b'>') {
600                    self.pos = start + 2;
601                    self.tok(TokenKind::FatArrow, start)
602                } else {
603                    self.pos = start + 1;
604                    self.tok(TokenKind::Equals, start)
605                }
606            }
607            b'!' => {
608                if self.check_at(1, b'=') {
609                    if self.check_at(2, b'=') {
610                        self.pos = start + 3;
611                        self.tok(TokenKind::BangEqualsEquals, start)
612                    } else {
613                        self.pos = start + 2;
614                        self.tok(TokenKind::BangEquals, start)
615                    }
616                } else {
617                    self.pos = start + 1;
618                    self.tok(TokenKind::Bang, start)
619                }
620            }
621            b'<' => self.scan_less_than(start),
622            b'>' => {
623                if self.check_at(1, b'>') {
624                    if self.check_at(2, b'=') {
625                        self.pos = start + 3;
626                        self.tok(TokenKind::ShiftRightEquals, start)
627                    } else {
628                        self.pos = start + 2;
629                        self.tok(TokenKind::ShiftRight, start)
630                    }
631                } else if self.check_at(1, b'=') {
632                    self.pos = start + 2;
633                    self.tok(TokenKind::GreaterThanEquals, start)
634                } else {
635                    self.pos = start + 1;
636                    self.tok(TokenKind::GreaterThan, start)
637                }
638            }
639            b'&' => {
640                if self.check_at(1, b'&') {
641                    self.pos = start + 2;
642                    self.tok(TokenKind::AmpersandAmpersand, start)
643                } else if self.check_at(1, b'=') {
644                    self.pos = start + 2;
645                    self.tok(TokenKind::AmpersandEquals, start)
646                } else {
647                    self.pos = start + 1;
648                    self.tok(TokenKind::Ampersand, start)
649                }
650            }
651            b'|' => {
652                if self.check_at(1, b'|') {
653                    self.pos = start + 2;
654                    self.tok(TokenKind::PipePipe, start)
655                } else if self.check_at(1, b'=') {
656                    self.pos = start + 2;
657                    self.tok(TokenKind::PipeEquals, start)
658                } else if self.check_at(1, b'>') {
659                    self.pos = start + 2;
660                    self.tok(TokenKind::PipeArrow, start)
661                } else {
662                    self.pos = start + 1;
663                    self.tok(TokenKind::Pipe, start)
664                }
665            }
666            b'^' => {
667                if self.check_at(1, b'=') {
668                    self.pos = start + 2;
669                    self.tok(TokenKind::CaretEquals, start)
670                } else {
671                    self.pos = start + 1;
672                    self.tok(TokenKind::Caret, start)
673                }
674            }
675            b'~' => {
676                self.pos = start + 1;
677                self.tok(TokenKind::Tilde, start)
678            }
679            b'?' => {
680                if self.check_at(1, b'>') {
681                    self.pos = start + 2;
682                    self.mode = LexerMode::InlineHtml;
683                    self.tok(TokenKind::CloseTag, start)
684                } else if self.check_at(1, b'?') {
685                    if self.check_at(2, b'=') {
686                        self.pos = start + 3;
687                        self.tok(TokenKind::CoalesceEquals, start)
688                    } else {
689                        self.pos = start + 2;
690                        self.tok(TokenKind::QuestionQuestion, start)
691                    }
692                } else if self.check_at(1, b'-') && self.check_at(2, b'>') {
693                    self.pos = start + 3;
694                    self.tok(TokenKind::NullsafeArrow, start)
695                } else {
696                    self.pos = start + 1;
697                    self.tok(TokenKind::Question, start)
698                }
699            }
700            b':' => {
701                if self.check_at(1, b':') {
702                    self.pos = start + 2;
703                    self.tok(TokenKind::DoubleColon, start)
704                } else {
705                    self.pos = start + 1;
706                    self.tok(TokenKind::Colon, start)
707                }
708            }
709            b'@' => {
710                self.pos = start + 1;
711                self.tok(TokenKind::At, start)
712            }
713            b'\\' => {
714                self.pos = start + 1;
715                self.tok(TokenKind::Backslash, start)
716            }
717            b'#' => {
718                // # comments are handled by skip_whitespace_and_comments.
719                // If we get here with #, it must be #[
720                if self.check_at(1, b'[') {
721                    self.pos = start + 2;
722                    self.tok(TokenKind::HashBracket, start)
723                } else {
724                    // Shouldn't normally happen, but skip and retry
725                    self.pos = start + 1;
726                    self.read_next_token()
727                }
728            }
729
730            // --- Delimiters ---
731            b'(' => {
732                self.pos = start + 1;
733                self.tok(TokenKind::LeftParen, start)
734            }
735            b')' => {
736                self.pos = start + 1;
737                self.tok(TokenKind::RightParen, start)
738            }
739            b'[' => {
740                self.pos = start + 1;
741                self.tok(TokenKind::LeftBracket, start)
742            }
743            b']' => {
744                self.pos = start + 1;
745                self.tok(TokenKind::RightBracket, start)
746            }
747            b'{' => {
748                self.pos = start + 1;
749                self.tok(TokenKind::LeftBrace, start)
750            }
751            b'}' => {
752                self.pos = start + 1;
753                self.tok(TokenKind::RightBrace, start)
754            }
755            b';' => {
756                self.pos = start + 1;
757                self.tok(TokenKind::Semicolon, start)
758            }
759            b',' => {
760                self.pos = start + 1;
761                self.tok(TokenKind::Comma, start)
762            }
763
764            // --- Strings ---
765            b'\'' => self.scan_single_quoted_string(),
766            b'"' => self.scan_double_quoted_string(),
767            b'`' => self.scan_backtick_string(),
768
769            // --- Variables ---
770            b'$' => {
771                if start + 1 < bytes.len() && is_ident_start(bytes[start + 1]) {
772                    self.pos = start + 2;
773                    while self.pos < bytes.len() && is_ident_continue(bytes[self.pos]) {
774                        self.pos += 1;
775                    }
776                    self.tok(TokenKind::Variable, start)
777                } else {
778                    self.pos = start + 1;
779                    self.tok(TokenKind::Dollar, start)
780                }
781            }
782
783            // --- Numbers ---
784            b'0'..=b'9' => self.scan_number(),
785
786            // --- Identifiers and keywords ---
787            _ if is_ident_start(b) => {
788                // Check for binary-prefixed strings and heredocs
789                if b == b'b' || b == b'B' {
790                    if self.check_at(1, b'\'') {
791                        return self.scan_single_quoted_string();
792                    }
793                    if self.check_at(1, b'"') {
794                        return self.scan_double_quoted_string();
795                    }
796                    if self.check_at(1, b'<') && self.check_at(2, b'<') && self.check_at(3, b'<') {
797                        let remaining = &self.source[self.pos..];
798                        if let Some(token) = self.try_lex_heredoc(remaining) {
799                            return token;
800                        }
801                    }
802                }
803                self.scan_identifier()
804            }
805
806            // Unknown byte - skip and retry
807            _ => {
808                self.pos = start + 1;
809                self.read_next_token()
810            }
811        }
812    }
813
814    /// Handle the `<` family of tokens, including heredoc.
815    fn scan_less_than(&mut self, start: usize) -> Token {
816        if self.check_at(1, b'<') {
817            if self.check_at(2, b'<') {
818                // <<< - try heredoc
819                let remaining = &self.source[self.pos..];
820                if let Some(token) = self.try_lex_heredoc(remaining) {
821                    return token;
822                }
823                // Not heredoc, fall through to <<
824            }
825            if self.check_at(2, b'=') {
826                self.pos = start + 3;
827                return self.tok(TokenKind::ShiftLeftEquals, start);
828            }
829            self.pos = start + 2;
830            return self.tok(TokenKind::ShiftLeft, start);
831        }
832        if self.check_at(1, b'=') {
833            if self.check_at(2, b'>') {
834                self.pos = start + 3;
835                return self.tok(TokenKind::Spaceship, start);
836            }
837            self.pos = start + 2;
838            return self.tok(TokenKind::LessThanEquals, start);
839        }
840        // `<>` is a legacy alternative spelling of `!=` (still supported by PHP).
841        if self.check_at(1, b'>') {
842            self.pos = start + 2;
843            return self.tok(TokenKind::BangEquals, start);
844        }
845        if self.check_at(1, b'?') {
846            let bytes = self.source.as_bytes();
847            if bytes.len() >= self.pos + 5
848                && bytes[self.pos + 2..self.pos + 5].eq_ignore_ascii_case(b"php")
849            {
850                self.pos = start + 5;
851                return self.tok(TokenKind::OpenTag, start);
852            }
853            if self.source[self.pos..].starts_with("<?=") {
854                self.pos = start + 3;
855                return self.tok(TokenKind::OpenTag, start);
856            }
857        }
858        self.pos = start + 1;
859        self.tok(TokenKind::LessThan, start)
860    }
861
862    // --- String scanning ---
863
864    fn scan_single_quoted_string(&mut self) -> Token {
865        let start = self.pos;
866        let bytes = self.source.as_bytes();
867        let mut p = self.pos;
868        // Skip optional binary prefix
869        if bytes[p] == b'b' || bytes[p] == b'B' {
870            p += 1;
871        }
872        p += 1; // skip opening '
873        loop {
874            match memchr2(b'\\', b'\'', &bytes[p..]) {
875                None => {
876                    self.errors.push(LexerError {
877                        kind: LexerErrorKind::UnterminatedString,
878                        message: "unterminated string literal".to_string(),
879                        span: Span::new(start as u32, self.source.len() as u32),
880                    });
881                    self.mark_non_verbatim(start);
882                    self.pos = self.source.len();
883                    return self.tok(TokenKind::SingleQuotedString, start);
884                }
885                Some(offset) => {
886                    p += offset;
887                    match bytes[p] {
888                        b'\\' => {
889                            self.mark_non_verbatim(start);
890                            p += 1;
891                            if p < bytes.len() {
892                                p += 1;
893                            }
894                        }
895                        _ => {
896                            // b'\''
897                            p += 1;
898                            break;
899                        }
900                    }
901                }
902            }
903        }
904        self.pos = p;
905        self.tok(TokenKind::SingleQuotedString, start)
906    }
907
908    fn scan_double_quoted_string(&mut self) -> Token {
909        let start = self.pos;
910        let bytes = self.source.as_bytes();
911        let mut p = self.pos;
912        // Skip optional binary prefix
913        if bytes[p] == b'b' || bytes[p] == b'B' {
914            p += 1;
915        }
916        p += 1; // skip opening "
917        let mut verbatim = true;
918        loop {
919            let segment = p;
920            let Some(offset) = memchr::memchr3(b'\\', b'"', b'$', &bytes[p..]) else {
921                self.errors.push(LexerError {
922                    kind: LexerErrorKind::UnterminatedString,
923                    message: "unterminated string literal".to_string(),
924                    span: Span::new(start as u32, self.source.len() as u32),
925                });
926                self.mark_non_verbatim(start);
927                self.pos = self.source.len();
928                return self.tok(TokenKind::DoubleQuotedString, start);
929            };
930            p += offset;
931            match bytes[p] {
932                b'\\' => {
933                    verbatim = false;
934                    p = (p + 2).min(bytes.len());
935                }
936                b'"' => {
937                    p += 1;
938                    break;
939                }
940                // b'$'
941                _ => {
942                    verbatim = false;
943                    // `{$...}` is PHP's complex expression interpolation. The
944                    // expression inside may contain nested strings, so skip over
945                    // the matching `}` using a balanced-brace scan that respects
946                    // string quoting. A `{` before `segment` was escaped.
947                    if p > segment && bytes[p - 1] == b'{' {
948                        p = skip_complex_interp(bytes, p - 1);
949                    } else {
950                        p += 1;
951                    }
952                }
953            }
954        }
955        if !verbatim {
956            self.mark_non_verbatim(start);
957        }
958        self.pos = p;
959        self.tok(TokenKind::DoubleQuotedString, start)
960    }
961
962    #[inline]
963    fn mark_non_verbatim(&mut self, start: usize) {
964        // A string's start is recorded at most once and tokens are lexed in order.
965        if self.non_verbatim_strings.last() != Some(&(start as u32)) {
966            self.non_verbatim_strings.push(start as u32);
967        }
968    }
969
970    fn scan_backtick_string(&mut self) -> Token {
971        let start = self.pos;
972        let bytes = self.source.as_bytes();
973        let mut p = self.pos;
974        p += 1; // skip opening `
975        loop {
976            match memchr2(b'\\', b'`', &bytes[p..]) {
977                None => {
978                    self.errors.push(LexerError {
979                        kind: LexerErrorKind::UnterminatedString,
980                        message: "unterminated string literal".to_string(),
981                        span: Span::new(start as u32, self.source.len() as u32),
982                    });
983                    self.pos = self.source.len();
984                    return self.tok(TokenKind::BacktickString, start);
985                }
986                Some(offset) => {
987                    p += offset;
988                    match bytes[p] {
989                        b'\\' => {
990                            p += 1;
991                            if p < bytes.len() {
992                                p += 1;
993                            }
994                        }
995                        _ => {
996                            // b'`'
997                            p += 1;
998                            break;
999                        }
1000                    }
1001                }
1002            }
1003        }
1004        self.pos = p;
1005        self.tok(TokenKind::BacktickString, start)
1006    }
1007
1008    // --- Number scanning ---
1009
1010    fn scan_number(&mut self) -> Token {
1011        let start = self.pos;
1012        let bytes = self.source.as_bytes();
1013
1014        // Check for 0x, 0b, 0o prefixes
1015        if bytes[start] == b'0' && start + 1 < bytes.len() {
1016            match bytes[start + 1] {
1017                b'x' | b'X' => {
1018                    self.pos = start + 2;
1019                    if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1020                        self.consume_invalid_numeric_rest();
1021                        return self.invalid_numeric(start);
1022                    }
1023                    if self.scan_digits(u8::is_ascii_hexdigit) {
1024                        if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1025                            self.consume_invalid_numeric_rest();
1026                            return self.invalid_numeric(start);
1027                        }
1028                        return self.tok(TokenKind::HexIntLiteral, start);
1029                    }
1030                    // No hex digits after 0x - backtrack to decimal
1031                    self.pos = start;
1032                }
1033                b'b' | b'B' => {
1034                    self.pos = start + 2;
1035                    if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1036                        self.consume_invalid_numeric_rest();
1037                        return self.invalid_numeric(start);
1038                    }
1039                    if self.scan_digits(|b| b == &b'0' || b == &b'1') {
1040                        if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1041                            self.consume_invalid_numeric_rest();
1042                            return self.invalid_numeric(start);
1043                        }
1044                        return self.tok(TokenKind::BinIntLiteral, start);
1045                    }
1046                    // No binary digits - backtrack
1047                    self.pos = start;
1048                }
1049                b'o' | b'O' => {
1050                    self.pos = start + 2;
1051                    if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1052                        self.consume_invalid_numeric_rest();
1053                        return self.invalid_numeric(start);
1054                    }
1055                    if self.scan_digits(|b| (b'0'..=b'7').contains(b)) {
1056                        if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1057                            self.consume_invalid_numeric_rest();
1058                            return self.invalid_numeric(start);
1059                        }
1060                        return self.tok(TokenKind::OctIntLiteralNew, start);
1061                    }
1062                    // No octal digits - backtrack
1063                    self.pos = start;
1064                }
1065                _ => {}
1066            }
1067        }
1068
1069        // Scan decimal integer portion: [0-9](_?[0-9])*
1070        self.pos = start;
1071        self.scan_digits(u8::is_ascii_digit);
1072        let integer_end = self.pos;
1073        let mut kind = TokenKind::IntLiteral;
1074
1075        // Check for legacy octal: 0[0-9]+ where PHP reads leading octal digits (0-7)
1076        // and silently ignores invalid digits (8, 9).
1077        if bytes[start] == b'0' && integer_end > start + 1 {
1078            kind = TokenKind::OctIntLiteral;
1079        }
1080
1081        // Check for decimal point
1082        if self.pos < bytes.len() && bytes[self.pos] == b'.' {
1083            if self.pos + 1 < bytes.len() && bytes[self.pos + 1].is_ascii_digit() {
1084                // Decimal point followed by digit: 1.5, 0.0, etc.
1085                self.pos += 1; // consume '.'
1086                self.scan_digits(u8::is_ascii_digit);
1087                kind = TokenKind::FloatLiteralSimple;
1088            } else if self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'_' {
1089                // Invalid separator after decimal: 1._0
1090                self.consume_invalid_numeric_rest();
1091                return self.invalid_numeric(start);
1092            } else if self.pos + 1 >= bytes.len() || bytes[self.pos + 1] != b'.' {
1093                // Trailing dot without digit: 1. (not followed by another dot for .. or ...)
1094                self.pos += 1; // consume '.'
1095                kind = TokenKind::FloatLiteralSimple;
1096            }
1097        }
1098
1099        // Check for exponent
1100        if self.pos < bytes.len() && matches!(bytes[self.pos], b'e' | b'E') {
1101            if self.try_scan_exponent() {
1102                kind = TokenKind::FloatLiteral;
1103            } else if self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'_' {
1104                // Invalid separator after exponent: 1e_2
1105                self.consume_invalid_numeric_rest();
1106                return self.invalid_numeric(start);
1107            }
1108        }
1109
1110        // Check for invalid trailing underscore
1111        if self.pos < bytes.len() && bytes[self.pos] == b'_' {
1112            self.consume_invalid_numeric_rest();
1113            return self.invalid_numeric(start);
1114        }
1115
1116        self.tok(kind, start)
1117    }
1118
1119    /// Scan digits with optional underscores: digit (_? digit)*
1120    /// Returns true if at least one digit was consumed.
1121    fn scan_digits(&mut self, is_valid: fn(&u8) -> bool) -> bool {
1122        let bytes = self.source.as_bytes();
1123        if self.pos >= bytes.len() || !is_valid(&bytes[self.pos]) {
1124            return false;
1125        }
1126        self.pos += 1;
1127        loop {
1128            if self.pos >= bytes.len() {
1129                break;
1130            }
1131            if is_valid(&bytes[self.pos]) {
1132                self.pos += 1;
1133            } else if bytes[self.pos] == b'_'
1134                && self.pos + 1 < bytes.len()
1135                && is_valid(&bytes[self.pos + 1])
1136            {
1137                self.pos += 2;
1138            } else {
1139                break;
1140            }
1141        }
1142        true
1143    }
1144
1145    /// Try to scan an exponent part: [eE][+-]?[0-9](_?[0-9])*
1146    /// Returns true if successful, false (with backtrack) if not.
1147    fn try_scan_exponent(&mut self) -> bool {
1148        let bytes = self.source.as_bytes();
1149        let saved = self.pos;
1150        self.pos += 1; // consume 'e'/'E'
1151
1152        // Optional sign
1153        if self.pos < bytes.len() && matches!(bytes[self.pos], b'+' | b'-') {
1154            self.pos += 1;
1155        }
1156
1157        // Must have at least one digit
1158        if self.scan_digits(u8::is_ascii_digit) {
1159            true
1160        } else {
1161            self.pos = saved;
1162            false
1163        }
1164    }
1165
1166    // --- Identifier scanning ---
1167
1168    fn scan_identifier(&mut self) -> Token {
1169        let start = self.pos;
1170        let bytes = self.source.as_bytes();
1171        self.pos += 1; // consume first ident char
1172        while self.pos < bytes.len() && is_ident_continue(bytes[self.pos]) {
1173            self.pos += 1;
1174        }
1175        let text = &self.source[start..self.pos];
1176        let kind = resolve_keyword(text).unwrap_or(TokenKind::Identifier);
1177        self.tok(kind, start)
1178    }
1179
1180    // --- Helpers ---
1181
1182    /// Skip a line comment body (`//` or `#` already consumed).
1183    /// Advances `pos` to the newline (inclusive stop) or `?>` or end of file.
1184    /// Leaves `pos` at `\n` / `?` so the surrounding loops consume them correctly.
1185    #[inline]
1186    fn skip_line_comment_body(bytes: &[u8], pos: &mut usize) {
1187        loop {
1188            match memchr2(b'\n', b'?', &bytes[*pos..]) {
1189                None => {
1190                    *pos = bytes.len();
1191                    return;
1192                }
1193                Some(offset) => {
1194                    let p = *pos + offset;
1195                    if bytes[p] == b'\n' {
1196                        *pos = p; // stop at newline; outer whitespace loop will consume it
1197                        return;
1198                    }
1199                    // b'?': check for ?>
1200                    if p + 1 < bytes.len() && bytes[p + 1] == b'>' {
1201                        *pos = p; // leave ?> for scan_token to produce CloseTag
1202                        return;
1203                    }
1204                    // Lone '?' — keep searching
1205                    *pos = p + 1;
1206                }
1207            }
1208        }
1209    }
1210
1211    #[inline]
1212    fn check_at(&self, offset: usize, expected: u8) -> bool {
1213        self.source.as_bytes().get(self.pos + offset) == Some(&expected)
1214    }
1215
1216    #[inline]
1217    fn tok(&self, kind: TokenKind, start: usize) -> Token {
1218        Token::new(kind, Span::new(start as u32, self.pos as u32))
1219    }
1220
1221    fn invalid_numeric(&mut self, start: usize) -> Token {
1222        let span = Span::new(start as u32, self.pos as u32);
1223        self.errors.push(LexerError {
1224            kind: LexerErrorKind::Other,
1225            message: "Invalid numeric literal".to_string(),
1226            span,
1227        });
1228        Token::new(TokenKind::InvalidNumericLiteral, span)
1229    }
1230
1231    /// Consume characters that form an invalid numeric literal rest (digits, underscores, dots, hex chars, exponent markers).
1232    fn consume_invalid_numeric_rest(&mut self) {
1233        let bytes = self.source.as_bytes();
1234        while self.pos < bytes.len() {
1235            let b = bytes[self.pos];
1236            if b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'+' || b == b'-' {
1237                // Only consume +/- after e/E
1238                if (b == b'+' || b == b'-') && self.pos > 0 {
1239                    let prev = bytes[self.pos - 1];
1240                    if prev != b'e' && prev != b'E' {
1241                        break;
1242                    }
1243                }
1244                self.pos += 1;
1245            } else {
1246                break;
1247            }
1248        }
1249    }
1250
1251    /// Try to lex a heredoc/nowdoc starting at the current position.
1252    /// `remaining` is the source from `self.pos` onward.
1253    /// Returns Some(Token) if a heredoc/nowdoc was found, None otherwise.
1254    fn try_lex_heredoc(&mut self, remaining: &str) -> Option<Token> {
1255        // Skip leading whitespace (and newlines) to find <<< (or b<<<)
1256        let trimmed = remaining.trim_start_matches(|c: char| {
1257            c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\x0C'
1258        });
1259        let ws_len = remaining.len() - trimmed.len();
1260
1261        // Handle optional binary prefix: b<<< or B<<<
1262        let (after_prefix, prefix_len) = if (trimmed.starts_with("b<<<")
1263            || trimmed.starts_with("B<<<"))
1264            && !trimmed[1..].starts_with("<<<>")
1265        {
1266            (&trimmed[1..], 1)
1267        } else {
1268            (trimmed, 0)
1269        };
1270
1271        if !after_prefix.starts_with("<<<") {
1272            return None;
1273        }
1274
1275        let base_pos = self.pos; // position of start of remaining
1276        let start = base_pos + ws_len; // position of b<<< or <<<
1277        let after_arrows = &after_prefix[3..];
1278        let after_arrows_trimmed = after_arrows.trim_start_matches([' ', '\t']);
1279        let arrows_offset =
1280            ws_len + prefix_len + 3 + (after_arrows.len() - after_arrows_trimmed.len());
1281
1282        // Determine if nowdoc (quoted) or heredoc
1283        let (label, is_nowdoc, label_line_end);
1284        if let Some(after_quote) = after_arrows_trimmed.strip_prefix('\'') {
1285            // Nowdoc: <<<'LABEL'
1286            let closing = after_quote.find('\'')?;
1287            label = &after_quote[..closing];
1288            is_nowdoc = true;
1289            let after_label = &after_arrows_trimmed[2 + closing..];
1290            // Find end of line
1291            let nl = after_label.find('\n').unwrap_or(after_label.len());
1292            label_line_end = arrows_offset + 2 + closing + nl;
1293            if label_line_end < remaining.len() {
1294                // +1 for the newline
1295            }
1296        } else {
1297            // Heredoc: <<<LABEL or <<<"LABEL"
1298            let s = if let Some(after_dquote) = after_arrows_trimmed.strip_prefix('"') {
1299                let closing = after_dquote.find('"')?;
1300                label = &after_dquote[..closing];
1301                &after_dquote[1 + closing..]
1302            } else {
1303                // Bare identifier
1304                let end = after_arrows_trimmed
1305                    .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1306                    .unwrap_or(after_arrows_trimmed.len());
1307                if end == 0 {
1308                    return None;
1309                }
1310                label = &after_arrows_trimmed[..end];
1311                &after_arrows_trimmed[end..]
1312            };
1313            is_nowdoc = false;
1314            let nl = s.find('\n').unwrap_or(s.len());
1315            label_line_end = arrows_offset + (after_arrows_trimmed.len() - s.len()) + nl;
1316        };
1317
1318        if label.is_empty() {
1319            return None;
1320        }
1321
1322        // Body starts after the first newline
1323        let body_start_in_remaining = if label_line_end < remaining.len() {
1324            label_line_end + 1 // skip \n
1325        } else {
1326            return None; // no body
1327        };
1328
1329        let body = &remaining[body_start_in_remaining..];
1330
1331        // Find the end marker: label on its own line (optionally indented)
1332        let mut search_pos = 0;
1333        let end_marker_pos;
1334        loop {
1335            if search_pos >= body.len() {
1336                return None; // unterminated
1337            }
1338            let line_start = search_pos;
1339            let line_end = body[line_start..]
1340                .find('\n')
1341                .map(|p| line_start + p)
1342                .unwrap_or(body.len());
1343            let line = &body[line_start..line_end];
1344            let trimmed_line = line.trim_start_matches([' ', '\t']);
1345
1346            // PHP 7.3+ flexible heredoc: the closing marker is the label (optionally
1347            // indented) followed by any non-identifier character. PHP ends the label
1348            // at the first byte that isn't in `[A-Za-z0-9_\x80-\xff]`, so whitespace,
1349            // `;`, `,`, `)`, `]`, operators, etc. all close the label cleanly.
1350            if trimmed_line.len() >= label.len()
1351                && &trimmed_line.as_bytes()[..label.len()] == label.as_bytes()
1352                && !trimmed_line
1353                    .as_bytes()
1354                    .get(label.len())
1355                    .copied()
1356                    .is_some_and(is_ident_continue)
1357            {
1358                end_marker_pos = line_start;
1359                break;
1360            }
1361
1362            search_pos = if line_end < body.len() {
1363                line_end + 1
1364            } else {
1365                body.len()
1366            };
1367        }
1368
1369        // Position after the end marker label (not including ; or newline)
1370        let end_marker_line = &body[end_marker_pos..];
1371        let trimmed = end_marker_line.trim_start_matches([' ', '\t']);
1372        let indent_len = end_marker_line.len() - trimmed.len();
1373        let token_end_in_remaining =
1374            body_start_in_remaining + end_marker_pos + indent_len + label.len();
1375        self.pos = base_pos + token_end_in_remaining;
1376
1377        let span = Span::new(start as u32, self.pos as u32);
1378
1379        if is_nowdoc {
1380            Some(Token::new(TokenKind::Nowdoc, span))
1381        } else {
1382            Some(Token::new(TokenKind::Heredoc, span))
1383        }
1384    }
1385}
1386
1387/// Lex an entire source file into a token vector upfront.
1388///
1389/// This is used by the parser to enable indexed token access instead of lazy lexing,
1390/// which eliminates branching in the hot Pratt loop path.
1391///
1392/// Returns a tuple of (tokens, errors). The token vector is guaranteed to end with
1393/// an Eof token, and includes a second Eof sentinel to make peek2 safe.
1394pub fn lex_all(source: &str) -> (Vec<Token>, Vec<LexerError>) {
1395    let mut lexer = Lexer::new(source);
1396    // `lexer.source()` is empty when `source` was rejected as too large.
1397    let mut tokens = Vec::with_capacity(lexer.source().len() / 8);
1398
1399    loop {
1400        let tok = lexer.next_token();
1401        let is_eof = tok.kind == TokenKind::Eof;
1402        tokens.push(tok);
1403        if is_eof {
1404            break;
1405        }
1406    }
1407
1408    // Push a second Eof sentinel so peek2 on the last real token is safe.
1409    // This allows the parser to do `self.tokens[self.pos + 1].kind` without bounds checking.
1410    // The Eof token from the lexer is always added to tokens before the loop exits
1411    let eof_span = tokens
1412        .last()
1413        .expect("tokens guaranteed to contain at least the Eof token from lexer")
1414        .span;
1415    tokens.push(Token::new(TokenKind::Eof, eof_span));
1416
1417    let errors = lexer.errors;
1418    (tokens, errors)
1419}