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