Skip to main content

stryke/
lexer.rs

1use crate::error::{ErrorKind, StrykeError, StrykeResult};
2use crate::token::{keyword_or_ident, Token};
3
4/// Private-use character for a literal `$` inside double-quoted / `qq` strings (from `\$` in source).
5/// The parser maps this to `$` without variable interpolation (CPAN `eval qq/…/` code generators).
6pub const LITERAL_DOLLAR_IN_DQUOTE: char = '\u{E000}';
7/// Private-use character for a literal `@` inside double-quoted / `qq` strings (from `\@` in source).
8/// Mirrors `LITERAL_DOLLAR_IN_DQUOTE`; suppresses array interpolation so `"\@x"` is the literal `@x`.
9pub const LITERAL_AT_IN_DQUOTE: char = '\u{E001}';
10
11/// Resolve `\N{U+XXXX}` hex codepoints and `\N{LATIN SMALL LETTER E}` Unicode character names.
12fn parse_unicode_name(name: &str) -> Option<char> {
13    if let Some(hex) = name.strip_prefix("U+") {
14        let val = u32::from_str_radix(hex, 16).ok()?;
15        char::from_u32(val)
16    } else {
17        unicode_names2::character(name)
18    }
19}
20
21/// Flag letters after `m//`, `qr//`, etc. (`c` = `/gc`, `o` = compile once; CPAN uses both).
22const REGEX_FLAG_CHARS: &str = "gimsxecor";
23/// `Lexer` — see fields for layout.
24pub struct Lexer {
25    /// `input` field.
26    input: Vec<char>,
27    /// `pos` field.
28    pos: usize,
29    /// `line` field.
30    pub line: usize,
31    /// Line where the most recently-returned token starts. Set by
32    /// [`Self::next_token`] right after its leading
33    /// `skip_whitespace_and_comments` call so the value reflects the
34    /// **emitted** token's source position even when the call recursed
35    /// through a POD / heredoc skip (which advances `self.line` many
36    /// lines before producing the real token). Read by [`Self::tokenize`].
37    pub token_start_line: usize,
38    /// Tracks whether the last token was a term (value/variable/close-delim)
39    /// to disambiguate `/` as division vs regex and `{` as hash-ref vs block.
40    last_was_term: bool,
41    /// Tracks whether the last token was a method-call arrow (`->`). After
42    /// `->`, identifiers `s` / `tr` / `y` / `q` / `qq` / `qw` / `qr` / `m`
43    /// are method names — never substitution / transliteration / quote-like
44    /// operators. Without this gate, `$obj->y` followed by `,` would consume
45    /// `, …, …` as a transliteration body.
46    last_was_arrow: bool,
47    /// Snapshot of [`Self::last_was_arrow`] taken at the start of each
48    /// [`Self::next_token`] call so identifier-decoding logic can read the
49    /// previous-token state without racing against its own writes.
50    prev_arrow: bool,
51    /// Source path for [`StrykeError`] (e.g. real script or required `.pm` path).
52    error_file: String,
53    /// When > 0, the lexer treats `m` followed by `/` as a plain identifier
54    /// instead of `m//` regex syntax. Used in thread/pipeline stages where
55    /// `/m/` should be a regex grep filter, not `m//`.
56    pub suppress_m_regex: u32,
57    /// Set true by [`Self::next_token`] right before returning a token that
58    /// originated from a *bare* positional alias (`_`, `_0`, `_1`, …) — i.e.
59    /// without a leading `$` sigil. Read by [`Self::tokenize`] to record the
60    /// emitted token's index in [`Self::bare_positional_indices`]. Reset to
61    /// `false` at the top of every `next_token` call.
62    pub last_was_bare_positional: bool,
63    /// Indices into the token vector returned by [`Self::tokenize`] for every
64    /// bare-positional token. Used by the parser's `my $X = EXPR` rule to
65    /// auto-wrap an RHS that contains free positional aliases into an
66    /// implicit zero-arg coderef (so `my $f = _ * 2` ≡ `my $f = fn { _ * 2 }`).
67    pub bare_positional_indices: std::collections::HashSet<usize>,
68}
69
70impl Lexer {
71    /// `new` — see implementation.
72    pub fn new(input: &str) -> Self {
73        Self::new_with_file(input, "-e")
74    }
75    /// `new_with_file` — see implementation.
76    pub fn new_with_file(input: &str, file: impl Into<String>) -> Self {
77        Self {
78            input: input.chars().collect(),
79            pos: 0,
80            line: 1,
81            token_start_line: 1,
82            last_was_term: false,
83            last_was_arrow: false,
84            prev_arrow: false,
85            error_file: file.into(),
86            suppress_m_regex: 0,
87            last_was_bare_positional: false,
88            bare_positional_indices: std::collections::HashSet::new(),
89        }
90    }
91
92    fn syntax_err(&self, message: impl Into<String>, line: usize) -> StrykeError {
93        StrykeError::new(ErrorKind::Syntax, message, line, self.error_file.clone())
94    }
95
96    /// Used by the `s` / `tr` / `y` lexer arms when the identifier is followed
97    /// by `,`. Returns `true` only when the rest of the statement looks like a
98    /// genuine `s,PAT,REPL,FLAGS` / `tr,FROM,TO,FLAGS` shape — at least 2 more
99    /// commas before the next statement terminator (`;`, newline, EOF, or
100    /// closing brace/paren/bracket from the enclosing context). Without this
101    /// gate, `struct Pt { x, y, z }` would consume `y, z }` as a transliteration
102    /// body, and `$obj->y, ...` would eat the rest of the call.
103    fn lookahead_is_comma_delim_subst(&self) -> bool {
104        let mut commas = 0usize;
105        let mut depth_paren = 0i32;
106        let mut depth_bracket = 0i32;
107        let mut depth_brace = 0i32;
108        let mut i = self.pos;
109        while i < self.input.len() {
110            let c = self.input[i];
111            match c {
112                '\\' => {
113                    i += 2; // skip escaped char (regex backslash escapes are common in pat/repl)
114                    continue;
115                }
116                '(' => depth_paren += 1,
117                ')' => {
118                    if depth_paren == 0 {
119                        break;
120                    }
121                    depth_paren -= 1;
122                }
123                '[' => depth_bracket += 1,
124                ']' => {
125                    if depth_bracket == 0 {
126                        break;
127                    }
128                    depth_bracket -= 1;
129                }
130                '{' => depth_brace += 1,
131                '}' => {
132                    if depth_brace == 0 {
133                        break;
134                    }
135                    depth_brace -= 1;
136                }
137                ';' | '\n' => break,
138                ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
139                    commas += 1;
140                    if commas >= 3 {
141                        return true;
142                    }
143                }
144                _ => {}
145            }
146            i += 1;
147        }
148        // Need 3 total commas: `s,P,R,F` / `tr,F,T,F` (FLAGS may be empty,
149        // but the third comma must still be present).
150        commas >= 3
151    }
152
153    fn peek(&self) -> Option<char> {
154        self.input.get(self.pos).copied()
155    }
156
157    fn peek_at(&self, offset: usize) -> Option<char> {
158        self.input.get(self.pos + offset).copied()
159    }
160
161    /// True when `=` at `eq_pos` is Perl POD (`=head1`, `=cut`, …): first non-whitespace on the line.
162    /// Otherwise `$_=foo` would misparse `=f` as POD and swallow the rest of the file.
163    fn at_line_start_for_pod(&self, eq_pos: usize) -> bool {
164        let mut i = eq_pos;
165        while i > 0 {
166            i -= 1;
167            let c = self.input[i];
168            if c == '\n' {
169                return true;
170            }
171            if !c.is_whitespace() {
172                return false;
173            }
174        }
175        true
176    }
177
178    fn advance(&mut self) -> Option<char> {
179        let ch = self.input.get(self.pos).copied();
180        if let Some(c) = ch {
181            if c == '\n' {
182                self.line += 1;
183            }
184            self.pos += 1;
185        }
186        ch
187    }
188
189    fn skip_whitespace_and_comments(&mut self) {
190        while self.pos < self.input.len() {
191            let ch = self.input[self.pos];
192            if ch == '#' {
193                // Line comment
194                while self.pos < self.input.len() && self.input[self.pos] != '\n' {
195                    self.pos += 1;
196                }
197            } else if ch == '\\' && self.peek_at(1) == Some('\n') {
198                // Backslash-newline: line continuation (shell-style)
199                // Don't increment line — continued line is logically part of the same line
200                self.pos += 2;
201            } else if ch.is_whitespace() {
202                if ch == '\n' {
203                    self.line += 1;
204                }
205                self.pos += 1;
206            } else {
207                break;
208            }
209        }
210    }
211
212    /// Whitespace only — used after `q`/`qq`/`qr`/… before the opening delimiter so `#` is not
213    /// mistaken for a line comment (`qr#...#`, `qw#...#`).
214    fn skip_whitespace_only(&mut self) {
215        while self.pos < self.input.len() {
216            let ch = self.input[self.pos];
217            if ch.is_whitespace() {
218                if ch == '\n' {
219                    self.line += 1;
220                }
221                self.pos += 1;
222            } else {
223                break;
224            }
225        }
226    }
227
228    fn read_while(&mut self, pred: impl Fn(char) -> bool) -> String {
229        let mut s = String::new();
230        while let Some(ch) = self.peek() {
231            if pred(ch) {
232                s.push(ch);
233                self.advance();
234            } else {
235                break;
236            }
237        }
238        s
239    }
240
241    /// Peek past whitespace and check whether the next token starts a range
242    /// operator: `:`, `..`, `...`, or `~`. Used by the hex-integer lexer
243    /// to switch into range-friendly DoubleString mode so `0x00:0xFF:1` and
244    /// `0x00~0xFF~1` iterate with hex output instead of decimal. `~` here
245    /// must NOT be `~>` / `~>>` (the thread macro) — those are operators on
246    /// a value, not range starters.
247    fn next_is_range_separator(&self) -> bool {
248        let mut i = self.pos;
249        while i < self.input.len() && matches!(self.input[i], ' ' | '\t') {
250            i += 1;
251        }
252        if i >= self.input.len() {
253            return false;
254        }
255        match self.input[i] {
256            ':' => true,
257            '.' if self.input.get(i + 1) == Some(&'.') => true,
258            '~' if self.input.get(i + 1) != Some(&'>') => true,
259            _ => false,
260        }
261    }
262
263    /// `YYYY-MM-DD` / `YYYY-MM` lookahead. Called from [`Self::read_number`]
264    /// when the just-consumed integer part is exactly 4 digits followed by
265    /// `-<digit>`. Tries the longer `YYYY-MM-DD` shape first (full ISO date),
266    /// falling back to `YYYY-MM` (year-month). Both shapes require valid
267    /// month (01..=12) and, for the date form, valid day (01..=31). On match
268    /// returns the literal string and advances `self.pos` past it; on
269    /// failure restores `self.pos` so the caller falls through to the
270    /// existing arithmetic-as-`-` path. The 4-digit year requirement is the
271    /// disambiguator vs. plain subtraction (`2022-01-01` = date,
272    /// `5-2-1` = arithmetic).
273    fn try_consume_iso_date_tail(&mut self, start: usize) -> Option<String> {
274        let saved = self.pos;
275        let year: String = self.input[start..self.pos].iter().collect();
276        if year.len() != 4 || year.parse::<u16>().is_err() {
277            return None;
278        }
279        // Match `-MM`
280        if self.peek() != Some('-') {
281            return None;
282        }
283        if !self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
284            || !self.peek_at(2).is_some_and(|c| c.is_ascii_digit())
285        {
286            return None;
287        }
288        // Reject when third char after `-` is also a digit (e.g. `2022-100`)
289        // — that's arithmetic, not a month.
290        if self.peek_at(3).is_some_and(|c| c.is_ascii_digit()) {
291            return None;
292        }
293        let month_str: String = self.input[self.pos + 1..self.pos + 3].iter().collect();
294        let month: u8 = match month_str.parse() {
295            Ok(m) if (1..=12).contains(&m) => m,
296            _ => return None,
297        };
298        // Provisionally consume `-MM`
299        self.advance(); // -
300        self.advance(); // M
301        self.advance(); // M
302                        // Try `-DD` extension
303        if self.peek() == Some('-')
304            && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
305            && self.peek_at(2).is_some_and(|c| c.is_ascii_digit())
306            && !self.peek_at(3).is_some_and(|c| c.is_ascii_digit())
307        {
308            let day_str: String = self.input[self.pos + 1..self.pos + 3].iter().collect();
309            if let Ok(day) = day_str.parse::<u8>() {
310                if (1..=31).contains(&day) {
311                    self.advance(); // -
312                    self.advance(); // D
313                    self.advance(); // D
314                    let _ = month; // already validated
315                    return Some(format!("{}-{}-{:02}", year, month_str, day));
316                }
317            }
318        }
319        // Year-month form `YYYY-MM`. Reject if followed by another `-DIGIT`
320        // (would be arithmetic) — caught above by the third-digit guard.
321        Some(format!("{}-{}", year, month_str)).filter(|_| {
322            // No risky trailing chars beyond what we've already consumed.
323            let _ = saved;
324            true
325        })
326    }
327
328    /// IPv6 lookahead from an arbitrary starting pos. Called from
329    /// [`Self::read_number`] (digit-prefix), the identifier path (hex-letter
330    /// prefix `fe80::1`), and the `:` lexer arm (zero-compressed prefix
331    /// `::1`). `start` is where the IPv6 candidate begins in `self.input`;
332    /// `self.pos` may already be partway through but is reset here so the
333    /// scanner controls consumption. Greedily consumes hex digits, `:`, and
334    /// at most one `::`, then validates with Rust's [`std::net::Ipv6Addr`]
335    /// parser. On success returns the literal and leaves `self.pos` past
336    /// it; on failure restores `self.pos` to its pre-call value.
337    /// Acts only when the candidate has at least 2 colons — single-colon
338    /// `1:5` is unambiguous range syntax, and 3-segment chains of pure-digit
339    /// groups (`1:5:1`) never parse as IPv6 so range-with-step is preserved.
340    fn try_consume_ipv6_tail(&mut self, start: usize) -> Option<String> {
341        let saved = self.pos;
342        self.pos = start;
343        let mut seen_double_colon = false;
344        let mut prev_was_colon = false;
345        let mut colon_count = 0usize;
346        while self.pos < self.input.len() {
347            let c = self.input[self.pos];
348            if c == ':' {
349                colon_count += 1;
350                if prev_was_colon {
351                    if seen_double_colon {
352                        break;
353                    }
354                    seen_double_colon = true;
355                }
356                prev_was_colon = true;
357                self.advance();
358                continue;
359            }
360            if c.is_ascii_hexdigit() {
361                prev_was_colon = false;
362                self.advance();
363                continue;
364            }
365            break;
366        }
367        // Strip a trailing single colon (likely a range separator the lexer
368        // greedily ate); a trailing `::` is part of the address.
369        if self.pos > start
370            && self.input[self.pos - 1] == ':'
371            && (self.pos < start + 2 || self.input[self.pos - 2] != ':')
372        {
373            self.pos -= 1;
374            colon_count -= 1;
375        }
376        // Package-separator disambiguator: when the candidate ends right
377        // before an ASCII letter or `_` (an identifier continuation), the
378        // `::` is almost certainly a package qualifier (`B::GV::SAFENAME`)
379        // rather than IPv6 zero-compression. IPv6 lookahead bails so the
380        // standard package-separator path runs.
381        if self.pos < self.input.len() {
382            let next = self.input[self.pos];
383            if next.is_ascii_alphabetic() && !next.is_ascii_hexdigit() || next == '_' {
384                self.pos = saved;
385                return None;
386            }
387            // Three-segment package path: `A::B::C` greedy-matched `A::B` as
388            // IPv6 zero-compressed (legal: `0:0:0:0:0:0:A:B`). Detect the
389            // `::IDENT` continuation and bail so the standard PackageSep path
390            // runs. Without this, `package A::B::C` lexes as
391            // `Ident, DoubleString("A::B"), PackageSep, Ident("C")`.
392            if next == ':'
393                && self.input.get(self.pos + 1) == Some(&':')
394                && self
395                    .input
396                    .get(self.pos + 2)
397                    .is_some_and(|c| c.is_ascii_alphabetic() || *c == '_')
398            {
399                self.pos = saved;
400                return None;
401            }
402        }
403        let candidate: String = self.input[start..self.pos].iter().collect();
404        // Require at least one hex digit. The bare `::` form is technically
405        // valid IPv6 (all-zeros) but in real code it nearly always means
406        // something else — array-slice default step (`@a[::]`), package
407        // separator at the start of an empty list, etc. Users who want the
408        // unspecified address can write `::0`.
409        if colon_count < 2
410            || !candidate.chars().any(|c| c.is_ascii_hexdigit())
411            || candidate.parse::<std::net::Ipv6Addr>().is_err()
412        {
413            self.pos = saved;
414            return None;
415        }
416        // Package-qualifier disambiguator: a 2-segment candidate
417        // `LETTERS::LETTERS` with no digits anywhere parses as IPv6
418        // (e.g. `d::ab` ≡ `d:0:0:0:0:0:0:ab`, `dead::beef`) but in
419        // Perl/stryke source it is overwhelmingly a package qualifier
420        // (`fn d::ab ($x) { ... }`, `Foo::Bar->method`). Real IPv6
421        // literals in source carry at least one decimal digit (`fe80::1`,
422        // `::1`, `1::dead`), which keeps them on the IPv6 path. Bare
423        // pure-letter forms are routed back through PackageSep.
424        if !candidate.chars().any(|c| c.is_ascii_digit()) {
425            let segments: Vec<&str> = candidate.split("::").collect();
426            if segments.len() == 2
427                && segments
428                    .iter()
429                    .all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphabetic()))
430            {
431                self.pos = saved;
432                return None;
433            }
434        }
435        Some(candidate)
436    }
437
438    /// IPv4 dotted-quad lookahead. Called from [`Self::read_number`] when the
439    /// just-consumed integer part is followed by `.<digit>`. Speculatively
440    /// matches 3 more `.<digits>` segments and accepts only when every octet
441    /// parses as `u8` (0..=255). On match, returns the full dotted-quad
442    /// string (e.g. `"192.168.255.255"`) and advances `self.pos` past it; on
443    /// failure restores `self.pos` to its pre-call value so the caller falls
444    /// through to the existing float-lexing path.
445    fn try_consume_ipv4_tail(&mut self, start: usize) -> Option<String> {
446        let saved = self.pos;
447        // We've already consumed the first octet (start..self.pos).
448        let first: String = self.input[start..self.pos].iter().collect();
449        if first.parse::<u8>().is_err() {
450            return None;
451        }
452        let mut octets: Vec<String> = vec![first];
453        for _ in 0..3 {
454            if self.peek() != Some('.') {
455                self.pos = saved;
456                return None;
457            }
458            if !self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) {
459                self.pos = saved;
460                return None;
461            }
462            self.advance(); // consume '.'
463            let oct_start = self.pos;
464            while self.peek().is_some_and(|c| c.is_ascii_digit()) {
465                self.advance();
466            }
467            let octet: String = self.input[oct_start..self.pos].iter().collect();
468            if octet.parse::<u8>().is_err() {
469                self.pos = saved;
470                return None;
471            }
472            octets.push(octet);
473        }
474        // Reject 5-segment chains like `1.2.3.4.5` — the trailing `.<digit>`
475        // means the user wrote something else (e.g. version number, list of
476        // floats). Falling back to float lexing is safer than half-eating it.
477        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) {
478            self.pos = saved;
479            return None;
480        }
481        Some(octets.join("."))
482    }
483
484    fn read_number(&mut self) -> StrykeResult<Token> {
485        let start = self.pos;
486        let mut is_float = false;
487        let mut is_hex = false;
488        let mut is_oct = false;
489        let mut is_bin = false;
490
491        if self.peek() == Some('0') {
492            match self.peek_at(1) {
493                Some('x') | Some('X') => {
494                    is_hex = true;
495                    self.advance();
496                    self.advance();
497                }
498                Some('b') | Some('B') => {
499                    is_bin = true;
500                    self.advance();
501                    self.advance();
502                }
503                // `0o777` — Perl 5.34+ octal prefix (alongside the bare-`0`
504                // form). (BUG-082) Read the same digit pool as bare `0...`
505                // octals, but skip the `0o` prefix in the conversion.
506                Some('o') | Some('O') => {
507                    self.advance();
508                    self.advance();
509                    let digits = self.read_while(|c| c.is_ascii_digit() || c == '_');
510                    let clean: String = digits.chars().filter(|&c| c != '_').collect();
511                    let val = i64::from_str_radix(&clean, 8)
512                        .map_err(|_| self.syntax_err("Invalid octal literal", self.line))?;
513                    return Ok(Token::Integer(val));
514                }
515                Some(c) if c.is_ascii_digit() => {
516                    is_oct = true;
517                }
518                _ => {}
519            }
520        }
521
522        if is_hex {
523            let digits = self.read_while(|c| c.is_ascii_hexdigit() || c == '_');
524            let clean: String = digits.chars().filter(|&c| c != '_').collect();
525            let val = i64::from_str_radix(&clean, 16)
526                .map_err(|_| self.syntax_err("Invalid hex literal", self.line))?;
527            // Range-context lookahead: `0x00:0xFF:1` should iterate as hex
528            // strings (`0x00`, `0x01`, …, `0xFF`), preserving the leading
529            // `0x` and case-of-digits. When the next non-whitespace token is
530            // a range separator (`:`, `..`, `...`, or `!!!`), produce a
531            // string-typed literal so the runtime range op can detect the
532            // hex format and emit hex output. In all other contexts the
533            // hex is a normal integer (arithmetic, assignment, etc.).
534            if self.next_is_range_separator() {
535                let raw: String = self.input[start..self.pos].iter().collect();
536                return Ok(Token::DoubleString(raw));
537            }
538            return Ok(Token::Integer(val));
539        }
540        if is_bin {
541            let digits = self.read_while(|c| c == '0' || c == '1' || c == '_');
542            let clean: String = digits.chars().filter(|&c| c != '_').collect();
543            let val = i64::from_str_radix(&clean, 2)
544                .map_err(|_| self.syntax_err("Invalid binary literal", self.line))?;
545            return Ok(Token::Integer(val));
546        }
547
548        // Decimal or octal
549        let _int_part = self.read_while(|c| c.is_ascii_digit() || c == '_');
550        // IPv4 dotted-quad lookahead: `192.168.255.255` should lex as ONE
551        // string token, not as `192.168` (float) `.` `255.255` (float). Try
552        // to consume 3 more `.NUM` segments where every octet is 0..=255 AND
553        // not followed by another `.NUM` (so a 5-segment chain like
554        // `1.2.3.4.5` cleanly fails the ipv4 path and falls back to floats).
555        // Only fires when the current `.NUM` would have been a float decimal
556        // — preserves all existing float lexing.
557        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) {
558            if let Some(consumed) = self.try_consume_ipv4_tail(start) {
559                return Ok(Token::DoubleString(consumed));
560            }
561            is_float = true;
562            self.advance(); // consume '.'
563            let _frac = self.read_while(|c| c.is_ascii_digit() || c == '_');
564        }
565        // ISO-date / year-month lookahead: `2022-01-01` and `2022-01`.
566        // Distinct from arithmetic `2022 - 01 - 01` only because the lexer
567        // greedily consumes the dotted form here. The 4-digit-year guard
568        // inside [`Self::try_consume_iso_date_tail`] keeps `5-2-1` parsing
569        // as arithmetic.
570        if !is_float
571            && self.peek() == Some('-')
572            && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
573        {
574            if let Some(consumed) = self.try_consume_iso_date_tail(start) {
575                return Ok(Token::DoubleString(consumed));
576            }
577        }
578        // IPv6 lookahead: a hex-digit-only integer part followed by `:` and
579        // more hex / `:` could be IPv6. Try to parse and accept; on failure
580        // fall through to the existing range / arithmetic paths so plain
581        // numeric ranges (`1:10`) keep their meaning.
582        if !is_float && self.peek() == Some(':') {
583            if let Some(consumed) = self.try_consume_ipv6_tail(start) {
584                return Ok(Token::DoubleString(consumed));
585            }
586        }
587        // Scientific notation
588        if let Some('e') | Some('E') = self.peek() {
589            is_float = true;
590            self.advance();
591            if let Some('+') | Some('-') = self.peek() {
592                self.advance();
593            }
594            let _exp = self.read_while(|c| c.is_ascii_digit() || c == '_');
595        }
596
597        let raw: String = self.input[start..self.pos].iter().collect();
598        let clean: String = raw.chars().filter(|&c| c != '_').collect();
599
600        if is_float {
601            let val: f64 = clean
602                .parse()
603                .map_err(|_| self.syntax_err("Invalid float literal", self.line))?;
604            Ok(Token::Float(val))
605        } else if is_oct && clean.starts_with('0') && clean.len() > 1 {
606            let val = i64::from_str_radix(&clean[1..], 8)
607                .map_err(|_| self.syntax_err("Invalid octal literal", self.line))?;
608            Ok(Token::Integer(val))
609        } else {
610            let val: i64 = clean
611                .parse()
612                .map_err(|_| self.syntax_err("Invalid integer literal", self.line))?;
613            Ok(Token::Integer(val))
614        }
615    }
616
617    fn read_single_quoted_string(&mut self) -> StrykeResult<Token> {
618        self.advance(); // consume opening '
619        let mut s = String::new();
620        loop {
621            match self.advance() {
622                Some('\\') => match self.peek() {
623                    Some('\\') => {
624                        s.push('\\');
625                        self.advance();
626                    }
627                    Some('\'') => {
628                        s.push('\'');
629                        self.advance();
630                    }
631                    _ => s.push('\\'),
632                },
633                Some('\'') => break,
634                Some(c) => s.push(c),
635                None => return Err(self.syntax_err("Unterminated single-quoted string", self.line)),
636            }
637        }
638        Ok(Token::SingleString(s))
639    }
640
641    fn read_double_quoted_string(&mut self) -> StrykeResult<Token> {
642        self.advance(); // consume opening "
643                        // Triple-quoted form: `"""..."""` — multi-line interpolating string.
644                        // The opening `"` was just consumed; if the next two chars are also
645                        // `"`, we're in triple-quote mode. Read until the closing `"""`,
646                        // preserving raw newlines (no indent stripping). Interpolation
647                        // (`$var`, `@arr`, `#{expr}`) flows through unchanged because the
648                        // resulting `Token::DoubleString` body goes through the same
649                        // downstream interpolator as a normal `"..."`.
650        if self.peek() == Some('"') && self.peek_at(1) == Some('"') {
651            self.advance(); // consume 2nd "
652            self.advance(); // consume 3rd "
653            let s = self.read_triple_quoted_body(true)?;
654            return Ok(Token::DoubleString(s));
655        }
656        let s = self.read_interpolating_until('"')?;
657        Ok(Token::DoubleString(s))
658    }
659
660    /// Read the body of a triple-quoted string up to and including the
661    /// closing `"""`. `interpolate=true` honors backslash escapes the same
662    /// way `read_escaped_until` does (so `\n`, `\t`, `\\`, `\$`, `\@`,
663    /// `\"` etc. work inside `"""..."""`). `interpolate=false` is "raw"
664    /// mode: every byte is copied verbatim, including backslashes; the
665    /// only way out is a literal `"""`.
666    ///
667    /// Newlines are preserved verbatim — no indent stripping. The user
668    /// chose the indentation; we don't second-guess it.
669    fn read_triple_quoted_body(&mut self, interpolate: bool) -> StrykeResult<String> {
670        let mut s = String::new();
671        loop {
672            // Check for closing `"""` at the current position.
673            if self.peek() == Some('"')
674                && self.peek_at(1) == Some('"')
675                && self.peek_at(2) == Some('"')
676            {
677                self.advance(); // 1st "
678                self.advance(); // 2nd "
679                self.advance(); // 3rd "
680                return Ok(s);
681            }
682            let c = match self.advance() {
683                Some(c) => c,
684                None => {
685                    return Err(self.syntax_err(
686                        "Unterminated triple-quoted string (missing closing \"\"\")",
687                        self.line,
688                    ))
689                }
690            };
691            if interpolate && c == '\\' {
692                // Handle escapes the same way single-line `"..."` does.
693                // Reuse the existing escape table by hand for the common
694                // cases; anything we don't recognise falls through as
695                // `\` followed by the next char (matching Perl's
696                // permissive double-quote escape behavior).
697                let next = self.advance();
698                match next {
699                    Some('n') => s.push('\n'),
700                    Some('t') => s.push('\t'),
701                    Some('r') => s.push('\r'),
702                    Some('\\') => s.push('\\'),
703                    Some('"') => s.push('"'),
704                    Some('$') => s.push(LITERAL_DOLLAR_IN_DQUOTE),
705                    Some('@') => s.push(LITERAL_AT_IN_DQUOTE),
706                    Some('0') => s.push('\0'),
707                    Some('a') => s.push('\x07'),
708                    Some('b') => s.push('\x08'),
709                    Some('f') => s.push('\x0C'),
710                    Some('e') => s.push('\x1B'),
711                    Some(other) => {
712                        s.push('\\');
713                        s.push(other);
714                    }
715                    None => {
716                        return Err(self.syntax_err(
717                            "Unterminated escape at end of triple-quoted string",
718                            self.line,
719                        ))
720                    }
721                }
722                continue;
723            }
724            if c == '\n' {
725                self.line += 1;
726            }
727            s.push(c);
728        }
729    }
730
731    /// Same as [`Self::read_escaped_until`] but preserves `\1`..`\9` verbatim
732    /// (Perl's `s/(\d+)/\1/` numbered back-reference). Multi-digit octals
733    /// (`\012`, `\077`) still resolve here, matching Perl's documented
734    /// "two-or-more digit escapes are octal" rule.
735    fn read_substitution_replacement(&mut self, term: char) -> StrykeResult<String> {
736        self.read_escaped_until_inner(term, true, false)
737    }
738
739    fn read_escaped_until(&mut self, term: char) -> StrykeResult<String> {
740        self.read_escaped_until_inner(term, false, false)
741    }
742
743    /// Like [`Self::read_escaped_until`] but for interpolating bodies
744    /// (`"…"`, `qq…`, backticks): `#{…}` / `${…}` / `@{…}` expression
745    /// regions are tracked so a `"` (or whatever `term` is) inside them
746    /// does not end the string — `"ok #{"tom"}"` lexes as one token.
747    fn read_interpolating_until(&mut self, term: char) -> StrykeResult<String> {
748        self.read_escaped_until_inner(term, false, true)
749    }
750
751    /// Body parser for `q{…}`, `s/.../.../`, etc. When `defer_single_digit` is
752    /// `true`, `\1`..`\9` (followed by a non-octal-digit) are preserved
753    /// verbatim so the substitution layer can expand them as numbered
754    /// back-references (Perl's documented `s///` behavior). Multi-digit
755    /// octals (`\012`) still resolve numerically in both modes.
756    fn read_escaped_until_inner(
757        &mut self,
758        term: char,
759        defer_single_digit: bool,
760        interp: bool,
761    ) -> StrykeResult<String> {
762        // `#{expr}` / `${expr}` / `@{expr}` interpolation-region state
763        // (`interp` mode only). While `depth > 0` the terminator does not
764        // end the string, and `{` / `}` inside nested '…' / "…" literals
765        // (with `\` escapes) don't skew the depth count. State is tracked
766        // over the chars *pushed to the body* — the downstream interpolator
767        // re-lexes that body, so its escaping rules are the ones that govern.
768        fn interp_track(c: char, depth: &mut usize, quote: &mut Option<char>, esc: &mut bool) {
769            if let Some(q) = *quote {
770                if *esc {
771                    *esc = false;
772                } else if c == '\\' {
773                    *esc = true;
774                } else if c == q {
775                    *quote = None;
776                }
777            } else {
778                match c {
779                    '\'' | '"' => *quote = Some(c),
780                    '{' => *depth += 1,
781                    '}' => *depth = depth.saturating_sub(1),
782                    _ => {}
783                }
784            }
785        }
786        let mut interp_depth = 0usize;
787        let mut interp_quote: Option<char> = None;
788        let mut interp_esc = false;
789        let mut s = String::new();
790        loop {
791            match self.advance() {
792                Some('\\') => match self.advance() {
793                    Some('n') => s.push('\n'),
794                    Some('t') => s.push('\t'),
795                    Some('r') => s.push('\r'),
796                    Some('\\') => {
797                        if interp_depth > 0 {
798                            interp_track(
799                                '\\',
800                                &mut interp_depth,
801                                &mut interp_quote,
802                                &mut interp_esc,
803                            );
804                        }
805                        s.push('\\');
806                    }
807                    Some(c @ '0'..='7') => {
808                        // In substitution-replacement mode, defer `\1`..`\9`
809                        // (with no further octal digit) so the replacement
810                        // expander can read them as numbered back-refs.
811                        if defer_single_digit && c != '0' && !matches!(self.peek(), Some('0'..='7'))
812                        {
813                            s.push('\\');
814                            s.push(c);
815                            continue;
816                        }
817                        let mut oct = String::new();
818                        oct.push(c);
819                        for _ in 0..2 {
820                            match self.peek() {
821                                Some(d) if ('0'..='7').contains(&d) => {
822                                    oct.push(self.advance().unwrap());
823                                }
824                                _ => break,
825                            }
826                        }
827                        let val = u32::from_str_radix(&oct, 8).unwrap();
828                        let ch = char::from_u32(val)
829                            .ok_or_else(|| self.syntax_err("Invalid octal escape", self.line))?;
830                        s.push(ch);
831                    }
832                    Some('a') => s.push('\x07'),
833                    Some('b') => s.push('\x08'),
834                    Some('f') => s.push('\x0C'),
835                    Some('e') => s.push('\x1B'),
836                    Some('$') => s.push(LITERAL_DOLLAR_IN_DQUOTE),
837                    Some('@') => s.push(LITERAL_AT_IN_DQUOTE),
838                    Some('c') => {
839                        let ch = self
840                            .advance()
841                            .ok_or_else(|| self.syntax_err("Unterminated \\c escape", self.line))?;
842                        s.push(char::from(ch.to_ascii_uppercase() as u8 ^ 0x40));
843                    }
844                    Some('o') if self.peek() == Some('{') => {
845                        self.advance(); // '{'
846                        let oct = self.read_while(|c| c != '}');
847                        if self.peek() != Some('}') {
848                            return Err(
849                                self.syntax_err("Unterminated \\o{...} in string", self.line)
850                            );
851                        }
852                        self.advance(); // '}'
853                        if oct.is_empty() {
854                            return Err(self.syntax_err("Empty \\o{} in string", self.line));
855                        }
856                        let val = u32::from_str_radix(&oct, 8).map_err(|_| {
857                            self.syntax_err("Invalid octal digits in \\o{...}", self.line)
858                        })?;
859                        let c = char::from_u32(val).ok_or_else(|| {
860                            self.syntax_err("Invalid Unicode scalar value in \\o{...}", self.line)
861                        })?;
862                        s.push(c);
863                    }
864                    Some('u') if self.peek() == Some('{') => {
865                        self.advance(); // '{'
866                        let hex = self.read_while(|c| c != '}');
867                        if self.peek() != Some('}') {
868                            return Err(
869                                self.syntax_err("Unterminated \\u{...} in string", self.line)
870                            );
871                        }
872                        self.advance(); // '}'
873                        if hex.is_empty() {
874                            return Err(self.syntax_err("Empty \\u{} in string", self.line));
875                        }
876                        let val = u32::from_str_radix(&hex, 16).map_err(|_| {
877                            self.syntax_err("Invalid hex digits in \\u{...}", self.line)
878                        })?;
879                        let c = char::from_u32(val).ok_or_else(|| {
880                            self.syntax_err("Invalid Unicode scalar value in \\u{...}", self.line)
881                        })?;
882                        s.push(c);
883                    }
884                    Some('N') if self.peek() == Some('{') => {
885                        self.advance(); // '{'
886                        let name = self.read_while(|c| c != '}');
887                        if self.peek() != Some('}') {
888                            return Err(
889                                self.syntax_err("Unterminated \\N{...} in string", self.line)
890                            );
891                        }
892                        self.advance(); // '}'
893                        if name.is_empty() {
894                            return Err(self.syntax_err("Empty \\N{} in string", self.line));
895                        }
896                        let c = parse_unicode_name(&name).ok_or_else(|| {
897                            self.syntax_err(
898                                format!("Unknown Unicode character name: {name}"),
899                                self.line,
900                            )
901                        })?;
902                        s.push(c);
903                    }
904                    Some('x') => {
905                        if self.peek() == Some('{') {
906                            self.advance(); // '{'
907                            let hex = self.read_while(|c| c != '}');
908                            if self.peek() != Some('}') {
909                                return Err(
910                                    self.syntax_err("Unterminated \\x{...} in string", self.line)
911                                );
912                            }
913                            self.advance(); // '}'
914                            if hex.is_empty() {
915                                return Err(self.syntax_err("Empty \\x{} in string", self.line));
916                            }
917                            let val = u32::from_str_radix(&hex, 16).map_err(|_| {
918                                self.syntax_err("Invalid hex digits in \\x{...}", self.line)
919                            })?;
920                            let c = char::from_u32(val).ok_or_else(|| {
921                                self.syntax_err(
922                                    "Invalid Unicode scalar value in \\x{...}",
923                                    self.line,
924                                )
925                            })?;
926                            s.push(c);
927                        } else {
928                            // Unbraced: up to two hex digits (Perl: "\\x414" is "\\x41" + "4").
929                            let mut hex = String::new();
930                            for _ in 0..2 {
931                                match self.peek() {
932                                    Some(c) if c.is_ascii_hexdigit() => {
933                                        hex.push(self.advance().unwrap());
934                                    }
935                                    _ => break,
936                                }
937                            }
938                            if hex.is_empty() {
939                                // Perl: bare "\\x" in a string yields NUL.
940                                s.push('\0');
941                            } else if let Ok(val) = u32::from_str_radix(&hex, 16) {
942                                if let Some(c) = char::from_u32(val) {
943                                    s.push(c);
944                                } else {
945                                    return Err(self.syntax_err(
946                                        "Invalid code point in \\x escape",
947                                        self.line,
948                                    ));
949                                }
950                            }
951                        }
952                    }
953                    Some(c) if c == term => {
954                        if interp_depth > 0 {
955                            interp_track(c, &mut interp_depth, &mut interp_quote, &mut interp_esc);
956                        }
957                        s.push(c);
958                    }
959                    Some(c) => {
960                        if interp_depth > 0 {
961                            interp_track(
962                                '\\',
963                                &mut interp_depth,
964                                &mut interp_quote,
965                                &mut interp_esc,
966                            );
967                            interp_track(c, &mut interp_depth, &mut interp_quote, &mut interp_esc);
968                        }
969                        s.push('\\');
970                        s.push(c);
971                    }
972                    None => return Err(self.syntax_err("Unterminated string", self.line)),
973                },
974                Some(c) if c == term && interp_depth == 0 => break,
975                Some(c) => {
976                    if interp_depth > 0 {
977                        interp_track(c, &mut interp_depth, &mut interp_quote, &mut interp_esc);
978                    } else if interp
979                        && matches!(c, '$' | '@' | '#')
980                        && self.peek() == Some('{')
981                        && (c != '#' || !crate::compat_mode())
982                    {
983                        // `${…}` / `@{…}` always interpolate; `#{…}` is a
984                        // stryke extension, literal under --compat.
985                        interp_depth = 1;
986                        s.push(c);
987                        self.advance(); // the `{`
988                        s.push('{');
989                        continue;
990                    }
991                    s.push(c);
992                }
993                None => return Err(self.syntax_err("Unterminated string", self.line)),
994            }
995        }
996        Ok(s)
997    }
998
999    /// `q(...)` / `qq(...)` with pairing delimiters — Perl balances nested `()`, `[]`, `{}`, `<>`
1000    /// so `q(sub ($) { 1 })` does not end at the `)` in `($)` (core `Carp.pm` uses `eval(q(...))`).
1001    fn read_q_qq_balanced_body(
1002        &mut self,
1003        open: char,
1004        close: char,
1005        is_qq: bool,
1006    ) -> StrykeResult<String> {
1007        let mut s = String::new();
1008        let mut depth: usize = 1;
1009        loop {
1010            match self.peek() {
1011                Some('\\') => {
1012                    self.advance();
1013                    if is_qq {
1014                        match self.advance() {
1015                            Some('n') => s.push('\n'),
1016                            Some('t') => s.push('\t'),
1017                            Some('r') => s.push('\r'),
1018                            Some('\\') => s.push('\\'),
1019                            Some(c @ '0'..='7') => {
1020                                let mut oct = String::new();
1021                                oct.push(c);
1022                                for _ in 0..2 {
1023                                    match self.peek() {
1024                                        Some(d) if ('0'..='7').contains(&d) => {
1025                                            oct.push(self.advance().unwrap());
1026                                        }
1027                                        _ => break,
1028                                    }
1029                                }
1030                                let val = u32::from_str_radix(&oct, 8).unwrap();
1031                                let ch = char::from_u32(val).ok_or_else(|| {
1032                                    self.syntax_err("Invalid octal escape", self.line)
1033                                })?;
1034                                s.push(ch);
1035                            }
1036                            Some('a') => s.push('\x07'),
1037                            Some('b') => s.push('\x08'),
1038                            Some('f') => s.push('\x0C'),
1039                            Some('e') => s.push('\x1B'),
1040                            Some('$') => s.push(LITERAL_DOLLAR_IN_DQUOTE),
1041                            Some('@') => s.push(LITERAL_AT_IN_DQUOTE),
1042                            Some('c') => {
1043                                let ch = self.advance().ok_or_else(|| {
1044                                    self.syntax_err("Unterminated \\c escape", self.line)
1045                                })?;
1046                                s.push(char::from(ch.to_ascii_uppercase() as u8 ^ 0x40));
1047                            }
1048                            Some('o') if self.peek() == Some('{') => {
1049                                self.advance();
1050                                let oct = self.read_while(|c| c != '}');
1051                                if self.peek() != Some('}') {
1052                                    return Err(self.syntax_err(
1053                                        "Unterminated \\o{...} in qq string",
1054                                        self.line,
1055                                    ));
1056                                }
1057                                self.advance();
1058                                if oct.is_empty() {
1059                                    return Err(
1060                                        self.syntax_err("Empty \\o{} in qq string", self.line)
1061                                    );
1062                                }
1063                                let val = u32::from_str_radix(&oct, 8).map_err(|_| {
1064                                    self.syntax_err("Invalid octal digits in \\o{...}", self.line)
1065                                })?;
1066                                let c = char::from_u32(val).ok_or_else(|| {
1067                                    self.syntax_err(
1068                                        "Invalid Unicode scalar value in \\o{...}",
1069                                        self.line,
1070                                    )
1071                                })?;
1072                                s.push(c);
1073                            }
1074                            Some('u') if self.peek() == Some('{') => {
1075                                self.advance();
1076                                let hex = self.read_while(|c| c != '}');
1077                                if self.peek() != Some('}') {
1078                                    return Err(self.syntax_err(
1079                                        "Unterminated \\u{...} in qq string",
1080                                        self.line,
1081                                    ));
1082                                }
1083                                self.advance();
1084                                if hex.is_empty() {
1085                                    return Err(
1086                                        self.syntax_err("Empty \\u{} in qq string", self.line)
1087                                    );
1088                                }
1089                                let val = u32::from_str_radix(&hex, 16).map_err(|_| {
1090                                    self.syntax_err("Invalid hex digits in \\u{...}", self.line)
1091                                })?;
1092                                let c = char::from_u32(val).ok_or_else(|| {
1093                                    self.syntax_err(
1094                                        "Invalid Unicode scalar value in \\u{...}",
1095                                        self.line,
1096                                    )
1097                                })?;
1098                                s.push(c);
1099                            }
1100                            Some('N') if self.peek() == Some('{') => {
1101                                self.advance();
1102                                let name = self.read_while(|c| c != '}');
1103                                if self.peek() != Some('}') {
1104                                    return Err(self.syntax_err(
1105                                        "Unterminated \\N{...} in qq string",
1106                                        self.line,
1107                                    ));
1108                                }
1109                                self.advance();
1110                                if name.is_empty() {
1111                                    return Err(
1112                                        self.syntax_err("Empty \\N{} in qq string", self.line)
1113                                    );
1114                                }
1115                                let c = parse_unicode_name(&name).ok_or_else(|| {
1116                                    self.syntax_err(
1117                                        format!("Unknown Unicode character name: {name}"),
1118                                        self.line,
1119                                    )
1120                                })?;
1121                                s.push(c);
1122                            }
1123                            Some('x') => {
1124                                if self.peek() == Some('{') {
1125                                    self.advance();
1126                                    let hex = self.read_while(|c| c != '}');
1127                                    if self.peek() != Some('}') {
1128                                        return Err(self.syntax_err(
1129                                            "Unterminated \\x{...} in qq string",
1130                                            self.line,
1131                                        ));
1132                                    }
1133                                    self.advance();
1134                                    if hex.is_empty() {
1135                                        return Err(
1136                                            self.syntax_err("Empty \\x{} in qq string", self.line)
1137                                        );
1138                                    }
1139                                    let val = u32::from_str_radix(&hex, 16).map_err(|_| {
1140                                        self.syntax_err("Invalid hex digits in \\x{...}", self.line)
1141                                    })?;
1142                                    let c = char::from_u32(val).ok_or_else(|| {
1143                                        self.syntax_err(
1144                                            "Invalid Unicode scalar value in \\x{...}",
1145                                            self.line,
1146                                        )
1147                                    })?;
1148                                    s.push(c);
1149                                } else {
1150                                    let mut hex = String::new();
1151                                    for _ in 0..2 {
1152                                        match self.peek() {
1153                                            Some(c) if c.is_ascii_hexdigit() => {
1154                                                hex.push(self.advance().unwrap());
1155                                            }
1156                                            _ => break,
1157                                        }
1158                                    }
1159                                    if hex.is_empty() {
1160                                        s.push('\0');
1161                                    } else if let Ok(val) = u32::from_str_radix(&hex, 16) {
1162                                        if let Some(c) = char::from_u32(val) {
1163                                            s.push(c);
1164                                        } else {
1165                                            return Err(self.syntax_err(
1166                                                "Invalid code point in \\x escape",
1167                                                self.line,
1168                                            ));
1169                                        }
1170                                    }
1171                                }
1172                            }
1173                            Some(c) if c == close && depth == 1 => s.push(close),
1174                            Some(c) => {
1175                                s.push('\\');
1176                                s.push(c);
1177                            }
1178                            None => {
1179                                return Err(
1180                                    self.syntax_err("Unterminated qq(...) string", self.line)
1181                                );
1182                            }
1183                        }
1184                    } else {
1185                        match self.advance() {
1186                            Some(c) if c == close && depth == 1 => s.push(close),
1187                            Some(c) => {
1188                                s.push('\\');
1189                                s.push(c);
1190                            }
1191                            None => {
1192                                return Err(
1193                                    self.syntax_err("Unterminated q(...) string", self.line)
1194                                );
1195                            }
1196                        }
1197                    }
1198                }
1199                Some(c) if c == open => {
1200                    self.advance();
1201                    depth += 1;
1202                    s.push(open);
1203                }
1204                Some(c) if c == close => {
1205                    self.advance();
1206                    if depth == 1 {
1207                        break;
1208                    }
1209                    depth -= 1;
1210                    s.push(close);
1211                }
1212                Some(c) => {
1213                    self.advance();
1214                    s.push(c);
1215                }
1216                None => {
1217                    return Err(self.syntax_err("Unterminated q/qq bracketed string", self.line));
1218                }
1219            }
1220        }
1221        Ok(s)
1222    }
1223
1224    fn read_regex(&mut self) -> StrykeResult<Token> {
1225        self.advance(); // consume opening /
1226        let mut pattern = String::new();
1227        loop {
1228            match self.advance() {
1229                Some('\\') => {
1230                    pattern.push('\\');
1231                    if let Some(c) = self.advance() {
1232                        pattern.push(c);
1233                    }
1234                }
1235                Some('/') => break,
1236                Some(c) => pattern.push(c),
1237                None => return Err(self.syntax_err("Unterminated regex", self.line)),
1238            }
1239        }
1240        let flags = self.read_while(|c| REGEX_FLAG_CHARS.contains(c));
1241        Ok(Token::Regex(pattern, flags, '/'))
1242    }
1243
1244    fn read_qw(&mut self) -> StrykeResult<Token> {
1245        // Already consumed 'qw', now expect delimiter
1246        self.skip_whitespace_only();
1247        let open = self
1248            .advance()
1249            .ok_or_else(|| self.syntax_err("Expected delimiter after qw", self.line))?;
1250        let close = match open {
1251            '(' => ')',
1252            '[' => ']',
1253            '{' => '}',
1254            '<' => '>',
1255            c => c,
1256        };
1257        let mut words = Vec::new();
1258        if matches!(open, '(' | '[' | '{' | '<') {
1259            // Perl balances nested delimiters in `qw( ... )` / `qw[ ... ]` / … so
1260            // `qw( (SV*)pWARN_ALL )` is one word (core `B.pm` line 88).
1261            let mut depth: usize = 1;
1262            let mut buf = String::new();
1263            loop {
1264                match self.peek() {
1265                    None => {
1266                        return Err(self.syntax_err("Unterminated qw()", self.line));
1267                    }
1268                    Some(c) if depth == 1 && c.is_whitespace() => {
1269                        self.advance();
1270                        if !buf.is_empty() {
1271                            words.push(buf.clone());
1272                            buf.clear();
1273                        }
1274                        while self.peek().is_some_and(|c| c.is_whitespace()) {
1275                            self.advance();
1276                        }
1277                    }
1278                    Some(c) if c == close && depth == 1 => {
1279                        self.advance();
1280                        if !buf.is_empty() {
1281                            words.push(buf);
1282                        }
1283                        break;
1284                    }
1285                    Some(c) if c == open => {
1286                        depth += 1;
1287                        buf.push(self.advance().unwrap());
1288                    }
1289                    Some(c) if c == close => {
1290                        // `depth == 1 && close` is handled above (final qw delimiter).
1291                        debug_assert!(depth >= 2);
1292                        depth -= 1;
1293                        buf.push(self.advance().unwrap());
1294                    }
1295                    Some(_) => {
1296                        buf.push(self.advance().unwrap());
1297                    }
1298                }
1299            }
1300            return Ok(Token::QW(words));
1301        }
1302        loop {
1303            // Skip whitespace inside qw
1304            while let Some(ch) = self.peek() {
1305                if ch.is_whitespace() {
1306                    self.advance();
1307                } else {
1308                    break;
1309                }
1310            }
1311            if self.peek() == Some(close) {
1312                self.advance();
1313                break;
1314            }
1315            if self.peek().is_none() {
1316                return Err(self.syntax_err("Unterminated qw()", self.line));
1317            }
1318            let word = self.read_while(|c| !c.is_whitespace() && c != close);
1319            if !word.is_empty() {
1320                words.push(word);
1321            }
1322        }
1323        Ok(Token::QW(words))
1324    }
1325
1326    fn read_heredoc_tag(&mut self) -> StrykeResult<(String, bool, bool)> {
1327        self.read_heredoc_tag_inner(false)
1328    }
1329
1330    fn read_heredoc_tag_inner(&mut self, indented: bool) -> StrykeResult<(String, bool, bool)> {
1331        // We've consumed '<<'. Now figure out the tag.
1332        // Returns (tag, interpolate, indented).
1333        let quoted;
1334        let tag;
1335        match self.peek() {
1336            Some('\'') => {
1337                self.advance();
1338                tag = self.read_while(|c| c != '\'');
1339                self.advance(); // closing quote
1340                quoted = false; // no interpolation
1341            }
1342            Some('"') => {
1343                self.advance();
1344                tag = self.read_while(|c| c != '"');
1345                self.advance();
1346                quoted = true;
1347            }
1348            Some('~') => {
1349                self.advance(); // indented heredoc
1350                return self.read_heredoc_tag_inner(true); // recurse with indented=true
1351            }
1352            _ => {
1353                tag = self.read_while(|c| c.is_alphanumeric() || c == '_');
1354                quoted = true;
1355            }
1356        }
1357        Ok((tag, quoted, indented))
1358    }
1359
1360    fn read_heredoc_body(&mut self, tag: &str, indented: bool) -> StrykeResult<String> {
1361        // Read until we find a line that is exactly the tag (or, for indented heredocs,
1362        // a line whose trimmed content equals the tag).
1363        let mut lines: Vec<String> = Vec::new();
1364        // First, skip to end of current line
1365        while let Some(ch) = self.peek() {
1366            if ch == '\n' {
1367                self.advance();
1368                break;
1369            }
1370            self.advance();
1371        }
1372        let mut terminator_indent: Option<usize> = None;
1373        loop {
1374            let _line_start = self.pos;
1375            let line = self.read_while(|c| c != '\n');
1376            if line.trim() == tag {
1377                // For indented heredocs, the terminator's leading whitespace determines
1378                // how much to strip from all body lines.
1379                if indented {
1380                    terminator_indent = Some(line.len() - line.trim_start().len());
1381                }
1382                break;
1383            }
1384            lines.push(line);
1385            if self.peek() == Some('\n') {
1386                self.advance();
1387            } else if self.pos >= self.input.len() {
1388                return Err(self.syntax_err(
1389                    format!("Unterminated heredoc (looking for '{tag}')"),
1390                    self.line,
1391                ));
1392            }
1393        }
1394        if self.peek() == Some('\n') {
1395            self.advance();
1396        }
1397        // For indented heredocs (<<~), strip leading whitespace from each line,
1398        // up to the amount of indentation on the terminator line.
1399        if indented {
1400            let strip = terminator_indent.unwrap_or(0);
1401            let mut body = String::new();
1402            for line in lines {
1403                let ws_count = line.len() - line.trim_start().len();
1404                let to_strip = ws_count.min(strip);
1405                body.push_str(&line[to_strip..]);
1406                body.push('\n');
1407            }
1408            Ok(body)
1409        } else {
1410            let mut body = String::new();
1411            for line in lines {
1412                body.push_str(&line);
1413                body.push('\n');
1414            }
1415            Ok(body)
1416        }
1417    }
1418
1419    fn read_identifier(&mut self) -> String {
1420        self.read_while(|c| c.is_alphanumeric() || c == '_')
1421    }
1422
1423    /// `Foo::Bar::Baz` after the leading sigil.
1424    fn read_package_qualified_identifier(&mut self) -> String {
1425        let mut s = self.read_identifier();
1426        while self.peek() == Some(':') && self.input.get(self.pos + 1) == Some(&':') {
1427            self.advance();
1428            self.advance();
1429            s.push_str("::");
1430            s.push_str(&self.read_identifier());
1431        }
1432        s
1433    }
1434
1435    /// Body lines for `format N =` … `.` (excluding the closing `.` line).
1436    fn read_format_body(&mut self) -> StrykeResult<Vec<String>> {
1437        while self.peek().is_some_and(|c| c == ' ' || c == '\t') {
1438            self.advance();
1439        }
1440        if self.peek() == Some('\n') {
1441            self.advance();
1442        }
1443        let mut lines = Vec::new();
1444        loop {
1445            let mut line = String::new();
1446            while let Some(c) = self.peek() {
1447                if c == '\n' {
1448                    self.advance();
1449                    break;
1450                }
1451                if c == '\r' {
1452                    self.advance();
1453                    if self.peek() == Some('\n') {
1454                        self.advance();
1455                    }
1456                    break;
1457                }
1458                line.push(c);
1459                self.advance();
1460            }
1461            if line.trim() == "." {
1462                break;
1463            }
1464            lines.push(line);
1465            if self.peek().is_none() {
1466                return Err(self.syntax_err(
1467                    "Unterminated format (expected '.' on its own line before end of file)",
1468                    self.line,
1469                ));
1470            }
1471        }
1472        Ok(lines)
1473    }
1474
1475    fn read_variable_name(&mut self) -> String {
1476        // Handle special vars like $_, $!, $0, $/, $^I, etc.
1477        match self.peek() {
1478            // Second `$` in `$$_{` — with leading `$` already consumed, we have `$` `_` `{` → `$_` then `{`.
1479            Some('$')
1480                if self.input.get(self.pos + 1) == Some(&'_')
1481                    && self.input.get(self.pos + 2) == Some(&'{') =>
1482            {
1483                self.advance(); // second $
1484                self.advance(); // `_` of `$_`
1485                "_".to_string()
1486            }
1487            // `$::{$key}` / `$::Foo` — stash access (`%::`) and package names rooted at `::` (Perl `$::` ≡ main stash).
1488            Some(':') if self.input.get(self.pos + 1) == Some(&':') => {
1489                self.advance();
1490                self.advance();
1491                let mut s = "::".to_string();
1492                if self.peek().is_some_and(|c| c.is_alphabetic() || c == '_') {
1493                    s.push_str(&self.read_identifier());
1494                }
1495                while self.peek() == Some(':') && self.input.get(self.pos + 1) == Some(&':') {
1496                    self.advance();
1497                    self.advance();
1498                    s.push_str("::");
1499                    s.push_str(&self.read_identifier());
1500                }
1501                s
1502            }
1503            Some(c) if c.is_alphabetic() || c == '_' => {
1504                let mut ident = self.read_package_qualified_identifier();
1505                // `$main::!`, `$main::?`, `$main::0` — stryke
1506                // implements the Perl docs faithfully ("All
1507                // punctuation variables like $_ reside in main"). The
1508                // qualified identifier ends with the trailing `::`
1509                // (because the punctuation leaf isn't alphanumeric);
1510                // pick up a single punctuation / digit char or a
1511                // caret-prefixed letter (`$main::^O`) as the leaf.
1512                // The scope getter canonicalizes `main::PUNCT` →
1513                // `PUNCT` via `strip_main_prefix` so storage stays
1514                // unified.
1515                //
1516                // Disabled in `--compat`: Perl 5.42's parser rejects
1517                // `$main::!` (treats `$main::` as the empty-name var
1518                // and leaves `!` for the next token). To stay
1519                // byte-identical to perl(1) we don't consume the
1520                // punct leaf when compat mode is on. Stryke-strict
1521                // default mode still gets the docs-faithful behavior.
1522                if ident.ends_with("::") && !crate::compat_mode() {
1523                    match self.peek() {
1524                        Some('^')
1525                            if self
1526                                .input
1527                                .get(self.pos + 1)
1528                                .is_some_and(|c| c.is_alphabetic()) =>
1529                        {
1530                            self.advance();
1531                            let c2 = self.advance().unwrap();
1532                            ident.push('^');
1533                            ident.push(c2);
1534                        }
1535                        Some(c) if "!@$&*+;',\"\\|?/<>.0123456789~%-=()[]{}".contains(c) => {
1536                            self.advance();
1537                            ident.push(c);
1538                        }
1539                        _ => {}
1540                    }
1541                }
1542                // `$_<`, `$_<<`, … — outer topic chain (stryke extension). Also
1543                // applies to positional slots: `$_0<<<<<`, `$_1<<<<<`, etc. The
1544                // canonical scope key is `_<<<<<` (slot 0) or `_N<<<<<` (slot N).
1545                //
1546                // Indexed-ascent shortcut: `$_<N` ≡ `$_<<<...<` (N chevrons),
1547                // with N a positive integer. `$_<3` is much more readable than
1548                // `$_<<<` past depth 2. The lexer synthesizes the chevron form
1549                // so the rest of the system (scope keys, parse) is unchanged.
1550                let is_topic_slot = ident == "_"
1551                    || (ident.len() > 1
1552                        && ident.starts_with('_')
1553                        && ident[1..].bytes().all(|b| b.is_ascii_digit()));
1554                if is_topic_slot {
1555                    let mut lts = String::new();
1556                    while self.peek() == Some('<') {
1557                        self.advance();
1558                        lts.push('<');
1559                    }
1560                    // Indexed-ascent: after a single `<`, if the next chars are
1561                    // digits NOT followed by `>` or `:` (which would make it a
1562                    // string slice like `$_<1:5>`), expand `<N` to N chevrons.
1563                    if lts.len() == 1 && self.peek().is_some_and(|c| c.is_ascii_digit()) {
1564                        let mut peek_off = 0usize;
1565                        while self.peek_at(peek_off).is_some_and(|c| c.is_ascii_digit()) {
1566                            peek_off += 1;
1567                        }
1568                        let trailing = self.peek_at(peek_off);
1569                        let is_slice = matches!(trailing, Some(':') | Some('>'));
1570                        if !is_slice {
1571                            let mut digits = String::new();
1572                            for _ in 0..peek_off {
1573                                if let Some(c) = self.advance() {
1574                                    digits.push(c);
1575                                }
1576                            }
1577                            if let Ok(n) = digits.parse::<usize>() {
1578                                if n >= 1 {
1579                                    // Replace the single `<` already collected
1580                                    // with N chevrons (we already consumed 1).
1581                                    for _ in 1..n {
1582                                        lts.push('<');
1583                                    }
1584                                }
1585                            }
1586                        }
1587                    }
1588                    if !lts.is_empty() {
1589                        return format!("{}{}", ident, lts);
1590                    }
1591                }
1592                ident
1593            }
1594            Some('^') => {
1595                self.advance();
1596                // Perl `$^I`, `$^O`, … — caret plus one letter (or `^` alone).
1597                if self.peek().is_some_and(|c| c.is_alphabetic()) {
1598                    let c2 = self.advance().unwrap();
1599                    format!("^{}", c2)
1600                } else {
1601                    "^".to_string()
1602                }
1603            }
1604            // `${name}` — must run before the punctuation branch (`{` is also listed there).
1605            Some('{') => {
1606                self.advance(); // {
1607                let name = self.read_while(|c| c != '}');
1608                if self.peek() == Some('}') {
1609                    self.advance();
1610                }
1611                name
1612            }
1613            // Perl `$#name` — last index of `@name` (scalar name stored as `#name`).
1614            Some('#') => {
1615                self.advance();
1616                if self.peek().is_some_and(|c| c.is_alphabetic() || c == '_') {
1617                    let mut name = String::from("#");
1618                    name.push_str(&self.read_package_qualified_identifier());
1619                    name
1620                } else {
1621                    "#".to_string()
1622                }
1623            }
1624            Some(c) if "!@$&*+;',\"\\|?/<>.0123456789~%-=()[]{}".contains(c) => {
1625                self.advance();
1626                c.to_string()
1627            }
1628            _ => String::new(),
1629        }
1630    }
1631
1632    /// `${$name}` / `${$Foo::bar}` — when the braced body is a plain scalar `$identifier`, Perl treats it
1633    /// like `$$name` (scalar deref). The naive lexer otherwise yields a bogus [`Token::ScalarVar`] name
1634    /// containing a leading `$` (e.g. Try::Tiny's `${$code_ref}`).
1635    fn braced_body_symbolic_scalar_deref_name(body: &str) -> Option<&str> {
1636        let body = body.trim();
1637        let rest = body.strip_prefix('$')?;
1638        if rest.is_empty() {
1639            return None;
1640        }
1641        let mut chars = rest.chars();
1642        let c0 = chars.next()?;
1643        if !(c0.is_alphabetic() || c0 == '_') {
1644            return None;
1645        }
1646        for c in chars {
1647            if !(c.is_alphanumeric() || c == '_' || c == ':') {
1648                return None;
1649            }
1650        }
1651        Some(rest)
1652    }
1653    /// `next_token` — see implementation.
1654    pub fn next_token(&mut self) -> StrykeResult<Token> {
1655        self.skip_whitespace_and_comments();
1656        // Stamp the start line for [`Self::tokenize`]. Recursive calls
1657        // through POD / heredoc skip will overwrite this with the post-
1658        // skip line, so the emitted token always reports the source line
1659        // it actually lives on.
1660        self.token_start_line = self.line;
1661
1662        if self.pos >= self.input.len() {
1663            return Ok(Token::Eof);
1664        }
1665
1666        // `last_was_arrow` is consumed at most once per token: the s/tr/y/q/qq
1667        // /qw/qr/m guards check whether the IMMEDIATELY previous token was
1668        // `->`. Reset here; the Arrow / ArrowBrace return paths re-arm it
1669        // for the next `next_token` call. We snapshot before the reset so
1670        // identifier-decoding logic below can read the previous-token state
1671        // via `self.prev_arrow` (set up via a one-shot field swap).
1672        self.prev_arrow = self.last_was_arrow;
1673        self.last_was_arrow = false;
1674        self.last_was_bare_positional = false;
1675
1676        let ch = self.input[self.pos];
1677        match ch {
1678            // Variables
1679            '$' => {
1680                self.advance();
1681                // `$$foo` — symbolic scalar deref (Perl `${$foo}`-style lookup)
1682                if self.peek() == Some('$') {
1683                    // `$$_{` — Perl parses as `$_->{...}` (implicit arrow on `$_`), not `$$` PID + `_`.
1684                    let is_dollar_under_brace = self.input.get(self.pos + 1) == Some(&'_')
1685                        && self.input.get(self.pos + 2) == Some(&'{');
1686                    if !is_dollar_under_brace {
1687                        self.advance();
1688                        if self.peek().is_some_and(|c| c.is_alphabetic() || c == '_') {
1689                            let name = self.read_identifier();
1690                            self.last_was_term = true;
1691                            return Ok(Token::DerefScalarVar(name));
1692                        }
1693                        // `$$` — process id (Perl `$$`)
1694                        self.last_was_term = true;
1695                        return Ok(Token::ScalarVar("$$".to_string()));
1696                    }
1697                }
1698                let name = self.read_variable_name();
1699                if name.is_empty() {
1700                    return Err(self.syntax_err("Expected variable name after $", self.line));
1701                }
1702                // `--no-interop`: reject `$a` / `$b` (Perl's reduce/sort/pair*
1703                // comparator-bind globals). Stryke's runtime also binds `$_0`
1704                // / `$_1` for the same positions; in idiomatic-only mode users
1705                // must use those instead.
1706                if crate::no_interop_mode() && (name == "a" || name == "b") {
1707                    return Err(self.syntax_err(
1708                        format!(
1709                            "stryke uses `_` / `_1` (bareword in code) or `$_` / `$_1` \
1710                             (sigil inside string interpolation / when whitespace \
1711                             would change parsing) instead of `${}` (--no-interop is active)",
1712                            name
1713                        ),
1714                        self.line,
1715                    ));
1716                }
1717                self.last_was_term = true;
1718                if let Some(tail) = Self::braced_body_symbolic_scalar_deref_name(&name) {
1719                    return Ok(Token::DerefScalarVar(tail.to_string()));
1720                }
1721                Ok(Token::ScalarVar(name))
1722            }
1723            '@' => {
1724                self.advance();
1725                if self.peek() == Some('-') {
1726                    self.advance();
1727                    self.last_was_term = true;
1728                    return Ok(Token::ArrayVar("-".to_string()));
1729                }
1730                if self.peek() == Some('+') {
1731                    self.advance();
1732                    self.last_was_term = true;
1733                    return Ok(Token::ArrayVar("+".to_string()));
1734                }
1735                if self.peek() == Some('^')
1736                    && self
1737                        .input
1738                        .get(self.pos + 1)
1739                        .is_some_and(|c| c.is_alphabetic() || *c == '_')
1740                {
1741                    self.advance();
1742                    let name = format!("^{}", self.read_package_qualified_identifier());
1743                    self.last_was_term = true;
1744                    return Ok(Token::ArrayVar(name));
1745                }
1746                if self.peek() == Some('_') || self.peek().is_some_and(|c| c.is_alphabetic()) {
1747                    let name = self.read_package_qualified_identifier();
1748                    self.last_was_term = true;
1749                    return Ok(Token::ArrayVar(name));
1750                }
1751                self.last_was_term = false;
1752                Ok(Token::ArrayAt)
1753            }
1754            '%' if !self.last_was_term => {
1755                self.advance();
1756                // `%+` — named regex captures (Perl special hash)
1757                if self.peek() == Some('+') {
1758                    self.advance();
1759                    self.last_was_term = true;
1760                    return Ok(Token::HashVar("+".to_string()));
1761                }
1762                if self.peek() == Some('^')
1763                    && self
1764                        .input
1765                        .get(self.pos + 1)
1766                        .is_some_and(|c| c.is_alphabetic() || *c == '_')
1767                {
1768                    self.advance();
1769                    let name = format!("^{}", self.read_package_qualified_identifier());
1770                    self.last_was_term = true;
1771                    return Ok(Token::HashVar(name));
1772                }
1773                if self.peek().is_some_and(|c| c.is_alphabetic() || c == '_') {
1774                    let name = self.read_package_qualified_identifier();
1775                    self.last_was_term = true;
1776                    return Ok(Token::HashVar(name));
1777                }
1778                self.last_was_term = false;
1779                Ok(Token::HashPercent)
1780            }
1781
1782            // Numbers
1783            '0'..='9' => {
1784                let tok = self.read_number()?;
1785                self.last_was_term = true;
1786                Ok(tok)
1787            }
1788
1789            // Strings
1790            '\'' => {
1791                let tok = self.read_single_quoted_string()?;
1792                self.last_was_term = true;
1793                Ok(tok)
1794            }
1795            '"' => {
1796                let tok = self.read_double_quoted_string()?;
1797                self.last_was_term = true;
1798                Ok(tok)
1799            }
1800
1801            // Backtick — Perl `` `cmd` `` (qx), not a plain double-quoted string
1802            '`' => {
1803                self.advance();
1804                let cmd = self.read_interpolating_until('`')?;
1805                self.last_was_term = true;
1806                Ok(Token::BacktickString(cmd))
1807            }
1808
1809            // Regex or division
1810            '/' => {
1811                if !self.last_was_term {
1812                    let tok = self.read_regex()?;
1813                    self.last_was_term = true;
1814                    return Ok(tok);
1815                }
1816                self.advance();
1817                if self.peek() == Some('=') {
1818                    self.advance();
1819                    self.last_was_term = false;
1820                    return Ok(Token::DivAssign);
1821                }
1822                if self.peek() == Some('/') {
1823                    self.advance();
1824                    if self.peek() == Some('=') {
1825                        self.advance();
1826                        self.last_was_term = false;
1827                        return Ok(Token::DefinedOrAssign);
1828                    }
1829                    self.last_was_term = false;
1830                    return Ok(Token::DefinedOr);
1831                }
1832                self.last_was_term = false;
1833                Ok(Token::Slash)
1834            }
1835
1836            // Operators and punctuation
1837            '+' => {
1838                self.advance();
1839                if self.peek() == Some('+') {
1840                    self.advance();
1841                    // Whether it was term depends on context
1842                    return Ok(Token::Increment);
1843                }
1844                if self.peek() == Some('=') {
1845                    self.advance();
1846                    self.last_was_term = false;
1847                    return Ok(Token::PlusAssign);
1848                }
1849                self.last_was_term = false;
1850                Ok(Token::Plus)
1851            }
1852            '-' => {
1853                self.advance();
1854                // File test operators: -e, -f, -d, etc.
1855                if !self.last_was_term {
1856                    if let Some(c) = self.peek() {
1857                        if "efdlpSszrwxoRWXOBCTMAgut".contains(c)
1858                            && self.peek_at(1).is_none_or(|n| {
1859                                n.is_whitespace()
1860                                    || n == '$'
1861                                    || n == '\''
1862                                    || n == '"'
1863                                    || n == '('
1864                                    || n == ')'
1865                                    || n == '}'
1866                                    || n == ';'
1867                                    || n == ','
1868                            })
1869                        {
1870                            self.advance();
1871                            self.last_was_term = false;
1872                            return Ok(Token::FileTest(c));
1873                        }
1874                    }
1875                }
1876                if self.peek() == Some('-') {
1877                    self.advance();
1878                    return Ok(Token::Decrement);
1879                }
1880                if self.peek() == Some('=') {
1881                    self.advance();
1882                    self.last_was_term = false;
1883                    return Ok(Token::MinusAssign);
1884                }
1885                if self.peek() == Some('>') {
1886                    self.advance();
1887                    if self.peek() == Some('>') {
1888                        self.advance();
1889                        self.last_was_term = false;
1890                        return Ok(Token::ThreadArrowLast);
1891                    }
1892                    self.last_was_term = false;
1893                    // Arm the arrow flag so the next identifier (e.g. `y`,
1894                    // `s`, `tr`, `m`, `q…`) decodes as a method name, not
1895                    // a substitution / transliteration / quote-like body.
1896                    self.last_was_arrow = true;
1897                    return Ok(Token::Arrow);
1898                }
1899                self.last_was_term = false;
1900                Ok(Token::Minus)
1901            }
1902            '*' => {
1903                self.advance();
1904                if self.peek() == Some('*') {
1905                    self.advance();
1906                    if self.peek() == Some('=') {
1907                        self.advance();
1908                        self.last_was_term = false;
1909                        return Ok(Token::PowAssign);
1910                    }
1911                    self.last_was_term = false;
1912                    return Ok(Token::Power);
1913                }
1914                if self.peek() == Some('=') {
1915                    self.advance();
1916                    self.last_was_term = false;
1917                    return Ok(Token::MulAssign);
1918                }
1919                self.last_was_term = false;
1920                Ok(Token::Star)
1921            }
1922            '%' => {
1923                // Only reached when last_was_term is true (hash sigil handled above)
1924                self.advance();
1925                if self.peek() == Some('=') {
1926                    self.advance();
1927                    self.last_was_term = false;
1928                    return Ok(Token::ModAssign);
1929                }
1930                self.last_was_term = false;
1931                Ok(Token::Percent)
1932            }
1933            '.' => {
1934                self.advance();
1935                if self.peek() == Some('.') {
1936                    self.advance();
1937                    if self.peek() == Some('.') {
1938                        self.advance();
1939                        self.last_was_term = false;
1940                        return Ok(Token::RangeExclusive);
1941                    }
1942                    self.last_was_term = false;
1943                    return Ok(Token::Range);
1944                }
1945                if self.peek() == Some('=') {
1946                    self.advance();
1947                    self.last_was_term = false;
1948                    return Ok(Token::DotAssign);
1949                }
1950                self.last_was_term = false;
1951                Ok(Token::Dot)
1952            }
1953            '=' => {
1954                let eq_pos = self.pos;
1955                self.advance();
1956                if self.peek() == Some('=') {
1957                    self.advance();
1958                    self.last_was_term = false;
1959                    return Ok(Token::NumEq);
1960                }
1961                if self.peek() == Some('~') {
1962                    self.advance();
1963                    self.last_was_term = false;
1964                    return Ok(Token::BindMatch);
1965                }
1966                if self.peek() == Some('>') {
1967                    self.advance();
1968                    self.last_was_term = false;
1969                    return Ok(Token::FatArrow);
1970                }
1971                // POD: =head1 etc — only when `=` begins the line (after optional whitespace).
1972                if self.peek().is_some_and(|c| c.is_alphabetic())
1973                    && self.at_line_start_for_pod(eq_pos)
1974                {
1975                    // Skip POD
1976                    loop {
1977                        let line = self.read_while(|c| c != '\n');
1978                        if self.peek() == Some('\n') {
1979                            self.advance();
1980                        }
1981                        if line.starts_with("=cut") || self.pos >= self.input.len() {
1982                            break;
1983                        }
1984                    }
1985                    return self.next_token();
1986                }
1987                self.last_was_term = false;
1988                Ok(Token::Assign)
1989            }
1990            '!' => {
1991                self.advance();
1992                if self.peek() == Some('=') {
1993                    self.advance();
1994                    self.last_was_term = false;
1995                    return Ok(Token::NumNe);
1996                }
1997                if self.peek() == Some('~') {
1998                    self.advance();
1999                    self.last_was_term = false;
2000                    return Ok(Token::BindNotMatch);
2001                }
2002                self.last_was_term = false;
2003                Ok(Token::LogNot)
2004            }
2005            '<' => {
2006                self.advance();
2007                let after_lt = self.pos;
2008                // Readline `<$fh>` (scalar handle) — must come before `<IDENT>` / numeric `<`.
2009                if self.peek() == Some('$') {
2010                    self.advance();
2011                    let name = self.read_variable_name();
2012                    if !name.is_empty() && self.peek() == Some('>') {
2013                        self.advance();
2014                        self.last_was_term = true;
2015                        return Ok(Token::ReadLine(name));
2016                    }
2017                    self.pos = after_lt;
2018                }
2019                // Diamond operator <> or <STDIN>
2020                if self.peek() == Some('>') {
2021                    self.advance();
2022                    self.last_was_term = true;
2023                    return Ok(Token::Diamond);
2024                }
2025                if self.peek().is_some_and(|c| c.is_uppercase()) {
2026                    let name = self.read_identifier();
2027                    if self.peek() == Some('>') {
2028                        self.advance();
2029                        self.last_was_term = true;
2030                        return Ok(Token::ReadLine(name));
2031                    }
2032                    // Not a readline, put back — this is tricky, we'll handle as less-than
2033                    // followed by ident. For simplicity, return the ident separately.
2034                    self.last_was_term = false;
2035                    return Ok(Token::NumLt);
2036                }
2037                // `<main::STDIN>` — `main` is the default package for
2038                // special filehandles. Accept the qualified form and
2039                // strip the prefix so the runtime sees `STDIN`.
2040                if self.peek() == Some('m')
2041                    && self.input.get(after_lt + 1) == Some(&'a')
2042                    && self.input.get(after_lt + 2) == Some(&'i')
2043                    && self.input.get(after_lt + 3) == Some(&'n')
2044                    && self.input.get(after_lt + 4) == Some(&':')
2045                    && self.input.get(after_lt + 5) == Some(&':')
2046                    && self
2047                        .input
2048                        .get(after_lt + 6)
2049                        .is_some_and(|c| c.is_uppercase())
2050                {
2051                    // Consume `main::`.
2052                    for _ in 0..6 {
2053                        self.advance();
2054                    }
2055                    let name = self.read_identifier();
2056                    if self.peek() == Some('>') {
2057                        self.advance();
2058                        self.last_was_term = true;
2059                        return Ok(Token::ReadLine(name));
2060                    }
2061                    // Restore and fall through if it wasn't actually a readline.
2062                    self.pos = after_lt;
2063                }
2064                if self.peek() == Some('=') {
2065                    self.advance();
2066                    if self.peek() == Some('>') {
2067                        self.advance();
2068                        self.last_was_term = false;
2069                        return Ok(Token::Spaceship);
2070                    }
2071                    self.last_was_term = false;
2072                    return Ok(Token::NumLe);
2073                }
2074                if self.peek() == Some('<') {
2075                    self.advance();
2076                    if self.peek() == Some('=') {
2077                        self.advance();
2078                        self.last_was_term = false;
2079                        return Ok(Token::ShiftLeftAssign);
2080                    }
2081                    // `<<` — binary shift after a complete term (`1 << 4`, `"x" << 2`); heredoc when a
2082                    // term is expected (`print <<EOF`, `my $x = <<EOF`, after `.` / `,` / `(` …).
2083                    //
2084                    // `}` always sets `last_was_term=true`, but a `}` ending
2085                    // a block / fn body followed by a newline and `<<TAG` on
2086                    // the next line is unambiguously heredoc, not shift —
2087                    // `block << bareword` is meaningless. Disambiguate by
2088                    // peeking after `<<`: if the next char looks like the
2089                    // start of a heredoc tag (uppercase, `_`, `~`, `"`,
2090                    // `'`), prefer heredoc even when last_was_term is set.
2091                    // Numeric / sigil / lowercase still falls through to
2092                    // ShiftLeft so `1 << 4` and `$x << $shift` still work.
2093                    let looks_like_heredoc_tag =
2094                        matches!(self.peek(), Some('~') | Some('"') | Some('\'') | Some('_'),)
2095                            || self.peek().is_some_and(|c| c.is_ascii_uppercase());
2096                    if self.last_was_term && !looks_like_heredoc_tag {
2097                        self.last_was_term = false;
2098                        return Ok(Token::ShiftLeft);
2099                    }
2100                    let (tag, interpolate, indented) = self.read_heredoc_tag()?;
2101                    let body = self.read_heredoc_body(&tag, indented)?;
2102                    self.last_was_term = true;
2103                    return Ok(Token::HereDoc(tag, body, interpolate));
2104                }
2105                self.last_was_term = false;
2106                Ok(Token::NumLt)
2107            }
2108            '>' => {
2109                self.advance();
2110                if self.peek() == Some('{') {
2111                    self.advance();
2112                    self.last_was_term = false;
2113                    return Ok(Token::ArrowBrace);
2114                }
2115                if self.peek() == Some('=') {
2116                    self.advance();
2117                    self.last_was_term = false;
2118                    return Ok(Token::NumGe);
2119                }
2120                if self.peek() == Some('>') {
2121                    self.advance();
2122                    if self.peek() == Some('=') {
2123                        self.advance();
2124                        self.last_was_term = false;
2125                        return Ok(Token::ShiftRightAssign);
2126                    }
2127                    self.last_was_term = false;
2128                    return Ok(Token::ShiftRight);
2129                }
2130                self.last_was_term = false;
2131                Ok(Token::NumGt)
2132            }
2133            '&' => {
2134                self.advance();
2135                if self.peek() == Some('&') {
2136                    self.advance();
2137                    if self.peek() == Some('=') {
2138                        self.advance();
2139                        self.last_was_term = false;
2140                        return Ok(Token::AndAssign);
2141                    }
2142                    self.last_was_term = false;
2143                    return Ok(Token::LogAnd);
2144                }
2145                if self.peek() == Some('=') {
2146                    self.advance();
2147                    self.last_was_term = false;
2148                    return Ok(Token::BitAndAssign);
2149                }
2150                self.last_was_term = false;
2151                Ok(Token::BitAnd)
2152            }
2153            '|' => {
2154                self.advance();
2155                if self.peek() == Some('|') {
2156                    self.advance();
2157                    if self.peek() == Some('=') {
2158                        self.advance();
2159                        self.last_was_term = false;
2160                        return Ok(Token::OrAssign);
2161                    }
2162                    self.last_was_term = false;
2163                    return Ok(Token::LogOr);
2164                }
2165                if self.peek() == Some('=') {
2166                    self.advance();
2167                    self.last_was_term = false;
2168                    return Ok(Token::BitOrAssign);
2169                }
2170                if self.peek() == Some('>') {
2171                    self.advance();
2172                    self.last_was_term = false;
2173                    return Ok(Token::PipeForward);
2174                }
2175                self.last_was_term = false;
2176                Ok(Token::BitOr)
2177            }
2178            '^' => {
2179                self.advance();
2180                if self.peek() == Some('=') {
2181                    self.advance();
2182                    self.last_was_term = false;
2183                    return Ok(Token::XorAssign);
2184                }
2185                self.last_was_term = false;
2186                Ok(Token::BitXor)
2187            }
2188            '~' => {
2189                self.advance();
2190                if self.peek() == Some('>') {
2191                    self.advance();
2192                    if self.peek() == Some('>') {
2193                        self.advance();
2194                        self.last_was_term = false;
2195                        return Ok(Token::ThreadArrowLast);
2196                    }
2197                    self.last_was_term = false;
2198                    return Ok(Token::ThreadArrow);
2199                }
2200                // `~s>` (streaming thread-first) / `~s>>` (streaming thread-last)
2201                // — per-item streaming thread-macros that lower to
2202                // `par_pipeline_streaming`: each stage runs in its own worker
2203                // connected by bounded channels, items flow one-at-a-time.
2204                if self.peek() == Some('s') && self.peek_at(1) == Some('>') {
2205                    self.advance(); // consume 's'
2206                    self.advance(); // consume first '>'
2207                    if self.peek() == Some('>') {
2208                        self.advance(); // consume second '>'
2209                        self.last_was_term = false;
2210                        return Ok(Token::ThreadArrowStreamLast);
2211                    }
2212                    self.last_was_term = false;
2213                    return Ok(Token::ThreadArrowStream);
2214                }
2215                // `~p>` (parallel-chunk thread-first) / `~p>>` (thread-last)
2216                // — sugar for `par_reduce { stage1 |> stage2 |> ... } SOURCE`.
2217                // `||>` or `|then|` mid-pipeline switches back to a normal
2218                // `~>` continuation operating on the merged result.
2219                if self.peek() == Some('p') && self.peek_at(1) == Some('>') {
2220                    self.advance(); // consume 'p'
2221                    self.advance(); // consume first '>'
2222                    if self.peek() == Some('>') {
2223                        self.advance(); // consume second '>'
2224                        self.last_was_term = false;
2225                        return Ok(Token::ThreadArrowParLast);
2226                    }
2227                    self.last_was_term = false;
2228                    return Ok(Token::ThreadArrowPar);
2229                }
2230                // `~d>` (distributed thread-first) / `~d>>` (thread-last) —
2231                // mirrors `~p>` but each chunk is shipped to a remote worker
2232                // on a cluster. Syntax: `~d> on $cluster SOURCE stages...`.
2233                if self.peek() == Some('d') && self.peek_at(1) == Some('>') {
2234                    self.advance(); // consume 'd'
2235                    self.advance(); // consume first '>'
2236                    if self.peek() == Some('>') {
2237                        self.advance(); // consume second '>'
2238                        self.last_was_term = false;
2239                        return Ok(Token::ThreadArrowDistLast);
2240                    }
2241                    self.last_was_term = false;
2242                    return Ok(Token::ThreadArrowDist);
2243                }
2244                self.last_was_term = false;
2245                Ok(Token::BitNot)
2246            }
2247            '?' => {
2248                self.advance();
2249                // `??` / `??=` — stryke aliases for `//` / `//=` (C#/Swift
2250                // null-coalescing spelling). Literal under --compat. No
2251                // clash with ternary: two adjacent `?` are never valid there.
2252                if self.peek() == Some('?') && !crate::compat_mode() {
2253                    self.advance();
2254                    self.last_was_term = false;
2255                    if self.peek() == Some('=') {
2256                        self.advance();
2257                        return Ok(Token::DefinedOrAssign);
2258                    }
2259                    return Ok(Token::DefinedOr);
2260                }
2261                self.last_was_term = false;
2262                Ok(Token::Question)
2263            }
2264            ':' => {
2265                self.advance();
2266                if self.peek() == Some(':') {
2267                    self.advance();
2268                    // IPv6 zero-compressed prefix: `::1`, `::ffff:c000:280`.
2269                    // Only fires in term position (where `Pkg::ident` is
2270                    // impossible) and only when the chars after `::` form a
2271                    // valid IPv6 by Rust's parser. Skip when the `::` lives
2272                    // inside `[…]` — that's array-slice step syntax
2273                    // (`@a[::2]`, `@a[::-1]`), not an address.
2274                    let in_bracket_subscript =
2275                        self.input.get(self.pos.saturating_sub(3)).copied() == Some('[');
2276                    if !self.last_was_term && !in_bracket_subscript {
2277                        let saved = self.pos - 2;
2278                        if let Some(consumed) = self.try_consume_ipv6_tail(saved) {
2279                            self.last_was_term = true;
2280                            return Ok(Token::DoubleString(consumed));
2281                        }
2282                    }
2283                    self.last_was_term = false;
2284                    return Ok(Token::PackageSep);
2285                }
2286                self.last_was_term = false;
2287                Ok(Token::Colon)
2288            }
2289            '\\' => {
2290                self.advance();
2291                // Backslash-newline: line continuation (shell-style)
2292                // Don't increment line — continued line is logically part of the same line
2293                if self.peek() == Some('\n') {
2294                    self.pos += 1; // skip newline without incrementing self.line
2295                    return self.next_token();
2296                }
2297                self.last_was_term = false;
2298                Ok(Token::Backslash)
2299            }
2300            ',' => {
2301                self.advance();
2302                self.last_was_term = false;
2303                Ok(Token::Comma)
2304            }
2305            ';' => {
2306                self.advance();
2307                self.last_was_term = false;
2308                Ok(Token::Semicolon)
2309            }
2310            '(' => {
2311                self.advance();
2312                self.last_was_term = false;
2313                Ok(Token::LParen)
2314            }
2315            ')' => {
2316                self.advance();
2317                self.last_was_term = true;
2318                Ok(Token::RParen)
2319            }
2320            '[' => {
2321                self.advance();
2322                self.last_was_term = false;
2323                Ok(Token::LBracket)
2324            }
2325            ']' => {
2326                self.advance();
2327                self.last_was_term = true;
2328                Ok(Token::RBracket)
2329            }
2330            '{' => {
2331                self.advance();
2332                self.last_was_term = false;
2333                Ok(Token::LBrace)
2334            }
2335            '}' => {
2336                self.advance();
2337                self.last_was_term = true;
2338                Ok(Token::RBrace)
2339            }
2340
2341            // Identifiers and keywords
2342            c if c.is_alphabetic() || c == '_' => {
2343                let ident_start = self.pos;
2344                let mut ident = self.read_identifier();
2345
2346                // IPv6 lookahead for hex-letter prefixes: `fe80::1`, `abcd::ff`,
2347                // `dead:beef::1`, etc. Only fires when the just-consumed
2348                // identifier is a valid 1..=4 hex-digit group (i.e. could be
2349                // an IPv6 segment) AND the next char is `:`. Speculatively
2350                // greedily consumes hex / `:` / `::` and asks Rust's
2351                // `Ipv6Addr` parser to validate; on failure restores `pos`
2352                // so the identifier-as-bareword path runs unchanged.
2353                //
2354                // Skip when we're already in the middle of a package-qualified
2355                // path: `package A::B::C` lexes "A" then "::", and at that
2356                // point the next ident "B" must NOT be IPv6-trapped — `B::C`
2357                // is the rest of the package name, not the address
2358                // `0:0:0:0:0:0:B:C`. Same rule for `Foo::Bar::baz` mid-stream
2359                // with hex letters that happen to look like an address.
2360                let after_package_sep = ident_start >= 2
2361                    && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2362                    && self.input.get(ident_start.saturating_sub(1)) == Some(&':');
2363                if !after_package_sep
2364                    && self.peek() == Some(':')
2365                    && ident.len() <= 4
2366                    && ident.chars().all(|ch| ch.is_ascii_hexdigit())
2367                {
2368                    if let Some(consumed) = self.try_consume_ipv6_tail(ident_start) {
2369                        self.last_was_term = true;
2370                        return Ok(Token::DoubleString(consumed));
2371                    }
2372                }
2373
2374                // Outer-topic chain in bare form: `_<<<<` (slot 0) and
2375                // `_N<<<<` (slot N). Greedy consume `<` chevrons immediately
2376                // following `_` or `_<digits>`. This is what makes
2377                // `_<` ≡ `$_<` ≡ `_0<` ≡ `$_0<` work without a sigil.
2378                // Stryke power-user note: `_ < 5` (with whitespace) still
2379                // tokenizes as topic-then-less-than; only `_<` with no
2380                // intervening space becomes a topic-slot identifier.
2381                let is_topic_slot = ident == "_"
2382                    || (ident.len() > 1
2383                        && ident.starts_with('_')
2384                        && ident[1..].bytes().all(|b| b.is_ascii_digit()));
2385                if is_topic_slot {
2386                    // Greedy `<` chevrons for the outer-topic chain, BUT only
2387                    // when the chevron run isn't followed by a slice index.
2388                    // `_<1:5>` is a string slice; `_<<<<<` is the 5-deep
2389                    // outer-topic. Disambiguate by peeking past the run: if
2390                    // the first non-`<` char is a digit, `-`, `:`, or `>`,
2391                    // we're in a slice — bail out and let the parser handle
2392                    // `<...>` as postfix subscript.
2393                    //
2394                    // Indexed-ascent shortcut: `_<N` ≡ `_<<<...<` (N chevrons)
2395                    // when N is digits NOT followed by `>` or `:`. So `_<3` is
2396                    // a depth-3 reference (more readable than `_<<<`), while
2397                    // `_<3>` and `_<3:5>` remain string slices.
2398                    let mut peek_off = 0usize;
2399                    while self.peek_at(peek_off) == Some('<') {
2400                        peek_off += 1;
2401                    }
2402                    let trailing = self.peek_at(peek_off);
2403                    // Single `<` followed by digits: try indexed-ascent first.
2404                    // Only triggers for one-chevron runs because `_<<3` would
2405                    // mean "depth 2 of position 3" (which is not how the
2406                    // grammar works) — we only allow `_<digits` at depth 1.
2407                    let mut indexed_ascent: Option<usize> = None;
2408                    if peek_off == 1 && trailing.is_some_and(|c: char| c.is_ascii_digit()) {
2409                        let mut off = 1usize;
2410                        while self.peek_at(off).is_some_and(|c| c.is_ascii_digit()) {
2411                            off += 1;
2412                        }
2413                        let after_digits = self.peek_at(off);
2414                        let still_a_slice = matches!(after_digits, Some(':') | Some('>'));
2415                        if !still_a_slice {
2416                            // Parse the digit run.
2417                            let mut digits = String::new();
2418                            for k in 1..off {
2419                                if let Some(c) = self.peek_at(k) {
2420                                    digits.push(c);
2421                                }
2422                            }
2423                            if let Ok(n) = digits.parse::<usize>() {
2424                                if n >= 1 {
2425                                    indexed_ascent = Some(n);
2426                                    // Consume `<` + the digits.
2427                                    self.advance();
2428                                    for _ in 1..off {
2429                                        self.advance();
2430                                    }
2431                                }
2432                            }
2433                        }
2434                    }
2435                    if let Some(n) = indexed_ascent {
2436                        for _ in 0..n {
2437                            ident.push('<');
2438                        }
2439                    } else {
2440                        let is_slice = peek_off > 0
2441                            && matches!(trailing, Some(c) if c.is_ascii_digit() || c == '-' || c == ':' || c == '>');
2442                        if !is_slice {
2443                            for _ in 0..peek_off {
2444                                self.advance();
2445                                ident.push('<');
2446                            }
2447                        }
2448                    }
2449                    // `_N` (underscore + digits, ≥ 1 digit) is a reserved
2450                    // positional-alias name — never a function name. Emit
2451                    // ScalarVar directly so bareword `_1`, `_2`, ... in
2452                    // expression position resolves to the scalar slot
2453                    // instead of being looked up as a sub call. Bare `_`
2454                    // alone (without digits) keeps Ident shape so the
2455                    // existing topic/bareword machinery still runs.
2456                    if ident.len() > 1
2457                        && ident.starts_with('_')
2458                        && ident.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
2459                    {
2460                        self.last_was_term = true;
2461                        self.last_was_bare_positional = true;
2462                        return Ok(Token::ScalarVar(ident));
2463                    }
2464                    // Also reserve bare `_<+` (chevron-only topic ascent on
2465                    // slot 0) — these are never sub names.
2466                    if ident.starts_with('_') && ident.contains('<') {
2467                        self.last_was_term = true;
2468                        self.last_was_bare_positional = true;
2469                        return Ok(Token::ScalarVar(ident));
2470                    }
2471                }
2472
2473                // Special multi-char constructs
2474                match ident.as_str() {
2475                    "format" => {
2476                        // `$obj->format` — method call, not format declaration.
2477                        if self.prev_arrow {
2478                            self.last_was_term = true;
2479                            return Ok(Token::Ident(ident));
2480                        }
2481                        // `Foo::format` — namespaced identifier tail.
2482                        if ident_start >= 2
2483                            && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2484                            && self.input.get(ident_start.saturating_sub(1)) == Some(&':')
2485                        {
2486                            self.last_was_term = true;
2487                            return Ok(Token::Ident(ident));
2488                        }
2489                        // Hash-key bareword contexts: `$h{format}`, `{format => ...}`,
2490                        // `$h{format,...}`. The char immediately before `format`
2491                        // (modulo whitespace) being `{` means we're inside a
2492                        // hash-subscript or hash-literal — `format` is the key,
2493                        // not the FORMAT keyword.
2494                        {
2495                            let mut p = ident_start;
2496                            while p > 0 {
2497                                match self.input.get(p - 1) {
2498                                    Some(&' ') | Some(&'\t') => p -= 1,
2499                                    _ => break,
2500                                }
2501                            }
2502                            if p > 0 && self.input.get(p - 1) == Some(&'{') {
2503                                self.last_was_term = true;
2504                                return Ok(Token::Ident(ident));
2505                            }
2506                        }
2507                        // Lookahead-disambiguation: shapes that prove
2508                        // `format` is a bareword, not a declaration keyword.
2509                        // `}` / `,` / `;` / `)` / `]` — list/hash/expr context.
2510                        // `=>` — autoquoted hash key.
2511                        // `(` — function call (format() / format($x)).
2512                        // EOF — bare ident at end of input.
2513                        // A real `format NAME = ... .` declaration always has
2514                        // an identifier between `format` and `=`; if no
2515                        // identifier is found at the expected slot, it's
2516                        // a bareword.
2517                        {
2518                            let saved_pos = self.pos;
2519                            let saved_line = self.line;
2520                            self.skip_whitespace_only();
2521                            let bare = match self.peek() {
2522                                None => true,
2523                                Some(',' | ';' | ')' | ']' | '}' | '(') => true,
2524                                Some('=') if self.peek_at(1) == Some('>') => true,
2525                                Some(c) if !c.is_alphabetic() && c != '_' => true,
2526                                _ => false,
2527                            };
2528                            self.pos = saved_pos;
2529                            self.line = saved_line;
2530                            if bare {
2531                                self.last_was_term = true;
2532                                return Ok(Token::Ident(ident));
2533                            }
2534                        }
2535                        self.skip_whitespace_and_comments();
2536                        let fname = self.read_package_qualified_identifier();
2537                        self.skip_whitespace_and_comments();
2538                        if self.peek() != Some('=') {
2539                            return Err(
2540                                self.syntax_err("Expected '=' after format name", self.line)
2541                            );
2542                        }
2543                        self.advance();
2544                        let lines = self.read_format_body()?;
2545                        self.last_was_term = false;
2546                        return Ok(Token::FormatDecl { name: fname, lines });
2547                    }
2548                    "r" if self.peek() == Some('"')
2549                        && self.peek_at(1) == Some('"')
2550                        && self.peek_at(2) == Some('"') =>
2551                    {
2552                        // `r"""..."""` — raw triple-quoted string. No
2553                        // interpolation, no backslash escapes; every byte
2554                        // is copied verbatim until the closing `"""`.
2555                        // Only triggers when `r` is followed IMMEDIATELY
2556                        // by three quotes — `r 5`, `r->foo`, `r(...)` etc.
2557                        // still hit the generic identifier path below.
2558                        self.advance(); // 1st "
2559                        self.advance(); // 2nd "
2560                        self.advance(); // 3rd "
2561                        let s = self.read_triple_quoted_body(false)?;
2562                        self.last_was_term = true;
2563                        return Ok(Token::SingleString(s));
2564                    }
2565                    "qw" => {
2566                        // After `->`, `qw` is a method name, not a quote-word list.
2567                        if self.prev_arrow {
2568                            self.last_was_term = true;
2569                            return Ok(Token::Ident(ident));
2570                        }
2571                        // `qw` followed by `=>` is an autoquoted hash key, not qw().
2572                        let start_pos = self.pos;
2573                        let start_line = self.line;
2574                        self.skip_whitespace_only();
2575                        if let Some(c) = self.peek() {
2576                            if c == '=' && self.peek_at(1) == Some('>') {
2577                                self.pos = start_pos;
2578                                self.line = start_line;
2579                                self.last_was_term = true;
2580                                return Ok(Token::Ident(ident));
2581                            }
2582                            if matches!(c, ';' | ',' | ')' | ']' | '}' | '\n') {
2583                                self.pos = start_pos;
2584                                self.line = start_line;
2585                                self.last_was_term = true;
2586                                return Ok(Token::Ident(ident));
2587                            }
2588                        }
2589                        self.pos = start_pos; // restore for read_qw
2590                        self.line = start_line;
2591                        let tok = self.read_qw()?;
2592                        self.last_was_term = true;
2593                        return Ok(tok);
2594                    }
2595                    "qq" | "q" => {
2596                        // After `->`, `q` / `qq` are method names, not quote operators.
2597                        if self.prev_arrow {
2598                            self.last_was_term = true;
2599                            return Ok(Token::Ident(ident));
2600                        }
2601                        // After `::`, treat as namespaced identifier (`Foo::q`, `Foo::qq`).
2602                        if ident_start >= 2
2603                            && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2604                            && self.input.get(ident_start.saturating_sub(1)) == Some(&':')
2605                        {
2606                            self.last_was_term = true;
2607                            return Ok(Token::Ident(ident));
2608                        }
2609                        // `q` / `qq` followed by `=>` is an autoquoted hash key, not a quote operator.
2610                        // Also treat as identifier if followed by terminators like `;`, `,`, `)`, etc.
2611                        // Must check AFTER skipping whitespace to handle `q => 5`.
2612                        let start_pos = self.pos;
2613                        let start_line = self.line;
2614                        self.skip_whitespace_only();
2615                        if let Some(c) = self.peek() {
2616                            // `=` followed by `>` is fat comma — `q` is a bareword key
2617                            if c == '=' && self.peek_at(1) == Some('>') {
2618                                self.pos = start_pos; // restore position
2619                                self.line = start_line;
2620                                self.last_was_term = true;
2621                                return Ok(Token::Ident(ident));
2622                            }
2623                            // Other terminators: `q` is an identifier
2624                            if matches!(c, ';' | ',' | ')' | ']' | '}' | '\n') {
2625                                self.pos = start_pos;
2626                                self.line = start_line;
2627                                self.last_was_term = true;
2628                                return Ok(Token::Ident(ident));
2629                            }
2630                        }
2631                        let delim = self.advance().ok_or_else(|| {
2632                            self.syntax_err("Expected delimiter after q/qq", self.line)
2633                        })?;
2634                        let close = match delim {
2635                            '(' => ')',
2636                            '[' => ']',
2637                            '{' => '}',
2638                            '<' => '>',
2639                            c => c,
2640                        };
2641                        let s = if matches!(delim, '(' | '[' | '{' | '<') {
2642                            self.read_q_qq_balanced_body(delim, close, ident == "qq")?
2643                        } else if ident == "qq" {
2644                            self.read_interpolating_until(close)?
2645                        } else {
2646                            self.read_escaped_until(close)?
2647                        };
2648                        self.last_was_term = true;
2649                        if ident == "qq" {
2650                            return Ok(Token::DoubleString(s));
2651                        }
2652                        return Ok(Token::SingleString(s));
2653                    }
2654                    "qx" => {
2655                        // After `->`, `qx` is a method name, not a backtick command.
2656                        if self.prev_arrow {
2657                            self.last_was_term = true;
2658                            return Ok(Token::Ident(ident));
2659                        }
2660                        // After `::`, treat as namespaced identifier (`Foo::qx`).
2661                        if ident_start >= 2
2662                            && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2663                            && self.input.get(ident_start.saturating_sub(1)) == Some(&':')
2664                        {
2665                            self.last_was_term = true;
2666                            return Ok(Token::Ident(ident));
2667                        }
2668                        // `qx` followed by `=>` is an autoquoted hash key.
2669                        let start_pos = self.pos;
2670                        let start_line = self.line;
2671                        self.skip_whitespace_only();
2672                        if let Some(c) = self.peek() {
2673                            if c == '=' && self.peek_at(1) == Some('>') {
2674                                self.pos = start_pos;
2675                                self.line = start_line;
2676                                self.last_was_term = true;
2677                                return Ok(Token::Ident(ident));
2678                            }
2679                            if matches!(c, ';' | ',' | ')' | ']' | '}' | '\n') {
2680                                self.pos = start_pos;
2681                                self.line = start_line;
2682                                self.last_was_term = true;
2683                                return Ok(Token::Ident(ident));
2684                            }
2685                        }
2686                        let delim = self.advance().ok_or_else(|| {
2687                            self.syntax_err("Expected delimiter after qx", self.line)
2688                        })?;
2689                        let close = match delim {
2690                            '(' => ')',
2691                            '[' => ']',
2692                            '{' => '}',
2693                            '<' => '>',
2694                            c => c,
2695                        };
2696                        let s = self.read_interpolating_until(close)?;
2697                        self.last_was_term = true;
2698                        return Ok(Token::BacktickString(s));
2699                    }
2700                    "qr" => {
2701                        // After `->`, `qr` is a method name, not a quoted regex.
2702                        if self.prev_arrow {
2703                            self.last_was_term = true;
2704                            return Ok(Token::Ident(ident));
2705                        }
2706                        // After `::`, treat as namespaced identifier (`Foo::qr`).
2707                        if ident_start >= 2
2708                            && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2709                            && self.input.get(ident_start.saturating_sub(1)) == Some(&':')
2710                        {
2711                            self.last_was_term = true;
2712                            return Ok(Token::Ident(ident));
2713                        }
2714                        // `qr` followed by `=>` is an autoquoted hash key.
2715                        let start_pos = self.pos;
2716                        let start_line = self.line;
2717                        self.skip_whitespace_only();
2718                        if let Some(c) = self.peek() {
2719                            if c == '=' && self.peek_at(1) == Some('>') {
2720                                self.pos = start_pos;
2721                                self.line = start_line;
2722                                self.last_was_term = true;
2723                                return Ok(Token::Ident(ident));
2724                            }
2725                            if matches!(c, ';' | ',' | ')' | ']' | '}' | '\n') {
2726                                self.pos = start_pos;
2727                                self.line = start_line;
2728                                self.last_was_term = true;
2729                                return Ok(Token::Ident(ident));
2730                            }
2731                        }
2732                        let delim = self.advance().ok_or_else(|| {
2733                            self.syntax_err("Expected delimiter after qr", self.line)
2734                        })?;
2735                        let close = match delim {
2736                            '(' => ')',
2737                            '[' => ']',
2738                            '{' => '}',
2739                            '<' => '>',
2740                            c => c,
2741                        };
2742                        // Regex pattern: preserve backslash escapes raw so the
2743                        // regex engine sees `\$`, `\@`, `\d`, etc. as written.
2744                        // Do NOT route through `read_escaped_until` — that's
2745                        // for double-quoted strings and rewrites `\$` to a
2746                        // private-use sentinel that the regex compiler can't
2747                        // decode (would silently strip the `$`).
2748                        let mut pattern = String::new();
2749                        loop {
2750                            match self.advance() {
2751                                Some('\\') => {
2752                                    pattern.push('\\');
2753                                    if let Some(c) = self.advance() {
2754                                        pattern.push(c);
2755                                    }
2756                                }
2757                                Some(c) if c == close => break,
2758                                Some(c) => pattern.push(c),
2759                                None => {
2760                                    return Err(self.syntax_err("Unterminated qr regex", self.line))
2761                                }
2762                            }
2763                        }
2764                        let flags = self.read_while(|c| REGEX_FLAG_CHARS.contains(c));
2765                        self.last_was_term = true;
2766                        return Ok(Token::Regex(pattern, flags, delim));
2767                    }
2768                    "m" => {
2769                        // After `->`, `m` is a method name, not a regex match.
2770                        if self.prev_arrow {
2771                            self.last_was_term = true;
2772                            return Ok(Token::Ident(ident));
2773                        }
2774                        // `Foo::m` — after `::`, `m` is the tail of a namespaced
2775                        // identifier, never a regex-match operator.
2776                        if ident_start >= 2
2777                            && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2778                            && self.input.get(ident_start.saturating_sub(1)) == Some(&':')
2779                        {
2780                            self.last_was_term = true;
2781                            return Ok(Token::Ident(ident));
2782                        }
2783                        // `m` followed by terminators is a bareword, not match operator.
2784                        // Must check AFTER skipping whitespace to handle `m => "val"`.
2785                        let start_pos = self.pos;
2786                        let start_line = self.line;
2787                        self.skip_whitespace_only();
2788                        if let Some(d) = self.peek() {
2789                            if d == '=' && self.peek_at(1) == Some('>') {
2790                                self.pos = start_pos;
2791                                self.line = start_line;
2792                                self.last_was_term = true;
2793                                return Ok(Token::Ident(ident));
2794                            }
2795                            if matches!(d, ';' | ',' | ')' | ']' | '}' | '>' | ':' | '\n') {
2796                                self.pos = start_pos;
2797                                self.line = start_line;
2798                                self.last_was_term = true;
2799                                return Ok(Token::Ident(ident));
2800                            }
2801                        }
2802                        self.pos = start_pos;
2803                        self.line = start_line;
2804                        // m/pattern/flags — try parsing as regex, but backtrack if
2805                        // unterminated (handles thread stages where `/m/` is a grep filter)
2806                        if self.suppress_m_regex == 0 {
2807                            if let Some(delim) = self.peek() {
2808                                if !delim.is_alphanumeric() && delim != '_' {
2809                                    // Save state for backtracking
2810                                    let saved_pos = self.pos;
2811                                    let saved_line = self.line;
2812                                    self.advance(); // consume delimiter
2813                                    let close = match delim {
2814                                        '(' => ')',
2815                                        '[' => ']',
2816                                        '{' => '}',
2817                                        '<' => '>',
2818                                        c => c,
2819                                    };
2820                                    let mut pattern = String::new();
2821                                    let mut terminated = true;
2822                                    loop {
2823                                        match self.advance() {
2824                                            Some('\\') => {
2825                                                pattern.push('\\');
2826                                                if let Some(c) = self.advance() {
2827                                                    pattern.push(c);
2828                                                }
2829                                            }
2830                                            Some(c) if c == close => break,
2831                                            Some(c) if c == '\n' && close == '/' => {
2832                                                // Newline before closing / — not a valid m//
2833                                                terminated = false;
2834                                                break;
2835                                            }
2836                                            Some(c) => pattern.push(c),
2837                                            None => {
2838                                                return Err(self.syntax_err(
2839                                                    "Search pattern not terminated",
2840                                                    saved_line,
2841                                                ));
2842                                            }
2843                                        }
2844                                    }
2845                                    if terminated {
2846                                        let flags =
2847                                            self.read_while(|c| REGEX_FLAG_CHARS.contains(c));
2848                                        self.last_was_term = true;
2849                                        return Ok(Token::Regex(pattern, flags, delim));
2850                                    }
2851                                    // Newline before closing / — backtrack and treat `m` as identifier
2852                                    self.pos = saved_pos;
2853                                    self.line = saved_line;
2854                                }
2855                            }
2856                        }
2857                        // Just the identifier 'm'
2858                        self.last_was_term = true;
2859                        return Ok(Token::Ident(ident));
2860                    }
2861                    "s" => {
2862                        // `$obj->s` / `$obj->s(...)` — after `->`, `s` is a method name.
2863                        if self.prev_arrow {
2864                            self.last_was_term = true;
2865                            return Ok(Token::Ident(ident));
2866                        }
2867                        // `Foo::s` / `Foo::Bar::s` — after `::`, `s` is the tail
2868                        // segment of a namespaced identifier, never substitution.
2869                        // Same check shape as the IPv6 / `after_package_sep` guard
2870                        // up at line ~2158: previous two chars are `::`.
2871                        if ident_start >= 2
2872                            && self.input.get(ident_start.saturating_sub(2)) == Some(&':')
2873                            && self.input.get(ident_start.saturating_sub(1)) == Some(&':')
2874                        {
2875                            self.last_was_term = true;
2876                            return Ok(Token::Ident(ident));
2877                        }
2878                        // `s` followed by terminators is a bareword, not substitution.
2879                        // Must check AFTER skipping whitespace to handle `s => "val"`.
2880                        // `,` is treated as a terminator UNLESS the lookahead shows the
2881                        // full `s,PAT,REPL,FLAGS` shape (≥ 2 more commas before the
2882                        // statement ends) — that gates the comma-delim case to genuine
2883                        // substitutions like `perl -pe 's,\bt\b,b,g'` while leaving
2884                        // bareword `s` alone in struct fields, list literals, and
2885                        // function args.
2886                        let start_pos = self.pos;
2887                        let start_line = self.line;
2888                        self.skip_whitespace_only();
2889                        if let Some(d) = self.peek() {
2890                            if d == '=' && self.peek_at(1) == Some('>') {
2891                                self.pos = start_pos;
2892                                self.line = start_line;
2893                                self.last_was_term = true;
2894                                return Ok(Token::Ident(ident));
2895                            }
2896                            if matches!(d, ';' | ')' | ']' | '}' | '>' | ':' | '\n') {
2897                                self.pos = start_pos;
2898                                self.line = start_line;
2899                                self.last_was_term = true;
2900                                return Ok(Token::Ident(ident));
2901                            }
2902                            if d == ',' && !self.lookahead_is_comma_delim_subst() {
2903                                self.pos = start_pos;
2904                                self.line = start_line;
2905                                self.last_was_term = true;
2906                                return Ok(Token::Ident(ident));
2907                            }
2908                        }
2909                        self.pos = start_pos;
2910                        self.line = start_line;
2911                        // s/pattern/replacement/flags
2912                        if let Some(delim) = self.peek() {
2913                            if !delim.is_alphanumeric() && delim != '_' && delim != ' ' {
2914                                self.advance();
2915                                let close = match delim {
2916                                    '(' => ')',
2917                                    '[' => ']',
2918                                    '{' => '}',
2919                                    '<' => '>',
2920                                    c => c,
2921                                };
2922                                let mut pattern = String::new();
2923                                loop {
2924                                    match self.advance() {
2925                                        Some('\\') => {
2926                                            pattern.push('\\');
2927                                            if let Some(c) = self.advance() {
2928                                                pattern.push(c);
2929                                            }
2930                                        }
2931                                        Some(c) if c == close => break,
2932                                        Some(c) => pattern.push(c),
2933                                        None => {
2934                                            return Err(self.syntax_err(
2935                                                "Unterminated s/// pattern",
2936                                                self.line,
2937                                            ))
2938                                        }
2939                                    }
2940                                }
2941                                // For paired delimiters, read the opening of the replacement part
2942                                if "([{<".contains(delim) {
2943                                    self.skip_whitespace_only();
2944                                    let open2 = self.advance().unwrap_or(delim);
2945                                    let close = match open2 {
2946                                        '(' => ')',
2947                                        '[' => ']',
2948                                        '{' => '}',
2949                                        '<' => '>',
2950                                        c => c,
2951                                    };
2952                                    let replacement = self.read_substitution_replacement(close)?;
2953                                    let flags = self.read_while(|c| REGEX_FLAG_CHARS.contains(c));
2954                                    self.last_was_term = true;
2955                                    // Encode as special token — parser will decode
2956                                    // Format: \x00s\x00pattern\x00replacement\x00flags\x00delim
2957                                    return Ok(Token::Ident(format!(
2958                                        "\x00s\x00{}\x00{}\x00{}\x00{}",
2959                                        pattern, replacement, flags, delim
2960                                    )));
2961                                }
2962                                let replacement = self.read_substitution_replacement(close)?;
2963                                let flags = self.read_while(|c| REGEX_FLAG_CHARS.contains(c));
2964                                self.last_was_term = true;
2965                                return Ok(Token::Ident(format!(
2966                                    "\x00s\x00{}\x00{}\x00{}\x00{}",
2967                                    pattern, replacement, flags, delim
2968                                )));
2969                            }
2970                        }
2971                        self.last_was_term = true;
2972                        return Ok(Token::Ident(ident));
2973                    }
2974                    "tr" | "y" => {
2975                        // `$obj->tr` / `$obj->y` — after `->`, this is a method name,
2976                        // not transliteration.
2977                        if self.prev_arrow {
2978                            self.last_was_term = true;
2979                            return Ok(Token::Ident(ident));
2980                        }
2981                        // After `::`, treat as package-qualified identifier, not transliteration.
2982                        // e.g. `Foo::y(...)` is a function call, not `y///`.
2983                        if self.pos >= ident.len() + 2 {
2984                            let prev_start = self.pos - ident.len() - 2;
2985                            if self.input.get(prev_start) == Some(&':')
2986                                && self.input.get(prev_start + 1) == Some(&':')
2987                            {
2988                                self.last_was_term = true;
2989                                return Ok(Token::Ident(ident));
2990                            }
2991                        }
2992                        // `tr` / `y` followed by terminators is a bareword, not transliteration.
2993                        // Check BEFORE skipping whitespace to catch newlines (implicit semicolon).
2994                        // `,` is treated as a terminator UNLESS the lookahead shows the
2995                        // full `tr,FROM,TO,FLAGS` shape — same gating as `s` above so
2996                        // `y` / `tr` can still appear as struct field names, list elements,
2997                        // and arg names without being eaten as transliteration bodies.
2998                        if let Some(d) = self.peek() {
2999                            if matches!(d, ';' | ')' | ']' | '}' | '>' | ':' | '\n') {
3000                                self.last_was_term = true;
3001                                return Ok(Token::Ident(ident));
3002                            }
3003                            if d == ',' && !self.lookahead_is_comma_delim_subst() {
3004                                self.last_was_term = true;
3005                                return Ok(Token::Ident(ident));
3006                            }
3007                        } else {
3008                            self.last_was_term = true;
3009                            return Ok(Token::Ident(ident));
3010                        }
3011                        // Now skip whitespace to check for `=>` or `=`
3012                        let start_pos = self.pos;
3013                        let start_line = self.line;
3014                        self.skip_whitespace_only();
3015                        if let Some(d) = self.peek() {
3016                            // `=` alone (not `==` comparison) means assignment — y is an identifier
3017                            if d == '=' && self.peek_at(1) != Some('=') {
3018                                self.pos = start_pos;
3019                                self.line = start_line;
3020                                self.last_was_term = true;
3021                                return Ok(Token::Ident(ident));
3022                            }
3023                        }
3024                        self.pos = start_pos;
3025                        self.line = start_line;
3026                        // Check for function signature pattern: y(...) { — this is `fn y`, not tr
3027                        if self.peek() == Some('(') {
3028                            // Scan ahead to see if there's ) followed by {
3029                            let scan_pos = self.pos;
3030                            let scan_line = self.line;
3031                            self.advance(); // skip (
3032                            let mut depth = 1;
3033                            while depth > 0 {
3034                                match self.peek() {
3035                                    Some('(') => {
3036                                        self.advance();
3037                                        depth += 1;
3038                                    }
3039                                    Some(')') => {
3040                                        self.advance();
3041                                        depth -= 1;
3042                                    }
3043                                    Some(_) => {
3044                                        self.advance();
3045                                    }
3046                                    None => break,
3047                                }
3048                            }
3049                            self.skip_whitespace_only();
3050                            let is_func_def = self.peek() == Some('{');
3051                            self.pos = scan_pos;
3052                            self.line = scan_line;
3053                            if is_func_def {
3054                                self.last_was_term = true;
3055                                return Ok(Token::Ident(ident));
3056                            }
3057                        }
3058                        // tr/from/to/flags
3059                        if let Some(delim) = self.peek() {
3060                            if !delim.is_alphanumeric() && delim != '_' && delim != ' ' {
3061                                self.advance();
3062                                let close = match delim {
3063                                    '(' => ')',
3064                                    '[' => ']',
3065                                    '{' => '}',
3066                                    '<' => '>',
3067                                    c => c,
3068                                };
3069                                let from = self.read_escaped_until(close)?;
3070                                // For paired delimiters
3071                                if "([{<".contains(delim) {
3072                                    self.skip_whitespace_only();
3073                                    self.advance(); // open second pair
3074                                }
3075                                let to = self.read_escaped_until(close)?;
3076                                let flags = self.read_while(|c| "cdsr".contains(c));
3077                                self.last_was_term = true;
3078                                return Ok(Token::Ident(format!(
3079                                    "\x00tr\x00{}\x00{}\x00{}\x00{}",
3080                                    from, to, flags, delim
3081                                )));
3082                            }
3083                        }
3084                        self.last_was_term = true;
3085                        return Ok(Token::Ident(ident));
3086                    }
3087                    _ => {}
3088                }
3089
3090                // Fat arrow lookahead: ident followed by => is a string.
3091                // CRITICAL: save AND restore `self.line` too. `skip_whitespace_and_comments`
3092                // increments `self.line` for each `\n` it skips, and restoring only
3093                // `self.pos` leaves the line counter drifted by N. The drift bleeds into
3094                // every subsequent token (every `class { field\n field\n }` line shift
3095                // appearing on `my` / `p` / etc. after the class, and bytecode `lines[]`
3096                // metadata that no longer matches the source — breaking the DAP debugger
3097                // since BPs key off original line numbers).
3098                let saved_pos2 = self.pos;
3099                let saved_line2 = self.line;
3100                self.skip_whitespace_and_comments();
3101                if self.peek() == Some('=') && self.peek_at(1) == Some('>') {
3102                    self.pos = saved_pos2;
3103                    self.line = saved_line2;
3104                    self.last_was_term = true;
3105                    return Ok(Token::Ident(ident));
3106                }
3107                self.pos = saved_pos2;
3108                self.line = saved_line2;
3109
3110                // Perl: `x` is the string-repetition infix operator only after a complete term.
3111                // After `sub`, `package`, `(`, etc. a term is expected — bare `x` must be an
3112                // identifier (`sub x {`, `x::Foo`, leading `x` in `(x)`).
3113                //
3114                // After `::` (package separator) or `->` (method arrow), the
3115                // identifier is a path leaf or method name — never an infix
3116                // operator. Without these gates, `keyword_or_ident("eq")` returns
3117                // `Token::StrEq` so `Mat::eq` fails to parse and `$obj->eq(...)`
3118                // silently degrades to `$obj eq …`.
3119                let tok = if after_package_sep || self.prev_arrow {
3120                    Token::Ident(ident.clone())
3121                } else if ident == "x" && !self.last_was_term {
3122                    Token::Ident("x".to_string())
3123                } else {
3124                    keyword_or_ident(&ident)
3125                };
3126                // `x=` is the string-repetition compound assignment. The
3127                // identifier `x` has already been consumed; peek for the
3128                // trailing `=` and merge into `XAssign`. Skip whitespace
3129                // between `x` and `=` to mirror how Perl's lexer treats
3130                // `$s x= 3` and `$s x =3` identically.
3131                let tok = if matches!(tok, Token::X) {
3132                    let saved_pos = self.pos;
3133                    let saved_line = self.line;
3134                    while matches!(self.peek(), Some(' ') | Some('\t')) {
3135                        self.advance();
3136                    }
3137                    if self.peek() == Some('=') && self.peek_at(1) != Some('=') {
3138                        self.advance();
3139                        self.last_was_term = false;
3140                        Token::XAssign
3141                    } else {
3142                        self.pos = saved_pos;
3143                        self.line = saved_line;
3144                        tok
3145                    }
3146                } else {
3147                    tok
3148                };
3149                if matches!(tok, Token::Ident(ref s) if s == "_") {
3150                    self.last_was_bare_positional = true;
3151                }
3152                // Keywords that expect a variable next should not set last_was_term
3153                // so that % is parsed as hash sigil, not modulo
3154                self.last_was_term = match ident.as_str() {
3155                    // Keywords/builtins that always expect arguments — never a term,
3156                    // so the next `/` is always a regex start.
3157                    "my"
3158                    | "var"
3159                    | "val"
3160                    | "mysync"
3161                    | "varsync"
3162                    | "frozen"
3163                    | "const"
3164                    | "typed"
3165                    | "our"
3166                    | "oursync"
3167                    | "local"
3168                    | "state"
3169                    | "return"
3170                    | "print"
3171                    | "pr"
3172                    | "say"
3173                    | "p"
3174                    | "die"
3175                    | "warn"
3176                    | "push"
3177                    | "pop"
3178                    | "shift"
3179                    | "shuffle"
3180                    | "chunked"
3181                    | "windowed"
3182                    | "unshift"
3183                    | "splice"
3184                    | "delete"
3185                    | "exists"
3186                    | "chomp"
3187                    | "chop"
3188                    | "defined"
3189                    | "keys"
3190                    | "values"
3191                    | "each"
3192                    | "sub"
3193                    | "struct"
3194                    | "if"
3195                    | "unless"
3196                    | "while"
3197                    | "until"
3198                    // `loop { ... }` — Rust-style infinite loop, desugars to
3199                    // `while (1) { ... }`. Listed here so the lexer's
3200                    // last_was_term tracking treats `loop` like other
3201                    // statement-start keywords (next char is `{`, never a
3202                    // continuation of a term).
3203                    | "loop"
3204                    // `ploop [N] { ... }` / `pwhile [N] (COND) { ... }` —
3205                    // parallel `loop` / `while`; same statement-start
3206                    // treatment.
3207                    | "ploop"
3208                    | "pwhile"
3209                    | "for"
3210                    | "foreach"
3211                    | "elsif"
3212                    | "use"
3213                    | "import"
3214                    | "no"
3215                    | "require"
3216                    | "eval"
3217                    | "do"
3218                    | "map"
3219                    | "maps"
3220                    | "flat_maps"
3221                    | "grep"
3222                    | "greps"
3223                    | "sort"
3224                    | "all"
3225                    | "any"
3226                    | "none"
3227                    | "take_while"
3228                    | "drop_while"
3229                    | "skip_while"
3230                    | "skip"
3231                    | "first_or"
3232                    | "tap"
3233                    | "peek"
3234                    | "with_index"
3235                    | "pmap"
3236                    | "pflat_map"
3237                    | "puniq"
3238                    | "pfirst"
3239                    | "pany"
3240                    | "pmap_chunked"
3241                    | "pipeline"
3242                    | "pgrep"
3243                    | "pfor"
3244                    | "pforeach"
3245                    | "par_lines"
3246                    | "par_walk"
3247                    | "pwatch"
3248                    | "watch"
3249                    | "psort"
3250                    | "reduce"
3251                    | "fold"
3252                    | "inject"
3253                    | "first"
3254                    | "detect"
3255                    | "find"
3256                    | "find_all"
3257                    | "preduce"
3258                    | "preduce_init"
3259                    | "pmap_reduce"
3260                    | "pcache"
3261                    | "fan"
3262                    | "fan_cap"
3263                    | "pchannel"
3264                    | "pselect"
3265                    | "uniq"
3266                    | "distinct"
3267                    | "flatten"
3268                    | "set"
3269                    | "list_count"
3270                    | "list_size"
3271                    | "count"
3272                    | "len"
3273                    | "size"
3274                    | "cnt"
3275                    | "zip"
3276                    | "async"
3277                    | "trace"
3278                    | "timer"
3279                    | "await"
3280                    | "slurp"
3281                    | "swallow"
3282                    | "ingest"
3283                    | "burp"
3284                    | "god"
3285                    | "capture"
3286                    | "fetch_url"
3287                    | "fetch"
3288                    | "fetch_json"
3289                    | "fetch_async"
3290                    | "fetch_async_json"
3291                    | "par_fetch"
3292                    | "par_csv_read"
3293                    | "par_pipeline"
3294                    | "par_pipeline_stream"
3295                    | "par_sed"
3296                    | "join"
3297                    | "json_encode"
3298                    | "json_decode"
3299                    | "json_jq"
3300                    | "jwt_encode"
3301                    | "jwt_decode"
3302                    | "jwt_decode_unsafe"
3303                    | "log_info"
3304                    | "log_warn"
3305                    | "log_error"
3306                    | "log_debug"
3307                    | "log_trace"
3308                    | "log_json"
3309                    | "log_level"
3310                    | "sha256"
3311                    | "sha1"
3312                    | "md5"
3313                    | "hmac_sha256"
3314                    | "hmac"
3315                    | "uuid"
3316                    | "base64_encode"
3317                    | "base64_decode"
3318                    | "hex_encode"
3319                    | "hex_decode"
3320                    | "gzip"
3321                    | "gunzip"
3322                    | "zstd"
3323                    | "zstd_decode"
3324                    | "datetime_utc"
3325                    | "datetime_from_epoch"
3326                    | "datetime_parse_rfc3339"
3327                    | "datetime_strftime"
3328                    | "toml_decode"
3329                    | "toml_encode"
3330                    | "yaml_decode"
3331                    | "yaml_encode"
3332                    | "url_encode"
3333                    | "url_decode"
3334                    | "uri_escape"
3335                    | "uri_unescape"
3336                    | "split"
3337                    | "reverse"
3338                    | "reversed"
3339                    | "not"
3340                    | "ref"
3341                    | "scalar"
3342                    | "try"
3343                    | "catch"
3344                    | "finally"
3345                    | "given"
3346                    | "when"
3347                    | "default"
3348                    | "eval_timeout"
3349                    | "tie"
3350                    | "retry"
3351                    | "rate_limit"
3352                    | "every"
3353                    | "gen"
3354                    | "yield"
3355                    | "match"
3356                    | "filter"
3357                    | "f"
3358                    | "reject"
3359                    | "grepv"
3360                    | "collect"
3361                    | "compact"
3362                    | "concat"
3363                    | "chain"
3364                    | "min_by"
3365                    | "max_by"
3366                    | "sort_by"
3367                    | "tally"
3368                    | "find_index"
3369                    | "each_with_index"
3370                    | "fore"
3371                    | "e"
3372                    | "ep"
3373                    | "flat_map"
3374                    | "group_by"
3375                    | "chunk_by"
3376                    | "bench" => false,
3377                    // `thread`/`t` are ambiguous: at statement start they're the
3378                    // thread keyword (expect args → false), but after an operator
3379                    // they could be variable names (e.g., `$x / t / 2` → true).
3380                    "thread" | "t" => !self.last_was_term,
3381                    _ => matches!(tok, Token::Ident(_)),
3382                };
3383                Ok(tok)
3384            }
3385
3386            c => Err(self.syntax_err(format!("Unexpected character '{c}'"), self.line)),
3387        }
3388    }
3389
3390    /// Tokenize entire input.
3391    pub fn tokenize(&mut self) -> StrykeResult<Vec<(Token, usize)>> {
3392        let mut tokens = Vec::new();
3393        loop {
3394            // `next_token` internally skips whitespace + POD / heredoc and
3395            // stamps `self.token_start_line` to the line where the emitted
3396            // token actually starts. Use that, not a pre-call snapshot of
3397            // `self.line` — POD blocks can advance the line counter by
3398            // many lines before the real token is produced (see
3399            // `token_start_line` doc).
3400            let tok = self.next_token()?;
3401            let line = self.token_start_line;
3402            if self.last_was_bare_positional {
3403                self.bare_positional_indices.insert(tokens.len());
3404            }
3405            if tok == Token::Eof {
3406                tokens.push((Token::Eof, line));
3407                break;
3408            }
3409            tokens.push((tok, line));
3410        }
3411        Ok(tokens)
3412    }
3413}
3414
3415#[cfg(test)]
3416mod tests {
3417    use super::*;
3418    use crate::token::Token;
3419
3420    #[test]
3421    fn tokenize_empty_yields_eof() {
3422        let mut l = Lexer::new("");
3423        let t = l.tokenize().expect("tokenize");
3424        assert_eq!(t.len(), 1);
3425        assert!(matches!(t[0].0, Token::Eof));
3426    }
3427
3428    #[test]
3429    fn tokenize_integer_literal() {
3430        let mut l = Lexer::new("42");
3431        let t = l.tokenize().expect("tokenize");
3432        assert!(matches!(t[0].0, Token::Integer(42)));
3433    }
3434
3435    #[test]
3436    fn tokenize_keyword_my_and_semicolon() {
3437        let mut l = Lexer::new("my;");
3438        let t = l.tokenize().expect("tokenize");
3439        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "my"));
3440        assert!(matches!(t[1].0, Token::Semicolon));
3441    }
3442
3443    #[test]
3444    fn tokenize_skips_hash_line_comment() {
3445        let mut l = Lexer::new("1#comment\n2");
3446        let t = l.tokenize().expect("tokenize");
3447        assert!(matches!(t[0].0, Token::Integer(1)));
3448        assert!(matches!(t[1].0, Token::Integer(2)));
3449        assert!(matches!(t[2].0, Token::Eof));
3450    }
3451
3452    #[test]
3453    fn tokenize_double_quoted_string_literal() {
3454        let mut l = Lexer::new(r#""hi""#);
3455        let t = l.tokenize().expect("tokenize");
3456        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "hi"));
3457    }
3458
3459    #[test]
3460    fn tokenize_triple_quoted_interpolating_multiline() {
3461        // `"""..."""` — interpolating triple-quote. Newlines preserved
3462        // raw; interpolation flag stays on (Token::DoubleString flows
3463        // through the regular string-interp pipeline downstream).
3464        let mut l = Lexer::new("\"\"\"hello\nworld\nline\"\"\"");
3465        let t = l.tokenize().expect("tokenize");
3466        assert!(
3467            matches!(t[0].0, Token::DoubleString(ref s) if s == "hello\nworld\nline"),
3468            "got: {:?}",
3469            t[0].0
3470        );
3471    }
3472
3473    #[test]
3474    fn tokenize_triple_quoted_empty() {
3475        let mut l = Lexer::new("\"\"\"\"\"\"");
3476        let t = l.tokenize().expect("tokenize");
3477        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s.is_empty()));
3478    }
3479
3480    #[test]
3481    fn tokenize_triple_quoted_with_embedded_quotes() {
3482        // Two consecutive `""` inside a `"""..."""` body should not
3483        // close — only three `"""` in a row terminates.
3484        let mut l = Lexer::new("\"\"\"a \"\" b\"\"\"");
3485        let t = l.tokenize().expect("tokenize");
3486        assert!(
3487            matches!(t[0].0, Token::DoubleString(ref s) if s == "a \"\" b"),
3488            "got: {:?}",
3489            t[0].0
3490        );
3491    }
3492
3493    #[test]
3494    fn tokenize_raw_triple_quoted() {
3495        // `r"""..."""` — non-interpolating raw triple-quote. No escape
3496        // processing: `\n` stays as the two literal chars `\` and `n`.
3497        let mut l = Lexer::new("r\"\"\"raw \\n and $no_interp\"\"\"");
3498        let t = l.tokenize().expect("tokenize");
3499        assert!(
3500            matches!(t[0].0, Token::SingleString(ref s) if s == "raw \\n and $no_interp"),
3501            "got: {:?}",
3502            t[0].0
3503        );
3504    }
3505
3506    #[test]
3507    fn tokenize_r_bareword_not_triple_quote() {
3508        // Lone `r` (not followed by `"""`) is still a plain identifier.
3509        let mut l = Lexer::new("r => 5");
3510        let t = l.tokenize().expect("tokenize");
3511        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "r"));
3512    }
3513
3514    #[test]
3515    fn tokenize_unterminated_triple_quote_errors() {
3516        let mut l = Lexer::new("\"\"\"never closes");
3517        assert!(l.tokenize().is_err());
3518    }
3519
3520    #[test]
3521    fn tokenize_double_string_escaped_sigils_are_literal() {
3522        // `\$` in source becomes a sentinel + parser emits literal `$` (not outer interpolation).
3523        let mut l = Lexer::new(r#""my \$x""#);
3524        let t = l.tokenize().expect("tokenize");
3525        let want = format!("my {}x", LITERAL_DOLLAR_IN_DQUOTE);
3526        assert!(matches!(t[0].0, Token::DoubleString(ref s) if *s == want));
3527    }
3528
3529    #[test]
3530    fn tokenize_double_string_braced_hex_unicode_escape() {
3531        let mut l = Lexer::new(r#""\x{1215}""#);
3532        let t = l.tokenize().expect("tokenize");
3533        let want: String = ['\u{1215}'].into_iter().collect();
3534        assert!(matches!(t[0].0, Token::DoubleString(ref s) if *s == want));
3535    }
3536
3537    #[test]
3538    fn tokenize_double_string_braced_unicode_u_escape() {
3539        let mut l = Lexer::new(r#""\u{0301}""#);
3540        let t = l.tokenize().expect("tokenize");
3541        let want: String = ['\u{0301}'].into_iter().collect();
3542        assert!(matches!(t[0].0, Token::DoubleString(ref s) if *s == want));
3543    }
3544
3545    #[test]
3546    fn tokenize_double_string_braced_unicode_u_escape_multi() {
3547        // \u{0041} = 'A', \u{00E9} = 'é', \u{1F600} = '😀'
3548        let mut l = Lexer::new(r#""\u{0041}\u{00E9}\u{1F600}""#);
3549        let t = l.tokenize().expect("tokenize");
3550        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "Aé😀"));
3551    }
3552
3553    #[test]
3554    fn tokenize_double_string_octal_escape() {
3555        let mut l = Lexer::new(r#""\101""#);
3556        let t = l.tokenize().expect("tokenize");
3557        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "A"));
3558    }
3559
3560    #[test]
3561    fn tokenize_double_string_braced_octal_escape() {
3562        let mut l = Lexer::new(r#""\o{101}""#);
3563        let t = l.tokenize().expect("tokenize");
3564        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "A"));
3565    }
3566
3567    #[test]
3568    fn tokenize_double_string_control_char_escape() {
3569        let mut l = Lexer::new(r#""\cA""#);
3570        let t = l.tokenize().expect("tokenize");
3571        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "\x01"));
3572    }
3573
3574    #[test]
3575    fn tokenize_double_string_named_unicode_escape() {
3576        let mut l = Lexer::new(r#""\N{SNOWMAN}""#);
3577        let t = l.tokenize().expect("tokenize");
3578        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "☃"));
3579    }
3580
3581    #[test]
3582    fn tokenize_double_string_named_unicode_u_plus() {
3583        let mut l = Lexer::new(r#""\N{U+2603}""#);
3584        let t = l.tokenize().expect("tokenize");
3585        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "☃"));
3586    }
3587
3588    #[test]
3589    fn tokenize_double_string_unbraced_hex_two_digits() {
3590        let mut l = Lexer::new(r#""\x41""#);
3591        let t = l.tokenize().expect("tokenize");
3592        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "A"));
3593    }
3594
3595    #[test]
3596    fn tokenize_single_quoted_string_literal() {
3597        let mut l = Lexer::new("'x'");
3598        let t = l.tokenize().expect("tokenize");
3599        assert!(matches!(t[0].0, Token::SingleString(ref s) if s == "x"));
3600    }
3601
3602    #[test]
3603    fn tokenize_spaceship_operator() {
3604        let mut l = Lexer::new("1 <=> 2");
3605        let t = l.tokenize().expect("tokenize");
3606        assert!(matches!(t[0].0, Token::Integer(1)));
3607        assert!(matches!(t[1].0, Token::Spaceship));
3608        assert!(matches!(t[2].0, Token::Integer(2)));
3609    }
3610
3611    #[test]
3612    fn tokenize_m_regex_literal() {
3613        let mut l = Lexer::new("m/abc/");
3614        let t = l.tokenize().expect("tokenize");
3615        assert!(matches!(t[0].0, Token::Regex(ref p, ref f, _) if p == "abc" && f.is_empty()));
3616    }
3617
3618    #[test]
3619    fn tokenize_q_brace_constructor() {
3620        let mut l = Lexer::new("q{lit}");
3621        let t = l.tokenize().expect("tokenize");
3622        assert!(matches!(t[0].0, Token::SingleString(ref s) if s == "lit"));
3623    }
3624
3625    /// `q(sub ($) { 1 })` — nested `()` must not end at the `)` in `($)` (core `Carp.pm`).
3626    #[test]
3627    fn tokenize_q_paren_balances_nested_parens_in_prototype() {
3628        let mut l = Lexer::new("q(fn ($) { 1 })");
3629        let t = l.tokenize().expect("tokenize");
3630        assert!(matches!(t[0].0, Token::SingleString(ref s) if s == "fn ($) { 1 }"));
3631    }
3632
3633    /// `qw( (SV*)x )` — nested `()` inside `qw(...)` (core `B.pm`).
3634    #[test]
3635    fn tokenize_qw_paren_balances_nested_parens() {
3636        let mut l = Lexer::new("qw( (SV*)pWARN_ALL )");
3637        let t = l.tokenize().expect("tokenize");
3638        assert!(matches!(t[0].0, Token::QW(ref w) if w.len() == 1 && w[0] == "(SV*)pWARN_ALL"));
3639    }
3640
3641    #[test]
3642    fn tokenize_float_literal() {
3643        let mut l = Lexer::new("3.25");
3644        let t = l.tokenize().expect("tokenize");
3645        assert!(matches!(t[0].0, Token::Float(f) if (f - 3.25).abs() < f64::EPSILON));
3646    }
3647
3648    #[test]
3649    fn tokenize_scientific_float() {
3650        let mut l = Lexer::new("1e2");
3651        let t = l.tokenize().expect("tokenize");
3652        assert!(matches!(t[0].0, Token::Float(f) if (f - 100.0).abs() < 1e-9));
3653    }
3654
3655    #[test]
3656    fn tokenize_hex_with_underscore_separators() {
3657        let mut l = Lexer::new("0x_FF");
3658        let t = l.tokenize().expect("tokenize");
3659        assert!(matches!(t[0].0, Token::Integer(255)));
3660    }
3661
3662    #[test]
3663    fn tokenize_qr_regex_with_flags() {
3664        let mut l = Lexer::new("qr/pat/i");
3665        let t = l.tokenize().expect("tokenize");
3666        assert!(matches!(t[0].0, Token::Regex(ref p, ref f, _) if p == "pat" && f == "i"));
3667    }
3668
3669    #[test]
3670    fn tokenize_m_slash_includes_gc_flags() {
3671        let mut l = Lexer::new("m/./gc");
3672        let t = l.tokenize().expect("tokenize");
3673        assert!(matches!(&t[0].0, Token::Regex(p, f, _) if p == "." && f == "gc"));
3674    }
3675
3676    #[test]
3677    fn tokenize_m_hash_delimiter_includes_gc_flags() {
3678        let mut l = Lexer::new("m#\\w#gc");
3679        let t = l.tokenize().expect("tokenize");
3680        assert!(matches!(&t[0].0, Token::Regex(p, f, _) if p == r"\w" && f == "gc"));
3681    }
3682
3683    #[test]
3684    fn tokenize_qr_slash_includes_gco_flags() {
3685        let mut l = Lexer::new("qr/x/gco");
3686        let t = l.tokenize().expect("tokenize");
3687        assert!(matches!(&t[0].0, Token::Regex(p, f, _) if p == "x" && f == "gco"));
3688    }
3689
3690    #[test]
3691    fn tokenize_qw_hash_delimiter_not_line_comment() {
3692        // `#` after `qw` must be the opener, not `skip_whitespace_and_comments` eating the line.
3693        let mut l = Lexer::new("qw# a b #;");
3694        let t = l.tokenize().expect("tokenize");
3695        assert!(
3696            matches!(&t[0].0, Token::QW(w) if w == &["a", "b"]),
3697            "first={:?}",
3698            t.first()
3699        );
3700    }
3701
3702    #[test]
3703    fn tokenize_qq_hash_delimiter_single_line() {
3704        let mut l = Lexer::new("qq#x#;");
3705        let t = l.tokenize().expect("tokenize");
3706        assert!(matches!(&t[0].0, Token::DoubleString(s) if s == "x"));
3707    }
3708
3709    #[test]
3710    fn tokenize_qr_hash_delimiter_text_balanced_preamble() {
3711        let src = "qr#(\n    [!=]~\n    | split|grep|map\n    | not|and|or|xor\n)#x";
3712        let mut l = Lexer::new(src);
3713        let t = l.tokenize().expect("tokenize");
3714        let Token::Regex(p, f, _) = &t[0].0 else {
3715            panic!("expected Regex, got {:?}", t[0].0);
3716        };
3717        let rest: Vec<_> = t.iter().skip(1).take(8).map(|x| &x.0).collect();
3718        assert!(f.contains('x'), "flags={f:?} pattern={p:?} rest={rest:?}");
3719        assert!(p.contains("[!=]~"), "{p:?}");
3720        assert!(p.contains("split|grep|map"), "{p:?}");
3721    }
3722
3723    #[test]
3724    fn tokenize_octal_integer_literal() {
3725        let mut l = Lexer::new("010");
3726        let t = l.tokenize().expect("tokenize");
3727        assert!(matches!(t[0].0, Token::Integer(8)));
3728    }
3729
3730    #[test]
3731    fn tokenize_binary_integer_literal() {
3732        let mut l = Lexer::new("0b1010");
3733        let t = l.tokenize().expect("tokenize");
3734        assert!(matches!(t[0].0, Token::Integer(10)));
3735    }
3736
3737    #[test]
3738    fn tokenize_filetest_exists() {
3739        let mut l = Lexer::new("-e '.'");
3740        let t = l.tokenize().expect("tokenize");
3741        assert!(matches!(t[0].0, Token::FileTest('e')));
3742        assert!(matches!(t[1].0, Token::SingleString(ref s) if s == "."));
3743    }
3744
3745    #[test]
3746    fn tokenize_filetest_tty() {
3747        let mut l = Lexer::new("-t 'STDIN'");
3748        let t = l.tokenize().expect("tokenize");
3749        assert!(matches!(t[0].0, Token::FileTest('t')));
3750        assert!(matches!(t[1].0, Token::SingleString(ref s) if s == "STDIN"));
3751    }
3752
3753    #[test]
3754    fn tokenize_power_and_range_operators() {
3755        let mut l = Lexer::new("2 ** 3");
3756        let t = l.tokenize().expect("tokenize");
3757        assert!(matches!(t[0].0, Token::Integer(2)));
3758        assert!(matches!(t[1].0, Token::Power));
3759        assert!(matches!(t[2].0, Token::Integer(3)));
3760
3761        let mut l = Lexer::new("1..4");
3762        let t = l.tokenize().expect("tokenize");
3763        assert!(matches!(t[0].0, Token::Integer(1)));
3764        assert!(matches!(t[1].0, Token::Range));
3765        assert!(matches!(t[2].0, Token::Integer(4)));
3766    }
3767
3768    #[test]
3769    fn tokenize_numeric_equality_operators() {
3770        let mut l = Lexer::new("1 == 2");
3771        let t = l.tokenize().expect("tokenize");
3772        assert!(matches!(t[0].0, Token::Integer(1)));
3773        assert!(matches!(t[1].0, Token::NumEq));
3774        assert!(matches!(t[2].0, Token::Integer(2)));
3775
3776        let mut l = Lexer::new("3 != 4");
3777        let t = l.tokenize().expect("tokenize");
3778        assert!(matches!(t[0].0, Token::Integer(3)));
3779        assert!(matches!(t[1].0, Token::NumNe));
3780        assert!(matches!(t[2].0, Token::Integer(4)));
3781    }
3782
3783    #[test]
3784    fn tokenize_logical_and_or_plus_assign() {
3785        let mut l = Lexer::new("1 && 0");
3786        let t = l.tokenize().expect("tokenize");
3787        assert!(matches!(t[0].0, Token::Integer(1)));
3788        assert!(matches!(t[1].0, Token::LogAnd));
3789        assert!(matches!(t[2].0, Token::Integer(0)));
3790
3791        let mut l = Lexer::new("0 || 9");
3792        let t = l.tokenize().expect("tokenize");
3793        assert!(matches!(t[0].0, Token::Integer(0)));
3794        assert!(matches!(t[1].0, Token::LogOr));
3795        assert!(matches!(t[2].0, Token::Integer(9)));
3796
3797        let mut l = Lexer::new("n += 1");
3798        let t = l.tokenize().expect("tokenize");
3799        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "n"));
3800        assert!(matches!(t[1].0, Token::PlusAssign));
3801        assert!(matches!(t[2].0, Token::Integer(1)));
3802    }
3803
3804    #[test]
3805    fn tokenize_bitwise_and_operator() {
3806        let mut l = Lexer::new("3 & 5");
3807        let t = l.tokenize().expect("tokenize");
3808        assert!(matches!(t[0].0, Token::Integer(3)));
3809        assert!(matches!(t[1].0, Token::BitAnd));
3810        assert!(matches!(t[2].0, Token::Integer(5)));
3811    }
3812
3813    #[test]
3814    fn tokenize_braced_caret_scalar_global_phase() {
3815        let mut l = Lexer::new(r#"print ${^GLOBAL_PHASE}, "\n";"#);
3816        let t = l.tokenize().expect("tokenize");
3817        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "print"));
3818        assert!(matches!(t[1].0, Token::ScalarVar(ref s) if s == "^GLOBAL_PHASE"));
3819        assert!(matches!(t[2].0, Token::Comma));
3820        assert!(matches!(t[3].0, Token::DoubleString(ref s) if s == "\n"));
3821        assert!(matches!(t[4].0, Token::Semicolon));
3822    }
3823
3824    #[test]
3825    fn tokenize_bitwise_or_and_assign() {
3826        let mut l = Lexer::new("$a |= $b");
3827        let t = l.tokenize().expect("tokenize");
3828        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "a"));
3829        assert!(matches!(t[1].0, Token::BitOrAssign));
3830        assert!(matches!(t[2].0, Token::ScalarVar(ref s) if s == "b"));
3831
3832        let mut l = Lexer::new("$a &= $b");
3833        let t = l.tokenize().expect("tokenize");
3834        assert!(matches!(t[1].0, Token::BitAndAssign));
3835    }
3836
3837    #[test]
3838    fn tokenize_division_and_modulo() {
3839        let mut l = Lexer::new("7 / 2");
3840        let t = l.tokenize().expect("tokenize");
3841        assert!(matches!(t[1].0, Token::Slash));
3842
3843        let mut l = Lexer::new("7 % 3");
3844        let t = l.tokenize().expect("tokenize");
3845        assert!(matches!(t[1].0, Token::Percent));
3846    }
3847
3848    #[test]
3849    fn tokenize_comma_fat_arrow_and_semicolon() {
3850        let mut l = Lexer::new("a => 1;");
3851        let t = l.tokenize().expect("tokenize");
3852        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "a"));
3853        assert!(matches!(t[1].0, Token::FatArrow));
3854        assert!(matches!(t[2].0, Token::Integer(1)));
3855        assert!(matches!(t[3].0, Token::Semicolon));
3856    }
3857
3858    #[test]
3859    fn tokenize_minus_unary_vs_binary() {
3860        let mut l = Lexer::new("- 5");
3861        let t = l.tokenize().expect("tokenize");
3862        assert!(matches!(t[0].0, Token::Minus));
3863        assert!(matches!(t[1].0, Token::Integer(5)));
3864    }
3865
3866    #[test]
3867    fn tokenize_dollar_scalar_sigil() {
3868        let mut l = Lexer::new("$foo");
3869        let t = l.tokenize().expect("tokenize");
3870        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "foo"));
3871    }
3872
3873    /// `=` + letter is assignment unless `=` starts the line (POD). `$_=foo` must not skip POD.
3874    #[test]
3875    fn tokenize_assign_not_pod_when_eq_not_line_start() {
3876        let mut l = Lexer::new("$_=foo;");
3877        let t = l.tokenize().expect("tokenize");
3878        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "_"));
3879        assert!(matches!(t[1].0, Token::Assign));
3880        assert!(matches!(t[2].0, Token::Ident(ref s) if s == "foo"));
3881        assert!(matches!(t[3].0, Token::Semicolon));
3882    }
3883
3884    #[test]
3885    fn tokenize_pod_equals_still_skipped_at_line_start() {
3886        let mut l = Lexer::new("=head1 NAME\ncode\n=cut\n$x;");
3887        let t = l.tokenize().expect("tokenize");
3888        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "x"));
3889        assert!(matches!(t[1].0, Token::Semicolon));
3890    }
3891
3892    #[test]
3893    fn tokenize_at_array_sigil() {
3894        let mut l = Lexer::new("@arr");
3895        let t = l.tokenize().expect("tokenize");
3896        assert!(matches!(t[0].0, Token::ArrayVar(ref s) if s == "arr"));
3897    }
3898
3899    #[test]
3900    fn tokenize_at_caret_capture_array() {
3901        let mut l = Lexer::new("@^CAPTURE");
3902        let t = l.tokenize().expect("tokenize");
3903        assert!(matches!(t[0].0, Token::ArrayVar(ref s) if s == "^CAPTURE"));
3904    }
3905
3906    #[test]
3907    fn tokenize_percent_caret_hook_hash() {
3908        let mut l = Lexer::new("%^HOOK");
3909        let t = l.tokenize().expect("tokenize");
3910        assert!(matches!(t[0].0, Token::HashVar(ref s) if s == "^HOOK"));
3911    }
3912
3913    #[test]
3914    fn tokenize_caret_letter_and_at_minus_plus() {
3915        let mut l = Lexer::new("$^I@-@+");
3916        let t = l.tokenize().expect("tokenize");
3917        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "^I"));
3918        assert!(matches!(t[1].0, Token::ArrayVar(ref s) if s == "-"));
3919        assert!(matches!(t[2].0, Token::ArrayVar(ref s) if s == "+"));
3920    }
3921
3922    #[test]
3923    fn tokenize_percent_hash_sigil() {
3924        let mut l = Lexer::new("%h");
3925        let t = l.tokenize().expect("tokenize");
3926        assert!(matches!(t[0].0, Token::HashVar(ref s) if s == "h"));
3927    }
3928
3929    #[test]
3930    fn tokenize_percent_plus_named_capture_hash() {
3931        let mut l = Lexer::new("%+");
3932        let t = l.tokenize().expect("tokenize");
3933        assert!(matches!(t[0].0, Token::HashVar(ref s) if s == "+"));
3934    }
3935
3936    #[test]
3937    fn tokenize_dollar_dollar_under_brace_is_not_pid() {
3938        // `$$_{$k}` — second `$$` is not PID; tokenizes as `$_` then `{` (Perl `$_->{$k}`).
3939        let mut l = Lexer::new("$$_{$k}");
3940        let t = l.tokenize().expect("tokenize");
3941        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "_"));
3942        assert!(matches!(t[1].0, Token::LBrace));
3943    }
3944
3945    #[test]
3946    fn tokenize_braced_scalar_deref_try_tiny() {
3947        // `${$code_ref}` ≡ `$$code_ref` (Try::Tiny blesses scalar refs to coderefs).
3948        let mut l = Lexer::new("${$code_ref}");
3949        let t = l.tokenize().expect("tokenize");
3950        assert!(matches!(t[0].0, Token::DerefScalarVar(ref s) if s == "code_ref"));
3951    }
3952
3953    #[test]
3954    fn tokenize_braced_scalar_deref_package_qualified() {
3955        let mut l = Lexer::new("${$Foo::bar}");
3956        let t = l.tokenize().expect("tokenize");
3957        assert!(matches!(t[0].0, Token::DerefScalarVar(ref s) if s == "Foo::bar"));
3958    }
3959
3960    #[test]
3961    fn tokenize_dollar_colon_stash_brace() {
3962        // `$::{$k}` — `%::` main stash (core Carp.pm line 32).
3963        let mut l = Lexer::new("$::{$pack}");
3964        let t = l.tokenize().expect("tokenize");
3965        assert!(matches!(t[0].0, Token::ScalarVar(ref s) if s == "::"));
3966        assert!(matches!(t[1].0, Token::LBrace));
3967    }
3968
3969    #[test]
3970    fn tokenize_ampersand_then_ident_is_bitand_not_coderef() {
3971        // Subroutine coderef `&name` is not a distinct token; lexer emits `&` then ident.
3972        let mut l = Lexer::new("&f");
3973        let t = l.tokenize().expect("tokenize");
3974        assert!(matches!(t[0].0, Token::BitAnd));
3975        assert!(matches!(t[1].0, Token::Ident(ref s) if s == "f"));
3976    }
3977
3978    #[test]
3979    fn tokenize_qq_paren_constructor() {
3980        let mut l = Lexer::new("qq(x y)");
3981        let t = l.tokenize().expect("tokenize");
3982        assert!(matches!(t[0].0, Token::DoubleString(ref s) if s == "x y"));
3983    }
3984
3985    #[test]
3986    fn tokenize_qq_slash_escaped_dollar_is_literal() {
3987        let mut l = Lexer::new(r#"qq/my \$y/"#);
3988        let t = l.tokenize().expect("tokenize");
3989        let want = format!("my {}y", LITERAL_DOLLAR_IN_DQUOTE);
3990        assert!(matches!(t[0].0, Token::DoubleString(ref s) if *s == want));
3991    }
3992
3993    #[test]
3994    fn tokenize_s_substitution_alternate_delimiter() {
3995        let mut l = Lexer::new("s#a#b#");
3996        let t = l.tokenize().expect("tokenize");
3997        assert!(matches!(t[0].0, Token::Ident(ref s) if s.starts_with("\x00s\x00")));
3998    }
3999
4000    #[test]
4001    fn tokenize_tr_slash_delimiter() {
4002        let mut l = Lexer::new("tr/a/b/");
4003        let t = l.tokenize().expect("tokenize");
4004        assert!(matches!(t[0].0, Token::Ident(ref s) if s.starts_with("\x00tr\x00")));
4005    }
4006
4007    #[test]
4008    fn tokenize_y_synonym_for_tr() {
4009        let mut l = Lexer::new("y/x/y/");
4010        let t = l.tokenize().expect("tokenize");
4011        assert!(matches!(t[0].0, Token::Ident(ref s) if s.starts_with("\x00tr\x00")));
4012    }
4013
4014    #[test]
4015    fn tokenize_less_equal_greater_relops() {
4016        let mut l = Lexer::new("1 <= 2");
4017        let t = l.tokenize().expect("tokenize");
4018        assert!(matches!(t[1].0, Token::NumLe));
4019
4020        let mut l = Lexer::new("3 >= 2");
4021        let t = l.tokenize().expect("tokenize");
4022        assert!(matches!(t[1].0, Token::NumGe));
4023
4024        let mut l = Lexer::new("1 < 2");
4025        let t = l.tokenize().expect("tokenize");
4026        assert!(matches!(t[1].0, Token::NumLt));
4027
4028        let mut l = Lexer::new("3 > 2");
4029        let t = l.tokenize().expect("tokenize");
4030        assert!(matches!(t[1].0, Token::NumGt));
4031    }
4032
4033    #[test]
4034    fn tokenize_readline_scalar_handle() {
4035        let mut l = Lexer::new("<$fh>");
4036        let t = l.tokenize().expect("tokenize");
4037        assert!(matches!(t[0].0, Token::ReadLine(ref s) if s == "fh"));
4038    }
4039
4040    #[test]
4041    fn tokenize_shift_right_and_shift_left_assign() {
4042        let mut l = Lexer::new("8 >> 1");
4043        let t = l.tokenize().expect("tokenize");
4044        assert!(matches!(t[1].0, Token::ShiftRight));
4045
4046        let mut l = Lexer::new("8 << 1");
4047        let t = l.tokenize().expect("tokenize");
4048        assert!(matches!(t[1].0, Token::ShiftLeft));
4049
4050        let mut l = Lexer::new("x <<= 3");
4051        let t = l.tokenize().expect("tokenize");
4052        assert!(matches!(t[1].0, Token::ShiftLeftAssign));
4053    }
4054
4055    #[test]
4056    fn tokenize_heredoc_after_print_not_shift() {
4057        let src = "print <<EOT\nhi\nEOT\n";
4058        let mut l = Lexer::new(src);
4059        let t = l.tokenize().expect("tokenize");
4060        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "print"));
4061        assert!(
4062            matches!(&t[1].0, Token::HereDoc(tag, body, interpolate) if tag == "EOT" && body == "hi\n" && *interpolate),
4063            "got {:?}",
4064            t[1].0
4065        );
4066    }
4067
4068    #[test]
4069    fn tokenize_bitwise_or_xor() {
4070        let mut l = Lexer::new("3 | 1");
4071        let t = l.tokenize().expect("tokenize");
4072        assert!(matches!(t[1].0, Token::BitOr));
4073
4074        let mut l = Lexer::new("3 ^ 1");
4075        let t = l.tokenize().expect("tokenize");
4076        assert!(matches!(t[1].0, Token::BitXor));
4077    }
4078
4079    #[test]
4080    fn tokenize_pipe_forward_vs_bitor_vs_logor() {
4081        // `|>` must lex as a distinct token (not `|` followed by `>`).
4082        let mut l = Lexer::new("1 |> f");
4083        let t = l.tokenize().expect("tokenize");
4084        assert!(matches!(t[1].0, Token::PipeForward), "got {:?}", t[1].0);
4085
4086        // Make sure `|` and `||` still work alongside `|>`.
4087        let mut l = Lexer::new("a | b || c |> d");
4088        let t = l.tokenize().expect("tokenize");
4089        let kinds: Vec<_> = t.iter().map(|(k, _)| k.clone()).collect();
4090        assert!(kinds.iter().any(|k| matches!(k, Token::BitOr)));
4091        assert!(kinds.iter().any(|k| matches!(k, Token::LogOr)));
4092        assert!(kinds.iter().any(|k| matches!(k, Token::PipeForward)));
4093    }
4094
4095    #[test]
4096    fn tokenize_compare_and_three_way_string_ops() {
4097        let mut l = Lexer::new("\"a\" cmp \"b\"");
4098        let t = l.tokenize().expect("tokenize");
4099        assert!(matches!(t[1].0, Token::StrCmp));
4100    }
4101
4102    #[test]
4103    fn tokenize_package_double_colon_splits_qualified_name() {
4104        let mut l = Lexer::new("Foo::Bar::baz");
4105        let t = l.tokenize().expect("tokenize");
4106        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "Foo"));
4107        assert!(matches!(t[1].0, Token::PackageSep));
4108        assert!(matches!(t[2].0, Token::Ident(ref s) if s == "Bar"));
4109        assert!(matches!(t[3].0, Token::PackageSep));
4110        assert!(matches!(t[4].0, Token::Ident(ref s) if s == "baz"));
4111    }
4112
4113    #[test]
4114    fn tokenize_pod_line_skipped_like_comment_prefix() {
4115        // `=head1` at line start starts POD; lexer should skip until =cut
4116        let mut l = Lexer::new("=pod\n=cut\n42");
4117        let t = l.tokenize().expect("tokenize");
4118        assert!(matches!(t[0].0, Token::Integer(42)));
4119    }
4120
4121    #[test]
4122    fn tokenize_underscore_in_identifier() {
4123        let mut l = Lexer::new("__PACKAGE__");
4124        let t = l.tokenize().expect("tokenize");
4125        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "__PACKAGE__"));
4126    }
4127
4128    /// `x` is the repetition operator only in infix position; after `sub` it is a sub name (Perl).
4129    #[test]
4130    fn tokenize_x_repeat_vs_sub_name() {
4131        let mut l = Lexer::new("3 x 4");
4132        let t = l.tokenize().expect("tokenize");
4133        assert!(matches!(t[1].0, Token::X));
4134
4135        let mut l = Lexer::new("sub x { 1 }");
4136        let t = l.tokenize().expect("tokenize");
4137        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "sub"));
4138        assert!(matches!(t[1].0, Token::Ident(ref s) if s == "x"));
4139    }
4140
4141    /// Regression for the fat-arrow lookahead drift. Every identifier is
4142    /// followed by a speculative `skip_whitespace_and_comments` peek for
4143    /// `=>`, which advances `self.line` over any intervening newlines.
4144    /// Restoring only `self.pos` (the original bug) leaves the line counter
4145    /// drifted +1 per newline crossed, so every subsequent statement
4146    /// reports a wrong source line and breakpoints on those lines silently
4147    /// drop. With the save/restore fix, the line numbers match the source.
4148    #[test]
4149    fn fat_arrow_lookahead_does_not_drift_line() {
4150        let src = "x\ny\nz\n";
4151        let mut l = Lexer::new(src);
4152        let t = l.tokenize().expect("tokenize");
4153        // Token lines should match source lines.
4154        assert!(matches!(t[0].0, Token::Ident(ref s) if s == "x"));
4155        assert_eq!(t[0].1, 1, "x is on line 1");
4156        assert!(matches!(t[1].0, Token::Ident(ref s) if s == "y"));
4157        assert_eq!(t[1].1, 2, "y is on line 2");
4158        assert!(matches!(t[2].0, Token::Ident(ref s) if s == "z"));
4159        assert_eq!(t[2].1, 3, "z is on line 3");
4160    }
4161
4162    /// Specific case from the bug report: a class-body with a typed field
4163    /// followed by code. The bug had `=>` lookahead consume the `\n` after
4164    /// `Int`, then the recursive next_token / outer tokenize re-process the
4165    /// same `\n`, double-incrementing `self.line`. With the fix, statements
4166    /// after the class body report their real source lines.
4167    #[test]
4168    fn class_field_does_not_drift_subsequent_statement_line() {
4169        let src = "class Foo {\n    x: Int\n}\nmy $y = 1\np $y\n";
4170        let mut l = Lexer::new(src);
4171        let t = l.tokenize().expect("tokenize");
4172        // Find the `my` token after the class body.
4173        let my_line = t
4174            .iter()
4175            .find_map(|(tok, line)| match tok {
4176                Token::Ident(s) if s == "my" => Some(*line),
4177                _ => None,
4178            })
4179            .expect("expected `my` token");
4180        assert_eq!(my_line, 4, "my $y = 1 lives on source line 4");
4181    }
4182
4183    /// Same shape as `fat_arrow_lookahead_does_not_drift_line` but for the
4184    /// `qw` bareword-vs-quote-operator lookahead. When `qw` is followed by
4185    /// terminators (here a newline + closing paren) it's lexed as an Ident,
4186    /// not a `qw(...)` list. The `skip_whitespace_only` call inside the
4187    /// lookahead advances `self.line` for the `\n` it consumes; without
4188    /// restoring `self.line` the next token's line drifted +1.
4189    #[test]
4190    fn qw_bareword_lookahead_does_not_drift_line() {
4191        // `qw` followed by `)` (treated as bareword) on its own line, then
4192        // a new statement. The post-class-field pattern: a newline between
4193        // `qw` and the next statement must not bump the line counter.
4194        let src = "(qw\n)\nmy $x = 1\n";
4195        let mut l = Lexer::new(src);
4196        let t = l.tokenize().expect("tokenize");
4197        let my_line = t
4198            .iter()
4199            .find_map(|(tok, line)| match tok {
4200                Token::Ident(s) if s == "my" => Some(*line),
4201                _ => None,
4202            })
4203            .expect("expected `my` token");
4204        assert_eq!(my_line, 3, "my $x = 1 lives on source line 3");
4205    }
4206
4207    /// `m` as a bareword (the parser handles `$obj->m`, sort `m`-prefix,
4208    /// etc.) goes through the same skip-whitespace-only lookahead as `qw`.
4209    /// Newlines between `m` and the next non-terminator must not drift
4210    /// the lexer's `self.line` past restoration.
4211    #[test]
4212    fn m_bareword_lookahead_does_not_drift_line() {
4213        // `$obj->m` — after `->`, `m` is a method name; the followup token
4214        // tracking must keep its source line.
4215        let src = "$obj->m\nmy $y = 2\n";
4216        let mut l = Lexer::new(src);
4217        let t = l.tokenize().expect("tokenize");
4218        let my_line = t
4219            .iter()
4220            .find_map(|(tok, line)| match tok {
4221                Token::Ident(s) if s == "my" => Some(*line),
4222                _ => None,
4223            })
4224            .expect("expected `my` token");
4225        assert_eq!(my_line, 2, "my $y = 2 lives on source line 2");
4226    }
4227
4228    /// Bareword `_N` (underscore + ≥1 digit) lexes to `ScalarVar(_N)` —
4229    /// the implicit-closure-positional name that works without a sigil.
4230    /// The lexer carves these out at line 2241 so the parser sees them
4231    /// as variables, not as undefined sub names.
4232    #[test]
4233    fn bareword_underscore_n_lexes_as_scalar_var() {
4234        let mut l = Lexer::new("_1");
4235        let t = l.tokenize().expect("tokenize");
4236        assert!(
4237            matches!(t[0].0, Token::ScalarVar(ref s) if s == "_1"),
4238            "_1 → ScalarVar(_1): got {:?}",
4239            t[0].0
4240        );
4241    }
4242
4243    #[test]
4244    fn bareword_underscore_n_two_digits_lexes_as_scalar_var() {
4245        let mut l = Lexer::new("_42");
4246        let t = l.tokenize().expect("tokenize");
4247        assert!(
4248            matches!(t[0].0, Token::ScalarVar(ref s) if s == "_42"),
4249            "_42 → ScalarVar(_42): got {:?}",
4250            t[0].0
4251        );
4252    }
4253
4254    /// Bare `_` (no digit) stays an Ident so the existing topic /
4255    /// bareword-call machinery keeps working — `_` can be a sub call,
4256    /// a bareword filename, etc., context-dependent.
4257    #[test]
4258    fn bare_underscore_alone_lexes_as_ident() {
4259        let mut l = Lexer::new("_");
4260        let t = l.tokenize().expect("tokenize");
4261        assert!(
4262            matches!(t[0].0, Token::Ident(ref s) if s == "_"),
4263            "_ → Ident(_): got {:?}",
4264            t[0].0
4265        );
4266    }
4267
4268    /// `_foo` is a user-defined identifier — NOT a topic alias.
4269    /// Only `_<digits>` is reserved for the positional slot family.
4270    #[test]
4271    fn underscore_prefix_word_is_plain_ident() {
4272        let mut l = Lexer::new("_foo");
4273        let t = l.tokenize().expect("tokenize");
4274        assert!(
4275            matches!(t[0].0, Token::Ident(ref s) if s == "_foo"),
4276            "_foo → Ident(_foo): got {:?}",
4277            t[0].0
4278        );
4279    }
4280
4281    /// POD blocks (`=pod ... =cut`) advance `self.line` many lines during
4282    /// the skip but the original tokenize loop captured `line` *before*
4283    /// calling next_token, then pushed (token, captured_line) — so the
4284    /// first token after POD got the pre-POD line instead of its real
4285    /// post-skip line. Now next_token stamps `self.token_start_line` after
4286    /// the skip and tokenize reads from there.
4287    #[test]
4288    fn token_after_pod_block_uses_post_skip_line() {
4289        let src = "\
4290=pod
4291big POD
4292block here
4293=cut
4294my $x = 1
4295";
4296        let mut l = Lexer::new(src);
4297        let t = l.tokenize().expect("tokenize");
4298        // First non-Eof token is `my`, on source line 5.
4299        let (first_tok, first_line) = t
4300            .iter()
4301            .find(|(tok, _)| !matches!(tok, Token::Eof))
4302            .expect("non-empty tokens");
4303        assert!(matches!(first_tok, Token::Ident(ref s) if s == "my"));
4304        assert_eq!(
4305            *first_line, 5,
4306            "my $x lives on source line 5 (after POD ends)"
4307        );
4308    }
4309}