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