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