Skip to main content

perl_lexer/
lib.rs

1//! Context-aware Perl lexer with mode-based tokenization
2//!
3//! This crate provides a high-performance lexer for Perl that handles the inherently
4//! context-sensitive nature of the language. The lexer uses a mode-tracking system to
5//! correctly disambiguate ambiguous syntax like `/` (division vs. regex) and properly
6//! parse complex constructs like heredocs, quote-like operators, and nested delimiters.
7//!
8//! # Architecture
9//!
10//! The lexer is organized around several key concepts:
11//!
12//! - **Mode Tracking**: [`LexerMode`] tracks whether the parser expects a term or an operator,
13//!   enabling correct disambiguation of context-sensitive tokens.
14//! - **Checkpointing**: [`LexerCheckpoint`] and [`Checkpointable`] support incremental parsing
15//!   by allowing the lexer state to be saved and restored.
16//! - **Budget Limits**: Protection against pathological input with configurable size limits
17//!   for regex patterns, heredoc bodies, and delimiter nesting depth.
18//! - **Position Tracking**: [`Position`] maintains line/column information for error reporting
19//!   and LSP integration.
20//! - **Unicode Support**: Full Unicode identifier support following Perl 5.14+ semantics.
21//!
22//! # Usage
23//!
24//! ## Basic Tokenization
25//!
26//! ```rust
27//! use perl_lexer::{PerlLexer, TokenType};
28//!
29//! let mut lexer = PerlLexer::new("my $x = 42;");
30//! let tokens = lexer.collect_tokens();
31//!
32//! // First token is the keyword `my`
33//! assert!(matches!(&tokens[0].token_type, TokenType::Keyword(k) if &**k == "my"));
34//! // Tokens include variables, operators, literals, and EOF
35//! assert!(matches!(&tokens.last().map(|t| &t.token_type), Some(TokenType::EOF)));
36//! ```
37//!
38//! ## Context-Aware Parsing
39//!
40//! The lexer automatically tracks context to disambiguate operators:
41//!
42//! ```rust
43//! use perl_lexer::{PerlLexer, TokenType};
44//!
45//! // Division operator (after a term)
46//! let mut lexer = PerlLexer::new("42 / 2");
47//! // Regex operator (at start of expression)
48//! let mut lexer2 = PerlLexer::new("/pattern/");
49//! ```
50//!
51//! ## Checkpointing for Incremental Parsing
52//!
53//! ```rust,ignore
54//! use perl_lexer::{PerlLexer, Checkpointable};
55//!
56//! let mut lexer = PerlLexer::new("my $x = 1;");
57//! let checkpoint = lexer.checkpoint();
58//!
59//! // Parse some tokens
60//! let _ = lexer.next_token();
61//!
62//! // Restore to checkpoint
63//! lexer.restore(&checkpoint);
64//! ```
65//!
66//! ## Configuration Options
67//!
68//! ```rust
69//! use perl_lexer::{PerlLexer, LexerConfig};
70//!
71//! let config = LexerConfig {
72//!     parse_interpolation: true,  // Parse string interpolation
73//!     track_positions: true,      // Track line/column positions
74//!     max_lookahead: 1024,        // Maximum lookahead for disambiguation
75//!     symbol_table: None,         // No pre-scanned sub declarations
76//! };
77//!
78//! let mut lexer = PerlLexer::with_config("my $x = 1;", config);
79//! ```
80//!
81//! # Context Sensitivity Examples
82//!
83//! Perl's grammar is highly context-sensitive. The lexer handles these cases:
84//!
85//! - **Division vs. Regex**: `/` is division after terms, regex at expression start
86//! - **Modulo vs. Hash Sigil**: `%` is modulo after terms, hash sigil at expression start
87//! - **Glob vs. Exponent**: `**` can be exponentiation or glob pattern start
88//! - **Defined-or vs. Regex**: `//` is defined-or after terms, regex at expression start
89//! - **Heredoc Markers**: `<<` can be left shift, here-doc, or numeric less-than-less-than
90//!
91//! # Budget Limits
92//!
93//! To prevent hangs on pathological input, the lexer enforces these limits:
94//!
95//! - **MAX_REGEX_BYTES**: 64KB maximum for regex patterns
96//! - **MAX_HEREDOC_BYTES**: 256KB maximum for heredoc bodies
97//! - **MAX_DELIM_NEST**: 128 levels maximum nesting depth for delimiters
98//! - **MAX_REGEX_PARSE_STEPS**: 32K maximum scan iterations for regex literals
99//!
100//! When limits are exceeded, the lexer emits an `UnknownRest` token preserving
101//! all previously parsed symbols, allowing continued analysis.
102//!
103//! # Integration with perl-parser
104//!
105//! The lexer is designed to work seamlessly with `perl_parser_core::Parser`.
106//! You rarely need to use the lexer directly -- the parser creates and manages
107//! a `PerlLexer` instance internally:
108//!
109//! ```rust,ignore
110//! use perl_parser_core::Parser;
111//!
112//! let code = r#"sub hello { print "Hello, world!\n"; }"#;
113//! let mut parser = Parser::new(code);
114//! let ast = parser.parse().expect("should parse");
115//! ```
116
117#![warn(missing_docs)]
118#![allow(
119    // Core allows for lexer code
120    clippy::too_many_lines,
121    clippy::module_name_repetitions,
122    clippy::cast_possible_truncation,
123    clippy::cast_sign_loss,
124    clippy::cast_possible_wrap,
125    clippy::cast_precision_loss,
126    clippy::must_use_candidate,
127    clippy::missing_errors_doc,
128    clippy::missing_panics_doc,
129
130    // Lexer-specific patterns that are fine
131    clippy::match_same_arms,
132    clippy::redundant_else,
133    clippy::unnecessary_wraps,
134    clippy::unused_self,
135    clippy::items_after_statements,
136    clippy::struct_excessive_bools,
137    clippy::uninlined_format_args
138)]
139
140use std::sync::Arc;
141
142pub mod api;
143pub mod builtins;
144pub mod checkpoint;
145pub mod config;
146pub mod error;
147mod heredoc;
148pub mod keywords;
149mod lexer;
150pub mod limits;
151pub mod mode;
152mod quote_handler;
153pub mod symbol_table;
154pub mod token;
155pub mod tokenizer;
156mod unicode;
157
158pub use api::*;
159pub use checkpoint::{CheckpointCache, Checkpointable, LexerCheckpoint};
160pub use config::LexerConfig;
161pub use error::{LexerError, Result};
162pub use lexer::PerlLexer;
163pub use limits::MAX_REGEX_PARSE_STEPS;
164pub use mode::LexerMode;
165pub use perl_position_tracking::Position;
166pub use symbol_table::LocalSymbolTable;
167pub use token::{StringPart, Token, TokenType};
168
169use unicode::{is_perl_identifier_continue, is_perl_identifier_start};
170
171use crate::heredoc::HeredocSpec;
172use crate::lexer::helpers::{
173    empty_arc, is_builtin_function, is_compound_operator, is_keyword_fast, is_quote_op_word_prefix,
174    truncate_preview,
175};
176use crate::limits::{
177    HEREDOC_TIMEOUT_MS, MAX_DELIM_NEST, MAX_HEREDOC_BYTES, MAX_HEREDOC_DEPTH, MAX_REGEX_BYTES,
178};
179
180impl<'a> PerlLexer<'a> {
181    /// Create a new lexer that emits `HeredocBody` tokens (for LSP folding)
182    pub fn with_body_tokens(input: &'a str) -> Self {
183        let mut lexer = Self::new(input);
184        lexer.emit_heredoc_body_tokens = true;
185        lexer
186    }
187
188    /// Set the lexer mode (for resetting state at statement boundaries)
189    pub fn set_mode(&mut self, mode: LexerMode) {
190        self.mode = mode;
191    }
192
193    /// Advance the lexer and return the next token.
194    ///
195    /// Returns `None` only after an `EOF` token has already been emitted.
196    /// The final meaningful call returns `Some(Token { token_type: TokenType::EOF, .. })`.
197    pub fn next_token(&mut self) -> Option<Token> {
198        // Normalize file start (BOM) once
199        if self.position == 0 {
200            self.normalize_file_start();
201        }
202        self.normalize_char_boundary();
203
204        // Loop to avoid recursion when processing heredocs
205        loop {
206            // Handle format body parsing if we're in that mode
207            if matches!(self.mode, LexerMode::InFormatBody) {
208                return self.parse_format_body();
209            }
210
211            // Handle data section parsing if we're in that mode
212            if matches!(self.mode, LexerMode::InDataSection) {
213                return self.parse_data_body();
214            }
215
216            // Check if we're inside a heredoc body BEFORE skipping whitespace
217            let mut found_terminator = false;
218            if !self.pending_heredocs.is_empty() {
219                // Clone what we need to avoid holding a borrow
220                let (body_start, label, allow_indent) =
221                    if let Some(spec) = self.pending_heredocs.first() {
222                        if spec.body_start > 0
223                            && self.position >= spec.body_start
224                            && self.position < self.input.len()
225                        {
226                            (spec.body_start, spec.label.clone(), spec.allow_indent)
227                        } else {
228                            // Not in a heredoc body yet or at EOF
229                            (0, empty_arc(), false)
230                        }
231                    } else {
232                        (0, empty_arc(), false)
233                    };
234
235                if body_start > 0 {
236                    // We're inside a heredoc body - scan for the terminator
237
238                    // Scan line by line looking for the terminator
239                    while self.position < self.input.len() {
240                        // Timeout protection (Issue #443)
241                        if self.start_time.elapsed().as_millis() > HEREDOC_TIMEOUT_MS as u128 {
242                            self.pending_heredocs.remove(0);
243                            self.position = self.input.len();
244                            return Some(Token {
245                                token_type: TokenType::Error(Arc::from("Heredoc parsing timeout")),
246                                text: Arc::from(&self.input[body_start..]),
247                                start: body_start,
248                                end: self.input.len(),
249                            });
250                        }
251
252                        // Budget cap for huge bodies - optimized check
253                        if self.position - body_start > MAX_HEREDOC_BYTES {
254                            // Remove the pending heredoc to avoid infinite loop
255                            self.pending_heredocs.remove(0);
256                            self.position = self.input.len();
257                            return Some(Token {
258                                token_type: TokenType::UnknownRest,
259                                text: Arc::from(&self.input[body_start..]),
260                                start: body_start,
261                                end: self.input.len(),
262                            });
263                        }
264
265                        // Skip to start of next line if not at line start
266                        // Exception: if we're at body_start exactly, we're at the heredoc body start
267                        if !self.after_newline && self.position != body_start {
268                            while self.position < self.input.len()
269                                && self.input_bytes[self.position] != b'\n'
270                                && self.input_bytes[self.position] != b'\r'
271                            {
272                                self.advance();
273                            }
274                            self.consume_newline();
275                            continue;
276                        }
277
278                        // We're at line start - check if this line is the terminator
279                        let line_start = self.position;
280                        let line_end = Self::find_line_end(self.input_bytes, self.position);
281                        let line = &self.input[line_start..line_end];
282                        // Strip trailing spaces/tabs (Perl allows them)
283                        let trimmed_end = line.trim_end_matches([' ', '\t']);
284
285                        // Check if this line is the terminator
286                        let is_terminator = if allow_indent {
287                            // Allow any leading spaces/tabs before the label
288                            let mut p = 0;
289                            while p < trimmed_end.len() {
290                                let b = trimmed_end.as_bytes()[p];
291                                if b == b' ' || b == b'\t' {
292                                    p += 1;
293                                } else {
294                                    break;
295                                }
296                            }
297                            trimmed_end[p..] == *label
298                        } else {
299                            // Must start at column 0 (no leading whitespace)
300                            // The terminator is just the label (already trimmed trailing whitespace)
301                            trimmed_end == &*label
302                        };
303
304                        if is_terminator {
305                            // Found the terminator!
306                            self.pending_heredocs.remove(0);
307                            found_terminator = true;
308
309                            // Consume past the terminator line
310                            self.position = line_end;
311                            self.consume_newline();
312
313                            // Set body_start for the next pending heredoc (if any)
314                            if let Some(next) = self.pending_heredocs.first_mut()
315                                && next.body_start == 0
316                            {
317                                next.body_start = self.position;
318                            }
319
320                            // Only emit HeredocBody if requested (for folding)
321                            if self.emit_heredoc_body_tokens {
322                                return Some(Token {
323                                    token_type: TokenType::HeredocBody(empty_arc()),
324                                    text: empty_arc(),
325                                    start: body_start,
326                                    end: line_start,
327                                });
328                            }
329                            // Otherwise, continue the outer loop to get the next real token (avoiding recursion)
330                            break; // Break inner while loop, continue outer loop
331                        }
332
333                        // Not the terminator, continue to next line
334                        self.position = line_end;
335                        self.consume_newline();
336                    }
337
338                    // If we didn't find a terminator, we reached EOF - emit error token
339                    if !found_terminator {
340                        // Remove the pending heredoc to avoid infinite loop
341                        self.pending_heredocs.remove(0);
342                        self.position = self.input.len();
343                        return Some(Token {
344                            token_type: TokenType::UnknownRest,
345                            text: Arc::from(&self.input[body_start..]),
346                            start: body_start,
347                            end: self.input.len(),
348                        });
349                    }
350                }
351
352                // If we found a terminator, continue outer loop to get next token
353                if found_terminator {
354                    continue; // Continue outer loop to get next token
355                }
356            }
357
358            self.skip_whitespace_and_comments()?;
359
360            // Check again if we're now in a heredoc body (might have been set during skip_whitespace)
361            if !self.pending_heredocs.is_empty()
362                && let Some(spec) = self.pending_heredocs.first()
363                && spec.body_start > 0
364                && self.position >= spec.body_start
365                && self.position < self.input.len()
366            {
367                continue; // Go back to top of loop to process heredoc
368            }
369
370            // If we reach EOF with pending heredocs, clear them and emit EOF
371            if self.position >= self.input.len() && !self.pending_heredocs.is_empty() {
372                self.pending_heredocs.clear();
373            }
374
375            if self.position >= self.input.len() {
376                if self.eof_emitted {
377                    return None; // Stop the stream
378                }
379                self.eof_emitted = true;
380                return Some(Token {
381                    token_type: TokenType::EOF,
382                    text: empty_arc(),
383                    start: self.position,
384                    end: self.position,
385                });
386            }
387
388            let start = self.position;
389
390            // Check for special tokens first
391            if let Some(token) = self.try_heredoc() {
392                return Some(token);
393            }
394
395            if let Some(token) = self.try_string() {
396                return Some(token);
397            }
398
399            if let Some(token) = self.try_variable() {
400                return Some(token);
401            }
402
403            if let Some(token) = self.try_number() {
404                return Some(token);
405            }
406
407            if let Some(token) = self.try_vstring() {
408                return Some(token);
409            }
410
411            if let Some(token) = self.try_identifier_or_keyword() {
412                return Some(token);
413            }
414
415            // If we're expecting a delimiter for a quote operator, only try delimiter
416            if matches!(self.mode, LexerMode::ExpectDelimiter) && self.current_quote_op.is_some() {
417                if let Some(token) = self.try_delimiter() {
418                    return Some(token);
419                }
420                // Do NOT fall through to try_operator / try_punct / etc.
421                // Clear state first so we don't spin
422                self.mode = LexerMode::ExpectOperator;
423                self.current_quote_op = None;
424                continue;
425            }
426
427            if let Some(token) = self.try_operator() {
428                return Some(token);
429            }
430
431            if let Some(token) = self.try_delimiter() {
432                return Some(token);
433            }
434
435            // If nothing else matches, return an error token
436            let ch = self.current_char()?;
437            self.advance();
438
439            // Optimize error token creation - avoid expensive formatting in hot path
440            let text = if ch.is_ascii() {
441                // Fast path for ASCII characters
442                Arc::from(&self.input[start..self.position])
443            } else {
444                // Unicode path without intermediate heap allocation
445                let mut buf = [0_u8; 4];
446                Arc::from(ch.encode_utf8(&mut buf))
447            };
448
449            return Some(Token {
450                token_type: TokenType::Error(Arc::from("Unexpected character")),
451                text,
452                start,
453                end: self.position,
454            });
455        } // End of loop
456    }
457
458    /// Budget guard to prevent infinite loops and timeouts (Issue #422)
459    ///
460    /// **Purpose**: Protect against pathological input that could cause:
461    /// - Infinite loops in regex/heredoc parsing
462    /// - Excessive memory consumption
463    /// - LSP server hangs
464    ///
465    /// **Limits**:
466    /// - `MAX_REGEX_BYTES` (64KB): Maximum bytes in a single regex literal
467    /// - `MAX_DELIM_NEST` (128): Maximum delimiter nesting depth
468    ///
469    /// **Graceful Degradation**:
470    /// - Budget exceeded → emit `UnknownRest` token
471    /// - Jump to EOF to prevent further parsing of problematic region
472    /// - LSP client can emit soft diagnostic about truncation
473    /// - All previously parsed symbols remain valid
474    ///
475    /// **Performance**:
476    /// - Fast path: inlined subtraction + comparison (~1-2 CPU cycles)
477    /// - Slow path: Only triggered on pathological input
478    /// - Amortized cost: O(1) per token
479    #[allow(clippy::inline_always)] // Performance critical in lexer hot path
480    #[inline(always)]
481    fn budget_guard(&mut self, start: usize, depth: usize) -> Option<Token> {
482        // Fast path: most calls won't hit limits
483        let bytes_consumed = self.position - start;
484        if bytes_consumed <= MAX_REGEX_BYTES && depth <= MAX_DELIM_NEST {
485            return None;
486        }
487
488        // Slow path: budget exceeded - graceful degradation
489        #[cfg(debug_assertions)]
490        {
491            tracing::debug!(
492                bytes_consumed,
493                depth,
494                position = self.position,
495                "Lexer budget exceeded"
496            );
497        }
498
499        self.position = self.input.len();
500        Some(Token {
501            token_type: TokenType::UnknownRest,
502            text: Arc::from(""),
503            start,
504            end: self.position,
505        })
506    }
507
508    /// Peek at the next token without consuming it.
509    ///
510    /// Saves and restores the full lexer state so the next call to
511    /// [`next_token`](Self::next_token) returns the same token.
512    pub fn peek_token(&mut self) -> Option<Token> {
513        let saved_pos = self.position;
514        let saved_mode = self.mode;
515        let saved_delimiter_stack = self.delimiter_stack.clone();
516        let saved_prototype = self.in_prototype;
517        let saved_depth = self.prototype_depth;
518        let saved_after_sub = self.after_sub;
519        let saved_after_arrow = self.after_arrow;
520        let saved_hash_brace_depth = self.hash_brace_depth;
521        let saved_after_var_subscript = self.after_var_subscript;
522        let saved_paren_depth = self.paren_depth;
523        let saved_current_pos = self.current_pos;
524        let saved_after_newline = self.after_newline;
525        let saved_pending_heredocs = self.pending_heredocs.clone();
526        let saved_line_start_offset = self.line_start_offset;
527        let saved_current_quote_op = self.current_quote_op.clone();
528        let saved_eof_emitted = self.eof_emitted;
529        let saved_start_time = self.start_time;
530
531        let token = self.next_token();
532
533        self.position = saved_pos;
534        self.mode = saved_mode;
535        self.delimiter_stack = saved_delimiter_stack;
536        self.in_prototype = saved_prototype;
537        self.prototype_depth = saved_depth;
538        self.after_sub = saved_after_sub;
539        self.after_arrow = saved_after_arrow;
540        self.hash_brace_depth = saved_hash_brace_depth;
541        self.after_var_subscript = saved_after_var_subscript;
542        self.paren_depth = saved_paren_depth;
543        self.current_pos = saved_current_pos;
544        self.after_newline = saved_after_newline;
545        self.pending_heredocs = saved_pending_heredocs;
546        self.line_start_offset = saved_line_start_offset;
547        self.current_quote_op = saved_current_quote_op;
548        self.eof_emitted = saved_eof_emitted;
549        self.start_time = saved_start_time;
550
551        token
552    }
553
554    /// Consume all remaining tokens and return them as a vector.
555    ///
556    /// The returned vector always ends with an `EOF` token.
557    pub fn collect_tokens(&mut self) -> Vec<Token> {
558        let mut tokens = Vec::new();
559        while let Some(token) = self.next_token() {
560            if token.token_type == TokenType::EOF {
561                tokens.push(token);
562                break;
563            }
564            tokens.push(token);
565        }
566        tokens
567    }
568
569    /// Reset the lexer to the beginning of the input.
570    ///
571    /// Clears all internal state (mode, delimiter stack, heredoc queue, etc.)
572    /// so the lexer can re-tokenize the same source from scratch.
573    pub fn reset(&mut self) {
574        self.position = 0;
575        self.mode = LexerMode::ExpectTerm;
576        self.delimiter_stack.clear();
577        self.in_prototype = false;
578        self.prototype_depth = 0;
579        self.after_sub = false;
580        self.after_arrow = false;
581        self.hash_brace_depth = 0;
582        self.after_var_subscript = false;
583        self.paren_depth = 0;
584        self.current_pos = Position::start();
585        self.after_newline = true;
586        self.pending_heredocs.clear();
587        self.line_start_offset = 0;
588        self.current_quote_op = None;
589        self.eof_emitted = false;
590        self.start_time = std::time::Instant::now();
591    }
592
593    /// Switch the lexer into format-body parsing mode.
594    ///
595    /// In this mode the lexer consumes input verbatim until it encounters a
596    /// line containing only `.` (the Perl format terminator).
597    pub fn enter_format_mode(&mut self) {
598        self.mode = LexerMode::InFormatBody;
599    }
600
601    // Token-specific parsing methods
602
603    #[inline]
604    fn skip_whitespace_and_comments(&mut self) -> Option<()> {
605        // Don't reset after_newline if we're at the start of a line
606        if self.position > 0 && self.position != self.line_start_offset {
607            self.after_newline = false;
608        }
609
610        while self.position < self.input_bytes.len() {
611            let byte = Self::byte_at(self.input_bytes, self.position);
612            match byte {
613                // Fast path for ASCII whitespace - batch process
614                b' ' => {
615                    // Batch skip spaces for better cache efficiency
616                    let start = self.position;
617                    while self.position < self.input_bytes.len()
618                        && Self::byte_at(self.input_bytes, self.position) == b' '
619                    {
620                        self.position += 1;
621                    }
622                    // Continue outer loop if we processed any spaces
623                    if self.position > start {
624                        // Loop naturally continues to next iteration
625                    }
626                }
627                b'\t' | 0x0B | 0x0C => {
628                    // Batch skip horizontal tab, vertical tab, and form feed.
629                    // Perl treats these as whitespace separators.
630                    let start = self.position;
631                    while self.position < self.input_bytes.len()
632                        && matches!(
633                            Self::byte_at(self.input_bytes, self.position),
634                            b'\t' | 0x0B | 0x0C
635                        )
636                    {
637                        self.position += 1;
638                    }
639                    if self.position > start {
640                        // Loop naturally continues to next iteration
641                    }
642                }
643                b'\r' | b'\n' => {
644                    self.consume_newline();
645
646                    // Set body_start for the FIRST pending heredoc that needs it (FIFO)
647                    // Only check if we have pending heredocs to avoid unnecessary work
648                    if !self.pending_heredocs.is_empty() {
649                        for spec in &mut self.pending_heredocs {
650                            if spec.body_start == 0 {
651                                spec.body_start = self.position;
652                                break; // Only set for the first unresolved heredoc
653                            }
654                        }
655                    }
656                }
657                b'#' => {
658                    // In ExpectDelimiter mode, '#' is a delimiter, not a comment
659                    if matches!(self.mode, LexerMode::ExpectDelimiter) {
660                        break;
661                    }
662
663                    // Skip line comment using memchr for fast newline search
664                    self.position += 1; // Skip # directly
665
666                    // Use memchr2 to find CR/LF line endings quickly (supports LF, CRLF, and CR)
667                    if let Some(newline_offset) =
668                        memchr::memchr2(b'\n', b'\r', &self.input_bytes[self.position..])
669                    {
670                        self.position += newline_offset;
671                    } else {
672                        // No newline found, skip to end
673                        self.position = self.input_bytes.len();
674                    }
675                }
676                b'=' if self.position == 0
677                    || (self.position > 0
678                        && matches!(self.input_bytes[self.position - 1], b'\n' | b'\r')) =>
679                {
680                    // Check if this starts a POD section (=pod, =head, =over, etc.)
681                    // Use byte-safe checks — avoid slicing &str at arbitrary byte positions
682                    let remaining = &self.input_bytes[self.position..];
683                    if remaining.starts_with(b"=pod")
684                        || remaining.starts_with(b"=head")
685                        || remaining.starts_with(b"=over")
686                        || remaining.starts_with(b"=item")
687                        || remaining.starts_with(b"=back")
688                        || remaining.starts_with(b"=begin")
689                        || remaining.starts_with(b"=end")
690                        || remaining.starts_with(b"=for")
691                        || remaining.starts_with(b"=encoding")
692                    {
693                        // Scan forward for \n=cut (end of POD block)
694                        let search_start = self.position;
695                        let mut found_cut = false;
696                        let bytes = self.input_bytes;
697                        let mut i = search_start;
698                        while i < bytes.len() {
699                            // Look for =cut at the start of a line
700                            if (i == 0 || matches!(bytes[i - 1], b'\n' | b'\r'))
701                                && bytes[i..].starts_with(b"=cut")
702                            {
703                                i += 4; // Skip "=cut"
704                                // Skip rest of the =cut line
705                                while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
706                                    i += 1;
707                                }
708                                // Consume one line ending sequence if present
709                                if i < bytes.len() && bytes[i] == b'\r' {
710                                    i += 1;
711                                    if i < bytes.len() && bytes[i] == b'\n' {
712                                        i += 1;
713                                    }
714                                } else if i < bytes.len() && bytes[i] == b'\n' {
715                                    i += 1;
716                                }
717                                self.position = i;
718                                found_cut = true;
719                                break;
720                            }
721                            i += 1;
722                        }
723                        if !found_cut {
724                            // POD extends to end of file
725                            self.position = bytes.len();
726                        }
727                        continue;
728                    }
729                    // Not a POD directive - regular '=' token
730                    break;
731                }
732                _ => {
733                    // For non-ASCII whitespace, use char check only when needed
734                    if byte >= 128
735                        && let Some(ch) = self.current_char()
736                        && ch.is_whitespace()
737                    {
738                        self.advance();
739                        continue;
740                    }
741                    break;
742                }
743            }
744        }
745        Some(())
746    }
747
748    fn try_heredoc(&mut self) -> Option<Token> {
749        // `<<` is the left-shift operator, not a heredoc, when we are inside
750        // a parenthesized expression and have just finished a term.
751        // E.g. `(1<<index(...))` — the `1` sets ExpectOperator and paren_depth > 0,
752        // so `<<index` must be the bitshift operator, not a heredoc start.
753        //
754        // We must NOT fire the guard at statement level (paren_depth == 0) because
755        // `print $fh <<END` is valid Perl: `$fh` sets ExpectOperator but `<<END`
756        // is a heredoc.  The depth check distinguishes the two cases.
757        if self.mode == LexerMode::ExpectOperator && self.paren_depth > 0 {
758            return None;
759        }
760
761        // Check for heredoc start
762        if self.peek_byte(0) != Some(b'<') || self.peek_byte(1) != Some(b'<') {
763            return None;
764        }
765
766        let start = self.position;
767        let mut text = String::from("<<");
768        self.position += 2; // Skip <<
769
770        // Check for indented heredoc (~)
771        let allow_indent = if self.current_char() == Some('~') {
772            text.push('~');
773            self.advance();
774            true
775        } else {
776            false
777        };
778
779        // Skip whitespace
780        while let Some(ch) = self.current_char() {
781            if ch == ' ' || ch == '\t' {
782                text.push(ch);
783                self.advance();
784            } else {
785                break;
786            }
787        }
788
789        // Optional backslash disables interpolation, treat like single-quoted label
790        let backslashed = if self.current_char() == Some('\\') {
791            text.push('\\');
792            self.advance();
793            true
794        } else {
795            false
796        };
797
798        // Parse delimiter
799        let delimiter = if self.position < self.input.len() {
800            match self.current_char() {
801                Some('"') if !backslashed => self.parse_quoted_heredoc_delimiter('"', &mut text)?,
802                Some('\'') if !backslashed => {
803                    self.parse_quoted_heredoc_delimiter('\'', &mut text)?
804                }
805                Some('`') if !backslashed => self.parse_quoted_heredoc_delimiter('`', &mut text)?,
806                Some(c) if is_perl_identifier_start(c) => {
807                    // Bare word delimiter
808                    let mut delim = String::new();
809                    while self.position < self.input.len() {
810                        if let Some(c) = self.current_char() {
811                            if is_perl_identifier_continue(c) {
812                                delim.push(c);
813                                text.push(c);
814                                self.advance();
815                            } else {
816                                break;
817                            }
818                        } else {
819                            break;
820                        }
821                    }
822                    delim
823                }
824                _ => {
825                    // Not a valid heredoc delimiter - reset position and return None
826                    // This allows << to be parsed as bitshift operator (e.g., 1 << 2)
827                    self.position = start;
828                    return None;
829                }
830            }
831        } else {
832            // No delimiter found - reset position and return None
833            self.position = start;
834            return None;
835        };
836
837        // For now, return a placeholder token
838        // The actual heredoc body would be parsed later when we encounter it
839        self.mode = LexerMode::ExpectOperator;
840
841        // Recursion depth limit (Issue #443)
842        if self.pending_heredocs.len() >= MAX_HEREDOC_DEPTH {
843            return Some(Token {
844                token_type: TokenType::Error(Arc::from("Heredoc nesting too deep")),
845                text: Arc::from(text),
846                start,
847                end: self.position,
848            });
849        }
850
851        // Queue the heredoc spec with its label
852        self.pending_heredocs.push(HeredocSpec {
853            label: Arc::from(delimiter.as_str()),
854            body_start: 0, // Will be set when we see the newline after this line
855            allow_indent,
856        });
857
858        Some(Token {
859            token_type: TokenType::HeredocStart,
860            text: Arc::from(text),
861            start,
862            end: self.position,
863        })
864    }
865
866    fn try_string(&mut self) -> Option<Token> {
867        let start = self.position;
868        let quote = self.current_char()?;
869
870        match quote {
871            '"' => self.parse_double_quoted_string(start),
872            '\'' => self.parse_single_quoted_string(start),
873            '`' => self.parse_backtick_string(start),
874            'q' if self.peek_char(1) == Some('{') => self.parse_q_string(start),
875            _ => None,
876        }
877    }
878
879    #[inline]
880    fn try_number(&mut self) -> Option<Token> {
881        let start = self.position;
882
883        // Fast byte check for digits - optimized bounds checking
884        let bytes = self.input_bytes;
885        if self.position >= bytes.len() || !Self::byte_at(bytes, self.position).is_ascii_digit() {
886            return None;
887        }
888
889        // Check for hex (0x), binary (0b), or octal (0o) prefixes
890        let mut pos = self.position;
891        if Self::byte_at(bytes, pos) == b'0' && pos + 1 < bytes.len() {
892            let prefix_byte = bytes[pos + 1];
893            if prefix_byte == b'x' || prefix_byte == b'X' {
894                // Hexadecimal: 0x[0-9a-fA-F_]+
895                pos += 2; // consume '0x'
896                let digit_start = pos;
897                let mut saw_digit = false;
898                while pos < bytes.len() && (bytes[pos].is_ascii_hexdigit() || bytes[pos] == b'_') {
899                    saw_digit |= bytes[pos].is_ascii_hexdigit();
900                    pos += 1;
901                }
902                if pos > digit_start && saw_digit {
903                    self.position = pos;
904                    let text = &self.input[start..self.position];
905                    self.mode = LexerMode::ExpectOperator;
906                    return Some(Token {
907                        token_type: TokenType::Number(Arc::from(text)),
908                        text: Arc::from(text),
909                        start,
910                        end: self.position,
911                    });
912                }
913                // No hex digits after 0x - emit error
914                self.position = pos;
915                return Some(Token {
916                    token_type: TokenType::Error(Arc::from(
917                        "No digits found for hexadecimal literal",
918                    )),
919                    text: Arc::from(&self.input[start..pos]),
920                    start,
921                    end: pos,
922                });
923            } else if prefix_byte == b'b' || prefix_byte == b'B' {
924                // Binary: 0b[01_]+
925                pos += 2; // consume '0b'
926                let digit_start = pos;
927                let mut saw_digit = false;
928                while pos < bytes.len()
929                    && (bytes[pos] == b'0' || bytes[pos] == b'1' || bytes[pos] == b'_')
930                {
931                    saw_digit |= bytes[pos] == b'0' || bytes[pos] == b'1';
932                    pos += 1;
933                }
934                if pos > digit_start && saw_digit {
935                    self.position = pos;
936                    let text = &self.input[start..self.position];
937                    self.mode = LexerMode::ExpectOperator;
938                    return Some(Token {
939                        token_type: TokenType::Number(Arc::from(text)),
940                        text: Arc::from(text),
941                        start,
942                        end: self.position,
943                    });
944                }
945                // No binary digits after 0b - emit error
946                self.position = pos;
947                return Some(Token {
948                    token_type: TokenType::Error(Arc::from("No digits found for binary literal")),
949                    text: Arc::from(&self.input[start..pos]),
950                    start,
951                    end: pos,
952                });
953            } else if prefix_byte == b'o' || prefix_byte == b'O' {
954                // Octal (explicit): 0o[0-7_]+
955                pos += 2; // consume '0o'
956                let digit_start = pos;
957                let mut saw_digit = false;
958                while pos < bytes.len()
959                    && ((bytes[pos] >= b'0' && bytes[pos] <= b'7') || bytes[pos] == b'_')
960                {
961                    saw_digit |= (b'0'..=b'7').contains(&bytes[pos]);
962                    pos += 1;
963                }
964                if pos > digit_start && saw_digit {
965                    self.position = pos;
966                    let text = &self.input[start..self.position];
967                    self.mode = LexerMode::ExpectOperator;
968                    return Some(Token {
969                        token_type: TokenType::Number(Arc::from(text)),
970                        text: Arc::from(text),
971                        start,
972                        end: self.position,
973                    });
974                }
975                // No octal digits after 0o - emit error
976                self.position = pos;
977                return Some(Token {
978                    token_type: TokenType::Error(Arc::from("No digits found for octal literal")),
979                    text: Arc::from(&self.input[start..pos]),
980                    start,
981                    end: pos,
982                });
983            }
984        }
985
986        // Consume initial digits - unrolled for better performance
987        pos = self.position;
988        while pos < bytes.len() {
989            let byte = Self::byte_at(bytes, pos);
990            if byte.is_ascii_digit() || byte == b'_' {
991                pos += 1;
992            } else {
993                break;
994            }
995        }
996        self.position = pos;
997
998        // Check for decimal point - optimized with single bounds check
999        if pos < bytes.len() && Self::byte_at(bytes, pos) == b'.' {
1000            // Peek ahead to see what follows the dot
1001            let has_following_digit = pos + 1 < bytes.len() && bytes[pos + 1].is_ascii_digit();
1002
1003            // Optimized dot consumption logic
1004            let should_consume_dot = has_following_digit || {
1005                pos + 1 >= bytes.len() || {
1006                    // Use bitwise operations for faster character classification
1007                    let next_byte = bytes[pos + 1];
1008                    // Whitespace, delimiters, operators - optimized check
1009                    next_byte <= b' '
1010                        || matches!(
1011                            next_byte,
1012                            b';' | b','
1013                                | b')'
1014                                | b'}'
1015                                | b']'
1016                                | b'+'
1017                                | b'-'
1018                                | b'*'
1019                                | b'/'
1020                                | b'%'
1021                                | b'='
1022                                | b'<'
1023                                | b'>'
1024                                | b'!'
1025                                | b'&'
1026                                | b'|'
1027                                | b'^'
1028                                | b'~'
1029                                | b'e'
1030                                | b'E'
1031                        )
1032                }
1033            };
1034
1035            if should_consume_dot {
1036                pos += 1; // consume the dot
1037                // Consume fractional digits - batch processing
1038                while pos < bytes.len() && (bytes[pos].is_ascii_digit() || bytes[pos] == b'_') {
1039                    pos += 1;
1040                }
1041                self.position = pos;
1042            }
1043        }
1044
1045        // Check for exponent - optimized
1046        if pos < bytes.len() && (bytes[pos] == b'e' || bytes[pos] == b'E') {
1047            let exp_start = pos;
1048            pos += 1; // consume 'e' or 'E'
1049
1050            // Check for optional sign
1051            if pos < bytes.len() && (bytes[pos] == b'+' || bytes[pos] == b'-') {
1052                pos += 1;
1053            }
1054
1055            // Must have at least one digit after exponent (underscores allowed between digits)
1056            let mut saw_digit = false;
1057            while pos < bytes.len() {
1058                let byte = bytes[pos];
1059                if byte.is_ascii_digit() {
1060                    saw_digit = true;
1061                    pos += 1;
1062                } else if byte == b'_' {
1063                    pos += 1;
1064                } else {
1065                    break;
1066                }
1067            }
1068
1069            // If no digits after exponent, backtrack
1070            if !saw_digit {
1071                pos = exp_start;
1072            }
1073
1074            self.position = pos;
1075        }
1076
1077        // Avoid string slicing for common number cases - use Arc::from directly on slice
1078        let text = &self.input[start..self.position];
1079        self.mode = LexerMode::ExpectOperator;
1080
1081        Some(Token {
1082            token_type: TokenType::Number(Arc::from(text)),
1083            text: Arc::from(text),
1084            start,
1085            end: self.position,
1086        })
1087    }
1088
1089    fn parse_decimal_number(&mut self, start: usize) -> Option<Token> {
1090        // We're at the dot, consume it
1091        self.advance();
1092
1093        // Parse the fractional part
1094        while self.position < self.input_bytes.len() {
1095            let byte = self.input_bytes[self.position];
1096            match byte {
1097                b'0'..=b'9' | b'_' => self.position += 1,
1098                b'e' | b'E' => {
1099                    // Handle scientific notation.
1100                    // Save the position of 'e'/'E' so we can backtrack here if
1101                    // no digits follow the exponent marker (with or without sign).
1102                    let e_pos = self.position;
1103                    self.advance();
1104                    if self.position < self.input_bytes.len() {
1105                        let next = self.input_bytes[self.position];
1106                        if next == b'+' || next == b'-' {
1107                            self.advance();
1108                        }
1109                    }
1110                    // Parse exponent digits (underscores allowed between digits)
1111                    let mut saw_digit = false;
1112                    while self.position < self.input_bytes.len() {
1113                        let byte = self.input_bytes[self.position];
1114                        if byte.is_ascii_digit() {
1115                            saw_digit = true;
1116                            self.position += 1;
1117                        } else if byte == b'_' {
1118                            self.position += 1;
1119                        } else {
1120                            break;
1121                        }
1122                    }
1123
1124                    // No digits after exponent marker — backtrack to just before
1125                    // 'e'/'E' so the caller sees it as a separate token.
1126                    // Using e_pos (not exponent_start-1) avoids including 'e' in
1127                    // the number slice when a sign character was consumed.
1128                    if !saw_digit {
1129                        self.position = e_pos;
1130                    }
1131                    break;
1132                }
1133                _ => break,
1134            }
1135        }
1136
1137        let text = &self.input[start..self.position];
1138        self.mode = LexerMode::ExpectOperator;
1139
1140        Some(Token {
1141            token_type: TokenType::Number(Arc::from(text)),
1142            text: Arc::from(text),
1143            start,
1144            end: self.position,
1145        })
1146    }
1147
1148    fn try_variable(&mut self) -> Option<Token> {
1149        let start = self.position;
1150        let sigil = self.current_char()?;
1151
1152        match sigil {
1153            '$' | '@' | '%' | '*' => {
1154                // In ExpectOperator mode, treat % and * as operators rather than sigils
1155                if self.mode == LexerMode::ExpectOperator && matches!(sigil, '*' | '%') {
1156                    return None;
1157                }
1158                self.advance();
1159
1160                // Special case: After ->, sigils followed by { or [ should be tokenized separately
1161                // This is for postfix dereference like ->@*, ->%{}, ->@[]
1162                // We need to be careful with Unicode - check if we have enough bytes and valid char boundaries
1163                let check_arrow = self.position >= 3
1164                    && self.position.saturating_sub(1) <= self.input.len()
1165                    && self.input.is_char_boundary(self.position.saturating_sub(3))
1166                    && self.input.is_char_boundary(self.position.saturating_sub(1));
1167
1168                if check_arrow
1169                    && {
1170                        let saved = self.position;
1171                        self.position -= 3;
1172                        let arrow = self.matches_bytes(b"->");
1173                        self.position = saved;
1174                        arrow
1175                    }
1176                    && matches!(self.current_char(), Some('{' | '[' | '*'))
1177                {
1178                    // Just return the sigil
1179                    let text = &self.input[start..self.position];
1180                    self.mode = LexerMode::ExpectOperator;
1181
1182                    return Some(Token {
1183                        token_type: TokenType::Identifier(Arc::from(text)),
1184                        text: Arc::from(text),
1185                        start,
1186                        end: self.position,
1187                    });
1188                }
1189
1190                // Check for $# (array length operator)
1191                if sigil == '$' && self.current_char() == Some('#') {
1192                    self.advance(); // consume #
1193                    // Now parse the array name
1194                    while let Some(ch) = self.current_char() {
1195                        if is_perl_identifier_continue(ch) {
1196                            self.advance();
1197                        } else if ch == ':' && self.peek_char(1) == Some(':') {
1198                            // Package-qualified array name
1199                            self.advance();
1200                            self.advance();
1201                        } else {
1202                            break;
1203                        }
1204                    }
1205
1206                    let text = &self.input[start..self.position];
1207                    self.mode = LexerMode::ExpectOperator;
1208                    // $#foo is a complete variable token; a following `{` is a subscript.
1209                    self.after_var_subscript = true;
1210
1211                    return Some(Token {
1212                        token_type: TokenType::Identifier(Arc::from(text)),
1213                        text: Arc::from(text),
1214                        start,
1215                        end: self.position,
1216                    });
1217                }
1218
1219                // Check for special cases like ${^MATCH} or ${::{foo}} or *{$glob}
1220                if self.current_char() == Some('{') {
1221                    // Peek ahead to decide if we should consume the brace
1222                    let next_char = self.peek_char(1);
1223
1224                    // Check if this is a dereference like @{$ref} or @{[...]}
1225                    // If the next char suggests dereference, don't consume the brace.
1226                    // For @ and % sigils, identifiers inside braces are also derefs
1227                    // (e.g. @{Foo::Bar::baz} or %{Some::Hash}).
1228                    let is_deref = sigil != '*'
1229                        && (matches!(
1230                            next_char,
1231                            Some('$' | '@' | '%' | '*' | '&' | '[' | ' ' | '\t' | '\n' | '\r',)
1232                        ) || (matches!(sigil, '@' | '%')
1233                            && next_char.is_some_and(is_perl_identifier_start)));
1234                    if is_deref {
1235                        // This is a dereference, don't consume the brace
1236                        let text = &self.input[start..self.position];
1237                        self.mode = LexerMode::ExpectOperator;
1238                        // A standalone sigil token before `{` starts a dereference
1239                        // sequence (e.g. `${$ref}` / `@{$aref}` / `%{$href}` / `&{$cref}`).
1240                        // Mark it as subscript-capable so `{` increments brace depth
1241                        // and the closing `}` can enable chained `{...}` subscripts.
1242                        // (Broader form than master's `$|@|%` filter — `*` is already
1243                        // excluded by the `is_deref` guard above and `&` deref also
1244                        // benefits from chained-subscript handling.)
1245                        self.after_var_subscript = true;
1246
1247                        return Some(Token {
1248                            token_type: TokenType::Identifier(Arc::from(text)),
1249                            text: Arc::from(text),
1250                            start,
1251                            end: self.position,
1252                        });
1253                    }
1254
1255                    self.advance(); // consume {
1256
1257                    // Handle special variables with caret
1258                    if self.current_char() == Some('^') {
1259                        self.advance(); // consume ^
1260                        // Parse the special variable name
1261                        while let Some(ch) = self.current_char() {
1262                            if ch == '}' {
1263                                self.advance(); // consume }
1264                                break;
1265                            } else if is_perl_identifier_continue(ch) {
1266                                self.advance();
1267                            } else {
1268                                break;
1269                            }
1270                        }
1271                    }
1272                    // Handle stash access like $::{foo}
1273                    else if self.current_char() == Some(':') && self.peek_char(1) == Some(':') {
1274                        self.advance(); // consume first :
1275                        self.advance(); // consume second :
1276                        // Skip optional { and }
1277                        if self.current_char() == Some('{') {
1278                            self.advance();
1279                        }
1280                        // Parse the name
1281                        while let Some(ch) = self.current_char() {
1282                            if ch == '}' {
1283                                self.advance();
1284                                if self.current_char() == Some('}') {
1285                                    self.advance(); // consume closing } of ${...}
1286                                }
1287                                break;
1288                            } else if is_perl_identifier_continue(ch) {
1289                                self.advance();
1290                            } else {
1291                                break;
1292                            }
1293                        }
1294                    }
1295                    // Regular braced variable like ${foo} or glob like *{$glob}
1296                    else {
1297                        // Check if this is a dereference like ${$ref} or @{$ref} or @{[...]}
1298                        // If the next char is a sigil or other expression starter, we should stop here and let the parser handle it
1299                        // EXCEPT for globs - *{$glob} should be parsed as one token
1300                        // Also check for empty braces or EOF - in these cases we should split the tokens
1301                        if sigil != '*'
1302                            && !self.current_char().is_some_and(is_perl_identifier_start)
1303                        {
1304                            // This is a dereference or empty/invalid brace, backtrack
1305                            self.position = start + 1; // Just past the sigil
1306                            let text = &self.input[start..self.position];
1307                            self.mode = LexerMode::ExpectOperator;
1308                            // Same as above: sigil-only token means a dereference opener.
1309                            self.after_var_subscript = true;
1310
1311                            return Some(Token {
1312                                token_type: TokenType::Identifier(Arc::from(text)),
1313                                text: Arc::from(text),
1314                                start,
1315                                end: self.position,
1316                            });
1317                        }
1318
1319                        // For glob access, we need to consume everything inside braces
1320                        if sigil == '*' {
1321                            let mut brace_depth: usize = 1;
1322                            while let Some(ch) = self.current_char() {
1323                                if ch == '{' {
1324                                    brace_depth += 1;
1325                                } else if ch == '}' {
1326                                    brace_depth = brace_depth.saturating_sub(1);
1327                                    if brace_depth == 0 {
1328                                        self.advance(); // consume final }
1329                                        break;
1330                                    }
1331                                }
1332                                self.advance();
1333                            }
1334                        } else {
1335                            // Regular variable
1336                            while let Some(ch) = self.current_char() {
1337                                if ch == '}' {
1338                                    self.advance(); // consume }
1339                                    break;
1340                                } else if is_perl_identifier_continue(ch) {
1341                                    self.advance();
1342                                } else {
1343                                    break;
1344                                }
1345                            }
1346                        }
1347                    }
1348                }
1349                // Parse regular variable name
1350                else if let Some(ch) = self.current_char() {
1351                    if is_perl_identifier_start(ch) {
1352                        while let Some(ch) = self.current_char() {
1353                            if is_perl_identifier_continue(ch) {
1354                                self.advance();
1355                            } else {
1356                                break;
1357                            }
1358                        }
1359                        // Handle package-qualified segments like Foo::bar
1360                        while self.current_char() == Some(':') && self.peek_char(1) == Some(':') {
1361                            self.advance();
1362                            self.advance();
1363                            while let Some(ch) = self.current_char() {
1364                                if is_perl_identifier_continue(ch) {
1365                                    self.advance();
1366                                } else {
1367                                    break;
1368                                }
1369                            }
1370                        }
1371                    }
1372                    // Handle $^Letter (e.g. $^W, $^O, $^X) and bare $^ (format_top_name)
1373                    // Not inside prototypes where ^ is a literal prototype char
1374                    else if sigil == '$' && ch == '^' && !self.in_prototype {
1375                        self.advance(); // consume ^
1376                        // $^Letter: consume the single uppercase letter
1377                        if let Some(letter) = self.current_char()
1378                            && letter.is_ascii_uppercase()
1379                        {
1380                            self.advance();
1381                        }
1382                        // bare $^ (no uppercase letter follows): format_top_name — stop here
1383                    }
1384                    // Handle special punctuation variables
1385                    // Not inside prototypes where ; and , are literal prototype chars
1386                    else if sigil == '$'
1387                        && !self.in_prototype
1388                        && matches!(
1389                            ch,
1390                            '?' | '!'
1391                                | '@'
1392                                | '&'
1393                                | '`'
1394                                | '\''
1395                                | '.'
1396                                | '/'
1397                                | '\\'
1398                                | '|'
1399                                | '+'
1400                                | '-'
1401                                | '['
1402                                | ']'
1403                                | '$'
1404                                | '~'
1405                                | '='
1406                                | '%'
1407                                | ','
1408                                | '"'
1409                                | ';'
1410                                | '>'
1411                                | '<'
1412                                | ')'
1413                                | '(' // $( = real group ID of this process
1414                        )
1415                    {
1416                        self.advance(); // consume the special character
1417                    }
1418                    // $$ is the PID special variable, but only when it is not immediately
1419                    // followed by an identifier-start character. $$var is scalar dereference
1420                    // of $var, so keep the second $ for the next token.
1421                    else if sigil == '$' && ch == '$' {
1422                        if !self.peek_char(1).is_some_and(is_perl_identifier_start) {
1423                            self.advance(); // consume the second $ for bare $$ PID
1424                        }
1425                    }
1426                    // Handle special array/hash punctuation variables
1427                    else if (sigil == '@' || sigil == '%') && matches!(ch, '+' | '-') {
1428                        self.advance(); // consume the + or -
1429                    }
1430                }
1431
1432                let text = &self.input[start..self.position];
1433                self.mode = LexerMode::ExpectOperator;
1434                // A complete $foo, @foo, %foo token can be followed by a hash/slice
1435                // subscript `{`. Set the flag so the `{` handler knows to increment
1436                // hash_brace_depth. Glob tokens (*foo) are excluded: they don't take
1437                // hash subscripts in the same way.
1438                self.after_var_subscript = matches!(sigil, '$' | '@' | '%');
1439
1440                Some(Token {
1441                    token_type: TokenType::Identifier(Arc::from(text)),
1442                    text: Arc::from(text),
1443                    start,
1444                    end: self.position,
1445                })
1446            }
1447            _ => None,
1448        }
1449    }
1450
1451    /// Return the next non-space char and the char immediately following it (without consuming).
1452    /// Used to detect quote-operator delimiters while distinguishing `=>` (fat-arrow autoquote)
1453    /// from `=` used as a plain delimiter.
1454    fn peek_nonspace_and_following(&self) -> (Option<char>, Option<char>) {
1455        let mut i = self.position;
1456        while i < self.input.len() {
1457            let c = match self.input.get(i..).and_then(|s| s.chars().next()) {
1458                Some(c) => c,
1459                None => return (None, None),
1460            };
1461            if c.is_whitespace() {
1462                i += c.len_utf8();
1463                continue;
1464            }
1465            // Found non-space at position i; peek the next char after it
1466            let j = i + c.len_utf8();
1467            let following = self.input.get(j..).and_then(|s| s.chars().next());
1468            return (Some(c), following);
1469        }
1470        (None, None)
1471    }
1472
1473    /// Is `c` a valid quote-like delimiter? (non-alnum, including paired)
1474    fn is_quote_delim(c: char) -> bool {
1475        // Perl allows any non-alphanumeric, non-whitespace character as delimiter,
1476        // including control characters (e.g. s\x07pattern\x07replacement\x07).
1477        !c.is_ascii_alphanumeric() && !c.is_whitespace()
1478    }
1479
1480    #[inline]
1481    fn immediately_follows_sigil_prefix(&self, start: usize) -> bool {
1482        start > 0
1483            && matches!(
1484                Self::byte_at(self.input_bytes, start.saturating_sub(1)),
1485                b'$' | b'@' | b'%' | b'&' | b'*'
1486            )
1487    }
1488
1489    /// Try to parse a v-string (version string) like `v5.26.0` or `v5.10`.
1490    ///
1491    /// A v-string starts with `v` followed by one or more digits, then optionally
1492    /// `.` followed by digits, repeated. The `v` prefix distinguishes these from
1493    /// normal identifiers. Examples: `v5.26.0`, `v5.10`, `v1.2.3.4`.
1494    #[inline]
1495    fn try_vstring(&mut self) -> Option<Token> {
1496        let start = self.position;
1497        let bytes = self.input_bytes;
1498
1499        // Must start with 'v' followed by at least one digit
1500        if start >= bytes.len() || bytes[start] != b'v' {
1501            return None;
1502        }
1503
1504        let next_pos = start + 1;
1505        if next_pos >= bytes.len() || !bytes[next_pos].is_ascii_digit() {
1506            return None;
1507        }
1508
1509        // We have `v` followed by a digit — scan the rest of the v-string.
1510        // Pattern: v DIGITS (.DIGITS)*
1511        let mut pos = next_pos;
1512
1513        // Consume leading digits
1514        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1515            pos += 1;
1516        }
1517
1518        // Consume optional `.DIGITS` segments (require at least one digit after dot)
1519        while pos < bytes.len() && bytes[pos] == b'.' {
1520            let dot_pos = pos;
1521            pos += 1; // skip '.'
1522
1523            if pos >= bytes.len() || !bytes[pos].is_ascii_digit() {
1524                // Dot not followed by digit — not part of the v-string
1525                pos = dot_pos;
1526                break;
1527            }
1528
1529            // Consume digits after the dot
1530            while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1531                pos += 1;
1532            }
1533        }
1534
1535        // Make sure the v-string isn't followed by identifier-continuation characters
1536        // (e.g. `v5x` should remain an identifier, not a v-string `v5` + `x`)
1537        if pos < bytes.len() {
1538            let next_byte = bytes[pos];
1539            if next_byte == b'_' || next_byte.is_ascii_alphabetic() {
1540                return None;
1541            }
1542            // Also check for non-ASCII identifier continuations
1543            if next_byte >= 128
1544                && let Some(ch) = self.input.get(pos..).and_then(|s| s.chars().next())
1545                && is_perl_identifier_continue(ch)
1546            {
1547                return None;
1548            }
1549        }
1550
1551        // `v5` (no dots) is a valid Perl v-string meaning chr(5).
1552        let text = &self.input[start..pos];
1553
1554        self.position = pos;
1555        self.mode = LexerMode::ExpectOperator;
1556
1557        Some(Token {
1558            token_type: TokenType::Version(Arc::from(text)),
1559            text: Arc::from(text),
1560            start,
1561            end: self.position,
1562        })
1563    }
1564
1565    #[inline]
1566    fn apostrophe_starts_legacy_package_segment(&self, position: usize) -> bool {
1567        let next_position = position + '\''.len_utf8();
1568        self.input
1569            .get(next_position..)
1570            .and_then(|suffix| suffix.chars().next())
1571            .is_some_and(is_perl_identifier_start)
1572    }
1573
1574    #[inline]
1575    fn try_identifier_or_keyword(&mut self) -> Option<Token> {
1576        let start = self.position;
1577        let ch = self.current_char()?;
1578        let bytes = self.input_bytes;
1579        let len = bytes.len();
1580
1581        if is_perl_identifier_start(ch) {
1582            // Special case: substitution/transliteration with single-quote delimiter
1583            // The single quote is considered an identifier continuation, so we need to
1584            // detect these operators before consuming it as part of an identifier.
1585            let follows_sigil_prefix = self.immediately_follows_sigil_prefix(start);
1586            if !follows_sigil_prefix
1587                && !self.after_arrow
1588                && self.hash_brace_depth == 0
1589                && ch == 's'
1590                && self.peek_char(1) == Some('\'')
1591            {
1592                self.advance(); // consume 's'
1593                return self.parse_substitution(start);
1594            } else if !follows_sigil_prefix
1595                && !self.after_arrow
1596                && self.hash_brace_depth == 0
1597                && ch == 'y'
1598                && self.peek_char(1) == Some('\'')
1599            {
1600                self.advance(); // consume 'y'
1601                return self.parse_transliteration(start);
1602            } else if !follows_sigil_prefix
1603                && !self.after_arrow
1604                && self.hash_brace_depth == 0
1605                && ch == 't'
1606                && self.peek_char(1) == Some('r')
1607                && self.peek_char(2) == Some('\'')
1608            {
1609                self.advance(); // consume 't'
1610                self.advance(); // consume 'r'
1611                return self.parse_transliteration(start);
1612            }
1613
1614            // Fast ASCII path for identifier continuation.
1615            while self.position < len {
1616                let byte = bytes[self.position];
1617                if byte == b'\'' {
1618                    if is_quote_op_word_prefix(&bytes[start..self.position])
1619                        || !self.apostrophe_starts_legacy_package_segment(self.position)
1620                    {
1621                        // Keep apostrophe for quote/string parsing in cases like q'...'
1622                        // and split' ', while still accepting Foo'Bar package spelling.
1623                        break;
1624                    }
1625                    self.position += 1;
1626                    continue;
1627                }
1628
1629                if byte.is_ascii_alphanumeric() || byte == b'_' {
1630                    self.position += 1;
1631                    continue;
1632                }
1633
1634                if byte < 128 {
1635                    break;
1636                }
1637
1638                if let Some(ch) = self.current_char()
1639                    && is_perl_identifier_continue(ch)
1640                {
1641                    self.advance();
1642                    continue;
1643                }
1644                break;
1645            }
1646            // Handle package-qualified identifiers like Foo::bar.
1647            while self.config.max_lookahead >= 1
1648                && self.position + 1 < len
1649                && bytes[self.position] == b':'
1650                && bytes[self.position + 1] == b':'
1651            {
1652                self.position += 2; // consume '::'
1653
1654                // consume following identifier segment if present
1655                let Some(ch) = self.current_char() else {
1656                    break;
1657                };
1658                if !is_perl_identifier_start(ch) {
1659                    break;
1660                }
1661                self.advance();
1662                while self.position < len {
1663                    let byte = bytes[self.position];
1664                    if byte == b'\'' {
1665                        if !self.apostrophe_starts_legacy_package_segment(self.position) {
1666                            break;
1667                        }
1668                        self.position += 1;
1669                        continue;
1670                    }
1671
1672                    if byte.is_ascii_alphanumeric() || byte == b'_' {
1673                        self.position += 1;
1674                        continue;
1675                    }
1676                    if byte < 128 {
1677                        break;
1678                    }
1679                    if let Some(ch) = self.current_char()
1680                        && is_perl_identifier_continue(ch)
1681                    {
1682                        self.advance();
1683                        continue;
1684                    }
1685                    break;
1686                }
1687            }
1688
1689            let text = &self.input[start..self.position];
1690
1691            // Check for __DATA__ and __END__ markers using exact match
1692            // Only recognize these in code channel, not inside data/format sections or heredocs
1693            let in_code_channel =
1694                !matches!(self.mode, LexerMode::InDataSection | LexerMode::InFormatBody)
1695                    && self.pending_heredocs.is_empty();
1696
1697            let marker = if in_code_channel {
1698                if text == "__DATA__" {
1699                    Some("__DATA__")
1700                } else if text == "__END__" {
1701                    Some("__END__")
1702                } else {
1703                    None
1704                }
1705            } else {
1706                None
1707            };
1708
1709            if let Some(marker_text) = marker {
1710                // These must be at the beginning of a line
1711                // Use the after_newline flag to determine if we're at line start
1712                if self.after_newline {
1713                    // Check if rest of line is only whitespace
1714                    // Only treat as data marker if line has no trailing junk
1715                    if Self::trailing_ws_only(self.input_bytes, self.position) {
1716                        // Consume the rest of the line (the marker line)
1717                        while self.position < self.input.len()
1718                            && self.input_bytes[self.position] != b'\n'
1719                            && self.input_bytes[self.position] != b'\r'
1720                        {
1721                            self.advance();
1722                        }
1723                        self.consume_newline();
1724
1725                        // Switch to data section mode
1726                        self.mode = LexerMode::InDataSection;
1727
1728                        return Some(Token {
1729                            token_type: TokenType::DataMarker(Arc::from(marker_text)),
1730                            text: Arc::from(marker_text),
1731                            start,
1732                            end: self.position,
1733                        });
1734                    }
1735                }
1736            }
1737
1738            // Check for substitution/transliteration operators
1739            // Skip if after '->'  -- these are method names, not operators.
1740            #[allow(clippy::collapsible_if)]
1741            if !self.after_sub
1742                && !self.after_arrow
1743                && !follows_sigil_prefix
1744                && self.hash_brace_depth == 0
1745                && matches!(text, "s" | "tr" | "y")
1746            {
1747                let immediate = self.current_char();
1748                let (candidate, char_after_next, has_whitespace) =
1749                    if immediate.is_some_and(|c| c.is_whitespace()) {
1750                        let (nc, ca) = self.peek_nonspace_and_following();
1751                        (nc, ca, true)
1752                    } else {
1753                        let following = immediate.and_then(|c| {
1754                            let j = self.position + c.len_utf8();
1755                            self.input.get(j..).and_then(|s| s.chars().next())
1756                        });
1757                        (immediate, following, false)
1758                    };
1759
1760                if let Some(next) = candidate {
1761                    // `s => 1` should remain a fat-arrow hash key, not quote op.
1762                    let is_fat_arrow = next == '=' && char_after_next == Some('>');
1763                    let is_filetest_s = text == "s"
1764                        && self.input.get(..start).is_some_and(|prefix| prefix.ends_with('-'));
1765                    let is_paired_delim = matches!(next, '{' | '[' | '(' | '<');
1766                    let is_quote_char = matches!(next, '\'' | '"') && text != "s";
1767                    let transliteration_allows_whitespace = text == "tr" || text == "y";
1768                    let substitution_disallows_whitespace = text == "s" && has_whitespace;
1769                    let is_valid_delim = Self::is_quote_delim(next)
1770                        && !is_fat_arrow
1771                        && !is_filetest_s
1772                        && !substitution_disallows_whitespace
1773                        && (!has_whitespace
1774                            || is_paired_delim
1775                            || is_quote_char
1776                            || transliteration_allows_whitespace);
1777
1778                    if is_valid_delim {
1779                        match text {
1780                            "s" => return self.parse_substitution(start),
1781                            "tr" | "y" => return self.parse_transliteration(start),
1782                            unexpected => {
1783                                return Some(Token {
1784                                    token_type: TokenType::Error(Arc::from(format!(
1785                                        "Unexpected substitution operator '{}': expected 's', 'tr', or 'y' at position {}",
1786                                        unexpected, start
1787                                    ))),
1788                                    text: Arc::from(unexpected),
1789                                    start,
1790                                    end: self.position,
1791                                });
1792                            }
1793                        }
1794                    }
1795                }
1796            }
1797
1798            let token_type = if is_keyword_fast(text) {
1799                // Check for special keywords that affect lexer mode
1800                match text {
1801                    "if" | "unless" | "while" | "until" | "for" | "foreach" | "grep" | "map"
1802                    | "sort" | "split" | "and" | "or" | "xor" | "not"
1803                    // These keywords introduce an expression, so a following `/` is a
1804                    // regex, not division.  `return /re/`, `die /re/`, `warn /re/`,
1805                    // `do /file/`, and `eval /re/` are all valid Perl.
1806                    | "return" | "die" | "warn" | "do" | "eval"
1807                    // `given`/`when` (feature 'switch') also introduce an expression;
1808                    // `when /re/ { ... }` and `given /re/ { ... }` must lex `/` as
1809                    // regex, not division. (#818)
1810                    | "given" | "when" => {
1811                        self.mode = LexerMode::ExpectTerm;
1812                    }
1813                    "sub" | "method" => {
1814                        self.after_sub = true;
1815                        self.mode = LexerMode::ExpectTerm;
1816                    }
1817                    // Quote operators expect a delimiter next.
1818                    // Skip if after '->' -- these are method names, not operators.
1819                    // Inside hash subscript braces, regex-like operators stay bareword
1820                    // keys (`@h{m, s}`), but q-family operators can still introduce real
1821                    // quote expressions in slices (`@h{qw/a b/}`).
1822                    op if !self.after_sub
1823                        && !self.after_arrow
1824                        && !follows_sigil_prefix
1825                        && quote_handler::is_quote_operator(op)
1826                        && (self.hash_brace_depth == 0
1827                            || matches!(op, "q" | "qq" | "qw" | "qr" | "qx")) =>
1828                    {
1829                        // Perl allows whitespace between a quote-like operator and its delimiter,
1830                        // but ONLY for paired delimiters (s { ... } { ... }g).
1831                        // For non-paired delimiters (s/foo/bar/, s,foo,bar,), the delimiter
1832                        // must be immediately adjacent — otherwise `s $foo` would wrongly
1833                        // treat `$` as a delimiter instead of being a bareword `s` followed
1834                        // by a scalar variable.
1835                        //
1836                        // Strategy:
1837                        //   1. Check the immediately-adjacent char first (no whitespace skip).
1838                        //      If it is a valid delimiter → any non-alnum, non-whitespace char.
1839                        //   2. If the adjacent char is whitespace, peek past it.
1840                        //      Only accept PAIRED delimiters ({, [, (, <) in that case.
1841                        let immediate = self.current_char();
1842                        let (candidate, char_after_next, has_whitespace) =
1843                            if immediate.is_some_and(|c| c.is_whitespace()) {
1844                                // There is whitespace — peek past it
1845                                let (nc, ca) = self.peek_nonspace_and_following();
1846                                (nc, ca, true)
1847                            } else {
1848                                // No whitespace — use immediate char
1849                                let following = immediate.and_then(|c| {
1850                                    let j = self.position + c.len_utf8();
1851                                    self.input.get(j..).and_then(|s| s.chars().next())
1852                                });
1853                                (immediate, following, false)
1854                            };
1855
1856                        if let Some(next) = candidate {
1857                            // Fat-arrow autoquoting: `s => value` — `=` followed by `>` is '=>',
1858                            // not a valid substitution delimiter. Treat as identifier.
1859                            let is_fat_arrow = next == '=' && char_after_next == Some('>');
1860                            let is_filetest_s =
1861                                op == "s" && self.input.get(..start).is_some_and(|prefix| {
1862                                    prefix.ends_with('-')
1863                                });
1864
1865                            // When whitespace precedes the delimiter, only unambiguous
1866                            // delimiters are accepted:
1867                            //   - Paired delimiters ({, [, (, <) are always safe.
1868                            //   - ' and " are safe for all operators EXCEPT `s` — `-s 'filename'`
1869                            //     is a valid file-size filetest and must not be treated as a
1870                            //     substitution start. All other operators (qw, q, qq, qr, qx, m,
1871                            //     tr, y) have no corresponding file-test operator.
1872                            //   - / is safe for non-substitution quote operators; `qw /a b/` and
1873                            //     `m /re/` are common, while `s /foo/bar/` remains ambiguous with
1874                            //     the file-size test shape and stays rejected here.
1875                            //   - Non-paired, non-quote chars ($, @, ,, etc.) remain rejected.
1876                            let is_paired_delim = matches!(next, '{' | '[' | '(' | '<');
1877                            let is_quote_char = matches!(next, '\'' | '"') && op != "s";
1878                            let is_spaced_slash_delim = next == '/' && op != "s";
1879                            let is_hash_subscript_bare_key_boundary =
1880                                self.hash_brace_depth > 0 && matches!(next, ',' | '}');
1881                            let is_valid_delim = Self::is_quote_delim(next)
1882                                && !is_fat_arrow
1883                                && !is_filetest_s
1884                                && !is_hash_subscript_bare_key_boundary
1885                                && (!has_whitespace
1886                                    || is_paired_delim
1887                                    || is_quote_char
1888                                    || is_spaced_slash_delim);
1889
1890                            if is_valid_delim {
1891                                self.mode = LexerMode::ExpectDelimiter;
1892                                self.current_quote_op = Some(quote_handler::QuoteOperatorInfo {
1893                                    operator: op.to_string(),
1894                                    delimiter: '\0', // Will be set when we see the delimiter
1895                                    start_pos: start,
1896                                });
1897
1898                                // Don't return a keyword token - continue to parse the delimiter
1899                                // Skip any whitespace between operator and delimiter
1900                                while let Some(ch) = self.current_char() {
1901                                    if ch.is_whitespace() {
1902                                        self.advance();
1903                                    } else {
1904                                        break;
1905                                    }
1906                                }
1907
1908                                // Get the delimiter
1909                                #[allow(clippy::collapsible_if)]
1910                                if let Some(delim) = self.current_char() {
1911                                    if !delim.is_alphanumeric() {
1912                                        self.advance();
1913                                        if let Some(ref mut info) = self.current_quote_op {
1914                                            info.delimiter = delim;
1915                                        }
1916                                        // Parse the quote operator content and return the complete token
1917                                        return self.parse_quote_operator(delim);
1918                                    }
1919                                }
1920                            } else {
1921                                // Not a quote operator here → treat as IDENTIFIER
1922                                self.current_quote_op = None;
1923                                self.mode = LexerMode::ExpectOperator;
1924                                return Some(Token {
1925                                    token_type: TokenType::Identifier(Arc::from(text)),
1926                                    start,
1927                                    end: self.position,
1928                                    text: Arc::from(text),
1929                                });
1930                            }
1931                        } else {
1932                            // End-of-input after the word → also treat as IDENTIFIER
1933                            self.current_quote_op = None;
1934                            self.mode = LexerMode::ExpectOperator;
1935                            return Some(Token {
1936                                token_type: TokenType::Identifier(Arc::from(text)),
1937                                start,
1938                                end: self.position,
1939                                text: Arc::from(text),
1940                            });
1941                        }
1942                        // If we get here but haven't returned, something went wrong
1943                        // Fall through to treat as identifier
1944                        self.current_quote_op = None;
1945                        self.mode = LexerMode::ExpectOperator;
1946                        return Some(Token {
1947                            token_type: TokenType::Identifier(Arc::from(text)),
1948                            start,
1949                            end: self.position,
1950                            text: Arc::from(text),
1951                        });
1952                    }
1953                    // Format declarations need special handling
1954                    "format" => {
1955                        // We'll need to check for the = after the format name
1956                        // For now, just mark that we saw format
1957                    }
1958                    _ if is_builtin_function(text) => {
1959                        // Bare builtins are term-introducing in Perl.
1960                        self.mode = LexerMode::ExpectTerm;
1961                    }
1962                    _ => {
1963                        self.mode = LexerMode::ExpectOperator;
1964                    }
1965                }
1966                TokenType::Keyword(Arc::from(text))
1967            } else {
1968                // Mirror parser bare-builtin handling so `/` after builtins like
1969                // `join` or `print` is lexed as a regex term, not division.
1970                // Also treat known user-declared subs as term-introducing (issue #1353).
1971                if is_builtin_function(text)
1972                    || self.config.symbol_table.as_ref().is_some_and(|st| st.is_known_sub(text))
1973                {
1974                    self.mode = LexerMode::ExpectTerm;
1975                } else {
1976                    self.mode = LexerMode::ExpectOperator;
1977                }
1978                TokenType::Identifier(Arc::from(text))
1979            };
1980
1981            self.after_arrow = false;
1982            // A keyword/identifier is not a variable; `{` after it is a block opener.
1983            self.after_var_subscript = false;
1984            // hash_brace_depth is managed by { and } handlers, not cleared per-token
1985            Some(Token { token_type, text: Arc::from(text), start, end: self.position })
1986        } else {
1987            None
1988        }
1989    }
1990
1991    /// Parse data section body - consumes everything to EOF
1992    fn parse_data_body(&mut self) -> Option<Token> {
1993        if self.position >= self.input.len() {
1994            // Already at EOF
1995            self.mode = LexerMode::ExpectTerm;
1996            return Some(Token {
1997                token_type: TokenType::EOF,
1998                text: Arc::from(""),
1999                start: self.position,
2000                end: self.position,
2001            });
2002        }
2003
2004        let start = self.position;
2005        // Consume everything to EOF
2006        let body = &self.input[self.position..];
2007        self.position = self.input.len();
2008
2009        // Reset mode for next parse (though we're at EOF)
2010        self.mode = LexerMode::ExpectTerm;
2011
2012        Some(Token {
2013            token_type: TokenType::DataBody(Arc::from(body)),
2014            text: Arc::from(body),
2015            start,
2016            end: self.position,
2017        })
2018    }
2019
2020    /// Parse format body - consumes until a line with just a dot
2021    fn parse_format_body(&mut self) -> Option<Token> {
2022        let start = self.position;
2023        let mut body = String::new();
2024        let mut line_start = true;
2025
2026        while self.position < self.input.len() {
2027            // Check if we're at the start of a line and the next char is a dot
2028            if line_start && self.current_char() == Some('.') {
2029                // Check if this line contains only a dot
2030                let mut peek_pos = self.position + 1;
2031                let mut found_terminator = true;
2032
2033                // Skip any trailing whitespace on the dot line
2034                while peek_pos < self.input.len() {
2035                    match self.input_bytes[peek_pos] {
2036                        b' ' | b'\t' | b'\r' => peek_pos += 1,
2037                        b'\n' => break,
2038                        _ => {
2039                            found_terminator = false;
2040                            break;
2041                        }
2042                    }
2043                }
2044
2045                if found_terminator {
2046                    // We found the terminating dot, consume it
2047                    self.position = peek_pos;
2048                    if self.position < self.input.len() && self.input_bytes[self.position] == b'\n'
2049                    {
2050                        self.position += 1;
2051                    }
2052
2053                    // Switch back to normal mode
2054                    self.mode = LexerMode::ExpectTerm;
2055
2056                    return Some(Token {
2057                        token_type: TokenType::FormatBody(Arc::from(body.clone())),
2058                        text: Arc::from(body),
2059                        start,
2060                        end: self.position,
2061                    });
2062                }
2063            }
2064
2065            // Not a terminator, consume the character
2066            match self.current_char() {
2067                Some(ch) => {
2068                    body.push(ch);
2069                    self.advance();
2070
2071                    // Track if we're at the start of a line
2072                    line_start = ch == '\n';
2073                }
2074                None => {
2075                    // Reached EOF without finding terminator
2076                    break;
2077                }
2078            }
2079        }
2080
2081        // If we reach here, we didn't find a terminator
2082        self.mode = LexerMode::ExpectTerm;
2083        Some(Token {
2084            token_type: TokenType::Error(Arc::from("Unterminated format body")),
2085            text: Arc::from(body),
2086            start,
2087            end: self.position,
2088        })
2089    }
2090
2091    fn try_operator(&mut self) -> Option<Token> {
2092        // Skip operator parsing if we're expecting a delimiter for a quote operator
2093        if matches!(self.mode, LexerMode::ExpectDelimiter) && self.current_quote_op.is_some() {
2094            return None;
2095        }
2096
2097        let start = self.position;
2098        let ch = self.current_char()?;
2099
2100        // ═══════════════════════════════════════════════════════════════════════
2101        // SLASH DISAMBIGUATION STRATEGY (Issue #422)
2102        // ═══════════════════════════════════════════════════════════════════════
2103        //
2104        // Perl's `/` character is ambiguous:
2105        //   - Division operator: `$x / 2`
2106        //   - Regex delimiter: `/pattern/`
2107        //   - Defined-or operator: `$x // $y`
2108        //
2109        // **Disambiguation Strategy (Context-Aware Heuristics):**
2110        //
2111        // 1. **Mode-Based Decision (Primary)**:
2112        //    - `LexerMode::ExpectTerm` → `/` starts a regex
2113        //      Examples: `if (/pattern/)`, `=~ /test/`, `( /regex/`
2114        //    - `LexerMode::ExpectOperator` → `/` is division or `//`
2115        //      Examples: `$x / 2`, `$x // $y`, `) / 3`
2116        //
2117        // 2. **Context Heuristics (Secondary - Implicit in Mode)**:
2118        //    Mode is set based on previous token:
2119        //    - After identifier/number/closing paren → ExpectOperator → division
2120        //    - After operator/keyword/opening paren → ExpectTerm → regex
2121        //
2122        // 3. **Budget Protection**:
2123        //    - Regex parsing has a parse-step budget and byte budget
2124        //    - Budget exceeded → emit UnknownRest token (graceful degradation)
2125        //    - See `parse_regex()` and `budget_guard()` for implementation
2126        //
2127        // 4. **Performance Characteristics**:
2128        //    - Single-pass: O(1) decision based on mode flag
2129        //    - No backtracking: Mode updated after each token
2130        //    - Optimized: Byte-level operations for common cases
2131        //
2132        // **Metrics & Monitoring**:
2133        //    - Budget exceeded events tracked via UnknownRest token emission
2134        //    - LSP diagnostics generated for truncated regexes
2135        //    - Test coverage: lexer_slash_timeout_tests.rs (21 test cases)
2136        //
2137        // ═══════════════════════════════════════════════════════════════════════
2138
2139        if ch == '/' {
2140            if self.mode == LexerMode::ExpectTerm {
2141                // Mode indicates we're expecting a term → `/` starts a regex
2142                // Examples: `if (/pattern/)`, `=~ /test/`, `while (/match/)`
2143                return self.parse_regex(start);
2144            } else {
2145                // Mode indicates we're expecting an operator → `/` is division or `//`
2146                // Examples: `$x / 2`, `$x // $y`, `10 / 3`
2147                self.advance();
2148                // Check for // or //= using byte-level operations for speed
2149                if self.peek_byte(0) == Some(b'/') {
2150                    self.position += 1; // consume second / directly
2151                    if self.peek_byte(0) == Some(b'=') {
2152                        self.position += 1; // consume = directly
2153                        let text = &self.input[start..self.position];
2154                        self.mode = LexerMode::ExpectTerm;
2155                        return Some(Token {
2156                            token_type: TokenType::Operator(Arc::from(text)),
2157                            text: Arc::from(text),
2158                            start,
2159                            end: self.position,
2160                        });
2161                    } else {
2162                        // Use cached string for common "//" operator
2163                        self.mode = LexerMode::ExpectTerm;
2164                        return Some(Token {
2165                            token_type: TokenType::Operator(Arc::from("//")),
2166                            text: Arc::from("//"),
2167                            start,
2168                            end: self.position,
2169                        });
2170                    }
2171                } else if self.position < self.input_bytes.len()
2172                    && self.input_bytes[self.position] == b'='
2173                {
2174                    // /= division-assign operator
2175                    self.position += 1; // consume =
2176                    self.mode = LexerMode::ExpectTerm;
2177                    return Some(Token {
2178                        token_type: TokenType::Operator(Arc::from("/=")),
2179                        text: Arc::from("/="),
2180                        start,
2181                        end: self.position,
2182                    });
2183                } else {
2184                    // Use cached string for common "/" division
2185                    self.mode = LexerMode::ExpectTerm;
2186                    return Some(Token {
2187                        token_type: TokenType::Division,
2188                        text: Arc::from("/"),
2189                        start,
2190                        end: self.position,
2191                    });
2192                }
2193            }
2194        }
2195
2196        // Handle other operators - simplified
2197        match ch {
2198            '.' => {
2199                // Check if it's a decimal number like .5 -- but only when we
2200                // expect a term.  In operator position `.5` is concatenation
2201                // of the bareword/number on the left with the number `5`.
2202                if self.mode != LexerMode::ExpectOperator
2203                    && self.peek_char(1).is_some_and(|c| c.is_ascii_digit())
2204                {
2205                    return self.parse_decimal_number(start);
2206                }
2207                self.advance();
2208                // Check for compound operators
2209                #[allow(clippy::collapsible_if)]
2210                if let Some(next) = self.current_char() {
2211                    if is_compound_operator(ch, next) {
2212                        self.advance();
2213
2214                        // Check for three-character operators like **=, <<=, >>=
2215                        if self.position < self.input.len() {
2216                            let third = self.current_char();
2217                            // Check for three-character operators
2218                            if matches!(
2219                                (ch, next, third),
2220                                ('*', '*', Some('='))
2221                                    | ('<', '<', Some('='))
2222                                    | ('>', '>', Some('='))
2223                                    | ('&', '&', Some('='))
2224                                    | ('|', '|', Some('='))
2225                                    | ('/', '/', Some('='))
2226                            ) {
2227                                self.advance(); // consume the =
2228                            } else if ch == '<' && next == '=' && third == Some('>') {
2229                                self.advance(); // consume the >
2230                            // Special case: <=> spaceship operator
2231                            } else if ch == '.' && next == '.' && third == Some('.') {
2232                                self.advance(); // consume the third .
2233                            }
2234                        }
2235                    }
2236                }
2237            }
2238            '+' | '-' | '*' | '%' | '&' | '|' | '^' | '~' | '!' | '=' | '<' | '>' | ':' | '?'
2239            | '\\' => {
2240                self.advance();
2241                // Check for compound operators
2242                #[allow(clippy::collapsible_if)]
2243                if let Some(next) = self.current_char() {
2244                    if is_compound_operator(ch, next) {
2245                        self.advance();
2246
2247                        // Check for three-character operators like **=, <<=, >>=
2248                        if self.position < self.input.len() {
2249                            let third = self.current_char();
2250                            // Check for three-character operators
2251                            if matches!(
2252                                (ch, next, third),
2253                                ('*', '*', Some('='))
2254                                    | ('<', '<', Some('='))
2255                                    | ('>', '>', Some('='))
2256                                    | ('&', '&', Some('='))
2257                                    | ('|', '|', Some('='))
2258                                    | ('/', '/', Some('='))
2259                            ) {
2260                                self.advance(); // consume the =
2261                            } else if ch == '<' && next == '=' && third == Some('>') {
2262                                self.advance(); // consume the >
2263                                // Special case: <=> spaceship operator
2264                            }
2265                        }
2266                    }
2267                }
2268            }
2269            _ => return None,
2270        }
2271
2272        let text = &self.input[start..self.position];
2273        // Operator ends prototype window (e.g. `:` for attributes)
2274        self.after_sub = false;
2275        // Track whether this operator is '->' for method name disambiguation
2276        self.after_arrow = text == "->";
2277        // Any operator token ends the "just saw a variable" window; `{` after
2278        // an operator is not a hash subscript (e.g. `foo() {`, `+ {`, etc.).
2279        self.after_var_subscript = false;
2280        // Postfix ++ and -- complete a term expression, so next token is an operator
2281        // (e.g., "$x++ / 2" → / is division, not regex)
2282        if (text == "++" || text == "--") && self.mode == LexerMode::ExpectOperator {
2283            // Postfix: stay in ExpectOperator
2284        } else {
2285            self.mode = LexerMode::ExpectTerm;
2286        }
2287
2288        Some(Token {
2289            token_type: TokenType::Operator(Arc::from(text)),
2290            text: Arc::from(text),
2291            start,
2292            end: self.position,
2293        })
2294    }
2295
2296    fn try_delimiter(&mut self) -> Option<Token> {
2297        let start = self.position;
2298        let ch = self.current_char()?;
2299
2300        // If we're expecting a delimiter for a quote operator, handle it specially
2301        if matches!(self.mode, LexerMode::ExpectDelimiter) && self.current_quote_op.is_some() {
2302            // Accept any non-alphanumeric character as a delimiter
2303            if !ch.is_alphanumeric() && !ch.is_whitespace() {
2304                self.advance();
2305                if let Some(ref mut info) = self.current_quote_op {
2306                    info.delimiter = ch;
2307                }
2308                // Now parse the quote operator content
2309                return self.parse_quote_operator(ch);
2310            }
2311        }
2312
2313        match ch {
2314            '(' => {
2315                // Check if this is a quote operator delimiter
2316                if matches!(self.mode, LexerMode::ExpectDelimiter)
2317                    && self.current_quote_op.is_some()
2318                {
2319                    self.advance();
2320                    if let Some(ref mut info) = self.current_quote_op {
2321                        info.delimiter = ch;
2322                    }
2323                    return self.parse_quote_operator(ch);
2324                }
2325
2326                self.advance();
2327                if self.after_sub {
2328                    // Promote after_sub to in_prototype now that we see '('
2329                    self.in_prototype = true;
2330                    self.after_sub = false;
2331                    self.prototype_depth = 1;
2332                } else if self.in_prototype {
2333                    self.prototype_depth += 1;
2334                }
2335                self.paren_depth += 1;
2336                self.after_var_subscript = false;
2337                self.mode = LexerMode::ExpectTerm;
2338                Some(Token {
2339                    token_type: TokenType::LeftParen,
2340                    text: Arc::from("("),
2341                    start,
2342                    end: self.position,
2343                })
2344            }
2345            ')' => {
2346                self.advance();
2347                if self.in_prototype && self.prototype_depth > 0 {
2348                    self.prototype_depth -= 1;
2349                    if self.prototype_depth == 0 {
2350                        self.in_prototype = false;
2351                    }
2352                }
2353                self.after_arrow = false;
2354                self.paren_depth = self.paren_depth.saturating_sub(1);
2355                // A closing paren ends any var-subscript context: `if ($var)` should
2356                // NOT leave after_var_subscript set, otherwise the following `{` would
2357                // incorrectly increment hash_brace_depth and suppress regex operators
2358                // inside the block body (issue #2844).
2359                self.after_var_subscript = false;
2360                self.mode = LexerMode::ExpectOperator;
2361                Some(Token {
2362                    token_type: TokenType::RightParen,
2363                    text: Arc::from(")"),
2364                    start,
2365                    end: self.position,
2366                })
2367            }
2368            ';' => {
2369                self.advance();
2370                // Semicolon ends prototype window (forward declaration)
2371                self.after_sub = false;
2372                // Semicolon is a statement boundary — any pending method-call chain is over.
2373                self.after_arrow = false;
2374                self.after_var_subscript = false;
2375                self.mode = LexerMode::ExpectTerm;
2376                Some(Token {
2377                    token_type: TokenType::Semicolon,
2378                    text: Arc::from(";"),
2379                    start,
2380                    end: self.position,
2381                })
2382            }
2383            ',' => {
2384                self.advance();
2385                self.after_var_subscript = false;
2386                self.mode = LexerMode::ExpectTerm;
2387                Some(Token {
2388                    token_type: TokenType::Comma,
2389                    text: Arc::from(","),
2390                    start,
2391                    end: self.position,
2392                })
2393            }
2394            '[' => {
2395                self.advance();
2396                self.after_var_subscript = false;
2397                self.mode = LexerMode::ExpectTerm;
2398                Some(Token {
2399                    token_type: TokenType::LeftBracket,
2400                    text: Arc::from("["),
2401                    start,
2402                    end: self.position,
2403                })
2404            }
2405            ']' => {
2406                self.advance();
2407                // A closing `]` from an array subscript leaves us in a state where
2408                // a `{` immediately following is a hash subscript — e.g. `$arr[$i]{key}`.
2409                // Set after_var_subscript so the `{` handler recognises it as such.
2410                // This mirrors the `}` handler's behavior when closing a hash subscript.
2411                self.after_var_subscript = true;
2412                self.mode = LexerMode::ExpectOperator;
2413                Some(Token {
2414                    token_type: TokenType::RightBracket,
2415                    text: Arc::from("]"),
2416                    start,
2417                    end: self.position,
2418                })
2419            }
2420            '{' => {
2421                self.advance();
2422                // Opening brace ends prototype window — no prototype follows
2423                self.after_sub = false;
2424                // `{` is a hash/slice subscript opener only when it immediately follows
2425                // a variable token ($x, @x, %x) — tracked by `after_var_subscript`.
2426                // This is narrower than the old `mode == ExpectOperator` check, which
2427                // incorrectly incremented depth for block-opening braces after `sub foo`,
2428                // `if (cond)`, `else`, `while (cond)`, etc., causing quote-op suppression
2429                // inside those block bodies and breaking m//, s///, qr//, tr/// etc.
2430                if self.after_var_subscript {
2431                    self.hash_brace_depth = self.hash_brace_depth.saturating_add(1);
2432                }
2433                self.after_var_subscript = false;
2434                self.mode = LexerMode::ExpectTerm;
2435                Some(Token {
2436                    token_type: TokenType::LeftBrace,
2437                    text: Arc::from("{"),
2438                    start,
2439                    end: self.position,
2440                })
2441            }
2442            '}' => {
2443                self.advance();
2444                self.after_arrow = false;
2445                // Decrement hash subscript brace depth only if we were inside one.
2446                // If depth > 0, this closes a hash subscript; enable chained subscripts
2447                // like $h{a}{b} by setting after_var_subscript so the next `{` is
2448                // recognized as another subscript opener.
2449                if self.hash_brace_depth > 0 {
2450                    self.hash_brace_depth -= 1;
2451                    // The subscript value is now the "variable" for a chained subscript.
2452                    self.after_var_subscript = true;
2453                } else {
2454                    // Block-close `}` — no subscript follows
2455                    self.after_var_subscript = false;
2456                }
2457                self.mode = LexerMode::ExpectOperator;
2458                Some(Token {
2459                    token_type: TokenType::RightBrace,
2460                    text: Arc::from("}"),
2461                    start,
2462                    end: self.position,
2463                })
2464            }
2465            '#' => {
2466                // Only treat as delimiter in ExpectDelimiter mode
2467                if matches!(self.mode, LexerMode::ExpectDelimiter) {
2468                    self.advance();
2469                    // Reset mode after consuming delimiter
2470                    self.mode = LexerMode::ExpectTerm;
2471                    Some(Token {
2472                        token_type: TokenType::Operator(Arc::from("#")),
2473                        text: Arc::from("#"),
2474                        start,
2475                        end: self.position,
2476                    })
2477                } else {
2478                    None
2479                }
2480            }
2481            _ => None,
2482        }
2483    }
2484
2485    fn parse_double_quoted_string(&mut self, start: usize) -> Option<Token> {
2486        self.advance(); // Skip opening quote
2487        let mut parts = Vec::new();
2488        let mut current_literal = String::new();
2489        let mut last_pos = self.position;
2490
2491        while let Some(ch) = self.current_char() {
2492            match ch {
2493                '"' => {
2494                    self.advance();
2495                    if !current_literal.is_empty() {
2496                        parts.push(StringPart::Literal(Arc::from(current_literal)));
2497                    }
2498
2499                    let text = &self.input[start..self.position];
2500                    self.mode = LexerMode::ExpectOperator;
2501
2502                    return Some(Token {
2503                        token_type: if parts.is_empty() {
2504                            TokenType::StringLiteral
2505                        } else {
2506                            TokenType::InterpolatedString(parts)
2507                        },
2508                        text: Arc::from(text),
2509                        start,
2510                        end: self.position,
2511                    });
2512                }
2513                '\\' => {
2514                    self.advance();
2515                    if let Some(escaped) = self.current_char() {
2516                        // Optimize by reserving space to avoid frequent reallocations
2517                        if current_literal.capacity() == 0 {
2518                            current_literal.reserve(32);
2519                        }
2520                        current_literal.push('\\');
2521                        current_literal.push(escaped);
2522                        self.advance();
2523                    }
2524                }
2525                '$' if self.config.parse_interpolation => {
2526                    // Handle variable interpolation - avoid unnecessary clone
2527                    if !current_literal.is_empty() {
2528                        parts.push(StringPart::Literal(Arc::from(current_literal)));
2529                        current_literal = String::new(); // Clear without cloning
2530                    }
2531
2532                    let part_start = self.position;
2533                    self.advance();
2534                    match self.current_char() {
2535                        Some('{') => {
2536                            let _ = self.consume_balanced_segment_in_string('{', '}', '"');
2537                            parts.push(StringPart::Expression(Arc::from(
2538                                &self.input[part_start..self.position],
2539                            )));
2540                        }
2541                        Some(ch) if is_perl_identifier_start(ch) => {
2542                            let var_start = self.position;
2543
2544                            // Fast path for ASCII identifier continuation
2545                            while self.position < self.input_bytes.len() {
2546                                let byte = self.input_bytes[self.position];
2547                                if byte.is_ascii_alphanumeric() || byte == b'_' {
2548                                    self.position += 1;
2549                                } else if byte >= 128 {
2550                                    // Only use UTF-8 parsing for non-ASCII
2551                                    if let Some(ch) = self.current_char() {
2552                                        if is_perl_identifier_continue(ch) {
2553                                            self.advance();
2554                                        } else {
2555                                            break;
2556                                        }
2557                                    } else {
2558                                        break;
2559                                    }
2560                                } else {
2561                                    break;
2562                                }
2563                            }
2564
2565                            if self.position > var_start {
2566                                let var_name = &self.input[part_start..self.position];
2567                                parts.push(StringPart::Variable(Arc::from(var_name)));
2568
2569                                if self.matches_bytes(b"->") {
2570                                    let tail_start = self.position;
2571                                    self.advance();
2572                                    self.advance();
2573
2574                                    match self.current_char() {
2575                                        Some('[') => {
2576                                            let _ = self
2577                                                .consume_balanced_segment_in_string('[', ']', '"');
2578                                            parts.push(StringPart::MethodCall(Arc::from(
2579                                                &self.input[tail_start..self.position],
2580                                            )));
2581                                        }
2582                                        Some('{') => {
2583                                            let _ = self
2584                                                .consume_balanced_segment_in_string('{', '}', '"');
2585                                            parts.push(StringPart::MethodCall(Arc::from(
2586                                                &self.input[tail_start..self.position],
2587                                            )));
2588                                        }
2589                                        Some('(') => {
2590                                            let _ = self
2591                                                .consume_balanced_segment_in_string('(', ')', '"');
2592                                            parts.push(StringPart::MethodCall(Arc::from(
2593                                                &self.input[tail_start..self.position],
2594                                            )));
2595                                        }
2596                                        Some(ch) if is_perl_identifier_start(ch) => {
2597                                            while self.position < self.input_bytes.len() {
2598                                                let byte = self.input_bytes[self.position];
2599                                                if byte.is_ascii_alphanumeric() || byte == b'_' {
2600                                                    self.position += 1;
2601                                                } else if byte >= 128 {
2602                                                    if let Some(ch) = self.current_char() {
2603                                                        if is_perl_identifier_continue(ch) {
2604                                                            self.advance();
2605                                                        } else {
2606                                                            break;
2607                                                        }
2608                                                    } else {
2609                                                        break;
2610                                                    }
2611                                                } else {
2612                                                    break;
2613                                                }
2614                                            }
2615                                            if self.current_char() == Some('(') {
2616                                                let _ = self.consume_balanced_segment_in_string(
2617                                                    '(', ')', '"',
2618                                                );
2619                                            }
2620                                            parts.push(StringPart::MethodCall(Arc::from(
2621                                                &self.input[tail_start..self.position],
2622                                            )));
2623                                        }
2624                                        _ => {
2625                                            parts.push(StringPart::MethodCall(Arc::from(
2626                                                &self.input[tail_start..self.position],
2627                                            )));
2628                                        }
2629                                    }
2630                                } else if self.current_char() == Some('[') {
2631                                    let tail_start = self.position;
2632                                    let _ = self.consume_balanced_segment_in_string('[', ']', '"');
2633                                    parts.push(StringPart::ArraySlice(Arc::from(
2634                                        &self.input[tail_start..self.position],
2635                                    )));
2636                                } else if self.current_char() == Some('{') {
2637                                    let tail_start = self.position;
2638                                    let _ = self.consume_balanced_segment_in_string('{', '}', '"');
2639                                    parts.push(StringPart::Expression(Arc::from(
2640                                        &self.input[tail_start..self.position],
2641                                    )));
2642                                }
2643                            }
2644                        }
2645                        _ => {}
2646                    }
2647                }
2648                _ => {
2649                    // Optimize string building with better capacity management
2650                    if current_literal.capacity() == 0 {
2651                        current_literal.reserve(32);
2652                    }
2653                    current_literal.push(ch);
2654                    self.advance();
2655                }
2656            }
2657
2658            // Safety check: ensure we're making progress
2659            if self.position == last_pos {
2660                break;
2661            }
2662            last_pos = self.position;
2663        }
2664
2665        Some(self.unterminated_string_error(start))
2666    }
2667
2668    fn parse_single_quoted_string(&mut self, start: usize) -> Option<Token> {
2669        self.advance(); // Skip opening quote
2670
2671        let mut last_pos = self.position;
2672
2673        while let Some(ch) = self.current_char() {
2674            match ch {
2675                '\'' => {
2676                    self.advance();
2677                    let text = &self.input[start..self.position];
2678                    self.mode = LexerMode::ExpectOperator;
2679
2680                    return Some(Token {
2681                        token_type: TokenType::StringLiteral,
2682                        text: Arc::from(text),
2683                        start,
2684                        end: self.position,
2685                    });
2686                }
2687                '\\' => {
2688                    self.advance();
2689                    if self.current_char() == Some('\'') || self.current_char() == Some('\\') {
2690                        self.advance();
2691                    }
2692                }
2693                _ => self.advance(),
2694            }
2695
2696            // Safety check: ensure we're making progress
2697            if self.position == last_pos {
2698                break;
2699            }
2700            last_pos = self.position;
2701        }
2702
2703        Some(self.unterminated_string_error(start))
2704    }
2705
2706    fn parse_backtick_string(&mut self, start: usize) -> Option<Token> {
2707        self.advance(); // Skip opening backtick
2708
2709        let mut last_pos = self.position;
2710
2711        while let Some(ch) = self.current_char() {
2712            match ch {
2713                '`' => {
2714                    self.advance();
2715                    let text = &self.input[start..self.position];
2716                    self.mode = LexerMode::ExpectOperator;
2717
2718                    return Some(Token {
2719                        token_type: TokenType::QuoteCommand,
2720                        text: Arc::from(text),
2721                        start,
2722                        end: self.position,
2723                    });
2724                }
2725                '\\' => {
2726                    self.advance();
2727                    if self.current_char().is_some() {
2728                        self.advance();
2729                    }
2730                }
2731                _ => self.advance(),
2732            }
2733
2734            // Safety check: ensure we're making progress
2735            if self.position == last_pos {
2736                break;
2737            }
2738            last_pos = self.position;
2739        }
2740
2741        Some(self.unterminated_string_error(start))
2742    }
2743
2744    fn parse_q_string(&mut self, _start: usize) -> Option<Token> {
2745        // Simplified q-string parsing
2746        None
2747    }
2748
2749    #[inline]
2750    fn unterminated_string_error(&mut self, start: usize) -> Token {
2751        // Consume to EOF so the caller receives a single terminal error token.
2752        let end = self.input.len();
2753        self.position = end;
2754
2755        Token {
2756            token_type: TokenType::Error(Arc::from("unterminated string")),
2757            text: Arc::from(&self.input[start..end]),
2758            start,
2759            end,
2760        }
2761    }
2762
2763    fn parse_substitution(&mut self, start: usize) -> Option<Token> {
2764        // We've already consumed 's'
2765        let delimiter = self.current_char()?;
2766        self.advance(); // Skip delimiter
2767        self.parse_substitution_with_delimiter(start, delimiter)
2768    }
2769
2770    fn parse_substitution_with_delimiter(
2771        &mut self,
2772        start: usize,
2773        delimiter: char,
2774    ) -> Option<Token> {
2775        let (_pattern, pattern_closed) = self.read_delimited_body(delimiter);
2776        let replacement_closed;
2777
2778        let pattern_is_paired = quote_handler::paired_close(delimiter).is_some();
2779        if pattern_is_paired {
2780            self.skip_paired_substitution_replacement_gap();
2781
2782            if let Some(repl_delim) = self.current_char()
2783                && Self::is_quote_delim(repl_delim)
2784            {
2785                self.advance();
2786                let (_replacement, closed) = self.read_substitution_replacement_body(repl_delim);
2787                replacement_closed = closed;
2788            } else {
2789                replacement_closed = false;
2790            }
2791        } else {
2792            let (_replacement, closed) = self.read_substitution_replacement_body(delimiter);
2793            replacement_closed = closed;
2794        }
2795
2796        // Parse modifiers - include all alphanumeric for proper validation in parser (MUT_005 fix)
2797        while let Some(ch) = self.current_char() {
2798            if ch.is_ascii_alphanumeric() {
2799                self.advance();
2800            } else {
2801                break;
2802            }
2803        }
2804
2805        let text = &self.input[start..self.position];
2806        self.mode = LexerMode::ExpectOperator;
2807
2808        let token_type = if pattern_closed && replacement_closed {
2809            TokenType::Substitution
2810        } else {
2811            TokenType::Error(Arc::from(format!(
2812                "unclosed quote-like operator 's' delimiter '{}'",
2813                delimiter
2814            )))
2815        };
2816
2817        Some(Token { token_type, text: Arc::from(text), start, end: self.position })
2818    }
2819
2820    fn skip_paired_substitution_replacement_gap(&mut self) {
2821        let mut comment_eligible = false;
2822        loop {
2823            let mut saw_whitespace = false;
2824            while self.current_char().is_some_and(char::is_whitespace) {
2825                self.advance();
2826                saw_whitespace = true;
2827            }
2828            comment_eligible |= saw_whitespace;
2829
2830            if comment_eligible && self.current_char() == Some('#') {
2831                while let Some(ch) = self.current_char() {
2832                    self.advance();
2833                    if matches!(ch, '\n' | '\r') {
2834                        break;
2835                    }
2836                }
2837                comment_eligible = true;
2838                continue;
2839            }
2840
2841            break;
2842        }
2843    }
2844
2845    fn read_substitution_replacement_body(&mut self, delim: char) -> (String, bool) {
2846        if quote_handler::paired_close(delim).is_some() {
2847            return self.read_delimited_body(delim);
2848        }
2849
2850        self.read_unpaired_substitution_replacement_body(delim)
2851    }
2852
2853    fn read_unpaired_substitution_replacement_body(&mut self, delim: char) -> (String, bool) {
2854        let mut body = String::new();
2855        let mut escaped = false;
2856
2857        while let Some(ch) = self.current_char() {
2858            if escaped {
2859                body.push(ch);
2860                self.advance();
2861                escaped = false;
2862                continue;
2863            }
2864
2865            match ch {
2866                '\\' => {
2867                    body.push(ch);
2868                    self.advance();
2869                    escaped = true;
2870                }
2871                '"' | '\'' if ch != delim => {
2872                    if let Some((string_end, true)) =
2873                        self.scan_inner_string_for_delimiter(self.position, ch, delim)
2874                    {
2875                        if let Some(string_text) = self.input.get(self.position..string_end) {
2876                            body.push_str(string_text);
2877                            self.position = string_end;
2878                        } else {
2879                            body.push(ch);
2880                            self.advance();
2881                        }
2882                    } else {
2883                        body.push(ch);
2884                        self.advance();
2885                    }
2886                }
2887                c if c == delim => {
2888                    self.advance();
2889                    return (body, true);
2890                }
2891                _ => {
2892                    body.push(ch);
2893                    self.advance();
2894                }
2895            }
2896        }
2897
2898        (body, false)
2899    }
2900
2901    fn scan_inner_string_for_delimiter(
2902        &self,
2903        start: usize,
2904        quote: char,
2905        delim: char,
2906    ) -> Option<(usize, bool)> {
2907        if Self::is_word_apostrophe(self.input, start, quote) {
2908            return None;
2909        }
2910        // Adjacent quotes are literal replacement text (for example s/"/""/g),
2911        // not a string literal to skip while hunting for the replacement delimiter.
2912        if self.input.get(..start).and_then(|text| text.chars().next_back()) == Some(quote) {
2913            return None;
2914        }
2915        let mut pos = start.checked_add(quote.len_utf8())?;
2916        let expression_quote = Self::can_start_replacement_expression_quote(self.input, start);
2917        if !expression_quote && self.input.get(pos..).is_some_and(|text| text.starts_with(delim)) {
2918            return None;
2919        }
2920        if self.input.get(pos..).is_some_and(|text| text.starts_with(quote)) {
2921            return None;
2922        }
2923        let mut escaped = false;
2924        let mut contains_delim = false;
2925
2926        while let Some(ch) = self.input.get(pos..).and_then(|text| text.chars().next()) {
2927            if matches!(ch, '\n' | '\r') {
2928                return None;
2929            }
2930            if !expression_quote && matches!(ch, ';' | '#') {
2931                return None;
2932            }
2933
2934            if escaped {
2935                if ch == delim {
2936                    contains_delim = true;
2937                }
2938                pos += ch.len_utf8();
2939                escaped = false;
2940                continue;
2941            }
2942
2943            match ch {
2944                '\\' => {
2945                    pos += ch.len_utf8();
2946                    escaped = true;
2947                }
2948                c if c == quote => {
2949                    return Some((pos + ch.len_utf8(), contains_delim));
2950                }
2951                c if c == delim => {
2952                    contains_delim = true;
2953                    pos += ch.len_utf8();
2954                }
2955                _ => {
2956                    pos += ch.len_utf8();
2957                }
2958            }
2959        }
2960
2961        None
2962    }
2963
2964    // Only skip delimiter-bearing inner strings in positions that look like
2965    // replacement expressions; literal replacement quotes still let the next
2966    // delimiter close the substitution.
2967    fn can_start_replacement_expression_quote(input: &str, pos: usize) -> bool {
2968        input
2969            .get(..pos)
2970            .and_then(|text| text.chars().rev().find(|ch| !ch.is_whitespace()))
2971            .is_some_and(|ch| {
2972                matches!(
2973                    ch,
2974                    '(' | '['
2975                        | '{'
2976                        | ','
2977                        | '='
2978                        | ':'
2979                        | '?'
2980                        | '!'
2981                        | '~'
2982                        | '+'
2983                        | '-'
2984                        | '*'
2985                        | '%'
2986                        | '&'
2987                        | '|'
2988                        | '^'
2989                        | '<'
2990                        | '>'
2991                )
2992            })
2993    }
2994
2995    fn is_word_apostrophe(input: &str, pos: usize, quote: char) -> bool {
2996        quote == '\''
2997            && input
2998                .get(..pos)
2999                .and_then(|text| text.chars().next_back())
3000                .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
3001    }
3002
3003    fn parse_transliteration(&mut self, start: usize) -> Option<Token> {
3004        // We've already consumed 'tr' or 'y'
3005        while self.current_char().is_some_and(char::is_whitespace) {
3006            self.advance();
3007        }
3008
3009        let delimiter = self.current_char()?;
3010        self.advance(); // Skip delimiter
3011        self.parse_transliteration_with_delimiter(start, delimiter)
3012    }
3013
3014    fn parse_transliteration_with_delimiter(
3015        &mut self,
3016        start: usize,
3017        delimiter: char,
3018    ) -> Option<Token> {
3019        let (_search, search_closed) = self.read_delimited_body(delimiter);
3020        let replacement_closed;
3021
3022        let search_is_paired = quote_handler::paired_close(delimiter).is_some();
3023        if search_is_paired {
3024            while self.current_char().is_some_and(char::is_whitespace) {
3025                self.advance();
3026            }
3027
3028            if let Some(repl_delim) = self.current_char()
3029                && Self::is_quote_delim(repl_delim)
3030            {
3031                self.advance();
3032                let (_replacement, closed) = self.read_delimited_body(repl_delim);
3033                replacement_closed = closed;
3034            } else {
3035                replacement_closed = false;
3036            }
3037        } else {
3038            let (_replacement, closed) = self.read_delimited_body(delimiter);
3039            replacement_closed = closed;
3040        }
3041
3042        // Parse modifiers - include all alphanumeric for proper validation in parser (MUT_005 fix)
3043        while let Some(ch) = self.current_char() {
3044            if ch.is_ascii_alphanumeric() {
3045                self.advance();
3046            } else {
3047                break;
3048            }
3049        }
3050
3051        let text = &self.input[start..self.position];
3052        self.mode = LexerMode::ExpectOperator;
3053
3054        let token_type = if search_closed && replacement_closed {
3055            TokenType::Transliteration
3056        } else {
3057            TokenType::Error(Arc::from(format!(
3058                "unclosed quote-like operator '{}' delimiter '{}'",
3059                if self.input[start..].starts_with("tr") { "tr" } else { "y" },
3060                delimiter
3061            )))
3062        };
3063
3064        Some(Token { token_type, text: Arc::from(text), start, end: self.position })
3065    }
3066
3067    /// Read content between delimiters.
3068    ///
3069    /// Returns `(body, closed)` where `closed` is `true` if the closing
3070    /// delimiter was found before EOF, and `false` if EOF was reached first.
3071    fn read_delimited_body(&mut self, delim: char) -> (String, bool) {
3072        let paired = quote_handler::paired_close(delim);
3073        let close = paired.unwrap_or(delim);
3074        let mut body = String::new();
3075        let mut depth = i32::from(paired.is_some());
3076
3077        while let Some(ch) = self.current_char() {
3078            if ch == '\\' {
3079                body.push(ch);
3080                self.advance();
3081                if let Some(next) = self.current_char() {
3082                    body.push(next);
3083                    self.advance();
3084                }
3085                continue;
3086            }
3087
3088            if paired.is_some() && ch == delim {
3089                body.push(ch);
3090                self.advance();
3091                depth += 1;
3092                continue;
3093            }
3094
3095            if ch == close {
3096                if paired.is_some() {
3097                    depth -= 1;
3098                    if depth == 0 {
3099                        self.advance();
3100                        return (body, true);
3101                    }
3102                    body.push(ch);
3103                    self.advance();
3104                } else {
3105                    self.advance();
3106                    return (body, true);
3107                }
3108                continue;
3109            }
3110
3111            body.push(ch);
3112            self.advance();
3113        }
3114
3115        // EOF reached without finding the closing delimiter
3116        (body, false)
3117    }
3118
3119    /// Parse a quote operator after we've seen the delimiter
3120    fn parse_quote_operator(&mut self, delimiter: char) -> Option<Token> {
3121        let info = self.current_quote_op.as_ref()?;
3122        let start = info.start_pos;
3123        let operator = info.operator.clone();
3124
3125        // Clear the quote-op context eagerly so any early-return path (s/tr/y delegations
3126        // below) does not leave a stale reference behind. The post-match cleanup at the
3127        // bottom of this function would otherwise be skipped for those operators.
3128        self.current_quote_op = None;
3129
3130        // Parse based on operator type; track whether all delimiters were closed.
3131        let closed = match operator.as_str() {
3132            "s" => {
3133                return self.parse_substitution_with_delimiter(start, delimiter);
3134            }
3135            "tr" | "y" => {
3136                return self.parse_transliteration_with_delimiter(start, delimiter);
3137            }
3138            "qr" => {
3139                let (_pattern, body_closed) = self.read_delimited_body(delimiter);
3140                self.parse_regex_modifiers(&quote_handler::QR_SPEC);
3141                body_closed
3142            }
3143            "m" => {
3144                let (_pattern, body_closed) = self.read_delimited_body(delimiter);
3145                self.parse_regex_modifiers(&quote_handler::M_SPEC);
3146                body_closed
3147            }
3148            _ => {
3149                // q, qq, qw, qx - no modifiers
3150                let (_body, body_closed) = self.read_delimited_body(delimiter);
3151                body_closed
3152            }
3153        };
3154
3155        let text = &self.input[start..self.position];
3156
3157        self.mode = LexerMode::ExpectOperator;
3158
3159        if !closed {
3160            // EOF reached before finding the closing delimiter — emit an error
3161            // token so the parser's recovery mechanism records a diagnostic.
3162            return Some(Token {
3163                token_type: TokenType::Error(Arc::from(format!(
3164                    "unclosed {} delimiter '{}'",
3165                    operator, delimiter
3166                ))),
3167                text: Arc::from(text),
3168                start,
3169                end: self.position,
3170            });
3171        }
3172
3173        let token_type = quote_handler::get_quote_token_type(&operator);
3174        Some(Token { token_type, text: Arc::from(text), start, end: self.position })
3175    }
3176
3177    /// Parse regex modifiers according to the given spec
3178    ///
3179    /// This function includes ALL characters that could be intended as modifiers,
3180    /// including invalid ones. This allows the parser to properly reject invalid
3181    /// modifiers with a clear error message, rather than leaving them as separate
3182    /// tokens that could be confusingly parsed.
3183    fn parse_regex_modifiers(&mut self, _spec: &quote_handler::ModSpec) {
3184        // Consume all alphanumeric characters that could be intended as modifiers
3185        // The parser will validate and reject invalid ones
3186        while let Some(ch) = self.current_char() {
3187            if ch.is_ascii_alphanumeric() {
3188                self.advance();
3189            } else {
3190                break;
3191            }
3192        }
3193        // Note: We no longer validate here - the parser will validate and provide
3194        // clear error messages for invalid modifiers (MUT_005 fix)
3195    }
3196
3197    /// Parse a regex literal starting with `/`
3198    ///
3199    /// **Budget Protection (Issue #422)**:
3200    /// - Budget guards prevent runaway scanning on pathological input
3201    /// - `MAX_REGEX_PARSE_STEPS` bounds literal scanning before the byte budget
3202    /// - `MAX_REGEX_BYTES` bounds total bytes consumed in a single regex literal
3203    /// - Graceful degradation: emit UnknownRest token if budget exceeded
3204    ///
3205    /// **Performance**:
3206    /// - Single-pass scanning with escape handling
3207    /// - Budget check per iteration (amortized O(1) via inline fast path)
3208    /// - Typical regex: <10μs, Large regex (64KB): ~1ms
3209    fn parse_regex(&mut self, start: usize) -> Option<Token> {
3210        self.advance(); // Skip opening /
3211
3212        let mut regex_parse_steps: usize = 0;
3213        let mut in_character_class = false;
3214
3215        while let Some(ch) = self.current_char() {
3216            regex_parse_steps += 1;
3217            if regex_parse_steps > MAX_REGEX_PARSE_STEPS {
3218                #[cfg(debug_assertions)]
3219                {
3220                    let text = &self.input[start..self.position];
3221                    let preview = truncate_preview(text, 50);
3222                    tracing::debug!(
3223                        limit = MAX_REGEX_PARSE_STEPS,
3224                        pattern_preview = %preview,
3225                        "Regex parse step budget exceeded"
3226                    );
3227                }
3228                self.position = self.input.len();
3229                return Some(Token {
3230                    token_type: TokenType::UnknownRest,
3231                    text: empty_arc(),
3232                    start,
3233                    end: self.position,
3234                });
3235            }
3236
3237            // Budget guard: prevent timeout on pathological input (Issue #422)
3238            // If exceeded, returns UnknownRest token for graceful degradation
3239            if let Some(token) = self.budget_guard(start, 0) {
3240                return Some(token);
3241            }
3242
3243            match ch {
3244                '/' if !in_character_class => {
3245                    self.advance();
3246                    // Parse flags - include all alphanumeric for proper validation in parser (MUT_005 fix)
3247                    while let Some(ch) = self.current_char() {
3248                        if ch.is_ascii_alphanumeric() {
3249                            self.advance();
3250                        } else {
3251                            break;
3252                        }
3253                    }
3254
3255                    let text = &self.input[start..self.position];
3256                    self.mode = LexerMode::ExpectOperator;
3257
3258                    return Some(Token {
3259                        token_type: TokenType::RegexMatch,
3260                        text: Arc::from(text),
3261                        start,
3262                        end: self.position,
3263                    });
3264                }
3265                '\\' => {
3266                    // Handle escape sequences: consume backslash + next char
3267                    self.advance();
3268                    if self.current_char().is_some() {
3269                        self.advance();
3270                    }
3271                }
3272                '[' => {
3273                    in_character_class = true;
3274                    self.advance();
3275                }
3276                ']' if in_character_class => {
3277                    in_character_class = false;
3278                    self.advance();
3279                }
3280                _ => self.advance(),
3281            }
3282        }
3283
3284        // Unterminated regex - EOF reached before closing /
3285        // Parser will emit diagnostic for unterminated literal
3286        None
3287    }
3288}
3289
3290// Checkpoint support for incremental parsing
3291
3292mod checkpoint_impl;
3293
3294#[cfg(test)]
3295mod test_format_debug;
3296#[cfg(test)]
3297mod tests;