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