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}