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("e_handler::QR_SPEC);
3141 body_closed
3142 }
3143 "m" => {
3144 let (_pattern, body_closed) = self.read_delimited_body(delimiter);
3145 self.parse_regex_modifiers("e_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: "e_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;