Skip to main content

php_lexer/
lexer.rs

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