kaish_kernel/lexer.rs
1//! Lexer for kaish source code.
2//!
3//! Converts source text into a stream of tokens using the logos lexer
4//! generator. The lexer is designed to be unambiguous: every valid input
5//! produces exactly one token sequence, and invalid input produces clear
6//! errors.
7//!
8//! # Pipeline (GH #95)
9//!
10//! 1. **Scan** — one composed source-order pass with explicit
11//! quote/escape/comment state extracts heredoc bodies and `$((expr))`
12//! arithmetic, producing a rewritten buffer plus a complete
13//! replacement table (both coordinate systems).
14//! 2. **logos** — the regex vocabulary below classifies the rewritten
15//! buffer into tokens.
16//! 3. **Marker resolution** — scanner markers become `Arithmetic` /
17//! `HereDoc` tokens, matched POSITIONALLY against the replacement
18//! table (never by fishing identifier text); a word glued onto a
19//! marker is split so the parser can reject it loudly.
20//! 4. **Span correction** — every token span maps back to exact
21//! original-source byte ranges via the replacement table.
22//! 5. **Fusion** — flag-metachar, colon, and glob merges join
23//! span-adjacent runs, with fused text sliced VERBATIM from the
24//! source; `compute_value_context` (an explicit frame stack plus a
25//! statement-head DFA) decides where fusion is suppressed.
26//!
27//! # Token Categories
28//!
29//! - **Keywords**: `set`, `if`, `then`, `else`, `fi`, `for`, `in`, `do`, `done`
30//! - **Literals**: strings, integers, floats, booleans (`true`/`false`)
31//! - **Operators**: `=`, `|`, `&`, `>`, `>>`, `<`, `2>`, `&>`, `&&`, `||`
32//! - **Punctuation**: `;`, `:`, `,`, `.`, `{`, `}`, `[`, `]`
33//! - **Variable references**: `${...}` with nested path access
34//! - **Identifiers**: command names, variable names, parameter names
35
36use logos::{Logos, Span};
37use std::fmt;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::UNIX_EPOCH;
40use kaish_types::clock::system_now;
41use kaish_types::Value;
42
43/// Global counter for generating unique markers across all tokenize calls.
44static MARKER_COUNTER: AtomicU64 = AtomicU64::new(0);
45
46/// Maximum nesting depth for parentheses in arithmetic expressions.
47/// Prevents stack overflow from pathologically nested inputs like $((((((...
48const MAX_PAREN_DEPTH: usize = 256;
49
50
51/// Generate a unique marker ID that's extremely unlikely to collide with user code.
52/// Uses a combination of timestamp, counter, and process ID.
53fn unique_marker_id() -> String {
54 let timestamp = system_now()
55 .duration_since(UNIX_EPOCH)
56 .map(|d| d.as_nanos())
57 .unwrap_or(0);
58 let counter = MARKER_COUNTER.fetch_add(1, Ordering::Relaxed);
59 // No process ids on any wasm target (WASI or the browser);
60 // std::process::id() is unsupported there and panics.
61 #[cfg(target_family = "wasm")]
62 let pid = 0u32;
63 #[cfg(not(target_family = "wasm"))]
64 let pid = std::process::id();
65 format!("{:x}_{:x}_{:x}", timestamp, counter, pid)
66}
67
68/// A token with its span in the source text.
69#[derive(Debug, Clone, PartialEq)]
70pub struct Spanned<T> {
71 pub token: T,
72 pub span: Span,
73}
74
75impl<T> Spanned<T> {
76 pub fn new(token: T, span: Span) -> Self {
77 Self { token, span }
78 }
79}
80
81/// Lexer error types.
82#[derive(Debug, Clone, PartialEq, Default)]
83#[non_exhaustive]
84pub enum LexerError {
85 #[default]
86 UnexpectedCharacter,
87 UnterminatedString,
88 UnterminatedVarRef,
89 InvalidEscape,
90 InvalidNumber,
91 /// An integer numeral parsed but did not fit in `i64`. The regex behind
92 /// `lex_int`/`parse_int` admits only `-?[0-9]+`, so overflow is the only
93 /// way that parse fails — this is a distinct variant so the message can
94 /// name the limit instead of just saying "invalid".
95 IntegerOutOfRange,
96 InvalidFloatNoLeading,
97 InvalidFloatNoTrailing,
98 /// Nesting depth exceeded (too many nested parentheses in arithmetic).
99 NestingTooDeep,
100 /// A `$(` opened inside a double-quoted string and the input ended before
101 /// its `)`. Reported instead of `UnterminatedString` because the missing
102 /// `)` is the error and the unterminated string is only its consequence:
103 /// `echo "pre $(echo hi"` is a forgotten paren, not a forgotten quote.
104 UnterminatedCommandSubst,
105 /// Arithmetic expansion `$((` reached end of input without a closing `))`.
106 /// Silently evaluating the partial expression would mask a typo (`$(( 1 + 2`
107 /// would compute `3`), so we surface it loudly instead.
108 UnterminatedArithmetic,
109 /// Heredoc body ended without seeing the closing delimiter on its own line.
110 /// The user almost certainly meant to type the delimiter — silently using
111 /// whatever was collected up to EOF would mask missing data.
112 UnterminatedHeredoc { delimiter: String },
113 /// Backtick command substitution. Kaish drops backticks intentionally —
114 /// they're listed in `docs/LANGUAGE.md` and the help system as not supported.
115 /// We surface this as a dedicated error (rather than `UnexpectedCharacter`)
116 /// so the message can point users at the `$(cmd)` replacement.
117 BackticksNotSupported,
118 /// `$((expr))` inside a bare `${...}` reference (e.g. `${X:-$((1+2))}`).
119 /// There is no representation for arithmetic inside a variable
120 /// reference — the pre-#95 pipeline silently leaked internal marker
121 /// text here — so it is a loud error instead. (Inside double-quoted
122 /// strings the same construct works via string interpolation.)
123 ArithmeticInVarRef,
124 /// A `-flag`/`--flag`/`+flag` word matched but contained a non-ASCII
125 /// character. Flag names are ASCII-only; see the note on `Token`.
126 /// `kind` is always `"flag"` — it stays a field because the message reads
127 /// off it, and a second ASCII-only name class would use the same shape.
128 /// `text` is the whole matched word (sigil included).
129 NonAsciiName { kind: &'static str, text: String },
130 /// A `#` that is not at the start of a word — `$x#3`, `"abc"#3`, `$(f)#3`.
131 /// POSIX opens a comment only at a word start, and the word classes carry
132 /// `#` as an ordinary character, so a `#` that reaches this error follows
133 /// something that cannot absorb it. Commenting from here would drop the
134 /// rest of the line, `;` separators and whole commands included, at exit 0.
135 HashInsideWord,
136}
137
138/// Message for a numeral outside i64 range. Shared with `arithmetic::parse_number`
139/// so the lexer and `$(( ))` name the same limit in the same words.
140pub(crate) const INTEGER_OUT_OF_RANGE: &str =
141 "does not fit in a 64-bit integer (-9223372036854775808..9223372036854775807); \
142 quote it to keep the text";
143
144impl fmt::Display for LexerError {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 match self {
147 LexerError::UnexpectedCharacter => write!(f, "unexpected character"),
148 LexerError::UnterminatedString => write!(f, "unterminated string"),
149 LexerError::UnterminatedVarRef => write!(f, "unterminated variable reference"),
150 // Same wording as the parser's sibling scanner, which reports this
151 // when the `$(` is found inside an already-closed string. One
152 // error, one message, whichever scanner reaches it first.
153 LexerError::UnterminatedCommandSubst => {
154 write!(f, "unterminated command substitution: missing `)`")
155 }
156 LexerError::InvalidEscape => write!(f, "invalid escape sequence"),
157 LexerError::InvalidNumber => write!(f, "invalid number"),
158 LexerError::IntegerOutOfRange => write!(f, "{INTEGER_OUT_OF_RANGE}"),
159 LexerError::InvalidFloatNoLeading => write!(f, "float must have leading digit"),
160 LexerError::InvalidFloatNoTrailing => write!(f, "float must have trailing digit"),
161 LexerError::NestingTooDeep => write!(f, "nesting depth exceeded (max {})", MAX_PAREN_DEPTH),
162 LexerError::UnterminatedArithmetic => {
163 write!(f, "unterminated arithmetic expansion, expected closing `))`")
164 }
165 LexerError::UnterminatedHeredoc { delimiter } => {
166 write!(f, "unterminated heredoc, expected closing delimiter `{}` on its own line", delimiter)
167 }
168 LexerError::BackticksNotSupported => {
169 write!(f, "backticks are not supported in kaish; use $(cmd) instead")
170 }
171 LexerError::ArithmeticInVarRef => {
172 write!(
173 f,
174 "arithmetic expansion inside ${{...}} is not supported; \
175 assign it to a variable first, e.g. N=$((expr)); ${{X:-$N}}"
176 )
177 }
178 LexerError::NonAsciiName { kind, text } => write!(
179 f,
180 "{kind} `{text}` has a non-ASCII character; {kind}s are ASCII-only — \
181 quote it to use as a literal word instead"
182 ),
183 LexerError::HashInsideWord => write!(
184 f,
185 "`#` starts a comment only at the start of a word — quote the whole \
186 word to keep `#` inside it, e.g. \"$x#3\", or put a space before `#` \
187 to start a comment."
188 ),
189 }
190 }
191}
192
193/// Tokens produced by the kaish lexer.
194///
195/// The order of variants matters for logos priority. More specific patterns
196/// (like keywords) should come before more general ones (like identifiers).
197///
198/// Tokens that carry semantic values (strings, numbers, identifiers) include
199/// the parsed value directly. This ensures the parser has access to actual
200/// data, not just token types.
201/// Here-doc content data.
202///
203/// - `literal` is true when the delimiter was quoted (`<<'EOF'` or `<<"EOF"`),
204/// meaning no variable expansion should occur.
205/// - `strip_tabs` is true for the `<<-EOF` form. Per POSIX, leading tabs on
206/// each body line are stripped at materialization time. Stripping happens
207/// downstream of the parser so byte offsets in `content` stay aligned with
208/// their original-source positions for span-tracking purposes.
209/// - `body_start_offset` is the exact byte offset of the first character of
210/// `content` in the original source passed to `tokenize`. This lets the
211/// parser compute absolute spans for parts found inside the body during
212/// interpolation. (For interpolated bodies containing `$((..))`, spans of
213/// parts AFTER the rewritten expression drift by the rewrite's length
214/// difference — the body-local `${__ARITH:expr__}` form is longer than
215/// the source text; see `rewrite_body_arithmetic`.)
216/// - `delimiter` is the word as written with its quotes removed (`PY` for
217/// both `<<PY` and `<<'PY'`), kept because it is the language hint the
218/// author chose and a plan publishes it.
219/// - `source_body` is the body exactly as it appears in the source. It differs
220/// from `content` only for an interpolated body containing `$((…))`, which
221/// `content` carries in the rewritten `${__ARITH:…}` form — a kernel-internal
222/// spelling that must never reach a plan.
223#[derive(Debug, Clone, PartialEq)]
224pub struct HereDocData {
225 pub content: String,
226 pub source_body: String,
227 pub delimiter: String,
228 pub literal: bool,
229 pub strip_tabs: bool,
230 pub body_start_offset: usize,
231}
232
233/// A numeral's typed value and its verbatim source text — see
234/// `Token::NumericLiteral`. Logos requires a single-field variant payload,
235/// hence the wrapper struct (the same shape as `HereDocData`, above).
236#[derive(Debug, Clone, PartialEq)]
237pub struct NumericLiteralData {
238 pub value: Value,
239 pub raw: String,
240}
241
242/// A word is anything that is not whitespace and not an operator, so the
243/// bareword and path rules below admit `\u{80}-\u{10FFFF}` — this file's
244/// spelling of "any non-ASCII scalar value" — alongside their ASCII classes.
245/// bash never inspects a word's bytes for alphabetic-ness, and `café`,
246/// `日本語`, and `~/文書` lex the same shape as their ASCII equivalents.
247///
248/// Variable names accept the same characters, and are NFC-normalized where the
249/// reference is built (`VarPath::simple`), so a name spelled with a combining
250/// mark and one spelled precomposed reach the same variable.
251///
252/// Flag names (`LongFlag`, `ShortFlag`, `PlusFlag`) are the exception and stay
253/// ASCII. `--café` is ambiguous — a flag no tool defines, or a word the caller
254/// meant literally — so kaish refuses rather than guessing, and the error says
255/// to quote it. Those rules still *match* a non-ASCII tail and reject it in
256/// their callback with `LexerError::NonAsciiName`; declining to match would
257/// split the word into a flag plus a stray bareword argument instead.
258#[derive(Logos, Debug, Clone, PartialEq)]
259#[logos(error = LexerError)]
260#[logos(skip r"[ \t]+")]
261#[non_exhaustive]
262pub enum Token {
263 // ═══════════════════════════════════════════════════════════════════
264 // Keywords (must come before Ident for priority)
265 // ═══════════════════════════════════════════════════════════════════
266 #[token("set")]
267 Set,
268
269 #[token("local")]
270 Local,
271
272 #[token("if")]
273 If,
274
275 #[token("then")]
276 Then,
277
278 #[token("else")]
279 Else,
280
281 #[token("elif")]
282 Elif,
283
284 #[token("fi")]
285 Fi,
286
287 #[token("for")]
288 For,
289
290 #[token("while")]
291 While,
292
293 #[token("in")]
294 In,
295
296 #[token("do")]
297 Do,
298
299 #[token("done")]
300 Done,
301
302 #[token("case")]
303 Case,
304
305 #[token("esac")]
306 Esac,
307
308 #[token("function")]
309 Function,
310
311 #[token("break")]
312 Break,
313
314 #[token("continue")]
315 Continue,
316
317 #[token("return")]
318 Return,
319
320 #[token("exit")]
321 Exit,
322
323 #[token("true")]
324 True,
325
326 #[token("false")]
327 False,
328
329 // ═══════════════════════════════════════════════════════════════════
330 // Type keywords (for tool parameters)
331 // ═══════════════════════════════════════════════════════════════════
332 #[token("string")]
333 TypeString,
334
335 #[token("int")]
336 TypeInt,
337
338 #[token("float")]
339 TypeFloat,
340
341 #[token("bool")]
342 TypeBool,
343
344 // ═══════════════════════════════════════════════════════════════════
345 // Multi-character operators (must come before single-char versions)
346 // ═══════════════════════════════════════════════════════════════════
347 #[token("&&")]
348 And,
349
350 #[token("||")]
351 Or,
352
353 #[token("==")]
354 EqEq,
355
356 #[token("!=")]
357 NotEq,
358
359 #[token("=~")]
360 Match,
361
362 #[token("!~")]
363 NotMatch,
364
365 #[token(">=")]
366 GtEq,
367
368 #[token("<=")]
369 LtEq,
370
371 #[token(">>")]
372 GtGt,
373
374 #[token("2>&1")]
375 StderrToStdout,
376
377 #[token("1>&2")]
378 StdoutToStderr,
379
380 #[token(">&2")]
381 StdoutToStderr2,
382
383 #[token("2>")]
384 Stderr,
385
386 #[token("&>")]
387 Both,
388
389 #[token("<<<")]
390 HereString,
391
392 #[token("<<")]
393 HereDocStart,
394
395 #[token(";;")]
396 DoubleSemi,
397
398 // ═══════════════════════════════════════════════════════════════════
399 // Single-character operators and punctuation
400 // ═══════════════════════════════════════════════════════════════════
401 #[token("=")]
402 Eq,
403
404 #[token("|")]
405 Pipe,
406
407 #[token("&")]
408 Amp,
409
410 #[token(">")]
411 Gt,
412
413 #[token("<")]
414 Lt,
415
416 #[token(";")]
417 Semi,
418
419 #[token(":")]
420 Colon,
421
422 #[token(",")]
423 Comma,
424
425 /// Spread operator: `[...$xs date]`. Only meaningful inside a list literal
426 /// (value context); inert everywhere else. logos resolves the `"..."` vs
427 /// `".."` (`DotDot`) ambiguity by longest match, so no explicit priority
428 /// is needed here.
429 #[token("...")]
430 DotDotDot,
431
432 #[token("..")]
433 DotDot,
434
435 #[token(".")]
436 Dot,
437
438 /// Tilde path: `~/foo`, `~user/bar` - value includes the full string.
439 #[regex(r"~[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]+", lex_tilde_path, priority = 3)]
440 TildePath(String),
441
442 /// Bare tilde: `~` alone (expands to $HOME)
443 #[token("~")]
444 Tilde,
445
446 /// Relative path: `../foo/bar`, bare `src/kaish` (ident containing `/`),
447 /// or a directory reference with a trailing slash like `dest/`. The
448 /// trailing-slash form uses `*` (not `+`) after the slash so `dest/`
449 /// lexes as one token instead of `Ident("dest")` + `Path("/")` — the
450 /// latter split silently turned `cp a b dest/` into a 4-operand command.
451 #[regex(r"\.\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_relative_path, priority = 3)]
452 #[regex(r"[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*/[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]*", lex_relative_path, priority = 3)]
453 RelativePath(String),
454
455 /// Dot-slash path: `./foo`, `./script.sh`.
456 #[regex(r"\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_dot_slash_path, priority = 3)]
457 DotSlashPath(String),
458
459 /// Dot-prefixed bareword: `.parent`, `.gitignore`, `.foo.bar`.
460 /// Treated as an opaque string in argv position. Distinct from `Token::Dot`
461 /// (the POSIX `.` source alias) which only matches a bare `.` — the source
462 /// alias requires whitespace before its file argument (`. script`), so
463 /// `.parent` (no space) is unambiguously a single bareword.
464 #[regex(r"\.[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*", lex_dotted_ident, priority = 3)]
465 DottedIdent(String),
466
467 #[token("{")]
468 LBrace,
469
470 #[token("}")]
471 RBrace,
472
473 #[token("[")]
474 LBracket,
475
476 #[token("]")]
477 RBracket,
478
479 #[token("(")]
480 LParen,
481
482 #[token(")")]
483 RParen,
484
485 #[token("*")]
486 Star,
487
488 #[token("!")]
489 Bang,
490
491 #[token("?")]
492 Question,
493
494 /// Merged glob word: span-adjacent tokens containing `*`, `?`, or `[...]`.
495 /// Synthesized by `merge_glob_adjacent()`, never produced by logos directly.
496 GlobWord(String),
497
498 // ═══════════════════════════════════════════════════════════════════
499 // Command substitution
500 // ═══════════════════════════════════════════════════════════════════
501
502 /// Arithmetic expression content: synthesized by preprocessing.
503 /// Contains the expression string between `$((` and `))`.
504 Arithmetic(String),
505
506 /// Bare `(( expr ))` arithmetic condition: synthesized by preprocessing,
507 /// the same way as `Arithmetic` but for the unquoted, `$`-less form.
508 /// Contains the expression string between the two `(` and the two `)`.
509 ArithCond(String),
510
511 /// Command substitution start: `$(` - begins a command substitution
512 #[token("$(")]
513 CmdSubstStart,
514
515 // ═══════════════════════════════════════════════════════════════════
516 // Flags (must come before Int to win over negative numbers)
517 // ═══════════════════════════════════════════════════════════════════
518
519 /// Long flag: `--name` or `--foo-bar`. Flag names are ASCII-only; the
520 /// match region still admits non-ASCII in the tail so the regex claims the
521 /// WHOLE word instead of
522 /// stopping at the ASCII prefix; without that, `--café` would lex as
523 /// `LongFlag(caf)` plus a silently separate `Ident(é)` argument rather
524 /// than one loud error. `lex_long_flag` rejects the match if it isn't
525 /// pure ASCII.
526 #[regex(r"--[a-zA-Z][a-zA-Z0-9\-\u{80}-\u{10FFFF}]*", lex_long_flag, priority = 3)]
527 LongFlag(String),
528
529 /// Short flag: `-l`, `-la` (combined short flags), or a dash-word with
530 /// internal hyphens like `-not-a-flag`. Internal hyphens are part of the
531 /// single shell word — without them the word fragments into separate flag
532 /// tokens, which breaks `echo -- -not-a-flag` and the like. A leading `--`
533 /// is still `DoubleDash` (the second char must be a letter here) unless
534 /// the third char isn't a letter either, in which case it's
535 /// `DoubleDashBare` — see below — and whether the word is a flag or a
536 /// literal is the binding layer's call.
537 #[regex(r"-[a-zA-Z][a-zA-Z0-9\-\u{80}-\u{10FFFF}]*", lex_short_flag, priority = 3)]
538 ShortFlag(String),
539
540 /// Plus flag: `+e` or `+x` (for set +e to disable options).
541 #[regex(r"\+[a-zA-Z][a-zA-Z0-9\u{80}-\u{10FFFF}]*", lex_plus_flag, priority = 3)]
542 PlusFlag(String),
543
544 /// Double dash: `--` alone marks end of flags. Only matches when nothing
545 /// else follows (a longer match always wins) — a `--`-prefixed word with
546 /// more characters after it either lexes as `LongFlag` (3rd char is a
547 /// letter) or `DoubleDashBare` (3rd char is anything else).
548 #[token("--")]
549 DoubleDash,
550
551 /// Bare word starting with `--` whose continuation isn't a valid
552 /// long-flag name: `---`, `----`, `--=x`, `--1`, etc. Without this, the
553 /// plain `--` literal above always won the length tie against a lone
554 /// `--`, silently truncating a dash-only operand to its trailing
555 /// remainder (`echo ---` printed `-` instead of `---` — GH #137). Mirrors
556 /// `MinusBare`/`PlusBare` (bare-word fallback for an unrecognized
557 /// flag-shaped prefix), just generalized to the `--` prefix. A standalone
558 /// `--` (followed by whitespace/EOF) still lexes as `DoubleDash` — this
559 /// regex requires at least one more non-whitespace character, so the two
560 /// never tie in match length and no priority tiebreak is load-bearing;
561 /// `priority = 2` is set for consistency with `PlusBare`'s tier.
562 ///
563 /// Both character classes exclude the unquoted shell operator characters
564 /// `()|&;<>` in addition to whitespace (GH #144): without that exclusion
565 /// a case pattern like `---)` swallowed the closing paren into the token
566 /// text (`DoubleDashBare("---)"`), leaving no `RParen` for the branch
567 /// parser to find — the same silent-truncation failure mode as #137, just
568 /// on the other side of the word.
569 #[regex(r"--[^a-zA-Z\s()|&;<>][^\s()|&;<>]*", lex_double_dash_bare, priority = 2)]
570 DoubleDashBare(String),
571
572 /// Bare word starting with + followed by non-letter: `+%s`, `+%Y-%m-%d`
573 /// For date format strings and similar. Lower priority than PlusFlag.
574 /// See `DoubleDashBare` above for why `()|&;<>` are excluded (GH #144).
575 #[regex(r"\+[^a-zA-Z\s()|&;<>][^\s()|&;<>]*", lex_plus_bare, priority = 2)]
576 PlusBare(String),
577
578 /// Bare word starting with - followed by non-letter/digit/dash: `-%`, etc.
579 /// For rare cases. Lower priority than ShortFlag, Int, and DoubleDash.
580 /// Excludes - after first - to avoid matching --name patterns.
581 /// See `DoubleDashBare` above for why `()|&;<>` are excluded (GH #144).
582 #[regex(r"-[^a-zA-Z0-9\s\-()|&;<>][^\s()|&;<>]*", lex_minus_bare, priority = 1)]
583 MinusBare(String),
584
585 /// Job specifier: `%1`, `%2` — the bash idiom for `wait`/`kill` targets.
586 /// Keeps the leading `%` (kill uses it to distinguish a job from a PID;
587 /// wait strips it). Without this token a bare `%1` is a lexer error.
588 #[regex(r"%[0-9]+", lex_job_spec)]
589 JobSpec(String),
590
591 /// Standalone - (stdin indicator for cat -, diff - -, etc.)
592 /// Only matches when followed by whitespace or end.
593 /// This is handled specially in the parser as a positional arg.
594 #[token("-")]
595 MinusAlone,
596
597 // ═══════════════════════════════════════════════════════════════════
598 // Literals (with values)
599 // ═══════════════════════════════════════════════════════════════════
600
601 /// Double-quoted string: `"..."` — value is the parsed content (quotes
602 /// removed, escapes processed). The regex matches only the opening quote;
603 /// the callback extends the token to the quote that actually closes it,
604 /// tracking `$(` depth so a quoted word INSIDE a substitution belongs to
605 /// the substitution: `"$(basename "$p")"` is one string, not two. Same
606 /// technique as `VarRef` below, for the same reason (GH #173).
607 #[regex(r#"""#, lex_string)]
608 String(String),
609
610 /// Single-quoted string: `'...'` - literal content, no escape processing
611 #[regex(r"'[^']*'", lex_single_string)]
612 SingleString(String),
613
614 /// Braced variable reference: `${VAR}`, `${VAR.field}`, or a default
615 /// form with a NESTED reference like `${X:-${Y}}` — value is the raw
616 /// `${...}` text. The regex matches only the `${` opener; the callback
617 /// extends the token to the BALANCED closing brace (GH #173 — a plain
618 /// `[^}]+` regex stopped at the first `}` and split nested references).
619 /// `${#VAR}` still lexes as `VarLength`: its full regex out-matches this
620 /// two-character opener, so logos selects it first.
621 #[regex(r"\$\{", lex_varref)]
622 VarRef(String),
623
624 /// Simple variable reference: `$NAME` - just the identifier. A name is
625 /// ASCII alphanumerics, `_`, or any non-ASCII scalar value, so `$café`,
626 /// `$名前`, and `$😁` name variables the same way `$NAME` does. The name is
627 /// NFC-normalized when the reference is built (`VarPath::simple`).
628 #[regex(r"\$[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_\u{80}-\u{10FFFF}]*", lex_simple_varref)]
629 SimpleVarRef(String),
630
631 /// Positional parameter: `$0` through `$9`
632 #[regex(r"\$[0-9]", lex_positional)]
633 Positional(usize),
634
635 /// All positional parameters: `$@`
636 #[token("$@")]
637 AllArgs,
638
639 /// Number of positional parameters: `$#`
640 #[token("$#")]
641 ArgCount,
642
643 /// Last exit code: `$?`
644 #[token("$?")]
645 LastExitCode,
646
647 /// Current shell PID: `$$`
648 #[token("$$")]
649 CurrentPid,
650
651 /// Variable string length: `${#VAR}` or a subscripted path `${#u[tags]}`.
652 /// The trailing `(\[[^\]]*\])*` admits chained bracket subscripts so a
653 /// length-of-path lexes in expression position, not just inside strings; the
654 /// parser turns the captured inner into a `VarPath`.
655 #[regex(r"\$\{#[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_\u{80}-\u{10FFFF}]*(\[[^\]]*\])*\}", lex_var_length)]
656 VarLength(String),
657
658 /// Here-doc content: synthesized by preprocessing, not directly lexed.
659 /// Contains the full content of the here-doc (without the delimiter lines).
660 HereDoc(HereDocData),
661
662 /// Integer literal - value is the parsed i64
663 #[regex(r"-?[0-9]+", lex_int, priority = 2)]
664 Int(i64),
665
666 /// Float literal - value is the parsed f64
667 #[regex(r"-?[0-9]+\.[0-9]+", lex_float)]
668 Float(f64),
669
670 /// A plain `Int`/`Float` whose own `Display` does not reproduce the source
671 /// text it was lexed from — a negative zero (`-0`, `-0.0`) or a
672 /// non-canonical trailing fraction digit (`0.10`, `1.0`). Carries the
673 /// typed value, so arithmetic and `--json` still see a real number, and
674 /// the verbatim text, so argv and plan rendering reproduce what was typed.
675 /// See `docs/LANGUAGE.md`, "A bare number follows JSON rules".
676 ///
677 /// A leading zero (`007`) is a different case, reclassified to
678 /// `NumberIdent` — see `has_invalid_leading_zero`.
679 ///
680 /// Never produced by logos. `preserve_numeric_source_text` synthesizes it
681 /// as the LAST step of `tokenize_impl`, after the fusion passes, which
682 /// match `Int`/`Float` directly. The common case pays nothing.
683 NumericLiteral(NumericLiteralData),
684
685 // ═══════════════════════════════════════════════════════════════════
686 // Invalid patterns (caught before valid tokens for better errors)
687 // ═══════════════════════════════════════════════════════════════════
688
689 /// Digit-leading bareword: `019dda1c` (SHA prefix), UUIDs, version-ish
690 /// strings. Distinguished from `Int` because at least one alpha character
691 /// follows the leading digits — the lexer commits to "this is a string,
692 /// not a number." Treated as a bareword string in expression position.
693 #[regex(r"[0-9]+[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*", lex_number_ident, priority = 3)]
694 NumberIdent(String),
695
696 /// Numeric word containing an embedded hyphen run, or a minus-led numeric
697 /// word with a non-numeric suffix. These are single contiguous shell words
698 /// the user typed — ISO dates (`2024-01-02`), `N-M` ranges (`10-20`,
699 /// `cut -f 1-3`, `tr -d 0-9`), float-dash forms (`1.5-2`), and `find`
700 /// predicate values like `-1k` (smaller than 1k). Without this token they
701 /// fragment into adjacent `Int`/`Float`/flag tokens and trip the
702 /// no-token-pasting guard. The raw slice is preserved verbatim (so leading
703 /// zeros survive). A plain `2024`/`1.5`/`-1` stays `Int`/`Float` — the
704 /// digit-hyphen form requires a `-segment`, and the minus-led form requires
705 /// an alpha after the digits.
706 #[regex(r"[0-9]+(\.[0-9]+)?(-[0-9a-zA-Z._\u{80}-\u{10FFFF}]+)+", lex_slice_word, priority = 3)]
707 #[regex(r"-[0-9]+[a-zA-Z_\u{80}-\u{10FFFF}][0-9a-zA-Z._\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)]
708 DashNumWord(String),
709
710 /// Leading-`@` bareword: `@scope/pkg` (scoped package), `@0` (epoch in
711 /// `date -d @0`), or bare `@`. Mid-word `@` (`user@host`) is handled by
712 /// `Ident`; this covers the leading-`@` cases that would otherwise be an
713 /// "unexpected character" lexer error.
714 #[regex(r"@[a-zA-Z0-9_./@\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)]
715 AtWord(String),
716
717 /// Invalid: float without leading digit (like .5)
718 #[regex(r"\.[0-9]+", lex_invalid_float_no_leading, priority = 3)]
719 InvalidFloatNoLeading,
720
721 /// Invalid: float without trailing digit (like 5.)
722 /// Logos uses longest-match, so valid floats like 5.5 will match Float pattern instead
723 #[regex(r"[0-9]+\.", lex_invalid_float_no_trailing, priority = 2)]
724 InvalidFloatNoTrailing,
725
726 // ═══════════════════════════════════════════════════════════════════
727 // Paths (absolute paths starting with /)
728 // ═══════════════════════════════════════════════════════════════════
729
730 /// Absolute path: `/tmp/out`, `/etc/hosts`, `/tmp/日本語`, etc.
731 #[regex(r"/[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]*", lex_path)]
732 Path(String),
733
734 // ═══════════════════════════════════════════════════════════════════
735 // Identifiers (command names, barewords, etc. — NOT `$name` variable
736 // references; those are `SimpleVarRef` above)
737 // ═══════════════════════════════════════════════════════════════════
738
739 /// Identifier - value is the identifier string
740 /// Allows dots for filenames like `script.kai` and `@` for `user@host`,
741 /// `a@b.com` (bare `@` is an ordinary word character, as in bash). The
742 /// leading class excludes digits — `NumberIdent`/`Int` own digit-leading
743 /// words — and the ASCII operator/whitespace set.
744 #[regex(r"[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.@#\-\u{80}-\u{10FFFF}]*", lex_ident)]
745 Ident(String),
746
747 // ═══════════════════════════════════════════════════════════════════
748 // Structural tokens
749 // ═══════════════════════════════════════════════════════════════════
750
751 /// Comment: `# ...` to end of line, and only where a word can start.
752 ///
753 /// `#` is an ordinary character inside a word — `echo abc#3` prints
754 /// `abc#3`, as it does in bash and `sh` — so the word classes above carry
755 /// `#` and a mid-word `#` never reaches this rule. What does reach it
756 /// after a non-word character is a loud error, not a comment: see
757 /// `lex_comment`.
758 #[regex(r"#[^\n\r]*", lex_comment, allow_greedy = true)]
759 Comment,
760
761 /// Newline (significant in kaish - ends statements)
762 #[regex(r"\n|\r\n")]
763 Newline,
764
765 /// Line continuation: backslash at end of line
766 #[regex(r"\\[ \t]*(\n|\r\n)")]
767 LineContinuation,
768
769 /// Backtick command substitution — explicitly rejected. Kaish drops
770 /// backticks; the callback always errors so users get a dedicated
771 /// `BackticksNotSupported` message instead of the generic
772 /// `UnexpectedCharacter` they would have hit before. Backticks inside
773 /// single/double-quoted strings, heredoc bodies, and comments don't
774 /// reach this match — those tokens are matched as a single unit
775 /// (strings) or extracted before logos runs (heredocs) or skipped to
776 /// EOL (comments).
777 #[token("`", reject_backtick)]
778 BacktickRejected,
779}
780
781/// Semantic category for syntax highlighting.
782///
783/// Stable enum that groups tokens by purpose. Consumers match on categories
784/// instead of individual tokens, insulating them from lexer evolution.
785///
786/// Not `#[non_exhaustive]`, deliberately: this is the enum `Token` (which
787/// *is* `#[non_exhaustive]`) exists to protect embedders from — it is the
788/// small, closed vocabulary a syntax highlighter matches exhaustively so a
789/// new `Token` variant never breaks it. Making this one grow too would
790/// defeat the reason it exists.
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
792pub enum TokenCategory {
793 /// Keywords: if, then, else, for, while, function, return, etc.
794 Keyword,
795 /// Operators: |, &&, ||, >, >>, 2>&1, =, ==, etc.
796 Operator,
797 /// String literals: "...", '...', heredocs
798 String,
799 /// Numeric literals: 123, 3.14, arithmetic expressions
800 Number,
801 /// Variable references: $foo, ${bar}, $1, $@, $#, $?, $$
802 Variable,
803 /// Comments: # ...
804 Comment,
805 /// Punctuation: ; , . ( ) { } [ ]
806 Punctuation,
807 /// Identifiers in command position
808 Command,
809 /// Absolute paths: /foo/bar
810 Path,
811 /// Flags: --long, -s, +x
812 Flag,
813 /// Invalid tokens
814 Error,
815}
816
817impl Token {
818 /// Returns the semantic category for syntax highlighting.
819 pub fn category(&self) -> TokenCategory {
820 match self {
821 // Keywords
822 Token::If
823 | Token::Then
824 | Token::Else
825 | Token::Elif
826 | Token::Fi
827 | Token::For
828 | Token::In
829 | Token::Do
830 | Token::Done
831 | Token::While
832 | Token::Case
833 | Token::Esac
834 | Token::Function
835 | Token::Return
836 | Token::Break
837 | Token::Continue
838 | Token::Exit
839 | Token::Set
840 | Token::Local
841 | Token::True
842 | Token::False
843 | Token::TypeString
844 | Token::TypeInt
845 | Token::TypeFloat
846 | Token::TypeBool => TokenCategory::Keyword,
847
848 // Operators and redirections
849 Token::Pipe
850 | Token::And
851 | Token::Or
852 | Token::Amp
853 | Token::Eq
854 | Token::EqEq
855 | Token::NotEq
856 | Token::Match
857 | Token::NotMatch
858 | Token::Lt
859 | Token::Gt
860 | Token::LtEq
861 | Token::GtEq
862 | Token::GtGt
863 | Token::Stderr
864 | Token::Both
865 | Token::HereDocStart
866 | Token::HereString
867 | Token::StderrToStdout
868 | Token::StdoutToStderr
869 | Token::StdoutToStderr2 => TokenCategory::Operator,
870
871 // Strings
872 Token::String(_) | Token::SingleString(_) | Token::HereDoc(_) => TokenCategory::String,
873
874 // Numbers
875 Token::Int(_) | Token::Float(_) | Token::Arithmetic(_) | Token::NumericLiteral(_) => {
876 TokenCategory::Number
877 }
878
879 // Variables
880 Token::VarRef(_)
881 | Token::SimpleVarRef(_)
882 | Token::Positional(_)
883 | Token::AllArgs
884 | Token::ArgCount
885 | Token::VarLength(_)
886 | Token::LastExitCode
887 | Token::CurrentPid => TokenCategory::Variable,
888
889 // Flags
890 Token::LongFlag(_)
891 | Token::ShortFlag(_)
892 | Token::PlusFlag(_)
893 | Token::DoubleDash => TokenCategory::Flag,
894
895 // Punctuation
896 Token::Semi
897 | Token::DoubleSemi
898 | Token::Colon
899 | Token::Comma
900 | Token::Dot
901 | Token::LParen
902 | Token::RParen
903 | Token::LBrace
904 | Token::RBrace
905 | Token::LBracket
906 | Token::RBracket
907 | Token::Bang
908 | Token::Question
909 | Token::Star
910 | Token::Newline
911 | Token::LineContinuation
912 | Token::CmdSubstStart
913 | Token::DotDotDot
914 | Token::ArithCond(_) => TokenCategory::Punctuation,
915
916 // Glob words (merged tokens containing wildcards)
917 Token::GlobWord(_) => TokenCategory::Path,
918
919 // Comments
920 Token::Comment => TokenCategory::Comment,
921
922 // Paths
923 Token::Path(_)
924 | Token::TildePath(_)
925 | Token::RelativePath(_)
926 | Token::Tilde
927 | Token::DotDot
928 | Token::DotSlashPath(_) => TokenCategory::Path,
929
930 // Commands/identifiers (and bare words)
931 Token::Ident(_)
932 | Token::PlusBare(_)
933 | Token::MinusBare(_)
934 | Token::DoubleDashBare(_)
935 | Token::MinusAlone
936 | Token::NumberIdent(_)
937 | Token::DashNumWord(_)
938 | Token::AtWord(_)
939 | Token::DottedIdent(_)
940 | Token::JobSpec(_) => TokenCategory::Command,
941
942 // Errors
943 Token::InvalidFloatNoLeading
944 | Token::InvalidFloatNoTrailing
945 | Token::BacktickRejected => TokenCategory::Error,
946 }
947 }
948}
949
950/// Lex a double-quoted string literal, processing escape sequences.
951/// Extend a `"` match to the quote that closes the string, then parse the
952/// literal.
953///
954/// A double-quoted string used to be a flat regex, `r#""([^"\\]|\\.)*""#`,
955/// which ends at the first unescaped `"` anywhere. That made
956/// `echo "$(basename "$p")"` a parse error: the string ended at the inner
957/// quote and the remainder was nonsense. A quote inside a `$(…)` is the
958/// substitution's, not the string's, so the scan has to know where it is.
959///
960/// The scan carries a stack of the regions it is inside, because they nest
961/// both ways — a substitution can hold a quoted word, and that word can hold
962/// another substitution. Popping the last region is what ends the string.
963/// Single quotes are literal inside a substitution, so a `"` in them closes
964/// nothing. Parens are counted quote-blind, matching the sibling scanner in
965/// `parse_interpolated_string_spanned` that reads the body afterwards; a
966/// literal `)` inside a quoted word in a substitution body is a residual both
967/// share.
968///
969/// A string that never closes is still `UnterminatedString` — scanning
970/// further must not turn a typo into a program that runs.
971fn lex_string(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
972 /// Regions the scan can be inside, innermost last.
973 enum Region {
974 /// A double-quoted span. A `"` ends it.
975 Quoted,
976 /// A `$(…)` body. A balanced `)` ends it.
977 Substitution,
978 }
979
980 let mut stack = vec![Region::Quoted];
981 let mut extra = 0usize;
982 let mut chars = lex.remainder().chars().peekable();
983
984 while let Some(c) = chars.next() {
985 extra += c.len_utf8();
986 match c {
987 // An escape covers the next character wherever we are, so `\"`
988 // never closes a span and `\\` is not a half-escape.
989 '\\' => {
990 if let Some(next) = chars.next() {
991 extra += next.len_utf8();
992 }
993 }
994 // `$(` opens a substitution from inside either region.
995 '$' if chars.peek() == Some(&'(') => {
996 chars.next();
997 extra += 1;
998 stack.push(Region::Substitution);
999 }
1000 '"' => match stack.last() {
1001 Some(Region::Quoted) => {
1002 stack.pop();
1003 if stack.is_empty() {
1004 lex.bump(extra);
1005 return parse_string_literal(lex.slice());
1006 }
1007 }
1008 // A quoted word starting inside a substitution.
1009 Some(Region::Substitution) => stack.push(Region::Quoted),
1010 None => break,
1011 },
1012 // Only inside a substitution: a single-quoted run is literal, so
1013 // skip it whole rather than letting its `"` or `)` decide anything.
1014 '\'' if matches!(stack.last(), Some(Region::Substitution)) => {
1015 for c in chars.by_ref() {
1016 extra += c.len_utf8();
1017 if c == '\'' {
1018 break;
1019 }
1020 }
1021 }
1022 '(' if matches!(stack.last(), Some(Region::Substitution)) => {
1023 stack.push(Region::Substitution);
1024 }
1025 ')' if matches!(stack.last(), Some(Region::Substitution)) => {
1026 stack.pop();
1027 }
1028 _ => {}
1029 }
1030 }
1031
1032 // The stack says which region the input ran out inside. An unclosed `$(`
1033 // outranks the string around it: the author forgot the `)`, and saying
1034 // "unterminated string" would name the consequence and hide the cause.
1035 if stack.iter().any(|r| matches!(r, Region::Substitution)) {
1036 return Err(LexerError::UnterminatedCommandSubst);
1037 }
1038 Err(LexerError::UnterminatedString)
1039}
1040
1041/// Lex a single-quoted string literal (no escape processing).
1042fn lex_single_string(lex: &mut logos::Lexer<Token>) -> String {
1043 let s = lex.slice();
1044 // Strip the surrounding single quotes
1045 s[1..s.len() - 1].to_string()
1046}
1047
1048/// Lex a braced variable reference, extracting the inner content.
1049/// Extend a `${` match across the remainder to the balanced closing `}`
1050/// and return the full `${...}` text for later parsing of path segments
1051/// and default words. Brace depth counts raw `{`/`}` characters, matching
1052/// the scanner's `${...}` region tracking (quote-blind, like the old
1053/// first-`}` regex — a quoted `}` inside a default word still closes; see
1054/// GH #173). An empty `${}` stays an error (as it was when the regex
1055/// required at least one inner character); a reference that never closes
1056/// is a loud `UnterminatedVarRef`.
1057fn lex_varref(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
1058 let mut depth = 1usize;
1059 let mut extra = 0usize;
1060 for c in lex.remainder().chars() {
1061 extra += c.len_utf8();
1062 match c {
1063 '{' => depth += 1,
1064 '}' => {
1065 depth -= 1;
1066 if depth == 0 {
1067 if extra == 1 {
1068 // `${}` — empty reference, same error class the
1069 // old non-matching regex produced.
1070 return Err(LexerError::UnexpectedCharacter);
1071 }
1072 lex.bump(extra);
1073 return Ok(lex.slice().to_string());
1074 }
1075 }
1076 _ => {}
1077 }
1078 }
1079 Err(LexerError::UnterminatedVarRef)
1080}
1081
1082/// Lex a simple variable reference: `$NAME` → `NAME`.
1083fn lex_simple_varref(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
1084 // Strip the leading `$`
1085 Ok(lex.slice()[1..].to_string())
1086}
1087
1088/// Lex a positional parameter: `$1` → 1
1089fn lex_positional(lex: &mut logos::Lexer<Token>) -> usize {
1090 // Strip the leading `$` and parse the digit
1091 lex.slice()[1..].parse().unwrap_or(0)
1092}
1093
1094/// Lex a variable length: `${#VAR}` → "VAR"
1095fn lex_var_length(lex: &mut logos::Lexer<Token>) -> String {
1096 // Strip the leading `${#` and trailing `}`
1097 let s = lex.slice();
1098 s[3..s.len() - 1].to_string()
1099}
1100
1101/// Lex an integer literal.
1102fn lex_int(lex: &mut logos::Lexer<Token>) -> Result<i64, LexerError> {
1103 let slice = lex.slice();
1104 // A leading-zero numeral is text, not a number, whether or not its
1105 // digits fit in i64 — `09223372036854775808` is text (leading zero)
1106 // before it is ever an overflow. `preserve_numeric_source_text` reads
1107 // the source span, not this value, to reclassify the token into
1108 // `NumberIdent`, so any placeholder here is discarded.
1109 if has_invalid_leading_zero(slice) {
1110 return Ok(0);
1111 }
1112 slice.parse().map_err(|_| LexerError::IntegerOutOfRange)
1113}
1114
1115/// Lex a float literal.
1116fn lex_float(lex: &mut logos::Lexer<Token>) -> Result<f64, LexerError> {
1117 lex.slice().parse().map_err(|_| LexerError::InvalidNumber)
1118}
1119
1120/// Lex a digit-leading bareword like `019dda1c` or `019dda1c-5b3f-7000`.
1121/// Distinguished from `Int` because at least one alpha character follows the
1122/// leading digits — the slice is treated as a string, not a number.
1123fn lex_number_ident(lex: &mut logos::Lexer<Token>) -> String {
1124 lex.slice().to_string()
1125}
1126
1127/// Lex a dot-prefixed bareword like `.gitignore` or `.parent.parent`.
1128fn lex_dotted_ident(lex: &mut logos::Lexer<Token>) -> String {
1129 lex.slice().to_string()
1130}
1131
1132/// Lex a bareword by capturing its raw slice verbatim (used by `DashNumWord`
1133/// and `AtWord`, where exact characters — e.g. leading zeros — must survive).
1134fn lex_slice_word(lex: &mut logos::Lexer<Token>) -> String {
1135 lex.slice().to_string()
1136}
1137
1138/// Lex an invalid float without leading digit (like .5).
1139/// Always returns Err to produce a lexer error instead of a token.
1140fn lex_invalid_float_no_leading(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
1141 Err(LexerError::InvalidFloatNoLeading)
1142}
1143
1144/// Reject a backtick — kaish doesn't support backtick command substitution.
1145/// The dedicated error gives the user a `$(cmd)` hint instead of the generic
1146/// `UnexpectedCharacter` they would have hit otherwise.
1147fn reject_backtick(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
1148 Err(LexerError::BackticksNotSupported)
1149}
1150
1151/// True where a new word can begin, so a following `#` opens a comment.
1152///
1153/// Whitespace and the operators that end a command (`; | & < >`) and the
1154/// opening `(` are word boundaries in every shell. A closing `)` is
1155/// deliberately absent: `$(f)#3` is one word in bash, and the lexer cannot
1156/// tell that `)` from a subshell's here, so both are a loud error rather than
1157/// a silent comment.
1158pub(crate) fn opens_a_word(c: char) -> bool {
1159 c.is_whitespace() || matches!(c, ';' | '|' | '&' | '<' | '>' | '(')
1160}
1161
1162/// Accept a comment only at the start of a word.
1163///
1164/// POSIX opens a comment at a word start, and bash and `/bin/sh` agree:
1165/// `echo abc#3` prints `abc#3`, and only `echo abc #3` comments. kaish used to
1166/// start a comment at any `#`, which truncated the word and swallowed the rest
1167/// of the line — `;` separators and whole commands included — at exit 0. That
1168/// is indistinguishable from commands that ran and printed nothing, so the
1169/// position that cannot be a comment is an error instead.
1170fn lex_comment(lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
1171 match lex.source()[..lex.span().start].chars().next_back() {
1172 // Start of input, or a real word boundary: a comment.
1173 None => Ok(()),
1174 Some(c) if opens_a_word(c) => Ok(()),
1175 Some(_) => Err(LexerError::HashInsideWord),
1176 }
1177}
1178
1179/// Lex an invalid float without trailing digit (like 5.).
1180/// Always returns Err to produce a lexer error instead of a token.
1181fn lex_invalid_float_no_trailing(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
1182 Err(LexerError::InvalidFloatNoTrailing)
1183}
1184
1185/// Lex an identifier.
1186///
1187/// Every bareword arrives here, `yes`, `no`, `TRUE`, and `False` included —
1188/// they are ordinary words. Only lowercase `true` and `false` are boolean
1189/// literals, and those are their own tokens, so they never reach this
1190/// function. A lexer cannot see whether a boolean was wanted, so it does not
1191/// guess: `x=TRUE` binds the string `"TRUE"`.
1192fn lex_ident(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
1193 Ok(lex.slice().to_string())
1194}
1195
1196/// Lex a long flag: `--name` → `name`. Rejects a non-ASCII match whole; see
1197/// the note on `Token`.
1198fn lex_long_flag(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
1199 let s = lex.slice();
1200 if !s.is_ascii() {
1201 return Err(LexerError::NonAsciiName { kind: "flag", text: s.to_string() });
1202 }
1203 // Strip the leading `--`
1204 Ok(s[2..].to_string())
1205}
1206
1207/// Lex a short flag: `-l` → `l`, `-la` → `la`.
1208fn lex_short_flag(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
1209 let s = lex.slice();
1210 if !s.is_ascii() {
1211 return Err(LexerError::NonAsciiName { kind: "flag", text: s.to_string() });
1212 }
1213 // Strip the leading `-`
1214 Ok(s[1..].to_string())
1215}
1216
1217/// Lex a plus flag: `+e` → `e`, `+ex` → `ex`.
1218fn lex_plus_flag(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
1219 let s = lex.slice();
1220 if !s.is_ascii() {
1221 return Err(LexerError::NonAsciiName { kind: "flag", text: s.to_string() });
1222 }
1223 // Strip the leading `+`
1224 Ok(s[1..].to_string())
1225}
1226
1227/// Lex a plus bare word: `+%s` → `+%s` (keep the full string)
1228fn lex_plus_bare(lex: &mut logos::Lexer<Token>) -> String {
1229 lex.slice().to_string()
1230}
1231
1232/// Lex a minus bare word: `-%` → `-%` (keep the full string)
1233fn lex_minus_bare(lex: &mut logos::Lexer<Token>) -> String {
1234 lex.slice().to_string()
1235}
1236
1237/// Lex a double-dash bare word: `---` → `---`, `--=x` → `--=x` (keep the
1238/// full string; see GH #137).
1239fn lex_double_dash_bare(lex: &mut logos::Lexer<Token>) -> String {
1240 lex.slice().to_string()
1241}
1242
1243/// Lex a job specifier: `%1` → `%1` (keep the leading `%`).
1244fn lex_job_spec(lex: &mut logos::Lexer<Token>) -> String {
1245 lex.slice().to_string()
1246}
1247
1248/// Lex an absolute path: `/tmp/out` → `/tmp/out`
1249fn lex_path(lex: &mut logos::Lexer<Token>) -> String {
1250 lex.slice().to_string()
1251}
1252
1253/// Lex a tilde path: `~/foo` → `~/foo`
1254fn lex_tilde_path(lex: &mut logos::Lexer<Token>) -> String {
1255 lex.slice().to_string()
1256}
1257
1258/// Lex a relative path: `../foo` → `../foo`
1259fn lex_relative_path(lex: &mut logos::Lexer<Token>) -> String {
1260 lex.slice().to_string()
1261}
1262
1263/// Lex a dot-slash path: `./foo` → `./foo`
1264fn lex_dot_slash_path(lex: &mut logos::Lexer<Token>) -> String {
1265 lex.slice().to_string()
1266}
1267
1268impl fmt::Display for Token {
1269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1270 match self {
1271 Token::Set => write!(f, "set"),
1272 Token::Local => write!(f, "local"),
1273 Token::If => write!(f, "if"),
1274 Token::Then => write!(f, "then"),
1275 Token::Else => write!(f, "else"),
1276 Token::Elif => write!(f, "elif"),
1277 Token::Fi => write!(f, "fi"),
1278 Token::For => write!(f, "for"),
1279 Token::While => write!(f, "while"),
1280 Token::In => write!(f, "in"),
1281 Token::Do => write!(f, "do"),
1282 Token::Done => write!(f, "done"),
1283 Token::Case => write!(f, "case"),
1284 Token::Esac => write!(f, "esac"),
1285 Token::Function => write!(f, "function"),
1286 Token::Break => write!(f, "break"),
1287 Token::Continue => write!(f, "continue"),
1288 Token::Return => write!(f, "return"),
1289 Token::Exit => write!(f, "exit"),
1290 Token::True => write!(f, "true"),
1291 Token::False => write!(f, "false"),
1292 Token::TypeString => write!(f, "string"),
1293 Token::TypeInt => write!(f, "int"),
1294 Token::TypeFloat => write!(f, "float"),
1295 Token::TypeBool => write!(f, "bool"),
1296 Token::And => write!(f, "&&"),
1297 Token::Or => write!(f, "||"),
1298 Token::EqEq => write!(f, "=="),
1299 Token::NotEq => write!(f, "!="),
1300 Token::Match => write!(f, "=~"),
1301 Token::NotMatch => write!(f, "!~"),
1302 Token::GtEq => write!(f, ">="),
1303 Token::LtEq => write!(f, "<="),
1304 Token::GtGt => write!(f, ">>"),
1305 Token::StderrToStdout => write!(f, "2>&1"),
1306 Token::StdoutToStderr => write!(f, "1>&2"),
1307 Token::StdoutToStderr2 => write!(f, ">&2"),
1308 Token::Stderr => write!(f, "2>"),
1309 Token::Both => write!(f, "&>"),
1310 Token::HereDocStart => write!(f, "<<"),
1311 Token::HereString => write!(f, "<<<"),
1312 Token::DoubleSemi => write!(f, ";;"),
1313 Token::Eq => write!(f, "="),
1314 Token::Pipe => write!(f, "|"),
1315 Token::Amp => write!(f, "&"),
1316 Token::Gt => write!(f, ">"),
1317 Token::Lt => write!(f, "<"),
1318 Token::Semi => write!(f, ";"),
1319 Token::Colon => write!(f, ":"),
1320 Token::Comma => write!(f, ","),
1321 Token::Dot => write!(f, "."),
1322 Token::DotDot => write!(f, ".."),
1323 Token::DotDotDot => write!(f, "..."),
1324 Token::Tilde => write!(f, "~"),
1325 Token::TildePath(s) => write!(f, "{}", s),
1326 Token::RelativePath(s) => write!(f, "{}", s),
1327 Token::DotSlashPath(s) => write!(f, "{}", s),
1328 Token::LBrace => write!(f, "{{"),
1329 Token::RBrace => write!(f, "}}"),
1330 Token::LBracket => write!(f, "["),
1331 Token::RBracket => write!(f, "]"),
1332 Token::LParen => write!(f, "("),
1333 Token::RParen => write!(f, ")"),
1334 Token::Star => write!(f, "*"),
1335 Token::Bang => write!(f, "!"),
1336 Token::Question => write!(f, "?"),
1337 Token::GlobWord(s) => write!(f, "GLOB({})", s),
1338 Token::Arithmetic(s) => write!(f, "ARITHMETIC({})", s),
1339 Token::ArithCond(s) => write!(f, "((ARITHCOND({})))", s),
1340 Token::CmdSubstStart => write!(f, "$("),
1341 Token::LongFlag(s) => write!(f, "--{}", s),
1342 Token::ShortFlag(s) => write!(f, "-{}", s),
1343 Token::PlusFlag(s) => write!(f, "+{}", s),
1344 Token::DoubleDash => write!(f, "--"),
1345 Token::DoubleDashBare(s) => write!(f, "{}", s),
1346 Token::PlusBare(s) => write!(f, "{}", s),
1347 Token::MinusBare(s) => write!(f, "{}", s),
1348 Token::JobSpec(s) => write!(f, "{}", s),
1349 Token::MinusAlone => write!(f, "-"),
1350 Token::String(s) => write!(f, "STRING({:?})", s),
1351 Token::SingleString(s) => write!(f, "SINGLESTRING({:?})", s),
1352 Token::HereDoc(d) => write!(f, "HEREDOC({:?}, literal={})", d.content, d.literal),
1353 Token::VarRef(v) => write!(f, "VARREF({})", v),
1354 Token::SimpleVarRef(v) => write!(f, "SIMPLEVARREF({})", v),
1355 Token::Positional(n) => write!(f, "${}", n),
1356 Token::AllArgs => write!(f, "$@"),
1357 Token::ArgCount => write!(f, "$#"),
1358 Token::LastExitCode => write!(f, "$?"),
1359 Token::CurrentPid => write!(f, "$$"),
1360 Token::VarLength(v) => write!(f, "${{#{}}}", v),
1361 Token::Int(n) => write!(f, "INT({})", n),
1362 Token::Float(n) => write!(f, "FLOAT({})", n),
1363 Token::NumericLiteral(d) => write!(f, "NUMERICLITERAL({:?}, raw={:?})", d.value, d.raw),
1364 Token::Path(s) => write!(f, "PATH({})", s),
1365 Token::Ident(s) => write!(f, "IDENT({})", s),
1366 Token::NumberIdent(s) => write!(f, "NUMIDENT({})", s),
1367 Token::DashNumWord(s) => write!(f, "DASHNUM({})", s),
1368 Token::AtWord(s) => write!(f, "ATWORD({})", s),
1369 Token::DottedIdent(s) => write!(f, "DOTIDENT({})", s),
1370 Token::Comment => write!(f, "COMMENT"),
1371 Token::Newline => write!(f, "NEWLINE"),
1372 Token::LineContinuation => write!(f, "LINECONT"),
1373 // These variants should never be produced — their callbacks always return errors
1374 Token::InvalidFloatNoLeading => write!(f, "INVALID_FLOAT_NO_LEADING"),
1375 Token::InvalidFloatNoTrailing => write!(f, "INVALID_FLOAT_NO_TRAILING"),
1376 Token::BacktickRejected => write!(f, "BACKTICK_REJECTED"),
1377 }
1378 }
1379}
1380
1381impl Token {
1382 /// Returns true if this token is a keyword.
1383 // Must match the Keyword variants in `Token::category()` (minus the
1384 // TypeX variants, which `is_type()` covers separately). Currently
1385 // uncalled — kept exhaustive so future callers don't get wrong answers.
1386 pub fn is_keyword(&self) -> bool {
1387 matches!(
1388 self,
1389 Token::Set
1390 | Token::Local
1391 | Token::If
1392 | Token::Then
1393 | Token::Else
1394 | Token::Elif
1395 | Token::Fi
1396 | Token::For
1397 | Token::In
1398 | Token::Do
1399 | Token::Done
1400 | Token::While
1401 | Token::Case
1402 | Token::Esac
1403 | Token::Function
1404 | Token::Return
1405 | Token::Break
1406 | Token::Continue
1407 | Token::Exit
1408 | Token::True
1409 | Token::False
1410 )
1411 }
1412
1413 /// Returns true if this token is a type keyword.
1414 pub fn is_type(&self) -> bool {
1415 matches!(
1416 self,
1417 Token::TypeString
1418 | Token::TypeInt
1419 | Token::TypeFloat
1420 | Token::TypeBool
1421 )
1422 }
1423
1424 /// Returns true if this token starts a statement.
1425 // Currently uncalled — kept exhaustive so future callers don't get wrong answers.
1426 pub fn starts_statement(&self) -> bool {
1427 matches!(
1428 self,
1429 Token::Set
1430 | Token::Local
1431 | Token::Function
1432 | Token::If
1433 | Token::For
1434 | Token::While
1435 | Token::Case
1436 | Token::Ident(_)
1437 | Token::LBracket
1438 )
1439 }
1440
1441 /// Returns true if this token can appear in an expression.
1442 pub fn is_value(&self) -> bool {
1443 matches!(
1444 self,
1445 Token::String(_)
1446 | Token::SingleString(_)
1447 | Token::HereDoc(_)
1448 | Token::Arithmetic(_)
1449 | Token::Int(_)
1450 | Token::Float(_)
1451 | Token::NumericLiteral(_)
1452 | Token::True
1453 | Token::False
1454 | Token::VarRef(_)
1455 | Token::SimpleVarRef(_)
1456 | Token::CmdSubstStart
1457 | Token::Path(_)
1458 | Token::GlobWord(_)
1459 | Token::LastExitCode
1460 | Token::CurrentPid
1461 )
1462 }
1463}
1464
1465// ═══════════════════════════════════════════════════════════════════
1466// Lexing pipeline (GH #95 rewrite)
1467//
1468// One composed source-order scanner extracts heredocs and arithmetic in
1469// a single quote/escape/comment-aware pass, producing a rewritten buffer
1470// plus a COMPLETE replacement table (heredocs included — the pre-#95
1471// pipeline recorded no replacements for heredocs, so every span after a
1472// heredoc drifted). logos then lexes the rewritten buffer; markers are
1473// resolved back to `Arithmetic`/`HereDoc` tokens POSITIONALLY (keyed by
1474// the replacement table, never by fishing identifier names); and finally
1475// the fusion passes merge span-adjacent runs using VERBATIM source
1476// slices (leading zeros survive — `Int(007)` never round-trips through
1477// `to_string()`).
1478// ═══════════════════════════════════════════════════════════════════
1479
1480/// A text replacement performed by the scanner, in both coordinate
1481/// systems. `orig_*` addresses the original source; `new_*` addresses the
1482/// rewritten buffer fed to logos. The table is ordered by position and is
1483/// the single source of truth for span correction and marker resolution.
1484#[derive(Debug, Clone)]
1485struct Replacement {
1486 orig_start: usize,
1487 orig_len: usize,
1488 new_start: usize,
1489 new_len: usize,
1490 kind: ReplacementKind,
1491}
1492
1493#[derive(Debug, Clone, PartialEq)]
1494enum ReplacementKind {
1495 /// `$((expr))` → arithmetic marker; index into `ScanOutput::arithmetics`.
1496 Arith(usize),
1497 /// Heredoc delimiter word → heredoc marker; index into `ScanOutput::heredocs`.
1498 HeredocIntro(usize),
1499 /// Heredoc body + terminating delimiter line, elided from the buffer.
1500 Elision,
1501}
1502
1503/// Map a position from rewritten-buffer coordinates back to original-source
1504/// coordinates. `is_end` selects the boundary policy for half-open spans:
1505/// an END sitting exactly on a zero-width elision point must NOT be pushed
1506/// past the elided region, while a START there must be.
1507fn map_position(p: usize, is_end: bool, replacements: &[Replacement]) -> usize {
1508 let mut delta: isize = 0;
1509 for r in replacements {
1510 let r_end = r.new_start + r.new_len;
1511 let past = if is_end {
1512 p >= r_end && p > r.new_start
1513 } else {
1514 p >= r_end
1515 };
1516 if past {
1517 delta += r.orig_len as isize - r.new_len as isize;
1518 } else if p > r.new_start {
1519 // Strictly inside a replacement (a token glued onto a marker):
1520 // clamp into the original range.
1521 return r.orig_start + (p - r.new_start).min(r.orig_len);
1522 } else {
1523 break; // table is ordered; nothing further can affect p
1524 }
1525 }
1526 ((p as isize) + delta).max(0) as usize
1527}
1528
1529fn map_span(span: &Span, replacements: &[Replacement]) -> Span {
1530 let start = map_position(span.start, false, replacements);
1531 let end = map_position(span.end, true, replacements).max(start);
1532 start..end
1533}
1534
1535/// Per-heredoc data collected by the scanner.
1536///
1537/// `body` is the raw body bytes (tab stripping for `<<-` happens at
1538/// materialization). `body_start_offset` is the byte offset of the first
1539/// body character **in the original source** — exact, since the scanner
1540/// records every rewrite in the replacement table.
1541#[derive(Debug, Clone)]
1542struct HeredocExtract {
1543 body: String,
1544 /// The body before any arithmetic rewrite — what the author typed.
1545 source_body: String,
1546 delimiter: String,
1547 literal: bool,
1548 strip_tabs: bool,
1549 body_start_offset: usize,
1550}
1551
1552/// A heredoc whose introducer has been scanned but whose body hasn't been
1553/// collected yet — bodies start after the next unescaped newline, in
1554/// introducer order (`cat <<A <<B` queues two).
1555struct PendingHeredoc {
1556 delimiter: String,
1557 literal: bool,
1558 strip_tabs: bool,
1559 /// Span of the introducer (`<<` through the delimiter word) in
1560 /// original coordinates, for unterminated-heredoc errors.
1561 intro_span: Span,
1562}
1563
1564/// Scanner output: the rewritten buffer plus everything needed to resolve
1565/// markers and correct spans.
1566struct ScanOutput {
1567 text: String,
1568 /// (marker, expression, is_condition) triples, indexed by
1569 /// `ReplacementKind::Arith`. `is_condition` is set only for a bare
1570 /// `(( expr ))` at the top level — `$((expr))` is always `false`, and a
1571 /// quoted string's own `$((` never sets it (the condition form has no
1572 /// meaning inside a string).
1573 arithmetics: Vec<(String, String, bool)>,
1574 /// Heredoc extracts, indexed by `ReplacementKind::HeredocIntro`.
1575 heredocs: Vec<HeredocExtract>,
1576 replacements: Vec<Replacement>,
1577}
1578
1579/// The one composed scanner: a single pass over the original source with
1580/// explicit quote/escape/comment state. Extracts `$((expr))` arithmetic
1581/// and `<<WORD` heredocs; everything else is copied verbatim. Because one
1582/// state machine owns all the context, the pre-#95 mutual-blindness bugs
1583/// (apostrophes in heredoc bodies poisoning arithmetic, `<<` inside
1584/// strings or comments misfiring heredoc collection) are structurally
1585/// impossible.
1586fn scan(source: &str) -> Result<ScanOutput, Spanned<LexerError>> {
1587 let chars: Vec<(usize, char)> = source.char_indices().collect();
1588 let n = chars.len();
1589 let total_len = source.len();
1590 let byte_at = |i: usize| -> usize {
1591 if i < n { chars[i].0 } else { total_len }
1592 };
1593
1594 let mut out = String::with_capacity(source.len());
1595 let mut arithmetics: Vec<(String, String, bool)> = Vec::new();
1596 let mut heredocs: Vec<HeredocExtract> = Vec::new();
1597 let mut replacements: Vec<Replacement> = Vec::new();
1598 let mut pending: Vec<PendingHeredoc> = Vec::new();
1599
1600 let mut i = 0;
1601 // Tracks the previously copied character so `$#` (arg count) isn't
1602 // mistaken for a comment introducer.
1603
1604 while i < n {
1605 let (pos, ch) = chars[i];
1606
1607 // Backslash escape: copy both characters verbatim. This also
1608 // covers line continuations (`\` + newline) — the escaped newline
1609 // does not trigger heredoc body collection, matching how logos
1610 // treats it as a continuation, not a statement boundary.
1611 if ch == '\\' && i + 1 < n {
1612 out.push(ch);
1613 out.push(chars[i + 1].1);
1614 i += 2;
1615 continue;
1616 }
1617
1618 match ch {
1619 // Single-quoted string: opaque. No heredocs, no arithmetic,
1620 // no comments inside.
1621 '\'' => {
1622 out.push(ch);
1623 i += 1;
1624 while i < n && chars[i].1 != '\'' {
1625 out.push(chars[i].1);
1626 i += 1;
1627 }
1628 if i < n {
1629 out.push('\''); // closing quote
1630 i += 1;
1631 }
1632 }
1633
1634 // Double-quoted string: arithmetic still expands inside;
1635 // heredocs and comments do not.
1636 '"' => {
1637 out.push(ch);
1638 i += 1;
1639 while i < n {
1640 let (dpos, dch) = chars[i];
1641 if dch == '\\' && i + 1 < n {
1642 let next = chars[i + 1].1;
1643 if next == '"' || next == '\\' || next == '$' || next == '`' {
1644 out.push(dch);
1645 out.push(next);
1646 i += 2;
1647 continue;
1648 }
1649 }
1650 if dch == '"' {
1651 out.push(dch);
1652 i += 1;
1653 break;
1654 }
1655 if dch == '$'
1656 && i + 2 < n
1657 && chars[i + 1].1 == '('
1658 && chars[i + 2].1 == '('
1659 {
1660 extract_arithmetic(
1661 &chars,
1662 &mut i,
1663 dpos,
1664 total_len,
1665 &mut ScanBuffers {
1666 out: &mut out,
1667 arithmetics: &mut arithmetics,
1668 replacements: &mut replacements,
1669 },
1670 3,
1671 false,
1672 )?;
1673 continue;
1674 }
1675 // A `$(…)` inside the string is a COMMAND, and this pass
1676 // is not the one that reads commands. Copy the body
1677 // verbatim: it is parsed on its own later, and `scan` runs
1678 // over it again there, so arithmetic inside it is handled
1679 // at the level where it belongs.
1680 //
1681 // Reaching in from here was wrong in both directions.
1682 // `echo "$(echo '$((1+1))')"` printed `${__ARITH:1+1__}` —
1683 // an internal name where the author's text should be, with
1684 // no error, because a command's single quotes make that
1685 // arithmetic literal and this pass could not see it was
1686 // inside a command at all. And `echo "$(echo $((1+1)))"`
1687 // was a parse error, because the marker was substituted
1688 // into the OUTER string's token while the body is parsed
1689 // as its own program, which never resolves it.
1690 if dch == '$' && i + 1 < n && chars[i + 1].1 == '(' {
1691 copy_substitution_verbatim(&chars, &mut i, &mut out);
1692 continue;
1693 }
1694 out.push(dch);
1695 i += 1;
1696 }
1697 }
1698
1699 // Comment: copy verbatim through end-of-line (logos tokenizes
1700 // and drops it), but only where a word can start — `#` is an
1701 // ordinary word character mid-word.
1702 //
1703 // The test reads the last character of `out`, which is the exact
1704 // buffer logos will lex: deciding from it makes this pass and
1705 // `lex_comment` agree by construction. They must. The scanner
1706 // extracts `$((…))` and heredoc bodies, so a scanner that skipped
1707 // a mid-word `#` to end-of-line would drop an arithmetic expansion
1708 // that logos then meets as raw `$((`.
1709 //
1710 // This replaced a `prev_char` tracker that only approximated the
1711 // same character — it recorded `'_'` for marker text, which is the
1712 // real last byte of a marker. `out` needs no approximation. `$#`
1713 // stays intact without the old `$` guard: `$` does not open a word.
1714 '#' if out.chars().next_back().is_none_or(opens_a_word) => {
1715 while i < n && chars[i].1 != '\n' && chars[i].1 != '\r' {
1716 out.push(chars[i].1);
1717 i += 1;
1718 }
1719 }
1720
1721 // `<<<` here-string passes through; `<<` starts a heredoc.
1722 '<' if i + 1 < n && chars[i + 1].1 == '<' => {
1723 if i + 2 < n && chars[i + 2].1 == '<' {
1724 out.push_str("<<<");
1725 i += 3;
1726 continue;
1727 }
1728 let heredoc_index = heredocs.len() + pending.len();
1729 scan_heredoc_introducer(
1730 &chars,
1731 &mut i,
1732 pos,
1733 &mut out,
1734 &mut pending,
1735 &mut replacements,
1736 heredoc_index,
1737 );
1738 }
1739
1740 // `$((` arithmetic; `${...}` variable reference region.
1741 '$' if i + 2 < n && chars[i + 1].1 == '(' && chars[i + 2].1 == '(' => {
1742 extract_arithmetic(
1743 &chars,
1744 &mut i,
1745 pos,
1746 total_len,
1747 &mut ScanBuffers {
1748 out: &mut out,
1749 arithmetics: &mut arithmetics,
1750 replacements: &mut replacements,
1751 },
1752 3,
1753 false,
1754 )?;
1755 }
1756 // Bare `((expr))` — an arithmetic condition, the sibling of
1757 // `[[ ]]`. Unlike `$((`, there is no sigil to make this
1758 // unambiguous; it is safe because kaish gives a lone `(` no
1759 // other meaning at this (unquoted, top-level) position — see
1760 // `Stmt::Arith`/`Expr::Arith` in `ast/types.rs`.
1761 '(' if i + 1 < n && chars[i + 1].1 == '(' => {
1762 extract_arithmetic(
1763 &chars,
1764 &mut i,
1765 pos,
1766 total_len,
1767 &mut ScanBuffers {
1768 out: &mut out,
1769 arithmetics: &mut arithmetics,
1770 replacements: &mut replacements,
1771 },
1772 2,
1773 true,
1774 )?;
1775 }
1776 '$' if i + 1 < n && chars[i + 1].1 == '{' => {
1777 // Copy the ${...} region verbatim, tracking brace depth.
1778 // Arithmetic inside a bare ${...} cannot be represented
1779 // (the marker would leak into the reference text — the
1780 // pre-#95 pipeline silently corrupted this), so it is a
1781 // loud error instead.
1782 out.push('$');
1783 out.push('{');
1784 i += 2;
1785 let mut depth = 1usize;
1786 while i < n && depth > 0 {
1787 let (vpos, vch) = chars[i];
1788 if vch == '$'
1789 && i + 2 < n
1790 && chars[i + 1].1 == '('
1791 && chars[i + 2].1 == '('
1792 {
1793 return Err(Spanned::new(
1794 LexerError::ArithmeticInVarRef,
1795 vpos..(byte_at(i + 3)),
1796 ));
1797 }
1798 match vch {
1799 '{' => depth += 1,
1800 '}' => depth -= 1,
1801 _ => {}
1802 }
1803 out.push(vch);
1804 i += 1;
1805 }
1806 }
1807
1808 // Unescaped newline: copy it, then collect any pending
1809 // heredoc bodies (in introducer order).
1810 '\n' => {
1811 out.push('\n');
1812 i += 1;
1813 if !pending.is_empty() {
1814 collect_heredoc_bodies(
1815 &chars,
1816 &mut i,
1817 total_len,
1818 out.len(),
1819 &mut pending,
1820 &mut heredocs,
1821 &mut replacements,
1822 )?;
1823 }
1824 }
1825
1826 // Bare `\r` (Mac-classic line ending) terminating a heredoc
1827 // introducer line: normalize to `\n` (same byte length, so
1828 // spans are unaffected) so logos sees a real Newline, and
1829 // collect the pending bodies. A CRLF pair falls through to
1830 // the `\n` arm via the default copy of `\r`; a stray bare
1831 // `\r` with no heredoc pending stays verbatim (and stays a
1832 // lexer error, as before).
1833 '\r' if !pending.is_empty()
1834 && chars.get(i + 1).map(|c| c.1) != Some('\n') =>
1835 {
1836 out.push('\n');
1837 i += 1;
1838 collect_heredoc_bodies(
1839 &chars,
1840 &mut i,
1841 total_len,
1842 out.len(),
1843 &mut pending,
1844 &mut heredocs,
1845 &mut replacements,
1846 )?;
1847 }
1848
1849 _ => {
1850 out.push(ch);
1851 i += 1;
1852 }
1853 }
1854 }
1855
1856 // EOF with heredoc introducers whose bodies never started (no newline
1857 // after the introducer line).
1858 if let Some(p) = pending.first() {
1859 return Err(Spanned::new(
1860 LexerError::UnterminatedHeredoc {
1861 delimiter: p.delimiter.clone(),
1862 },
1863 p.intro_span.clone(),
1864 ));
1865 }
1866
1867 Ok(ScanOutput {
1868 text: out,
1869 arithmetics,
1870 heredocs,
1871 replacements,
1872 })
1873}
1874
1875/// Copy a `$(…)` from `chars[*i]` through its balanced `)` into `out`, byte
1876/// for byte, advancing `*i` past it.
1877///
1878/// Used by `scan`'s double-quoted-string arm so the pre-pass does not rewrite
1879/// anything inside a command body. It carries the same region stack
1880/// `lex_string` uses, for the same reason: a quoted word inside the body can
1881/// open another substitution, and a flat quote-skip would read that inner
1882/// opener as the outer closer.
1883///
1884/// This pass COPIES rather than parses, so a mis-counted paren cannot corrupt
1885/// the byte stream — the outer loop copies whatever this did not, and the
1886/// bytes are identical. What it decides is where arithmetic extraction
1887/// resumes, which is why every case that shows a miscount needs arithmetic
1888/// after the paren in question.
1889///
1890/// It still has no command grammar, so a `)` that is not structure — an
1891/// unquoted `case` pattern's, one in a `#` comment, one in a heredoc body —
1892/// is counted. Those are pre-existing and loud, and the parser's
1893/// `CmdSubstFrames` is the token-based scanner that gets them right.
1894///
1895/// An unterminated body is copied to end of input and left for the lexer to
1896/// report; this pass never decides that a program is malformed.
1897fn copy_substitution_verbatim(chars: &[(usize, char)], i: &mut usize, out: &mut String) {
1898 // Same regions, same rules as `lex_string` one pass later. A flat
1899 // "skip to the next quote of the same kind" loop is not enough: a quoted
1900 // word inside the body can open ANOTHER substitution, and the inner
1901 // opener then reads as the outer closer, so a `)` after it closes the
1902 // body early.
1903 enum Region {
1904 Quoted,
1905 Substitution,
1906 }
1907
1908 let n = chars.len();
1909 // `$` and `(` — the caller has already checked both are present.
1910 out.push('$');
1911 out.push('(');
1912 *i += 2;
1913 let mut stack = vec![Region::Substitution];
1914
1915 while *i < n {
1916 let c = chars[*i].1;
1917 // An escape covers the next character wherever we are.
1918 if c == '\\' {
1919 out.push(c);
1920 *i += 1;
1921 if *i < n {
1922 out.push(chars[*i].1);
1923 *i += 1;
1924 }
1925 continue;
1926 }
1927 // `$(` opens a substitution from inside either region.
1928 if c == '$' && *i + 1 < n && chars[*i + 1].1 == '(' {
1929 out.push('$');
1930 out.push('(');
1931 *i += 2;
1932 stack.push(Region::Substitution);
1933 continue;
1934 }
1935 match c {
1936 '"' => match stack.last() {
1937 Some(Region::Quoted) => {
1938 stack.pop();
1939 }
1940 Some(Region::Substitution) => stack.push(Region::Quoted),
1941 None => {}
1942 },
1943 // Literal only in command position; inside a double-quoted word a
1944 // single quote is an ordinary character.
1945 '\'' if matches!(stack.last(), Some(Region::Substitution)) => {
1946 out.push(c);
1947 *i += 1;
1948 while *i < n {
1949 let q = chars[*i].1;
1950 out.push(q);
1951 *i += 1;
1952 if q == '\'' {
1953 break;
1954 }
1955 }
1956 continue;
1957 }
1958 '(' if matches!(stack.last(), Some(Region::Substitution)) => {
1959 stack.push(Region::Substitution);
1960 }
1961 ')' if matches!(stack.last(), Some(Region::Substitution)) => {
1962 stack.pop();
1963 if stack.is_empty() {
1964 out.push(c);
1965 *i += 1;
1966 return;
1967 }
1968 }
1969 _ => {}
1970 }
1971 out.push(c);
1972 *i += 1;
1973 }
1974}
1975
1976/// The scanner buffers `extract_arithmetic` appends to, grouped so the
1977/// call carries one thing instead of three — `scan`'s own locals (`out`,
1978/// `arithmetics`, `replacements`) still own the data; this just borrows
1979/// all three for the one call.
1980struct ScanBuffers<'a> {
1981 out: &'a mut String,
1982 arithmetics: &'a mut Vec<(String, String, bool)>,
1983 replacements: &'a mut Vec<Replacement>,
1984}
1985
1986/// Extract `$((expr))` starting at `chars[*i]` (the `$`, or the first `(`
1987/// for the bare `((` condition form). Emits a unique marker into `out` and
1988/// records the replacement.
1989///
1990/// A `\` escape and a `$(…)` command substitution are read region-aware:
1991/// an escaped char is copied without touching `depth`, and a `$(…)` is
1992/// handed whole to `copy_substitution_verbatim`, so a quoted or escaped
1993/// paren inside either never counts as arithmetic grouping. Everything
1994/// else is a flat depth counter over literal `(`/`)`: single `)`
1995/// characters inside the expression are kept (only a `))` pair at depth
1996/// zero closes), matching the pre-#95 collector.
1997fn extract_arithmetic(
1998 chars: &[(usize, char)],
1999 i: &mut usize,
2000 start_pos: usize,
2001 total_len: usize,
2002 buffers: &mut ScanBuffers<'_>,
2003 marker_len: usize,
2004 is_condition: bool,
2005) -> Result<(), Spanned<LexerError>> {
2006 let n = chars.len();
2007 *i += marker_len; // consume `$((` (3) or bare `((` (2)
2008
2009 let mut expr = String::new();
2010 let mut depth = 0usize;
2011 let mut closed = false;
2012
2013 while *i < n {
2014 let c = chars[*i].1;
2015
2016 // An escape covers the next character; neither is paren structure,
2017 // matching every other scanner in this file (`scan`, `lex_string`,
2018 // `copy_substitution_verbatim`).
2019 if c == '\\' && *i + 1 < n {
2020 expr.push(c);
2021 expr.push(chars[*i + 1].1);
2022 *i += 2;
2023 continue;
2024 }
2025
2026 // A `$(…)` here is a command substitution, not arithmetic grouping.
2027 // Hand the whole run to the region-aware scanner `scan`'s
2028 // double-quoted-string arm already uses, so a quoted or escaped
2029 // paren in the substitution's body never reaches `depth` — that
2030 // was the bug: a flat counter read a quoted `(` inside `$(…)` as
2031 // arithmetic grouping and consumed the real `))` as its close.
2032 if c == '$' && *i + 1 < n && chars[*i + 1].1 == '(' {
2033 copy_substitution_verbatim(chars, i, &mut expr);
2034 continue;
2035 }
2036
2037 match c {
2038 '(' => {
2039 depth += 1;
2040 if depth > MAX_PAREN_DEPTH {
2041 return Err(Spanned::new(
2042 LexerError::NestingTooDeep,
2043 start_pos..chars[*i].0,
2044 ));
2045 }
2046 expr.push('(');
2047 *i += 1;
2048 }
2049 ')' => {
2050 if depth > 0 {
2051 depth -= 1;
2052 expr.push(')');
2053 *i += 1;
2054 } else if *i + 1 < n && chars[*i + 1].1 == ')' {
2055 *i += 2;
2056 closed = true;
2057 break;
2058 } else if *i + 1 == n {
2059 // Lone `)` at EOF can never be followed by its pair.
2060 break;
2061 } else {
2062 expr.push(')');
2063 *i += 1;
2064 }
2065 }
2066 _ => {
2067 expr.push(c);
2068 *i += 1;
2069 }
2070 }
2071 }
2072
2073 if !closed {
2074 // Don't silently evaluate a partial expression (`$(( 1 + 2` must
2075 // not become `3`).
2076 return Err(Spanned::new(
2077 LexerError::UnterminatedArithmetic,
2078 start_pos..total_len,
2079 ));
2080 }
2081
2082 let end_pos = if *i < n { chars[*i].0 } else { total_len };
2083 let marker = format!("__KAISH_ARITH_{}__", unique_marker_id());
2084 buffers.replacements.push(Replacement {
2085 orig_start: start_pos,
2086 orig_len: end_pos - start_pos,
2087 new_start: buffers.out.len(),
2088 new_len: marker.len(),
2089 kind: ReplacementKind::Arith(buffers.arithmetics.len()),
2090 });
2091 buffers.arithmetics.push((marker.clone(), expr, is_condition));
2092 buffers.out.push_str(&marker);
2093 Ok(())
2094}
2095
2096/// Scan a heredoc introducer starting at `chars[*i]` (the first `<`).
2097/// Collects the delimiter word bash-style (whole word, quote removal,
2098/// `literal` if any part was quoted), emits `<<` plus a unique marker, and
2099/// queues the body for collection at the next unescaped newline. If no
2100/// delimiter word follows, `<<` is copied verbatim (logos will surface
2101/// the syntax error).
2102fn scan_heredoc_introducer(
2103 chars: &[(usize, char)],
2104 i: &mut usize,
2105 intro_start: usize,
2106 out: &mut String,
2107 pending: &mut Vec<PendingHeredoc>,
2108 replacements: &mut Vec<Replacement>,
2109 heredoc_index: usize,
2110) {
2111 let n = chars.len();
2112 *i += 2; // consume `<<`
2113
2114 let strip_tabs = *i < n && chars[*i].1 == '-';
2115 if strip_tabs {
2116 *i += 1;
2117 }
2118
2119 // Skip horizontal whitespace before the delimiter word.
2120 while *i < n && (chars[*i].1 == ' ' || chars[*i].1 == '\t') {
2121 *i += 1;
2122 }
2123
2124 // Collect the delimiter word with bash-style quote removal: the word
2125 // runs until unquoted whitespace; single/double quotes are stripped
2126 // and any quoting makes the heredoc literal (`<<'EOF'` and `<<EO"F"`
2127 // both suppress interpolation).
2128 let mut delimiter = String::new();
2129 let mut literal = false;
2130 while *i < n {
2131 let c = chars[*i].1;
2132 match c {
2133 '\'' | '"' => {
2134 literal = true;
2135 let quote = c;
2136 *i += 1;
2137 while *i < n && chars[*i].1 != quote {
2138 delimiter.push(chars[*i].1);
2139 *i += 1;
2140 }
2141 if *i < n {
2142 *i += 1; // closing quote
2143 }
2144 }
2145 c if c.is_whitespace() => break,
2146 c => {
2147 delimiter.push(c);
2148 *i += 1;
2149 }
2150 }
2151 }
2152 let word_end = if *i < n {
2153 chars[*i].0
2154 } else {
2155 chars
2156 .last()
2157 .map(|(pos, c)| pos + c.len_utf8())
2158 .unwrap_or(intro_start + 2)
2159 };
2160
2161 if delimiter.is_empty() {
2162 // Not a heredoc after all — emit what we consumed verbatim.
2163 out.push_str("<<");
2164 if strip_tabs {
2165 out.push('-');
2166 }
2167 return;
2168 }
2169
2170 let marker = format!("__KAISH_HEREDOC_{}__", unique_marker_id());
2171 out.push_str("<<");
2172 replacements.push(Replacement {
2173 // The replaced original region runs from just after `<<` (the
2174 // optional `-` and whitespace included) through the delimiter
2175 // word; the marker stands in for all of it.
2176 orig_start: intro_start + 2,
2177 orig_len: word_end - (intro_start + 2),
2178 new_start: out.len(),
2179 new_len: marker.len(),
2180 kind: ReplacementKind::HeredocIntro(heredoc_index),
2181 });
2182 out.push_str(&marker);
2183 pending.push(PendingHeredoc {
2184 delimiter,
2185 literal,
2186 strip_tabs,
2187 intro_span: intro_start..word_end,
2188 });
2189}
2190
2191/// Collect the bodies of all pending heredocs, in introducer order,
2192/// starting at `chars[*i]` (the character after the newline that ended
2193/// the introducer line). Bodies (and their terminating delimiter lines)
2194/// are elided from the rewritten buffer; each elision is recorded so
2195/// spans after the heredoc stay exact.
2196fn collect_heredoc_bodies(
2197 chars: &[(usize, char)],
2198 i: &mut usize,
2199 total_len: usize,
2200 out_len: usize,
2201 pending: &mut Vec<PendingHeredoc>,
2202 heredocs: &mut Vec<HeredocExtract>,
2203 replacements: &mut Vec<Replacement>,
2204) -> Result<(), Spanned<LexerError>> {
2205 let n = chars.len();
2206
2207 for p in pending.drain(..) {
2208 let body_start = if *i < n { chars[*i].0 } else { total_len };
2209 let mut body = String::new();
2210 let mut found = false;
2211
2212 while !found {
2213 if *i >= n {
2214 // EOF: a final unterminated line was already checked below;
2215 // reaching here means the delimiter never appeared.
2216 return Err(Spanned::new(
2217 LexerError::UnterminatedHeredoc {
2218 delimiter: p.delimiter.clone(),
2219 },
2220 p.intro_span.clone(),
2221 ));
2222 }
2223
2224 // Read one line and its terminator (`\n`, `\r\n`, bare `\r`,
2225 // or EOF). The terminator is preserved verbatim in the body;
2226 // delimiter comparison strips it.
2227 let mut line = String::new();
2228 let mut terminator = "";
2229 let mut at_eof = false;
2230 loop {
2231 if *i >= n {
2232 at_eof = true;
2233 break;
2234 }
2235 let c = chars[*i].1;
2236 if c == '\n' {
2237 *i += 1;
2238 terminator = "\n";
2239 break;
2240 }
2241 if c == '\r' {
2242 *i += 1;
2243 if *i < n && chars[*i].1 == '\n' {
2244 *i += 1;
2245 terminator = "\r\n";
2246 } else {
2247 terminator = "\r";
2248 }
2249 break;
2250 }
2251 line.push(c);
2252 *i += 1;
2253 }
2254
2255 let compare = if p.strip_tabs {
2256 line.trim_start_matches('\t')
2257 } else {
2258 line.as_str()
2259 };
2260 if compare == p.delimiter {
2261 found = true;
2262 } else if at_eof {
2263 // The source ended without the closing delimiter. Crash
2264 // rather than silently using what was collected — missing
2265 // data is exactly where a silent fallback masks the bug.
2266 return Err(Spanned::new(
2267 LexerError::UnterminatedHeredoc {
2268 delimiter: p.delimiter.clone(),
2269 },
2270 p.intro_span.clone(),
2271 ));
2272 } else {
2273 body.push_str(&line);
2274 body.push_str(terminator);
2275 }
2276 }
2277
2278 let elide_end = if *i < n { chars[*i].0 } else { total_len };
2279 replacements.push(Replacement {
2280 orig_start: body_start,
2281 orig_len: elide_end - body_start,
2282 new_start: out_len,
2283 new_len: 0,
2284 kind: ReplacementKind::Elision,
2285 });
2286
2287 // For interpolated (non-literal) bodies, rewrite arithmetic to the
2288 // `${__ARITH:expr__}` form the interpolation parser understands
2289 // (see `parse_interpolated_string`). Literal bodies stay verbatim
2290 // — a `$((` there is prose, never an expression (this is the
2291 // pre-#95 false-positive fix). Bash expands `$(( ))` in heredoc
2292 // bodies regardless of quotes within the body, so the body scan
2293 // is deliberately quote-blind; `\$((` escapes it.
2294 let content = if p.literal {
2295 body.clone()
2296 } else {
2297 rewrite_body_arithmetic(&body, &p)?
2298 };
2299
2300 // `source_body` is load-bearing for span arithmetic, not a
2301 // readability nicety: a plan publishes it alongside `body_offset`, and
2302 // consumers slice the body back out of the source with the pair. The
2303 // rewrite above makes `content` longer than what the author typed, so
2304 // publishing `content` instead would shift every offset after a
2305 // `$((…))` and silently misplace the span.
2306 heredocs.push(HeredocExtract {
2307 body: content,
2308 source_body: body,
2309 delimiter: p.delimiter.clone(),
2310 literal: p.literal,
2311 strip_tabs: p.strip_tabs,
2312 body_start_offset: body_start,
2313 });
2314 }
2315
2316 Ok(())
2317}
2318
2319/// Skip a `<<[-]delimiter` heredoc introducer and its body, starting at
2320/// `chars[*i]` (the first `<` of `<<`), through the line that terminates
2321/// it. Advances `*i` past the whole heredoc — introducer line, body, and
2322/// delimiter line — and returns; the body's bytes, including any `(` or
2323/// `)` in it, are never inspected as structure.
2324///
2325/// A thin wrapper around the same [`scan_heredoc_introducer`] +
2326/// [`collect_heredoc_bodies`] pair `scan`'s own dispatch drives, for a
2327/// caller that only needs the boundary — `arithmetic::Tokenizer::skip_group`,
2328/// scanning a `$(...)` command-substitution body nested inside `$(( ))` —
2329/// not a registered [`HeredocExtract`] or a rewritten buffer. Scratch
2330/// buffers stand in for `scan`'s bookkeeping and are discarded; this is
2331/// still the one heredoc-parsing implementation, not a second one.
2332///
2333/// Only the single heredoc at `chars[*i]` is handled: a second `<<` later
2334/// on the SAME introducer line (`cat <<A <<B`) is not queued the way
2335/// `scan`'s own loop queues it when it drives the whole dispatch. A
2336/// command substitution nested in `$(( ))` with a multi-heredoc-per-line
2337/// body is a known residual, not silently accepted as handled.
2338pub(crate) fn skip_heredoc_body(
2339 chars: &[(usize, char)],
2340 i: &mut usize,
2341 total_len: usize,
2342) -> Result<(), Spanned<LexerError>> {
2343 let intro_start = chars[*i].0;
2344 let mut out = String::new();
2345 let mut pending = Vec::new();
2346 let mut replacements = Vec::new();
2347 scan_heredoc_introducer(chars, i, intro_start, &mut out, &mut pending, &mut replacements, 0);
2348 if pending.is_empty() {
2349 // No delimiter word followed `<<` — not a real heredoc; `scan`
2350 // leaves it for logos to report, and so does this caller.
2351 return Ok(());
2352 }
2353 let n = chars.len();
2354 while *i < n && chars[*i].1 != '\n' && chars[*i].1 != '\r' {
2355 *i += 1;
2356 }
2357 if *i < n {
2358 let terminator = chars[*i].1;
2359 *i += 1;
2360 if terminator == '\r' && *i < n && chars[*i].1 == '\n' {
2361 *i += 1;
2362 }
2363 }
2364 let mut heredocs = Vec::new();
2365 collect_heredoc_bodies(chars, i, total_len, out.len(), &mut pending, &mut heredocs, &mut replacements)
2366}
2367
2368/// Rewrite `$((expr))` inside an interpolated heredoc body to
2369/// `${__ARITH:expr__}`. `\$((` stays literal (minus nothing — the
2370/// backslash is preserved for the interpolation parser). An unterminated
2371/// `$((` in the body is a loud error, matching bash (which would fail the
2372/// expansion) and the shell's crash-over-corrupt stance.
2373fn rewrite_body_arithmetic(
2374 body: &str,
2375 p: &PendingHeredoc,
2376) -> Result<String, Spanned<LexerError>> {
2377 if !body.contains("$((") {
2378 return Ok(body.to_string());
2379 }
2380 let chars: Vec<char> = body.chars().collect();
2381 let n = chars.len();
2382 let mut out = String::with_capacity(body.len());
2383 let mut i = 0;
2384 while i < n {
2385 if chars[i] == '\\' && i + 1 < n {
2386 out.push(chars[i]);
2387 out.push(chars[i + 1]);
2388 i += 2;
2389 continue;
2390 }
2391 if chars[i] == '$' && i + 2 < n && chars[i + 1] == '(' && chars[i + 2] == '(' {
2392 i += 3;
2393 let mut expr = String::new();
2394 let mut depth = 0usize;
2395 let mut closed = false;
2396 while i < n {
2397 let c = chars[i];
2398 match c {
2399 '(' => {
2400 depth += 1;
2401 expr.push('(');
2402 i += 1;
2403 }
2404 ')' => {
2405 if depth > 0 {
2406 depth -= 1;
2407 expr.push(')');
2408 i += 1;
2409 } else if i + 1 < n && chars[i + 1] == ')' {
2410 i += 2;
2411 closed = true;
2412 break;
2413 } else {
2414 expr.push(')');
2415 i += 1;
2416 }
2417 }
2418 _ => {
2419 expr.push(c);
2420 i += 1;
2421 }
2422 }
2423 }
2424 if !closed {
2425 return Err(Spanned::new(
2426 LexerError::UnterminatedArithmetic,
2427 p.intro_span.clone(),
2428 ));
2429 }
2430 out.push_str(&format!("${{__ARITH:{}__}}", expr));
2431 continue;
2432 }
2433 out.push(chars[i]);
2434 i += 1;
2435 }
2436 Ok(out)
2437}
2438
2439// ═══════════════════════════════════════════════════════════════════
2440// Marker resolution (positional)
2441// ═══════════════════════════════════════════════════════════════════
2442
2443/// Resolve scanner markers back into real tokens, keyed by POSITION in the
2444/// replacement table (never by matching identifier text):
2445///
2446/// - an `Ident` exactly covering an arithmetic marker becomes `Arithmetic`;
2447/// - a `String` containing marker text (arithmetic inside a double-quoted
2448/// string) gets the `${__ARITH:expr__}` content swap the interpolation
2449/// parser understands;
2450/// - a word token GLUED onto a marker (`$((1+2))abc`) is SPLIT into the
2451/// `Arithmetic` plus re-lexed word fragments — span-adjacent, so the
2452/// parser's no-token-pasting guard rejects it loudly with a quoting hint
2453/// (the pre-#95 pipeline leaked raw marker text here);
2454/// - the `Ident` after a `HereDocStart` covering a heredoc marker becomes
2455/// the `HereDoc` token.
2456///
2457/// Tokens carry rewritten-buffer spans on entry and exit; the caller maps
2458/// them to original coordinates afterwards.
2459fn resolve_markers(
2460 tokens: Vec<Spanned<Token>>,
2461 scan: &ScanOutput,
2462) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
2463 let markers: Vec<&Replacement> = scan
2464 .replacements
2465 .iter()
2466 .filter(|r| !matches!(r.kind, ReplacementKind::Elision))
2467 .collect();
2468
2469 let mut result = Vec::with_capacity(tokens.len());
2470 let mut mi = 0usize;
2471
2472 for spanned in tokens {
2473 let span = spanned.span.clone();
2474 while mi < markers.len() && markers[mi].new_start + markers[mi].new_len <= span.start {
2475 mi += 1;
2476 }
2477
2478 // Collect the markers contained in this token's span.
2479 let mut contained = Vec::new();
2480 let mut mj = mi;
2481 while mj < markers.len() {
2482 let m = markers[mj];
2483 if m.new_start >= span.end {
2484 break;
2485 }
2486 if m.new_start >= span.start && m.new_start + m.new_len <= span.end {
2487 contained.push(m);
2488 }
2489 mj += 1;
2490 }
2491
2492 if contained.is_empty() {
2493 result.push(spanned);
2494 continue;
2495 }
2496
2497 match (&spanned.token, contained.as_slice()) {
2498 // Exact cover by a single arithmetic marker → Arithmetic token,
2499 // or ArithCond for a bare `((expr))` condition.
2500 (Token::Ident(_), [m])
2501 if matches!(m.kind, ReplacementKind::Arith(_))
2502 && m.new_start == span.start
2503 && m.new_start + m.new_len == span.end =>
2504 {
2505 let ReplacementKind::Arith(idx) = m.kind else {
2506 unreachable!("guarded by matches! above")
2507 };
2508 let (_, expr, is_condition) = &scan.arithmetics[idx];
2509 let token = if *is_condition {
2510 Token::ArithCond(expr.clone())
2511 } else {
2512 Token::Arithmetic(expr.clone())
2513 };
2514 result.push(Spanned::new(token, span));
2515 }
2516
2517 // Exact cover by a heredoc marker → HereDoc token (the parser
2518 // pairs it with the preceding HereDocStart).
2519 (Token::Ident(_), [m])
2520 if matches!(m.kind, ReplacementKind::HeredocIntro(_))
2521 && m.new_start == span.start
2522 && m.new_start + m.new_len == span.end =>
2523 {
2524 let ReplacementKind::HeredocIntro(idx) = m.kind else {
2525 unreachable!("guarded by matches! above")
2526 };
2527 let hd = &scan.heredocs[idx];
2528 result.push(Spanned::new(
2529 Token::HereDoc(HereDocData {
2530 content: hd.body.clone(),
2531 source_body: hd.source_body.clone(),
2532 delimiter: hd.delimiter.clone(),
2533 literal: hd.literal,
2534 strip_tabs: hd.strip_tabs,
2535 body_start_offset: hd.body_start_offset,
2536 }),
2537 span,
2538 ));
2539 }
2540
2541 // Arithmetic inside a double-quoted string: swap each marker's
2542 // text in the CONTENT for the interpolation form. Escape
2543 // processing never alters marker text (alphanumerics and
2544 // underscores), so a plain replace is exact.
2545 (Token::String(s), ms) => {
2546 let mut content = s.clone();
2547 for m in ms {
2548 let ReplacementKind::Arith(idx) = m.kind else {
2549 // A heredoc marker inside a string would mean the
2550 // scanner rewrote inside a quoted region — it
2551 // never does.
2552 unreachable!("heredoc marker inside string content")
2553 };
2554 let (marker, expr, is_condition) = &scan.arithmetics[idx];
2555 debug_assert!(!is_condition, "a bare `((` condition never scans inside a string");
2556 content =
2557 content.replacen(marker, &format!("${{__ARITH:{}__}}", expr), 1);
2558 }
2559 result.push(Spanned::new(Token::String(content), span));
2560 }
2561
2562 // A word token glued onto marker(s): split into fragments and
2563 // Arithmetic tokens. The fragments are re-lexed so `007` or
2564 // `-3` keep their real token identities; span adjacency then
2565 // triggers the parser's no-pasting guard — a loud error where
2566 // the old pipeline leaked marker text.
2567 _ => {
2568 let mut cursor = span.start;
2569 for m in &contained {
2570 if m.new_start > cursor {
2571 relex_fragment(
2572 &scan.text[cursor..m.new_start],
2573 cursor,
2574 &mut result,
2575 )?;
2576 }
2577 match m.kind {
2578 ReplacementKind::Arith(idx) => {
2579 let (_, expr, is_condition) = &scan.arithmetics[idx];
2580 let token = if *is_condition {
2581 Token::ArithCond(expr.clone())
2582 } else {
2583 Token::Arithmetic(expr.clone())
2584 };
2585 result.push(Spanned::new(token, m.new_start..m.new_start + m.new_len));
2586 }
2587 ReplacementKind::HeredocIntro(_) | ReplacementKind::Elision => {
2588 // Heredoc markers are always delimited by the
2589 // `<<` before them and line layout after; they
2590 // can't glue into a larger word.
2591 unreachable!("heredoc marker glued into word token")
2592 }
2593 }
2594 cursor = m.new_start + m.new_len;
2595 }
2596 if cursor < span.end {
2597 relex_fragment(&scan.text[cursor..span.end], cursor, &mut result)?;
2598 }
2599 }
2600 }
2601
2602 mi = mj;
2603 }
2604
2605 Ok(result)
2606}
2607
2608/// Re-lex a fragment of a split word token, offsetting spans by `base`.
2609fn relex_fragment(
2610 fragment: &str,
2611 base: usize,
2612 result: &mut Vec<Spanned<Token>>,
2613) -> Result<(), Vec<Spanned<LexerError>>> {
2614 // A fragment of a split word is mid-word by construction: the word token
2615 // it came from cannot begin with `#`, so a fragment that does is always
2616 // preceded by a marker or by the word's earlier part. Re-lexing starts at
2617 // byte 0 of the fragment, where `lex_comment` would see start-of-input and
2618 // mint a comment that swallows the rest of the line — the defect this
2619 // whole rule exists to close. Reject it here instead, with the same error
2620 // the direct path gives `$(f)#3`.
2621 if fragment.starts_with('#') {
2622 return Err(vec![Spanned::new(
2623 LexerError::HashInsideWord,
2624 base..base + fragment.len(),
2625 )]);
2626 }
2627
2628 let mut errors = Vec::new();
2629 for (tok, span) in Token::lexer(fragment).spanned() {
2630 let span = base + span.start..base + span.end;
2631 match tok {
2632 Ok(t) => result.push(Spanned::new(t, span)),
2633 Err(e) => errors.push(Spanned::new(e, span)),
2634 }
2635 }
2636 if errors.is_empty() { Ok(()) } else { Err(errors) }
2637}
2638
2639// ═══════════════════════════════════════════════════════════════════
2640// Value-context analysis (one pass, explicit stack)
2641// ═══════════════════════════════════════════════════════════════════
2642
2643/// Per-token context for the fusion passes: is this token part of a
2644/// value-position collection literal? Both merge passes suppress fusion
2645/// there so `x=[dog]` / `{port:8080}` reach the parser as primitive
2646/// tokens instead of a fused `GlobWord`/`Ident`. A fused token would reach
2647/// the parser as one word, and the literal's structure would be gone.
2648#[derive(Clone, Copy, Default)]
2649struct ValueContext {
2650 /// Inside (or opening) a value-position `[`/`{` literal — suppresses
2651 /// glob-merge bracket-pair fusion.
2652 in_literal: bool,
2653 /// Inside a value-position `{` record literal specifically —
2654 /// suppresses colon-merge fusion. Narrower than `in_literal` on
2655 /// purpose: a plain scalar assignment `x=foo:bar` must keep fusing.
2656 in_brace: bool,
2657 /// This token is (part of) `push`'s bracket-path TARGET — see
2658 /// [`PushTarget`]. Lets `flush_glob_run` fuse `services[web][tags]`
2659 /// verbatim into a single `Ident` (a path to walk) instead of a
2660 /// `GlobWord` (glob-expanded against the filesystem, GH #183).
2661 push_target: bool,
2662}
2663
2664/// Independent tiny tracker (parallel to, but NOT integrated into,
2665/// `StmtHead` below) for the one thing `push`'s bracket-path target needs:
2666/// recognizing `push`'s own target run so `flush_glob_run` can fuse it
2667/// verbatim, the same way an assignment's `=`-followed lvalue is
2668/// recognized. Kept separate from `StmtHead` on purpose — folding this into
2669/// the Lvalue-root slot would steal it from `push`'s actual target
2670/// identifier (see the GH #183 investigation) and threading a text match on
2671/// `StmtHead::Start` risks regressing a variable literally named `push`
2672/// (`push=5`, `push[0]=x`, both still routed entirely through the
2673/// untouched `StmtHead` machinery).
2674#[derive(Debug, Clone, Copy, PartialEq)]
2675enum PushTarget {
2676 /// Not tracking a `push` target right now.
2677 None,
2678 /// Just saw a bareword `push` at statement-head; the very next token is
2679 /// the bracket-path root, if it's an `Ident`.
2680 AwaitingRoot,
2681 /// Consumed the root identifier (and any glued `[...]` groups so far);
2682 /// `usize` is the byte offset just past the last consumed token — used
2683 /// to require the next `[` be glued (no whitespace) to extend the path.
2684 Root(usize),
2685 /// Inside a glued `[...]` group on the target; depth tracks nesting.
2686 RootSubscript(usize),
2687}
2688
2689/// Structural frames tracked by the context walker. The stack replaces the
2690/// pre-#95 bare counters: mismatched closers become detectable, `$( )`
2691/// bodies get fresh statement context (no more `[[ -n $(x=[a]) ]]`
2692/// test-depth leaks), and dangling literal state can be dropped at
2693/// statement boundaries instead of poisoning the rest of the buffer.
2694#[derive(Debug, Clone, Copy, PartialEq)]
2695enum Frame {
2696 /// `[[ ... ]]` test expression: `=` is comparison, `in` is membership.
2697 Test,
2698 /// Value-position `[ ... ]` list literal: bracket fusion suppressed.
2699 List,
2700 /// Value-position `{ ... }` record literal: colon fusion suppressed.
2701 Record,
2702 /// `$( ... )` command substitution: a fresh statement scope.
2703 Subst,
2704 /// Plain `( ... )` grouping (function parameter lists): inert, tracked
2705 /// only so `)` pops the right frame.
2706 Paren,
2707 /// `case ... in ... esac`. A `)` seen here is a branch pattern
2708 /// terminator (`case $x in a) …`, no matching `(`), not a close of some
2709 /// enclosing `Subst`/`Paren` — see the `RParen` arm below.
2710 /// `awaiting_pattern` is `true` right after `case … in` and right
2711 /// after a `;;` (a pattern or `esac` is expected next) and `false`
2712 /// once a pattern's `)` is consumed, for the rest of that branch's
2713 /// body — `Esac` closes this frame only while `awaiting_pattern` is
2714 /// `true`, so `esac` used as an ordinary word inside a still-open
2715 /// branch's own body doesn't get read as its closer. See the
2716 /// `Esac`/`DoubleSemi`/`RParen` arms below.
2717 Case { awaiting_pattern: bool },
2718}
2719
2720/// Statement-head DFA: decides whether an `=` is an ASSIGNMENT (which puts
2721/// the following tokens at value position — `x = [a b]` is a legal spaced
2722/// assignment with a list-literal RHS) or an argv-position literal
2723/// (`grep -E = [a-z]*` must keep glob-fusing). The pre-#95 pipeline
2724/// treated EVERY `=` outside `[[ ]]` as value-opening; the DFA follows the
2725/// grammar instead: an assignment's LHS is the first word of a statement
2726/// (after optional `local`), an identifier root plus optionally glued
2727/// `[subscript]` groups. After an assignment's value completes the DFA
2728/// returns to statement-head state, covering env-prefix chains
2729/// (`A=1 B=2 cmd`).
2730#[derive(Debug, Clone, Copy, PartialEq)]
2731enum StmtHead {
2732 /// At a statement start: the next identifier could be an lvalue root.
2733 Start,
2734 /// Consumed `local`; still expecting the lvalue root.
2735 AfterLocal,
2736 /// Consumed an identifier root (and any glued subscript groups);
2737 /// `usize` is the byte offset just past the last consumed token, for
2738 /// subscript-gluing checks.
2739 Lvalue(usize),
2740 /// Inside a glued `[subscript]` group on the LHS; `usize` is bracket
2741 /// depth within the group.
2742 LvalueSubscript(usize),
2743 /// The `=` fired; consuming the RHS value (frames may open and close
2744 /// during it). Returns to `Start` when the value completes.
2745 Value,
2746 /// Past the command word: `=` here is argv text, never an assignment.
2747 Argv,
2748}
2749
2750/// Tokens that terminate a statement (or arm/branch) and reset the
2751/// statement-head DFA. `Newline` is deliberately absent from the FRAME
2752/// reset set (multiline list/record literals are legal — the parser
2753/// consumes interior newlines) but does reset the DFA when no literal is
2754/// open, and pops dangling `Test` frames (kaish's `[[ ]]` grammar is
2755/// single-line).
2756pub(crate) fn is_statement_boundary(token: &Token) -> bool {
2757 // `LBrace`/`RBrace` also reset the DFA, but they have dedicated match
2758 // arms (record-literal vs block-brace discrimination) that run before
2759 // the boundary wildcard, so they are deliberately absent here.
2760 matches!(
2761 token,
2762 Token::Newline
2763 | Token::Semi
2764 | Token::DoubleSemi
2765 | Token::And
2766 | Token::Or
2767 | Token::Pipe
2768 | Token::Amp
2769 | Token::If
2770 | Token::Then
2771 | Token::Elif
2772 | Token::Else
2773 | Token::Fi
2774 | Token::While
2775 | Token::Do
2776 | Token::Done
2777 | Token::For
2778 | Token::Case
2779 | Token::Esac
2780 | Token::In
2781 )
2782}
2783
2784fn compute_value_context(tokens: &[Spanned<Token>]) -> Vec<ValueContext> {
2785 let mut ctx = vec![ValueContext::default(); tokens.len()];
2786
2787 // Frame stack plus per-scope statement DFA. `scopes` parallels the
2788 // Subst frames: scopes[0] is the top-level statement scope; pushing a
2789 // Subst pushes a fresh scope.
2790 let mut frames: Vec<Frame> = Vec::new();
2791 let mut scopes: Vec<StmtHead> = vec![StmtHead::Start];
2792 let mut expect_value = false;
2793 // Set after consuming the first token of a `[[`/`]]` pair so the
2794 // partner bracket has no structural effect.
2795 let mut skip_paired_bracket = false;
2796
2797 // Number of frames below the current scope's floor (frames belonging
2798 // to enclosing scopes, frozen while this scope is active).
2799 let mut scope_floors: Vec<usize> = vec![0];
2800
2801 // Independent `push`-target tracker — see `PushTarget`. Flat (not
2802 // scope-stacked like `scopes`/`frames`): a `push` inside `$( )` still
2803 // gets detected via the `StmtHead::Start` check below (scoped
2804 // correctly), and the tracker naturally resets once the target's
2805 // glued run ends, so it never leaks past one `push` invocation.
2806 let mut push_target = PushTarget::None;
2807
2808 for i in 0..tokens.len() {
2809 let tok = &tokens[i].token;
2810 let span = &tokens[i].span;
2811
2812 let floor = *scope_floors.last().unwrap_or(&0);
2813 let top = frames.last().copied();
2814 let in_open_literal = frames.len() > floor
2815 && matches!(top, Some(Frame::List) | Some(Frame::Record));
2816
2817 ctx[i] = ValueContext {
2818 in_literal: expect_value || in_open_literal,
2819 in_brace: matches!(top, Some(Frame::Record)),
2820 push_target: false, // set below once this token's transition is known
2821 };
2822
2823 // `in` membership: value position only inside a `[[ ]]` test in
2824 // the current scope (a `for`/`case` head `in` sits outside any
2825 // Test frame and opens nothing).
2826 let in_test = frames[floor..].contains(&Frame::Test);
2827
2828 let opens_value = expect_value;
2829 expect_value = false;
2830
2831 if skip_paired_bracket {
2832 skip_paired_bracket = false;
2833 continue;
2834 }
2835
2836 // Independent `push`-target tracker (see `PushTarget`) — entirely
2837 // separate from the `StmtHead` DFA below so a variable literally
2838 // named `push` (`push=5`, `push[0]=x`) keeps going through the
2839 // ordinary assignment path untouched. Computed from POST-transition
2840 // state: `ctx[i].push_target` should be true only for tokens
2841 // actually CONSUMED into the target path, not the token that ends
2842 // it (`push xs c` — "c" must not inherit the target's context).
2843 let stmt_head_is_start = matches!(scopes.last(), Some(StmtHead::Start));
2844 push_target = if is_statement_boundary(tok) {
2845 PushTarget::None
2846 } else {
2847 match (push_target, tok) {
2848 (PushTarget::None, Token::Ident(s)) if stmt_head_is_start && s == "push" => {
2849 PushTarget::AwaitingRoot
2850 }
2851 (PushTarget::AwaitingRoot, Token::Ident(_)) => PushTarget::Root(span.end),
2852 (PushTarget::Root(end), Token::LBracket) if span.start == end => {
2853 PushTarget::RootSubscript(1)
2854 }
2855 (PushTarget::RootSubscript(d), Token::LBracket) => {
2856 PushTarget::RootSubscript(d + 1)
2857 }
2858 (PushTarget::RootSubscript(d), Token::RBracket) => {
2859 if d == 1 {
2860 PushTarget::Root(span.end)
2861 } else {
2862 PushTarget::RootSubscript(d - 1)
2863 }
2864 }
2865 // Subscript interior (`Ident`/`Int`/`String`/`SimpleVarRef` —
2866 // whatever `lvalue_subscript_parser` accepts): hold depth.
2867 (PushTarget::RootSubscript(d), _) => PushTarget::RootSubscript(d),
2868 _ => PushTarget::None,
2869 }
2870 };
2871 ctx[i].push_target =
2872 matches!(push_target, PushTarget::Root(_) | PushTarget::RootSubscript(_));
2873
2874 match tok {
2875 Token::LBracket => {
2876 let next_adjacent_lbracket = tokens.get(i + 1).is_some_and(|t| {
2877 matches!(t.token, Token::LBracket) && t.span.start == span.end
2878 });
2879 let dfa = scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
2880 if let StmtHead::Lvalue(end) = *dfa {
2881 // A glued `[` after an lvalue root starts a subscript
2882 // group, not a literal or a test.
2883 if span.start == end {
2884 *dfa = StmtHead::LvalueSubscript(1);
2885 continue;
2886 }
2887 }
2888 if let StmtHead::LvalueSubscript(depth) = *dfa {
2889 *dfa = StmtHead::LvalueSubscript(depth + 1);
2890 continue;
2891 }
2892 if opens_value || in_open_literal {
2893 frames.push(Frame::List);
2894 } else if next_adjacent_lbracket {
2895 // `[[` opens a test. The value/literal guards above
2896 // keep a glued nested list (`x=[[a] [b]]`) as literal
2897 // brackets rather than a bogus test.
2898 frames.push(Frame::Test);
2899 skip_paired_bracket = true;
2900 }
2901 // A lone `[` in argv position (`ls [dog]`, a `[0-9]`
2902 // char-class) has no structural effect.
2903 }
2904 Token::RBracket => {
2905 let dfa = scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
2906 if let StmtHead::LvalueSubscript(depth) = *dfa {
2907 *dfa = if depth == 1 {
2908 StmtHead::Lvalue(span.end)
2909 } else {
2910 StmtHead::LvalueSubscript(depth - 1)
2911 };
2912 continue;
2913 }
2914 let next_adjacent_rbracket = tokens.get(i + 1).is_some_and(|t| {
2915 matches!(t.token, Token::RBracket) && t.span.start == span.end
2916 });
2917 if frames.len() > floor && top == Some(Frame::List) {
2918 frames.pop();
2919 if frames.len() == floor {
2920 // The literal was an assignment's RHS: the value
2921 // is complete, back to statement-head state
2922 // (`x=[a] y=[b]` chains).
2923 let dfa = scopes
2924 .last_mut()
2925 .unwrap_or_else(|| unreachable!("scopes never empty"));
2926 if *dfa == StmtHead::Value {
2927 *dfa = StmtHead::Start;
2928 }
2929 }
2930 } else if frames.len() > floor
2931 && top == Some(Frame::Test)
2932 && next_adjacent_rbracket
2933 {
2934 frames.pop();
2935 skip_paired_bracket = true;
2936 }
2937 }
2938 Token::LBrace => {
2939 if opens_value || in_open_literal {
2940 frames.push(Frame::Record);
2941 } else {
2942 // Block-open `{` (function bodies): new statement.
2943 *scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
2944 StmtHead::Start;
2945 }
2946 }
2947 Token::RBrace => {
2948 if frames.len() > floor && top == Some(Frame::Record) {
2949 frames.pop();
2950 if frames.len() == floor {
2951 let dfa = scopes
2952 .last_mut()
2953 .unwrap_or_else(|| unreachable!("scopes never empty"));
2954 if *dfa == StmtHead::Value {
2955 *dfa = StmtHead::Start;
2956 }
2957 }
2958 } else {
2959 *scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
2960 StmtHead::Start;
2961 }
2962 }
2963 Token::CmdSubstStart => {
2964 frames.push(Frame::Subst);
2965 scope_floors.push(frames.len());
2966 scopes.push(StmtHead::Start);
2967 }
2968 Token::LParen => {
2969 frames.push(Frame::Paren);
2970 }
2971 Token::Case => {
2972 // `case` opens a case-statement frame UNLESS it's
2973 // immediately followed by `=` — kaish permits shell
2974 // keywords as `key=value` argv keys (`in=a`, `do=b`; see
2975 // `keyword_word` in parser.rs), and `case` is no exception.
2976 // Pushing a frame for `case=x` would leave it stuck open
2977 // (nothing but a bareword `esac` or a stray `)` would ever
2978 // touch it again), corrupting fusion decisions for the
2979 // rest of the scan — see `CmdSubstFrames::step` in
2980 // parser.rs, which shares this exact rule.
2981 let is_argv_key =
2982 matches!(tokens.get(i + 1).map(|t| &t.token), Some(Token::Eq));
2983 if !is_argv_key {
2984 frames.push(Frame::Case { awaiting_pattern: true });
2985 }
2986 // `Case` is also a statement boundary (see
2987 // `is_statement_boundary`), but pushing a frame needs its
2988 // own arm, so replicate that arm's DFA reset here — this
2989 // reset applies either way, matching how `do=b`/`if=x`/
2990 // `for=c` already reset the DFA through the boundary
2991 // catch-all even though those keywords never push a frame.
2992 *scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
2993 StmtHead::Start;
2994 }
2995 Token::DoubleSemi => {
2996 // A branch's own terminator: the innermost `Case` frame (if
2997 // any) is now awaiting the next pattern, or `esac` — see the
2998 // `Esac` arm below. Otherwise behaves exactly like the
2999 // `is_statement_boundary` catch-all does for `;;` (DFA
3000 // reset, pop dangling Test/List/Record frames): `;;` needs
3001 // its own arm only because it ALSO carries the
3002 // `awaiting_pattern` update.
3003 if let Some(Frame::Case { awaiting_pattern }) = frames.last_mut() {
3004 *awaiting_pattern = true;
3005 }
3006 *scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
3007 StmtHead::Start;
3008 while frames.len() > floor
3009 && matches!(frames.last(), Some(Frame::Test) | Some(Frame::List) | Some(Frame::Record))
3010 {
3011 frames.pop();
3012 }
3013 }
3014 Token::Esac => {
3015 // Pop the `Case` frame only while it is innermost AND
3016 // awaiting a pattern (right after `case … in` or a `;;`) —
3017 // an `esac` used as an ordinary bareword INSIDE a branch's
3018 // own body (`case a in a) y=esac;; b) …`, still-open case)
3019 // is not a closer just because it spells the same word, and
3020 // popping there would corrupt the scan for the branch's
3021 // real `;;`/pattern/`esac` tokens the same way a flat
3022 // counter did. See `CmdSubstFrames` in parser.rs, which
3023 // shares this exact rule for the unquoted `$(...)` form.
3024 if matches!(frames.last(), Some(Frame::Case { awaiting_pattern: true })) {
3025 frames.pop();
3026 }
3027 *scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
3028 StmtHead::Start;
3029 }
3030 Token::RParen => {
3031 // Pop through any dangling literal/test frames to the
3032 // nearest Subst/Paren/Case — an unterminated literal inside
3033 // `$( )` must not leak into the enclosing scope.
3034 while let Some(f) = frames.last().copied() {
3035 match f {
3036 Frame::Subst => {
3037 frames.pop();
3038 scope_floors.pop();
3039 scopes.pop();
3040 if scopes.is_empty() {
3041 scopes.push(StmtHead::Start);
3042 }
3043 if scope_floors.is_empty() {
3044 scope_floors.push(0);
3045 }
3046 // The substitution may have been an
3047 // assignment's RHS in the enclosing scope
3048 // (`x=$(cmd) y=2`): its value is complete.
3049 let enclosing_floor = *scope_floors.last().unwrap_or(&0);
3050 if frames.len() == enclosing_floor {
3051 let dfa = scopes
3052 .last_mut()
3053 .unwrap_or_else(|| unreachable!("scopes never empty"));
3054 if *dfa == StmtHead::Value {
3055 *dfa = StmtHead::Start;
3056 }
3057 }
3058 break;
3059 }
3060 Frame::Paren => {
3061 frames.pop();
3062 // The paren just closed may have been a
3063 // case-branch pattern's optional leading `(`
3064 // (`(a)` is `a)` with an inert wrapper — the
3065 // same word either way), so this `)` also
3066 // consumed the pattern if a `Case` frame
3067 // awaiting one sits directly beneath. A POSIX
3068 // function's empty `()`, or a case-branch
3069 // body's own paren once `awaiting_pattern` is
3070 // already `false`, leave it untouched either
3071 // way.
3072 if let Some(Frame::Case { awaiting_pattern }) = frames.last_mut() {
3073 *awaiting_pattern = false;
3074 }
3075 break;
3076 }
3077 Frame::Case { .. } => {
3078 // Branch pattern terminator (`case $x in a) …`)
3079 // — no matching open on this stack. Leave the
3080 // frame alone (but it's no longer awaiting a
3081 // pattern — see the `Esac` arm above); the `)`
3082 // is ordinary body text.
3083 if let Some(Frame::Case { awaiting_pattern }) = frames.last_mut() {
3084 *awaiting_pattern = false;
3085 }
3086 break;
3087 }
3088 _ => {
3089 frames.pop();
3090 }
3091 }
3092 }
3093 }
3094 Token::Eq => {
3095 let dfa = scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
3096 if matches!(*dfa, StmtHead::Lvalue(_)) && !in_test {
3097 // Assignment `=`: the RHS is at value position.
3098 expect_value = true;
3099 *dfa = StmtHead::Value;
3100 } else if matches!(*dfa, StmtHead::Value) {
3101 // `=` while consuming a value (e.g. `x = a=b`): argv
3102 // text from here on.
3103 *dfa = StmtHead::Argv;
3104 }
3105 // Comparison `=` inside `[[ ]]` and argv `=` open nothing.
3106 }
3107 Token::In if in_test => {
3108 expect_value = true;
3109 }
3110 t if is_statement_boundary(t) => {
3111 // Reset the statement DFA; pop dangling literal frames at
3112 // hard separators. Newline keeps List/Record open
3113 // (multiline literals are legal) but closes Test (the
3114 // `[[ ]]` grammar is single-line).
3115 *scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
3116 StmtHead::Start;
3117 match t {
3118 Token::Newline => {
3119 while frames.len() > floor && frames.last() == Some(&Frame::Test) {
3120 frames.pop();
3121 }
3122 }
3123 Token::Semi
3124 | Token::DoubleSemi
3125 | Token::Pipe
3126 | Token::Amp
3127 | Token::And
3128 | Token::Or => {
3129 while frames.len() > floor
3130 && matches!(
3131 frames.last(),
3132 Some(Frame::Test) | Some(Frame::List) | Some(Frame::Record)
3133 )
3134 {
3135 frames.pop();
3136 }
3137 }
3138 _ => {}
3139 }
3140 }
3141 _ => {
3142 // Ordinary token: advance the statement DFA when no
3143 // literal frame is open in this scope.
3144 if !in_open_literal {
3145 let dfa =
3146 scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
3147 *dfa = match (*dfa, tok) {
3148 (StmtHead::Start, Token::Local) => StmtHead::AfterLocal,
3149 (StmtHead::Start, Token::Ident(_)) => StmtHead::Lvalue(span.end),
3150 (StmtHead::AfterLocal, Token::Ident(_)) => StmtHead::Lvalue(span.end),
3151 (StmtHead::LvalueSubscript(d), _) => StmtHead::LvalueSubscript(d),
3152 (StmtHead::Value, _) => StmtHead::Start,
3153 _ => StmtHead::Argv,
3154 };
3155 }
3156 }
3157 }
3158 }
3159
3160 ctx
3161}
3162
3163// ═══════════════════════════════════════════════════════════════════
3164// Fusion passes
3165//
3166// All fused text is a VERBATIM slice of the original source — never
3167// rebuilt from token values — so `a:007` stays `a:007` and `007*` globs
3168// as `007*` (the pre-#95 passes rebuilt through `Int::to_string()` and
3169// dropped leading zeros). Runs are span-adjacent by construction, so the
3170// slice is exact; a replacement boundary can never sit inside a run
3171// because marker-derived tokens (`Arithmetic`, `HereDoc`) are not
3172// mergeable.
3173// ═══════════════════════════════════════════════════════════════════
3174
3175/// True for token types that can participate in colon-adjacent merging.
3176fn is_colon_mergeable(token: &Token) -> bool {
3177 matches!(
3178 token,
3179 Token::Ident(_)
3180 | Token::NumberIdent(_)
3181 | Token::DashNumWord(_)
3182 | Token::AtWord(_)
3183 | Token::DottedIdent(_)
3184 | Token::Colon
3185 | Token::Int(_)
3186 | Token::Path(_)
3187 | Token::Float(_)
3188 )
3189}
3190
3191/// Merge span-adjacent token runs containing `Token::Colon` into single
3192/// `Ident` tokens.
3193///
3194/// In bash, `:` is a regular character in unquoted words. kaish tokenizes
3195/// it separately, which breaks Rust paths (`foo::bar`), URLs
3196/// (`host:8080`), etc. This pass fuses span-adjacent mergeable tokens
3197/// into a single `Ident` when the run contains at least one `Colon`.
3198/// A run that opens inside a value-position record literal
3199/// (`{port:8080}`) is exempted — see `compute_value_context` — so the
3200/// record parser sees the `Colon` as its own token.
3201fn merge_colon_adjacent(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spanned<Token>> {
3202 if tokens.is_empty() {
3203 return tokens;
3204 }
3205
3206 let value_ctx = compute_value_context(&tokens);
3207 let mut result = Vec::with_capacity(tokens.len());
3208 let mut run: Vec<&Spanned<Token>> = Vec::new();
3209 let mut run_start = 0usize;
3210
3211 for (idx, token) in tokens.iter().enumerate() {
3212 if run.is_empty() {
3213 if is_colon_mergeable(&token.token) {
3214 run.push(token);
3215 run_start = idx;
3216 } else {
3217 result.push(token.clone());
3218 }
3219 continue;
3220 }
3221
3222 // Safety: run is non-empty (checked above)
3223 let Some(last) = run.last() else { unreachable!() };
3224 let adjacent = last.span.end == token.span.start;
3225
3226 if adjacent && is_colon_mergeable(&token.token) {
3227 run.push(token);
3228 } else {
3229 flush_colon_run(&mut run, &mut result, value_ctx[run_start].in_brace, source);
3230 if is_colon_mergeable(&token.token) {
3231 run.push(token);
3232 run_start = idx;
3233 } else {
3234 result.push(token.clone());
3235 }
3236 }
3237 }
3238
3239 flush_colon_run(&mut run, &mut result, value_ctx[run_start].in_brace, source);
3240
3241 result
3242}
3243
3244/// Flush a run of colon-mergeable tokens: merge to a single `Ident` (text
3245/// sliced verbatim from the source) if it contains a colon, otherwise emit
3246/// individually. `suppress` (true when the run opened inside a
3247/// value-position record literal) forces individual emission.
3248fn flush_colon_run(
3249 run: &mut Vec<&Spanned<Token>>,
3250 result: &mut Vec<Spanned<Token>>,
3251 suppress: bool,
3252 source: &str,
3253) {
3254 if run.is_empty() {
3255 return;
3256 }
3257
3258 let has_colon = run.iter().any(|t| matches!(t.token, Token::Colon));
3259
3260 if !suppress && run.len() >= 2 && has_colon {
3261 let start = run.first().map(|t| t.span.start).unwrap_or(0);
3262 let end = run.last().map(|t| t.span.end).unwrap_or(0);
3263 let text = source.get(start..end).unwrap_or_default().to_string();
3264 result.push(Spanned::new(Token::Ident(text), start..end));
3265 } else {
3266 for t in run.iter() {
3267 result.push((*t).clone());
3268 }
3269 }
3270
3271 run.clear();
3272}
3273
3274/// True for token types that can participate in a glob word.
3275fn is_glob_mergeable(token: &Token) -> bool {
3276 matches!(
3277 token,
3278 Token::Star
3279 | Token::Question
3280 | Token::Dot
3281 | Token::DotDot
3282 | Token::Ident(_)
3283 | Token::NumberIdent(_)
3284 | Token::DashNumWord(_)
3285 | Token::AtWord(_)
3286 | Token::DottedIdent(_)
3287 | Token::Path(_)
3288 | Token::Int(_)
3289 | Token::LBracket
3290 | Token::RBracket
3291 | Token::Bang
3292 | Token::DotSlashPath(_)
3293 | Token::RelativePath(_)
3294 | Token::TildePath(_)
3295 | Token::Tilde
3296 | Token::LBrace
3297 | Token::RBrace
3298 | Token::Comma
3299 )
3300}
3301
3302/// Merge a span-adjacent metacharacter onto a flag token.
3303///
3304/// Handles the `awk -F:` idiom: the lexer emits `-F` as `ShortFlag("F")`
3305/// and `:` as `Token::Colon`. When span-adjacent, the `:` is part of the
3306/// flag value, not a shell operator, so they fuse into `ShortFlag("F:")`
3307/// for the arg-binding layer (the same mechanism used for `cut -f1`).
3308/// Consecutive colons are all absorbed (`-F::` → `ShortFlag("F::")`).
3309///
3310/// `;` (Semi) and `|` (Pipe) are shell operators and must NOT be fused
3311/// even when span-adjacent — in bash, `-F;` and `-F|` require quoting
3312/// (`-F';'`), and kaish matches that contract. Space-separated `cmd -F :`
3313/// leaves a span gap and never reaches this merge.
3314fn merge_flag_metachar_adjacent(tokens: Vec<Spanned<Token>>) -> Vec<Spanned<Token>> {
3315 if tokens.len() < 2 {
3316 return tokens;
3317 }
3318
3319 let mut result = Vec::with_capacity(tokens.len());
3320 let mut i = 0;
3321
3322 while i < tokens.len() {
3323 let token = &tokens[i];
3324
3325 if let Token::ShortFlag(flag_name) = &token.token {
3326 let mut fused = flag_name.clone();
3327 let mut end_span = token.span.end;
3328 let mut j = i + 1;
3329
3330 while let Some(next) = tokens.get(j) {
3331 if next.span.start == end_span {
3332 if let Token::Colon = &next.token {
3333 fused.push(':');
3334 end_span = next.span.end;
3335 j += 1;
3336 continue;
3337 }
3338 }
3339 break;
3340 }
3341
3342 if j > i + 1 {
3343 let span = token.span.start..end_span;
3344 result.push(Spanned::new(Token::ShortFlag(fused), span));
3345 i = j;
3346 continue;
3347 }
3348 }
3349
3350 result.push(token.clone());
3351 i += 1;
3352 }
3353
3354 result
3355}
3356
3357/// Merge span-adjacent token runs containing glob metacharacters into
3358/// `GlobWord` tokens.
3359///
3360/// A run is merged when it contains at least one `Star`, `Question`, or a
3361/// `LBracket`+`RBracket` pair. Runs after colon merge: `foo::bar` stays
3362/// `Ident("foo::bar")` because colon merge already fused it.
3363///
3364/// A run that opens at *value position* (`x=[dog]`, `[[ $a in [dog] ]]`)
3365/// is exempted from bracket-pair fusion — see `compute_value_context` —
3366/// so the list-literal parser sees primitive `LBracket`/`RBracket`
3367/// tokens. Argv-position brackets (`ls [dog]`, `for x in [a]`) fuse as
3368/// before.
3369///
3370/// A SEPARATE trigger suppresses fusion for an **assignment lvalue**
3371/// (`fruits[0]=kiwi`, `services[web][port]=9090`): a bracket-pair run
3372/// (no `*`/`?`) led by an `Ident` and immediately followed by `Token::Eq`
3373/// is a subscripted assignment target, not a glob — see `docs/LANGUAGE.md`,
3374/// "Assignment — bracket-path lvalues".
3375fn merge_glob_adjacent(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spanned<Token>> {
3376 if tokens.is_empty() {
3377 return tokens;
3378 }
3379
3380 let value_ctx = compute_value_context(&tokens);
3381 let bracket_depth = compute_bracket_depth(&tokens);
3382 let mut result = Vec::with_capacity(tokens.len());
3383 let mut run: Vec<&Spanned<Token>> = Vec::new();
3384 let mut run_start = 0usize;
3385
3386 for (idx, token) in tokens.iter().enumerate() {
3387 if run.is_empty() {
3388 if is_glob_mergeable(&token.token) {
3389 run.push(token);
3390 run_start = idx;
3391 } else {
3392 result.push(token.clone());
3393 }
3394 continue;
3395 }
3396
3397 // Safety: run is non-empty (checked at top of loop)
3398 let Some(last) = run.last() else { unreachable!() };
3399 let adjacent = last.span.end == token.span.start;
3400
3401 if adjacent && is_glob_mergeable(&token.token) {
3402 run.push(token);
3403 } else {
3404 // `token` is whatever broke the run — an lvalue's `=` is never
3405 // glob-mergeable, so it always lands here regardless of
3406 // whitespace (`fruits[0]=kiwi` and `fruits[0] = kiwi` both).
3407 let followed_by_eq = matches!(token.token, Token::Eq);
3408 flush_glob_run(
3409 &mut run,
3410 &mut result,
3411 value_ctx[run_start].in_literal,
3412 followed_by_eq,
3413 value_ctx[run_start].push_target,
3414 bracket_depth[run_start],
3415 source,
3416 );
3417 if is_glob_mergeable(&token.token) {
3418 run.push(token);
3419 run_start = idx;
3420 } else {
3421 result.push(token.clone());
3422 }
3423 }
3424 }
3425
3426 // End of input: no token follows the final run, so it can't be an
3427 // lvalue (an assignment always has a value after `=`) — but it CAN
3428 // still be a `push` target (`push xs[0]` with nothing after it).
3429 flush_glob_run(
3430 &mut run,
3431 &mut result,
3432 value_ctx[run_start].in_literal,
3433 false,
3434 value_ctx[run_start].push_target,
3435 bracket_depth[run_start],
3436 source,
3437 );
3438
3439 result
3440}
3441
3442/// Bracket depth (count of open, unmatched `[`/`{`) in effect immediately
3443/// BEFORE each token — one entry per token, index-aligned with `tokens`.
3444/// The sole consumer is `run_has_bare_comma` below: a comma is a
3445/// literal/pattern separator while depth > 0 (`{js,ts}`, `[1, 2, 3]`,
3446/// `[{a: 1}, {b: 2}]`), an ordinary bareword character at depth 0
3447/// (`1,3p`, `a,b`).
3448///
3449/// Deliberately CRUDER than `compute_value_context`'s frame stack: it
3450/// resets to 0 at every `is_statement_boundary` token, INCLUDING
3451/// `Newline` (unlike the frame stack, which keeps a value-position
3452/// List/Record frame open across newlines for multi-line literals). That
3453/// asymmetry is safe, not a gap — multi-line value-position literals are
3454/// gated by the SEPARATE `value_position_suppress` flag in
3455/// `flush_glob_run`, computed from `compute_value_context` and unaffected
3456/// by this counter. This counter exists only to catch the constructs
3457/// `compute_value_context` doesn't track at all — argv-position brackets
3458/// and case-pattern braces — and those are always single-line, so
3459/// resetting on every newline both matches their grammar and guarantees a
3460/// stray unclosed bracket can never wedge the comma decision past the
3461/// line it's on.
3462fn compute_bracket_depth(tokens: &[Spanned<Token>]) -> Vec<usize> {
3463 let mut depths = Vec::with_capacity(tokens.len());
3464 let mut depth: i32 = 0;
3465 for t in tokens {
3466 if is_statement_boundary(&t.token) {
3467 depth = 0;
3468 }
3469 depths.push(depth.max(0) as usize);
3470 match &t.token {
3471 Token::LBracket | Token::LBrace => depth += 1,
3472 Token::RBracket | Token::RBrace => depth = (depth - 1).max(0),
3473 _ => {}
3474 }
3475 }
3476 depths
3477}
3478
3479/// True when `run` contains a `Token::Comma` sitting outside any
3480/// `[...]`/`{...}` pair — `start_depth` (from `compute_bracket_depth`,
3481/// read at the run's first token) seeds the count, since the opening
3482/// bracket of a pair often lands in an earlier, whitespace-separated run
3483/// (`[1, 2, 3]` is three runs: `[1,`, `2,`, `3]`). A comma still inside an
3484/// open pair (`{js,ts}`, a glued `[a,b]`) is left for the grammar that
3485/// consumes it — case-pattern brace expansion, or a bracket-pair run that
3486/// also flushes here via `has_bracket_pair` below; a comma with no
3487/// enclosing pair (`1,3p`, `a,b`) has no grammatical role outside a
3488/// literal/pattern.
3489fn run_has_bare_comma(run: &[&Spanned<Token>], start_depth: usize) -> bool {
3490 let mut depth = start_depth as i32;
3491 let mut found = false;
3492 for t in run.iter() {
3493 match &t.token {
3494 Token::LBracket | Token::LBrace => depth += 1,
3495 Token::RBracket | Token::RBrace => depth = (depth - 1).max(0),
3496 Token::Comma if depth == 0 => found = true,
3497 _ => {}
3498 }
3499 }
3500 found
3501}
3502
3503/// Flush a run of glob-mergeable tokens: merge to a `GlobWord` (text
3504/// sliced verbatim from the source) if it contains glob metacharacters,
3505/// or to an `Ident` if its only reason to fuse is a bare comma (see
3506/// below).
3507///
3508/// `value_position_suppress` (run opened at value position) forces
3509/// individual emission for bracket-bearing runs, so a `[`-leading run at
3510/// value position always reaches the parser as primitive tokens for the
3511/// list-literal grammar. A pure `Star`/`Question` glob with no brackets
3512/// (`X=*.txt`) keeps fusing — it evaluates to a literal string at value
3513/// position exactly as before collection literals existed. The SAME flag
3514/// also gates bare-comma folding below: a value-position run (list or
3515/// record literal, tracked across whitespace/newlines by
3516/// `compute_value_context`, unlike this function's own per-run bracket
3517/// count) must reach the parser as primitive tokens even when the run
3518/// itself contains no bracket pair — `x = [ 1,2 ]` splits into "[",
3519/// "1,2", "]" runs on the spaces, and the middle run has no bracket
3520/// token to see.
3521///
3522/// `followed_by_eq` is the SEPARATE lvalue trigger: an `Ident`-led
3523/// bracket-pair run with no `*`/`?` immediately before `=` is a
3524/// subscripted assignment target (`fruits[0]=kiwi`), not a glob.
3525///
3526/// `push_target` is a THIRD, independent trigger (see `PushTarget`):
3527/// `push`'s own bracket-path target (`push services[web][tags] item`) has
3528/// no trailing `=` to key off, so it's recognized separately and fused
3529/// verbatim into a single `Ident` (GH #183) — a path for `push` to walk,
3530/// never a glob to expand against the filesystem.
3531///
3532/// A bare comma (`1,3p`, `cut -f 1,3`, `sort -k 2,2n`) has no
3533/// grammatical role outside a `[...]`/`{...}` literal or pattern — see
3534/// `run_has_bare_comma` — so a run whose only fusion trigger is such a
3535/// comma folds into an `Ident`, never a `GlobWord`: nothing here should
3536/// reach the filesystem glob matcher, and `Expr::GlobPattern("1,3p")`
3537/// would try to `stat` a file named that and fail with "no matches".
3538fn flush_glob_run(
3539 run: &mut Vec<&Spanned<Token>>,
3540 result: &mut Vec<Spanned<Token>>,
3541 value_position_suppress: bool,
3542 followed_by_eq: bool,
3543 push_target: bool,
3544 bracket_depth_at_start: usize,
3545 source: &str,
3546) {
3547 if run.is_empty() {
3548 return;
3549 }
3550
3551 let has_bracket_pair = run.iter().any(|t| matches!(t.token, Token::LBracket))
3552 && run.iter().any(|t| matches!(t.token, Token::RBracket));
3553 let has_star_or_question = run
3554 .iter()
3555 .any(|t| matches!(t.token, Token::Star | Token::Question));
3556 let has_glob = has_star_or_question || has_bracket_pair;
3557
3558 // An lvalue subscript run is a ROOT IDENTIFIER followed by brackets
3559 // (`arr[0]=` → run is `arr [ 0 ]`). A bare char-class comparison
3560 // operand starts with `[` instead (`[[ [a] = b ]]`), so requiring an
3561 // `Ident`-led run keeps that fusing-and-comparing while still
3562 // catching every real lvalue.
3563 let run_starts_with_ident = matches!(run.first().map(|t| &t.token), Some(Token::Ident(_)));
3564 let lvalue_suppress =
3565 followed_by_eq && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
3566 let push_target_suppress =
3567 push_target && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
3568 let suppress = (value_position_suppress && has_bracket_pair) || lvalue_suppress;
3569 let has_bare_comma =
3570 !value_position_suppress && run_has_bare_comma(run, bracket_depth_at_start);
3571
3572 if push_target_suppress && run.len() >= 2 {
3573 // `push`'s target: fuse verbatim to a single `Ident` (never a
3574 // `GlobWord` — nothing here is meant to glob-expand).
3575 let start = run.first().map(|t| t.span.start).unwrap_or(0);
3576 let end = run.last().map(|t| t.span.end).unwrap_or(0);
3577 let text = source.get(start..end).unwrap_or_default().to_string();
3578 result.push(Spanned::new(Token::Ident(text), start..end));
3579 } else if !suppress && run.len() >= 2 && has_glob {
3580 let start = run.first().map(|t| t.span.start).unwrap_or(0);
3581 let end = run.last().map(|t| t.span.end).unwrap_or(0);
3582 let text = source.get(start..end).unwrap_or_default().to_string();
3583 result.push(Spanned::new(Token::GlobWord(text), start..end));
3584 } else if run.len() >= 2 && has_bare_comma {
3585 let start = run.first().map(|t| t.span.start).unwrap_or(0);
3586 let end = run.last().map(|t| t.span.end).unwrap_or(0);
3587 let text = source.get(start..end).unwrap_or_default().to_string();
3588 result.push(Spanned::new(Token::Ident(text), start..end));
3589 } else {
3590 for t in run.iter() {
3591 result.push((*t).clone());
3592 }
3593 }
3594
3595 run.clear();
3596}
3597
3598// ═══════════════════════════════════════════════════════════════════
3599// Pipeline entry points
3600// ═══════════════════════════════════════════════════════════════════
3601
3602/// Tokenize kaish source into spanned tokens.
3603///
3604/// Pipeline: one composed scan (heredocs + arithmetic extracted with full
3605/// quote/escape/comment awareness, complete replacement table) → logos →
3606/// positional marker resolution → span correction back to original
3607/// coordinates → fusion passes (flag-metachar, colon, glob) with
3608/// verbatim-slice text. All spans — including `HereDoc` tokens and
3609/// everything after them — are exact original-source byte ranges.
3610pub fn tokenize(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
3611 tokenize_impl(source, false)
3612}
3613
3614/// Tokenize, preserving `Comment` and `LineContinuation` tokens.
3615///
3616/// Runs the SAME pipeline as `tokenize` (pre-#95 this was a divergent
3617/// second pipeline with no preprocessing or merges). Useful for
3618/// pretty-printing and formatting tools.
3619pub fn tokenize_with_comments(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
3620 tokenize_impl(source, true)
3621}
3622
3623fn tokenize_impl(
3624 source: &str,
3625 keep_comments: bool,
3626) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
3627 let scan_output = scan(source).map_err(|e| vec![e])?;
3628
3629 // map_position's early `break` depends on the table being ordered by
3630 // rewritten-buffer position; the scanner appends in scan order, which
3631 // guarantees it.
3632 debug_assert!(
3633 scan_output
3634 .replacements
3635 .windows(2)
3636 .all(|w| w[0].new_start <= w[1].new_start),
3637 "replacement table must be ordered by new_start"
3638 );
3639
3640 let mut tokens = Vec::new();
3641 let mut errors = Vec::new();
3642 for (result, span) in Token::lexer(&scan_output.text).spanned() {
3643 // A token that scans for its own terminator (`"`, `${`) reads to
3644 // end-of-input when the terminator is missing, and logos then retries
3645 // from the next character — so a file of N unterminated openers costs
3646 // O(N²) to lex: 20000 `"$(echo "` openers took 4.6s uncapped and 0.02s
3647 // at this cap. Both renderers join every collected error
3648 // (`Kernel::execute`, the REPL), so the cap also bounds what a single
3649 // typo can print; 64 diagnostics is already past what anyone reads,
3650 // and one runaway opener should not bury the first real error.
3651 const MAX_LEXER_ERRORS: usize = 64;
3652 if errors.len() >= MAX_LEXER_ERRORS {
3653 break;
3654 }
3655 match result {
3656 Ok(token) => {
3657 if !keep_comments
3658 && matches!(token, Token::Comment | Token::LineContinuation)
3659 {
3660 continue;
3661 }
3662 // Rewritten-buffer spans here; mapped to original
3663 // coordinates after marker resolution.
3664 tokens.push(Spanned::new(token, span));
3665 }
3666 Err(err) => {
3667 errors.push(Spanned::new(err, map_span(&span, &scan_output.replacements)));
3668 }
3669 }
3670 }
3671 if !errors.is_empty() {
3672 return Err(errors);
3673 }
3674
3675 let resolved = resolve_markers(tokens, &scan_output).map_err(|errs| {
3676 errs.into_iter()
3677 .map(|e| Spanned::new(e.token, map_span(&e.span, &scan_output.replacements)))
3678 .collect::<Vec<_>>()
3679 })?;
3680
3681 let mapped: Vec<Spanned<Token>> = resolved
3682 .into_iter()
3683 .map(|s| {
3684 let span = map_span(&s.span, &scan_output.replacements);
3685 Spanned::new(s.token, span)
3686 })
3687 .collect();
3688
3689 Ok(preserve_numeric_source_text(
3690 merge_glob_adjacent(
3691 merge_colon_adjacent(merge_flag_metachar_adjacent(mapped), source),
3692 source,
3693 ),
3694 source,
3695 ))
3696}
3697
3698/// True when a numeral's integer part is not a valid JSON number (RFC 8259:
3699/// `int = zero / (digit1-9 *DIGIT)`) — more than one digit and a leading `0`:
3700/// `007`, `010`, `-022`, and the integer part of `007.5`. A lone `0` (`0`,
3701/// `-0`, `0.5`) is the `zero` alternative and is fine.
3702///
3703/// `fromjson '007'` is already a parse error; the lexer now agrees.
3704fn has_invalid_leading_zero(raw: &str) -> bool {
3705 let unsigned = raw.strip_prefix('-').unwrap_or(raw);
3706 let int_part = unsigned.split('.').next().unwrap_or(unsigned);
3707 int_part.len() > 1 && int_part.starts_with('0')
3708}
3709
3710/// True when a word is a numeral in every respect except its leading zero —
3711/// `007`, `010`, `-022`, `007.5`. These lex as [`Token::NumberIdent`].
3712///
3713/// Callers use this where kaish needs a number and got text, so the error can
3714/// name the leading zero rather than report a shape mismatch. A word that is
3715/// not otherwise a numeral (`007abc`) answers false: there is no number the
3716/// author might have meant.
3717pub(crate) fn is_leading_zero_numeral(word: &str) -> bool {
3718 let unsigned = word.strip_prefix('-').unwrap_or(word);
3719 let mut parts = unsigned.split('.');
3720 let int_part = parts.next().unwrap_or("");
3721 let frac_part = parts.next();
3722 if parts.next().is_some() {
3723 return false;
3724 }
3725 let digits_only = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
3726 if !digits_only(int_part) {
3727 return false;
3728 }
3729 if let Some(frac) = frac_part
3730 && !digits_only(frac)
3731 {
3732 return false;
3733 }
3734 has_invalid_leading_zero(word)
3735}
3736
3737/// Reclassify a plain `Int`/`Float` token from its own source text, into
3738/// [`Token::NumberIdent`] or [`Token::NumericLiteral`]. The common case
3739/// (`-1`, `42`, `3.14`) pays one string comparison and stays unchanged.
3740///
3741/// Runs as the LAST step of `tokenize_impl`, after every fusion pass:
3742/// `is_colon_mergeable` matches `Int` and `Float` directly and
3743/// `is_glob_mergeable` matches `Int`, so a numeral must still present its
3744/// ordinary shape while fusion decides.
3745/// Spans are original-source coordinates by now, so `source[span]` is the
3746/// exact word the author typed.
3747fn preserve_numeric_source_text(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spanned<Token>> {
3748 tokens
3749 .into_iter()
3750 .map(|t| {
3751 let (value, canonical): (Value, String) = match &t.token {
3752 Token::Int(n) => (Value::Int(*n), n.to_string()),
3753 Token::Float(n) => (Value::Float(*n), n.to_string()),
3754 _ => return t,
3755 };
3756 let Some(raw) = source.get(t.span.start..t.span.end) else {
3757 return t;
3758 };
3759 if has_invalid_leading_zero(raw) {
3760 return Spanned::new(Token::NumberIdent(raw.to_string()), t.span);
3761 }
3762 if raw == canonical {
3763 return t;
3764 }
3765 Spanned::new(
3766 Token::NumericLiteral(NumericLiteralData { value, raw: raw.to_string() }),
3767 t.span,
3768 )
3769 })
3770 .collect()
3771}
3772
3773/// Extract the string content from a string token (removes quotes, processes escapes).
3774pub fn parse_string_literal(source: &str) -> Result<String, LexerError> {
3775 // Remove surrounding quotes
3776 if source.len() < 2 || !source.starts_with('"') || !source.ends_with('"') {
3777 return Err(LexerError::UnterminatedString);
3778 }
3779
3780 let inner = &source[1..source.len() - 1];
3781 let mut result = String::with_capacity(inner.len());
3782 let mut chars = inner.chars().peekable();
3783
3784 while let Some(ch) = chars.next() {
3785 if ch == '\\' {
3786 match chars.next() {
3787 Some('n') => result.push('\n'),
3788 Some('t') => result.push('\t'),
3789 Some('r') => result.push('\r'),
3790 Some('\\') => result.push('\\'),
3791 Some('"') => result.push('"'),
3792 // Use a unique marker for escaped dollar that won't be re-interpreted
3793 // parse_interpolated_string will convert this back to $
3794 Some('$') => result.push_str("__KAISH_ESCAPED_DOLLAR__"),
3795 Some('u') => {
3796 // Unicode escape: \uXXXX
3797 let mut hex = String::with_capacity(4);
3798 for _ in 0..4 {
3799 match chars.next() {
3800 Some(h) if h.is_ascii_hexdigit() => hex.push(h),
3801 _ => return Err(LexerError::InvalidEscape),
3802 }
3803 }
3804 let codepoint = u32::from_str_radix(&hex, 16)
3805 .map_err(|_| LexerError::InvalidEscape)?;
3806 let ch = char::from_u32(codepoint)
3807 .ok_or(LexerError::InvalidEscape)?;
3808 result.push(ch);
3809 }
3810 // Unknown escapes: preserve the backslash (for regex patterns like `\.`)
3811 Some(next) => {
3812 result.push('\\');
3813 result.push(next);
3814 }
3815 None => return Err(LexerError::InvalidEscape),
3816 }
3817 } else {
3818 result.push(ch);
3819 }
3820 }
3821
3822 Ok(result)
3823}
3824
3825/// Parse a variable reference, extracting the path segments.
3826/// Input: `"${VAR.field[0].nested}"` → `["VAR", "field", "[0]", "nested"]`
3827///
3828/// The `[...]` collector is quote-aware (GH #183): a subscript opening with
3829/// `"` or `'` consumes verbatim up to its OWN matching closing quote before
3830/// resuming the search for the subscript's terminating `]` — so an embedded
3831/// `]` inside a quoted key (`${r["weird]key"]}`) is just data, not the
3832/// bracket's end. Un-quoted subscripts (`[0]`, `[$k]`, `[web]`) are
3833/// unaffected — the quote check only fires when the subscript's first
3834/// character is actually a quote.
3835pub fn parse_var_ref(source: &str) -> Result<Vec<String>, LexerError> {
3836 // Remove ${ and }
3837 if source.len() < 4 || !source.starts_with("${") || !source.ends_with('}') {
3838 return Err(LexerError::UnterminatedVarRef);
3839 }
3840
3841 let inner = &source[2..source.len() - 1];
3842
3843 // Special case: $? (last result)
3844 if inner == "?" {
3845 return Ok(vec!["?".to_string()]);
3846 }
3847
3848 let mut segments = Vec::new();
3849 let mut current = String::new();
3850 let mut chars = inner.chars().peekable();
3851
3852 while let Some(ch) = chars.next() {
3853 match ch {
3854 '.' => {
3855 if !current.is_empty() {
3856 segments.push(current.clone());
3857 current.clear();
3858 }
3859 }
3860 '[' => {
3861 if !current.is_empty() {
3862 segments.push(current.clone());
3863 current.clear();
3864 }
3865 // Collect the index. Quote-aware: a quoted key's own
3866 // matching closer is consumed FIRST, verbatim, so an
3867 // embedded `]` inside it (`["weird]key"]`) can't be
3868 // mistaken for the subscript's terminator (GH #183).
3869 let mut index = String::from("[");
3870 if let Some("e) = chars.peek() {
3871 if quote == '"' || quote == '\'' {
3872 if let Some(q) = chars.next() {
3873 index.push(q);
3874 }
3875 for c in chars.by_ref() {
3876 index.push(c);
3877 if c == quote {
3878 break;
3879 }
3880 }
3881 }
3882 }
3883 while let Some(&c) = chars.peek() {
3884 if let Some(c) = chars.next() {
3885 index.push(c);
3886 }
3887 if c == ']' {
3888 break;
3889 }
3890 }
3891 segments.push(index);
3892 }
3893 _ => {
3894 current.push(ch);
3895 }
3896 }
3897 }
3898
3899 if !current.is_empty() {
3900 segments.push(current);
3901 }
3902
3903 Ok(segments)
3904}
3905
3906/// Parse an integer literal.
3907pub fn parse_int(source: &str) -> Result<i64, LexerError> {
3908 source.parse().map_err(|_| LexerError::IntegerOutOfRange)
3909}
3910
3911/// Parse a float literal.
3912pub fn parse_float(source: &str) -> Result<f64, LexerError> {
3913 source.parse().map_err(|_| LexerError::InvalidNumber)
3914}
3915
3916#[cfg(test)]
3917#[allow(clippy::approx_constant)]
3918mod tests {
3919 use super::*;
3920
3921 fn lex(source: &str) -> Vec<Token> {
3922 tokenize(source)
3923 .expect("lexer should succeed")
3924 .into_iter()
3925 .map(|s| s.token)
3926 .collect()
3927 }
3928
3929
3930 // ═══════════════════════════════════════════════════════════════════
3931 // Keyword tests
3932 // ═══════════════════════════════════════════════════════════════════
3933
3934 #[test]
3935 fn keywords() {
3936 assert_eq!(lex("set"), vec![Token::Set]);
3937 assert_eq!(lex("if"), vec![Token::If]);
3938 assert_eq!(lex("then"), vec![Token::Then]);
3939 assert_eq!(lex("else"), vec![Token::Else]);
3940 assert_eq!(lex("elif"), vec![Token::Elif]);
3941 assert_eq!(lex("fi"), vec![Token::Fi]);
3942 assert_eq!(lex("for"), vec![Token::For]);
3943 assert_eq!(lex("in"), vec![Token::In]);
3944 assert_eq!(lex("do"), vec![Token::Do]);
3945 assert_eq!(lex("done"), vec![Token::Done]);
3946 assert_eq!(lex("case"), vec![Token::Case]);
3947 assert_eq!(lex("esac"), vec![Token::Esac]);
3948 assert_eq!(lex("function"), vec![Token::Function]);
3949 assert_eq!(lex("true"), vec![Token::True]);
3950 assert_eq!(lex("false"), vec![Token::False]);
3951 }
3952
3953 #[test]
3954 fn double_semicolon() {
3955 assert_eq!(lex(";;"), vec![Token::DoubleSemi]);
3956 // In case pattern context
3957 assert_eq!(lex("echo \"hi\";;"), vec![
3958 Token::Ident("echo".to_string()),
3959 Token::String("hi".to_string()),
3960 Token::DoubleSemi,
3961 ]);
3962 }
3963
3964 #[test]
3965 fn type_keywords() {
3966 assert_eq!(lex("string"), vec![Token::TypeString]);
3967 assert_eq!(lex("int"), vec![Token::TypeInt]);
3968 assert_eq!(lex("float"), vec![Token::TypeFloat]);
3969 assert_eq!(lex("bool"), vec![Token::TypeBool]);
3970 }
3971
3972 // ═══════════════════════════════════════════════════════════════════
3973 // Operator tests
3974 // ═══════════════════════════════════════════════════════════════════
3975
3976 #[test]
3977 fn single_char_operators() {
3978 assert_eq!(lex("="), vec![Token::Eq]);
3979 assert_eq!(lex("|"), vec![Token::Pipe]);
3980 assert_eq!(lex("&"), vec![Token::Amp]);
3981 assert_eq!(lex(">"), vec![Token::Gt]);
3982 assert_eq!(lex("<"), vec![Token::Lt]);
3983 assert_eq!(lex(";"), vec![Token::Semi]);
3984 assert_eq!(lex(":"), vec![Token::Colon]);
3985 assert_eq!(lex(","), vec![Token::Comma]);
3986 assert_eq!(lex("."), vec![Token::Dot]);
3987 }
3988
3989 #[test]
3990 fn multi_char_operators() {
3991 assert_eq!(lex("&&"), vec![Token::And]);
3992 assert_eq!(lex("||"), vec![Token::Or]);
3993 assert_eq!(lex("=="), vec![Token::EqEq]);
3994 assert_eq!(lex("!="), vec![Token::NotEq]);
3995 assert_eq!(lex("=~"), vec![Token::Match]);
3996 assert_eq!(lex("!~"), vec![Token::NotMatch]);
3997 assert_eq!(lex(">="), vec![Token::GtEq]);
3998 assert_eq!(lex("<="), vec![Token::LtEq]);
3999 assert_eq!(lex(">>"), vec![Token::GtGt]);
4000 assert_eq!(lex("2>"), vec![Token::Stderr]);
4001 assert_eq!(lex("&>"), vec![Token::Both]);
4002 }
4003
4004 #[test]
4005 fn brackets() {
4006 assert_eq!(lex("{"), vec![Token::LBrace]);
4007 assert_eq!(lex("}"), vec![Token::RBrace]);
4008 assert_eq!(lex("["), vec![Token::LBracket]);
4009 assert_eq!(lex("]"), vec![Token::RBracket]);
4010 assert_eq!(lex("("), vec![Token::LParen]);
4011 assert_eq!(lex(")"), vec![Token::RParen]);
4012 }
4013
4014 // ═══════════════════════════════════════════════════════════════════
4015 // Literal tests
4016 // ═══════════════════════════════════════════════════════════════════
4017
4018 #[test]
4019 fn integers() {
4020 assert_eq!(lex("0"), vec![Token::Int(0)]);
4021 assert_eq!(lex("42"), vec![Token::Int(42)]);
4022 assert_eq!(lex("-1"), vec![Token::Int(-1)]);
4023 assert_eq!(lex("999999"), vec![Token::Int(999999)]);
4024 }
4025
4026 #[test]
4027 fn floats() {
4028 assert_eq!(lex("3.14"), vec![Token::Float(3.14)]);
4029 assert_eq!(lex("-0.5"), vec![Token::Float(-0.5)]);
4030 assert_eq!(lex("123.456"), vec![Token::Float(123.456)]);
4031 }
4032
4033 #[test]
4034 fn strings() {
4035 assert_eq!(lex(r#""hello""#), vec![Token::String("hello".to_string())]);
4036 assert_eq!(lex(r#""hello world""#), vec![Token::String("hello world".to_string())]);
4037 assert_eq!(lex(r#""""#), vec![Token::String("".to_string())]); // empty string
4038 assert_eq!(lex(r#""with \"quotes\"""#), vec![Token::String("with \"quotes\"".to_string())]);
4039 assert_eq!(lex(r#""with\nnewline""#), vec![Token::String("with\nnewline".to_string())]);
4040 }
4041
4042 #[test]
4043 fn var_refs() {
4044 assert_eq!(lex("${X}"), vec![Token::VarRef("${X}".to_string())]);
4045 assert_eq!(lex("${VAR}"), vec![Token::VarRef("${VAR}".to_string())]);
4046 assert_eq!(lex("${VAR.field}"), vec![Token::VarRef("${VAR.field}".to_string())]);
4047 assert_eq!(lex("${VAR[0]}"), vec![Token::VarRef("${VAR[0]}".to_string())]);
4048 }
4049
4050 #[test]
4051 fn var_ref_nested_default_is_one_token() {
4052 // GH #173: the balanced-brace callback keeps a nested reference in
4053 // a default word as ONE VarRef token (the old first-`}` regex split
4054 // it into VarRef + RBrace).
4055 assert_eq!(
4056 lex("${X:-${Y}}"),
4057 vec![Token::VarRef("${X:-${Y}}".to_string())]
4058 );
4059 assert_eq!(
4060 lex("${A:-${B:-${C}}}"),
4061 vec![Token::VarRef("${A:-${B:-${C}}}".to_string())]
4062 );
4063 // VarLength still out-matches the two-character `${` opener.
4064 assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
4065 }
4066
4067 #[test]
4068 fn var_ref_unterminated_and_empty_are_errors() {
4069 assert!(tokenize("${X:-${Y}").is_err(), "unbalanced nesting is loud");
4070 assert!(tokenize("${a{b}").is_err(), "extra open brace is loud");
4071 assert!(tokenize("${}").is_err(), "empty reference is loud");
4072 }
4073
4074 #[test]
4075 fn var_ref_closes_at_first_balanced_brace() {
4076 // Trailing `b}` after the balanced close is separate tokens — the
4077 // early-close contract (kaibo review, GH #173).
4078 assert_eq!(
4079 lex("${a}b}"),
4080 vec![
4081 Token::VarRef("${a}".to_string()),
4082 Token::Ident("b".to_string()),
4083 Token::RBrace,
4084 ]
4085 );
4086 }
4087
4088 // ═══════════════════════════════════════════════════════════════════
4089 // Identifier tests
4090 // ═══════════════════════════════════════════════════════════════════
4091
4092 #[test]
4093 fn identifiers() {
4094 assert_eq!(lex("foo"), vec![Token::Ident("foo".to_string())]);
4095 assert_eq!(lex("foo_bar"), vec![Token::Ident("foo_bar".to_string())]);
4096 assert_eq!(lex("foo-bar"), vec![Token::Ident("foo-bar".to_string())]);
4097 assert_eq!(lex("_private"), vec![Token::Ident("_private".to_string())]);
4098 assert_eq!(lex("cmd123"), vec![Token::Ident("cmd123".to_string())]);
4099 }
4100
4101 #[test]
4102 fn keyword_prefix_identifiers() {
4103 // Identifiers that start with keywords but aren't keywords
4104 assert_eq!(lex("setup"), vec![Token::Ident("setup".to_string())]);
4105 assert_eq!(lex("kaish-tools"), vec![Token::Ident("kaish-tools".to_string())]);
4106 assert_eq!(lex("iffy"), vec![Token::Ident("iffy".to_string())]);
4107 assert_eq!(lex("forked"), vec![Token::Ident("forked".to_string())]);
4108 assert_eq!(lex("done-with-it"), vec![Token::Ident("done-with-it".to_string())]);
4109 }
4110
4111 // ═══════════════════════════════════════════════════════════════════
4112 // Statement tests
4113 // ═══════════════════════════════════════════════════════════════════
4114
4115 #[test]
4116 fn assignment() {
4117 assert_eq!(
4118 lex("set X = 5"),
4119 vec![Token::Set, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
4120 );
4121 }
4122
4123 #[test]
4124 fn command_simple() {
4125 assert_eq!(lex("echo"), vec![Token::Ident("echo".to_string())]);
4126 assert_eq!(
4127 lex(r#"echo "hello""#),
4128 vec![Token::Ident("echo".to_string()), Token::String("hello".to_string())]
4129 );
4130 }
4131
4132 #[test]
4133 fn command_with_args() {
4134 assert_eq!(
4135 lex("cmd arg1 arg2"),
4136 vec![Token::Ident("cmd".to_string()), Token::Ident("arg1".to_string()), Token::Ident("arg2".to_string())]
4137 );
4138 }
4139
4140 #[test]
4141 fn command_with_named_args() {
4142 assert_eq!(
4143 lex("cmd key=value"),
4144 vec![Token::Ident("cmd".to_string()), Token::Ident("key".to_string()), Token::Eq, Token::Ident("value".to_string())]
4145 );
4146 }
4147
4148 #[test]
4149 fn pipeline() {
4150 assert_eq!(
4151 lex("a | b | c"),
4152 vec![Token::Ident("a".to_string()), Token::Pipe, Token::Ident("b".to_string()), Token::Pipe, Token::Ident("c".to_string())]
4153 );
4154 }
4155
4156 #[test]
4157 fn if_statement() {
4158 assert_eq!(
4159 lex("if true; then echo; fi"),
4160 vec![
4161 Token::If,
4162 Token::True,
4163 Token::Semi,
4164 Token::Then,
4165 Token::Ident("echo".to_string()),
4166 Token::Semi,
4167 Token::Fi
4168 ]
4169 );
4170 }
4171
4172 #[test]
4173 fn for_loop() {
4174 assert_eq!(
4175 lex("for X in items; do echo; done"),
4176 vec![
4177 Token::For,
4178 Token::Ident("X".to_string()),
4179 Token::In,
4180 Token::Ident("items".to_string()),
4181 Token::Semi,
4182 Token::Do,
4183 Token::Ident("echo".to_string()),
4184 Token::Semi,
4185 Token::Done
4186 ]
4187 );
4188 }
4189
4190 // ═══════════════════════════════════════════════════════════════════
4191 // Whitespace and newlines
4192 // ═══════════════════════════════════════════════════════════════════
4193
4194 #[test]
4195 fn whitespace_ignored() {
4196 assert_eq!(lex(" set X = 5 "), lex("set X = 5"));
4197 }
4198
4199 #[test]
4200 fn newlines_preserved() {
4201 let tokens = lex("a\nb");
4202 assert_eq!(
4203 tokens,
4204 vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
4205 );
4206 }
4207
4208 #[test]
4209 fn multiple_newlines() {
4210 let tokens = lex("a\n\n\nb");
4211 assert_eq!(
4212 tokens,
4213 vec![Token::Ident("a".to_string()), Token::Newline, Token::Newline, Token::Newline, Token::Ident("b".to_string())]
4214 );
4215 }
4216
4217 // ═══════════════════════════════════════════════════════════════════
4218 // Comments
4219 // ═══════════════════════════════════════════════════════════════════
4220
4221 #[test]
4222 fn comments_skipped() {
4223 assert_eq!(lex("# comment"), vec![]);
4224 assert_eq!(lex("a # comment"), vec![Token::Ident("a".to_string())]);
4225 assert_eq!(
4226 lex("a # comment\nb"),
4227 vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
4228 );
4229 }
4230
4231 #[test]
4232 fn comments_preserved_when_requested() {
4233 let tokens = tokenize_with_comments("a # comment")
4234 .expect("should succeed")
4235 .into_iter()
4236 .map(|s| s.token)
4237 .collect::<Vec<_>>();
4238 assert_eq!(tokens, vec![Token::Ident("a".to_string()), Token::Comment]);
4239 }
4240
4241 // ═══════════════════════════════════════════════════════════════════
4242 // String parsing
4243 // ═══════════════════════════════════════════════════════════════════
4244
4245 #[test]
4246 fn parse_simple_string() {
4247 assert_eq!(parse_string_literal(r#""hello""#).expect("ok"), "hello");
4248 }
4249
4250 #[test]
4251 fn parse_string_with_escapes() {
4252 assert_eq!(
4253 parse_string_literal(r#""hello\nworld""#).expect("ok"),
4254 "hello\nworld"
4255 );
4256 assert_eq!(
4257 parse_string_literal(r#""tab\there""#).expect("ok"),
4258 "tab\there"
4259 );
4260 assert_eq!(
4261 parse_string_literal(r#""quote\"here""#).expect("ok"),
4262 "quote\"here"
4263 );
4264 }
4265
4266 #[test]
4267 fn parse_string_with_unicode() {
4268 assert_eq!(
4269 parse_string_literal(r#""emoji \u2764""#).expect("ok"),
4270 "emoji ❤"
4271 );
4272 }
4273
4274 #[test]
4275 fn parse_string_with_escaped_dollar() {
4276 // \$ produces a marker that parse_interpolated_string will convert to $
4277 // The marker __KAISH_ESCAPED_DOLLAR__ is used to prevent re-interpretation
4278 assert_eq!(
4279 parse_string_literal(r#""\$VAR""#).expect("ok"),
4280 "__KAISH_ESCAPED_DOLLAR__VAR"
4281 );
4282 assert_eq!(
4283 parse_string_literal(r#""cost: \$100""#).expect("ok"),
4284 "cost: __KAISH_ESCAPED_DOLLAR__100"
4285 );
4286 }
4287
4288 // ═══════════════════════════════════════════════════════════════════
4289 // Variable reference parsing
4290 // ═══════════════════════════════════════════════════════════════════
4291
4292 #[test]
4293 fn parse_simple_var() {
4294 assert_eq!(
4295 parse_var_ref("${X}").expect("ok"),
4296 vec!["X"]
4297 );
4298 }
4299
4300 #[test]
4301 fn parse_var_with_field() {
4302 assert_eq!(
4303 parse_var_ref("${VAR.field}").expect("ok"),
4304 vec!["VAR", "field"]
4305 );
4306 }
4307
4308 #[test]
4309 fn parse_var_with_index() {
4310 assert_eq!(
4311 parse_var_ref("${VAR[0]}").expect("ok"),
4312 vec!["VAR", "[0]"]
4313 );
4314 }
4315
4316 #[test]
4317 fn parse_var_nested() {
4318 assert_eq!(
4319 parse_var_ref("${VAR.field[0].nested}").expect("ok"),
4320 vec!["VAR", "field", "[0]", "nested"]
4321 );
4322 }
4323
4324 #[test]
4325 fn parse_last_result() {
4326 assert_eq!(
4327 parse_var_ref("${?}").expect("ok"),
4328 vec!["?"]
4329 );
4330 }
4331
4332 /// GH #183: a `]` inside a QUOTED subscript key must not be mistaken for
4333 /// the subscript's own terminator. Double- and single-quoted keys alike.
4334 #[test]
4335 fn parse_var_quoted_subscript_with_embedded_bracket() {
4336 assert_eq!(
4337 parse_var_ref(r#"${r["weird]key"]}"#).expect("ok"),
4338 vec!["r", r#"["weird]key"]"#]
4339 );
4340 assert_eq!(
4341 parse_var_ref("${r['weird]key']}").expect("ok"),
4342 vec!["r", "['weird]key']"]
4343 );
4344 }
4345
4346 /// A quoted key with NO embedded bracket is unaffected by the
4347 /// quote-awareness — same segment shape as before.
4348 #[test]
4349 fn parse_var_quoted_subscript_without_embedded_bracket() {
4350 assert_eq!(
4351 parse_var_ref(r#"${r["normal"]}"#).expect("ok"),
4352 vec!["r", r#"["normal"]"#]
4353 );
4354 }
4355
4356 /// Trailing content after a quoted subscript closes (a further chained
4357 /// hop) still parses — the quote-awareness only governs the ONE
4358 /// subscript it opens inside.
4359 #[test]
4360 fn parse_var_quoted_subscript_with_embedded_bracket_then_more_path() {
4361 assert_eq!(
4362 parse_var_ref(r#"${r["weird]key"][0]}"#).expect("ok"),
4363 vec!["r", r#"["weird]key"]"#, "[0]"]
4364 );
4365 }
4366
4367 // ═══════════════════════════════════════════════════════════════════
4368 // Number parsing
4369 // ═══════════════════════════════════════════════════════════════════
4370
4371 #[test]
4372 fn parse_integers() {
4373 assert_eq!(parse_int("0").expect("ok"), 0);
4374 assert_eq!(parse_int("42").expect("ok"), 42);
4375 assert_eq!(parse_int("-1").expect("ok"), -1);
4376 }
4377
4378 #[test]
4379 fn parse_floats() {
4380 assert!((parse_float("3.14").expect("ok") - 3.14).abs() < f64::EPSILON);
4381 assert!((parse_float("-0.5").expect("ok") - (-0.5)).abs() < f64::EPSILON);
4382 }
4383
4384 // ═══════════════════════════════════════════════════════════════════
4385 // Edge cases and errors
4386 // ═══════════════════════════════════════════════════════════════════
4387
4388 #[test]
4389 fn empty_input() {
4390 assert_eq!(lex(""), vec![]);
4391 }
4392
4393 #[test]
4394 fn only_whitespace() {
4395 assert_eq!(lex(" \t\t "), vec![]);
4396 }
4397
4398 #[test]
4399 fn json_array() {
4400 assert_eq!(
4401 lex(r#"[1, 2, 3]"#),
4402 vec![
4403 Token::LBracket,
4404 Token::Int(1),
4405 Token::Comma,
4406 Token::Int(2),
4407 Token::Comma,
4408 Token::Int(3),
4409 Token::RBracket
4410 ]
4411 );
4412 }
4413
4414 #[test]
4415 fn json_object() {
4416 assert_eq!(
4417 lex(r#"{"key": "value"}"#),
4418 vec![
4419 Token::LBrace,
4420 Token::String("key".to_string()),
4421 Token::Colon,
4422 Token::String("value".to_string()),
4423 Token::RBrace
4424 ]
4425 );
4426 }
4427
4428 #[test]
4429 fn redirect_operators() {
4430 assert_eq!(
4431 lex("cmd > file"),
4432 vec![Token::Ident("cmd".to_string()), Token::Gt, Token::Ident("file".to_string())]
4433 );
4434 assert_eq!(
4435 lex("cmd >> file"),
4436 vec![Token::Ident("cmd".to_string()), Token::GtGt, Token::Ident("file".to_string())]
4437 );
4438 assert_eq!(
4439 lex("cmd 2> err"),
4440 vec![Token::Ident("cmd".to_string()), Token::Stderr, Token::Ident("err".to_string())]
4441 );
4442 assert_eq!(
4443 lex("cmd &> all"),
4444 vec![Token::Ident("cmd".to_string()), Token::Both, Token::Ident("all".to_string())]
4445 );
4446 }
4447
4448 #[test]
4449 fn background_job() {
4450 assert_eq!(
4451 lex("cmd &"),
4452 vec![Token::Ident("cmd".to_string()), Token::Amp]
4453 );
4454 }
4455
4456 #[test]
4457 fn command_substitution() {
4458 assert_eq!(
4459 lex("$(cmd)"),
4460 vec![Token::CmdSubstStart, Token::Ident("cmd".to_string()), Token::RParen]
4461 );
4462 assert_eq!(
4463 lex("$(cmd arg)"),
4464 vec![
4465 Token::CmdSubstStart,
4466 Token::Ident("cmd".to_string()),
4467 Token::Ident("arg".to_string()),
4468 Token::RParen
4469 ]
4470 );
4471 assert_eq!(
4472 lex("$(a | b)"),
4473 vec![
4474 Token::CmdSubstStart,
4475 Token::Ident("a".to_string()),
4476 Token::Pipe,
4477 Token::Ident("b".to_string()),
4478 Token::RParen
4479 ]
4480 );
4481 }
4482
4483 #[test]
4484 fn complex_pipeline() {
4485 assert_eq!(
4486 lex(r#"cat file | grep pattern="foo" | head count=10"#),
4487 vec![
4488 Token::Ident("cat".to_string()),
4489 Token::Ident("file".to_string()),
4490 Token::Pipe,
4491 Token::Ident("grep".to_string()),
4492 Token::Ident("pattern".to_string()),
4493 Token::Eq,
4494 Token::String("foo".to_string()),
4495 Token::Pipe,
4496 Token::Ident("head".to_string()),
4497 Token::Ident("count".to_string()),
4498 Token::Eq,
4499 Token::Int(10),
4500 ]
4501 );
4502 }
4503
4504 // ═══════════════════════════════════════════════════════════════════
4505 // Flag tests
4506 // ═══════════════════════════════════════════════════════════════════
4507
4508 #[test]
4509 fn short_flag() {
4510 assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
4511 assert_eq!(lex("-a"), vec![Token::ShortFlag("a".to_string())]);
4512 assert_eq!(lex("-v"), vec![Token::ShortFlag("v".to_string())]);
4513 }
4514
4515 #[test]
4516 fn short_flag_combined() {
4517 // Combined short flags like -la
4518 assert_eq!(lex("-la"), vec![Token::ShortFlag("la".to_string())]);
4519 assert_eq!(lex("-vvv"), vec![Token::ShortFlag("vvv".to_string())]);
4520 }
4521
4522 #[test]
4523 fn job_spec_lexes_as_one_token() {
4524 // `%N` is the bash jobspec for wait/kill — used to be a lexer error.
4525 assert_eq!(lex("%1"), vec![Token::JobSpec("%1".to_string())]);
4526 assert_eq!(lex("%12"), vec![Token::JobSpec("%12".to_string())]);
4527 assert_eq!(
4528 lex("wait %1 %2"),
4529 vec![
4530 Token::Ident("wait".to_string()),
4531 Token::JobSpec("%1".to_string()),
4532 Token::JobSpec("%2".to_string()),
4533 ]
4534 );
4535 }
4536
4537 #[test]
4538 fn short_flag_with_internal_hyphens_is_one_token() {
4539 // A dash-word with internal hyphens is ONE shell word, not three
4540 // flags — `-not-a-flag` must not fragment into `-not` `-a` `-flag`.
4541 // (Whether it's a flag or a literal is the binding layer's call.)
4542 assert_eq!(
4543 lex("-not-a-flag"),
4544 vec![Token::ShortFlag("not-a-flag".to_string())]
4545 );
4546 // The two-char terminator `--` is still DoubleDash, and a lone `-`
4547 // is still MinusAlone — the second char must be a letter to start a
4548 // short flag.
4549 assert_eq!(lex("--"), vec![Token::DoubleDash]);
4550 assert_eq!(lex("-"), vec![Token::MinusAlone]);
4551 }
4552
4553 #[test]
4554 fn long_flag() {
4555 assert_eq!(lex("--force"), vec![Token::LongFlag("force".to_string())]);
4556 assert_eq!(lex("--verbose"), vec![Token::LongFlag("verbose".to_string())]);
4557 assert_eq!(lex("--foo-bar"), vec![Token::LongFlag("foo-bar".to_string())]);
4558 }
4559
4560 #[test]
4561 fn double_dash() {
4562 // -- alone marks end of flags
4563 assert_eq!(lex("--"), vec![Token::DoubleDash]);
4564 }
4565
4566 #[test]
4567 fn flags_vs_negative_numbers() {
4568 // -123 should be a negative integer, not a flag
4569 assert_eq!(lex("-123"), vec![Token::Int(-123)]);
4570 // -l should be a flag
4571 assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
4572 // -1a is ambiguous - should be Int(-1) then Ident(a)
4573 // Actually the regex -[a-zA-Z] won't match -1a since 1 isn't a letter
4574 assert_eq!(
4575 lex("-1 a"),
4576 vec![Token::Int(-1), Token::Ident("a".to_string())]
4577 );
4578 }
4579
4580 #[test]
4581 fn command_with_flags() {
4582 assert_eq!(
4583 lex("ls -l"),
4584 vec![
4585 Token::Ident("ls".to_string()),
4586 Token::ShortFlag("l".to_string()),
4587 ]
4588 );
4589 assert_eq!(
4590 lex("git commit -m"),
4591 vec![
4592 Token::Ident("git".to_string()),
4593 Token::Ident("commit".to_string()),
4594 Token::ShortFlag("m".to_string()),
4595 ]
4596 );
4597 assert_eq!(
4598 lex("git push --force"),
4599 vec![
4600 Token::Ident("git".to_string()),
4601 Token::Ident("push".to_string()),
4602 Token::LongFlag("force".to_string()),
4603 ]
4604 );
4605 }
4606
4607 #[test]
4608 fn flag_with_value() {
4609 assert_eq!(
4610 lex(r#"git commit -m "message""#),
4611 vec![
4612 Token::Ident("git".to_string()),
4613 Token::Ident("commit".to_string()),
4614 Token::ShortFlag("m".to_string()),
4615 Token::String("message".to_string()),
4616 ]
4617 );
4618 assert_eq!(
4619 lex(r#"--message="hello""#),
4620 vec![
4621 Token::LongFlag("message".to_string()),
4622 Token::Eq,
4623 Token::String("hello".to_string()),
4624 ]
4625 );
4626 }
4627
4628 #[test]
4629 fn end_of_flags_marker() {
4630 assert_eq!(
4631 lex("git checkout -- file"),
4632 vec![
4633 Token::Ident("git".to_string()),
4634 Token::Ident("checkout".to_string()),
4635 Token::DoubleDash,
4636 Token::Ident("file".to_string()),
4637 ]
4638 );
4639 }
4640
4641 // ═══════════════════════════════════════════════════════════════════
4642 // Bash compatibility tokens
4643 // ═══════════════════════════════════════════════════════════════════
4644
4645 #[test]
4646 fn local_keyword() {
4647 assert_eq!(lex("local"), vec![Token::Local]);
4648 assert_eq!(
4649 lex("local X = 5"),
4650 vec![Token::Local, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
4651 );
4652 }
4653
4654 #[test]
4655 fn simple_var_ref() {
4656 assert_eq!(lex("$X"), vec![Token::SimpleVarRef("X".to_string())]);
4657 assert_eq!(lex("$foo"), vec![Token::SimpleVarRef("foo".to_string())]);
4658 assert_eq!(lex("$foo_bar"), vec![Token::SimpleVarRef("foo_bar".to_string())]);
4659 assert_eq!(lex("$_private"), vec![Token::SimpleVarRef("_private".to_string())]);
4660 }
4661
4662 #[test]
4663 fn simple_var_ref_in_command() {
4664 assert_eq!(
4665 lex("echo $NAME"),
4666 vec![Token::Ident("echo".to_string()), Token::SimpleVarRef("NAME".to_string())]
4667 );
4668 }
4669
4670 #[test]
4671 fn single_quoted_strings() {
4672 assert_eq!(lex("'hello'"), vec![Token::SingleString("hello".to_string())]);
4673 assert_eq!(lex("'hello world'"), vec![Token::SingleString("hello world".to_string())]);
4674 assert_eq!(lex("''"), vec![Token::SingleString("".to_string())]);
4675 // Single quotes don't process escapes or variables
4676 assert_eq!(lex(r"'no $VAR here'"), vec![Token::SingleString("no $VAR here".to_string())]);
4677 assert_eq!(lex(r"'backslash \n stays'"), vec![Token::SingleString(r"backslash \n stays".to_string())]);
4678 }
4679
4680 #[test]
4681 fn test_brackets() {
4682 // [[ and ]] are now two separate bracket tokens to avoid conflicts with nested arrays
4683 assert_eq!(lex("[["), vec![Token::LBracket, Token::LBracket]);
4684 assert_eq!(lex("]]"), vec![Token::RBracket, Token::RBracket]);
4685 assert_eq!(
4686 lex("[[ -f file ]]"),
4687 vec![
4688 Token::LBracket,
4689 Token::LBracket,
4690 Token::ShortFlag("f".to_string()),
4691 Token::Ident("file".to_string()),
4692 Token::RBracket,
4693 Token::RBracket
4694 ]
4695 );
4696 }
4697
4698 #[test]
4699 fn test_expression_syntax() {
4700 assert_eq!(
4701 lex(r#"[[ $X == "value" ]]"#),
4702 vec![
4703 Token::LBracket,
4704 Token::LBracket,
4705 Token::SimpleVarRef("X".to_string()),
4706 Token::EqEq,
4707 Token::String("value".to_string()),
4708 Token::RBracket,
4709 Token::RBracket
4710 ]
4711 );
4712 }
4713
4714 #[test]
4715 fn bash_style_assignment() {
4716 // NAME="value" (no spaces) - lexer sees IDENT EQ STRING
4717 assert_eq!(
4718 lex(r#"NAME="value""#),
4719 vec![
4720 Token::Ident("NAME".to_string()),
4721 Token::Eq,
4722 Token::String("value".to_string())
4723 ]
4724 );
4725 }
4726
4727 #[test]
4728 fn positional_params() {
4729 assert_eq!(lex("$0"), vec![Token::Positional(0)]);
4730 assert_eq!(lex("$1"), vec![Token::Positional(1)]);
4731 assert_eq!(lex("$9"), vec![Token::Positional(9)]);
4732 assert_eq!(lex("$@"), vec![Token::AllArgs]);
4733 assert_eq!(lex("$#"), vec![Token::ArgCount]);
4734 }
4735
4736 #[test]
4737 fn positional_in_context() {
4738 assert_eq!(
4739 lex("echo $1 $2"),
4740 vec![
4741 Token::Ident("echo".to_string()),
4742 Token::Positional(1),
4743 Token::Positional(2),
4744 ]
4745 );
4746 }
4747
4748 #[test]
4749 fn var_length() {
4750 assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
4751 assert_eq!(lex("${#NAME}"), vec![Token::VarLength("NAME".to_string())]);
4752 assert_eq!(lex("${#foo_bar}"), vec![Token::VarLength("foo_bar".to_string())]);
4753 }
4754
4755 #[test]
4756 fn var_length_with_subscript() {
4757 // The widened regex admits `[...]` subscripts so a length-of-path lexes
4758 // in expression position; the parser turns the inner into a VarPath.
4759 assert_eq!(lex("${#u[tags]}"), vec![Token::VarLength("u[tags]".to_string())]);
4760 assert_eq!(lex("${#a[0]}"), vec![Token::VarLength("a[0]".to_string())]);
4761 assert_eq!(lex("${#a[b][c]}"), vec![Token::VarLength("a[b][c]".to_string())]);
4762 assert_eq!(lex("${#r[$k]}"), vec![Token::VarLength("r[$k]".to_string())]);
4763 }
4764
4765 #[test]
4766 fn var_length_in_context() {
4767 assert_eq!(
4768 lex("echo ${#NAME}"),
4769 vec![
4770 Token::Ident("echo".to_string()),
4771 Token::VarLength("NAME".to_string()),
4772 ]
4773 );
4774 }
4775
4776 // ═══════════════════════════════════════════════════════════════════
4777 // Edge case tests: Flag ambiguities
4778 // ═══════════════════════════════════════════════════════════════════
4779
4780 #[test]
4781 fn plus_flag() {
4782 // Plus flags for set +e
4783 assert_eq!(lex("+e"), vec![Token::PlusFlag("e".to_string())]);
4784 assert_eq!(lex("+x"), vec![Token::PlusFlag("x".to_string())]);
4785 assert_eq!(lex("+ex"), vec![Token::PlusFlag("ex".to_string())]);
4786 }
4787
4788 #[test]
4789 fn set_with_plus_flag() {
4790 assert_eq!(
4791 lex("set +e"),
4792 vec![
4793 Token::Set,
4794 Token::PlusFlag("e".to_string()),
4795 ]
4796 );
4797 }
4798
4799 #[test]
4800 fn set_with_multiple_flags() {
4801 assert_eq!(
4802 lex("set -e -u"),
4803 vec![
4804 Token::Set,
4805 Token::ShortFlag("e".to_string()),
4806 Token::ShortFlag("u".to_string()),
4807 ]
4808 );
4809 }
4810
4811 #[test]
4812 fn flags_vs_negative_numbers_edge_cases() {
4813 // -1a should be negative int followed by ident
4814 assert_eq!(
4815 lex("-1 a"),
4816 vec![Token::Int(-1), Token::Ident("a".to_string())]
4817 );
4818 // -l is a flag
4819 assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
4820 // -123 is negative number
4821 assert_eq!(lex("-123"), vec![Token::Int(-123)]);
4822 }
4823
4824 #[test]
4825 fn single_dash_is_minus_alone() {
4826 // Single dash alone - now handled as MinusAlone for `cat -` stdin indicator
4827 let result = tokenize("-").expect("should lex");
4828 assert_eq!(result.len(), 1);
4829 assert!(matches!(result[0].token, Token::MinusAlone));
4830 }
4831
4832 #[test]
4833 fn plus_bare_for_date_format() {
4834 // `date +%s` - the +%s should be PlusBare
4835 let result = tokenize("+%s").expect("should lex");
4836 assert_eq!(result.len(), 1);
4837 assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%s"));
4838
4839 // `date +%Y-%m-%d` - format string with dashes
4840 let result = tokenize("+%Y-%m-%d").expect("should lex");
4841 assert_eq!(result.len(), 1);
4842 assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%Y-%m-%d"));
4843 }
4844
4845 #[test]
4846 fn plus_flag_still_works() {
4847 // `set +e` - should still be PlusFlag
4848 let result = tokenize("+e").expect("should lex");
4849 assert_eq!(result.len(), 1);
4850 assert!(matches!(result[0].token, Token::PlusFlag(ref s) if s == "e"));
4851 }
4852
4853 #[test]
4854 fn while_keyword_vs_while_loop() {
4855 // 'while' as keyword in loop context
4856 assert_eq!(lex("while"), vec![Token::While]);
4857 // 'while' at start followed by condition
4858 assert_eq!(
4859 lex("while true"),
4860 vec![Token::While, Token::True]
4861 );
4862 }
4863
4864 #[test]
4865 fn control_flow_keywords() {
4866 assert_eq!(lex("break"), vec![Token::Break]);
4867 assert_eq!(lex("continue"), vec![Token::Continue]);
4868 assert_eq!(lex("return"), vec![Token::Return]);
4869 assert_eq!(lex("exit"), vec![Token::Exit]);
4870 }
4871
4872 #[test]
4873 fn control_flow_with_numbers() {
4874 assert_eq!(
4875 lex("break 2"),
4876 vec![Token::Break, Token::Int(2)]
4877 );
4878 assert_eq!(
4879 lex("continue 3"),
4880 vec![Token::Continue, Token::Int(3)]
4881 );
4882 assert_eq!(
4883 lex("exit 1"),
4884 vec![Token::Exit, Token::Int(1)]
4885 );
4886 }
4887
4888 // ═══════════════════════════════════════════════════════════════════
4889 // Here-doc tests
4890 // ═══════════════════════════════════════════════════════════════════
4891
4892 #[test]
4893 fn heredoc_simple() {
4894 let source = "cat <<EOF\nhello\nworld\nEOF";
4895 let tokens = lex(source);
4896 // body_start_offset = byte offset of 'h' in "hello", i.e. just after "cat <<EOF\n"
4897 assert_eq!(tokens, vec![
4898 Token::Ident("cat".to_string()),
4899 Token::HereDocStart,
4900 Token::HereDoc(HereDocData {
4901 content: "hello\nworld\n".to_string(),
4902 source_body: "hello\nworld\n".to_string(),
4903 delimiter: "EOF".to_string(),
4904 literal: false,
4905 strip_tabs: false,
4906 body_start_offset: 10,
4907 }),
4908 Token::Newline,
4909 ]);
4910 }
4911
4912 #[test]
4913 fn heredoc_empty() {
4914 let source = "cat <<EOF\nEOF";
4915 let tokens = lex(source);
4916 assert_eq!(tokens, vec![
4917 Token::Ident("cat".to_string()),
4918 Token::HereDocStart,
4919 Token::HereDoc(HereDocData {
4920 content: "".to_string(),
4921 source_body: "".to_string(),
4922 delimiter: "EOF".to_string(),
4923 literal: false,
4924 strip_tabs: false,
4925 body_start_offset: 10,
4926 }),
4927 Token::Newline,
4928 ]);
4929 }
4930
4931 #[test]
4932 fn heredoc_with_special_chars() {
4933 let source = "cat <<EOF\n$VAR and \"quoted\" 'single'\nEOF";
4934 let tokens = lex(source);
4935 assert_eq!(tokens, vec![
4936 Token::Ident("cat".to_string()),
4937 Token::HereDocStart,
4938 Token::HereDoc(HereDocData {
4939 content: "$VAR and \"quoted\" 'single'\n".to_string(),
4940 source_body: "$VAR and \"quoted\" 'single'\n".to_string(),
4941 delimiter: "EOF".to_string(),
4942 literal: false,
4943 strip_tabs: false,
4944 body_start_offset: 10,
4945 }),
4946 Token::Newline,
4947 ]);
4948 }
4949
4950 #[test]
4951 fn heredoc_multiline() {
4952 let source = "cat <<END\nline1\nline2\nline3\nEND";
4953 let tokens = lex(source);
4954 assert_eq!(tokens, vec![
4955 Token::Ident("cat".to_string()),
4956 Token::HereDocStart,
4957 Token::HereDoc(HereDocData {
4958 content: "line1\nline2\nline3\n".to_string(),
4959 source_body: "line1\nline2\nline3\n".to_string(),
4960 delimiter: "END".to_string(),
4961 literal: false,
4962 strip_tabs: false,
4963 body_start_offset: 10,
4964 }),
4965 Token::Newline,
4966 ]);
4967 }
4968
4969 #[test]
4970 fn heredoc_in_command() {
4971 let source = "cat <<EOF\nhello\nEOF\necho goodbye";
4972 let tokens = lex(source);
4973 assert_eq!(tokens, vec![
4974 Token::Ident("cat".to_string()),
4975 Token::HereDocStart,
4976 Token::HereDoc(HereDocData {
4977 content: "hello\n".to_string(),
4978 source_body: "hello\n".to_string(),
4979 delimiter: "EOF".to_string(),
4980 literal: false,
4981 strip_tabs: false,
4982 body_start_offset: 10,
4983 }),
4984 Token::Newline,
4985 Token::Ident("echo".to_string()),
4986 Token::Ident("goodbye".to_string()),
4987 ]);
4988 }
4989
4990 #[test]
4991 fn heredoc_strip_tabs() {
4992 let source = "cat <<-EOF\n\thello\n\tworld\n\tEOF";
4993 let tokens = lex(source);
4994 // Content keeps tabs verbatim — strip_tabs is recorded on the token so
4995 // the interpreter can apply POSIX leading-tab stripping at materialization
4996 // without disturbing source byte offsets used for span tracking.
4997 assert_eq!(tokens, vec![
4998 Token::Ident("cat".to_string()),
4999 Token::HereDocStart,
5000 Token::HereDoc(HereDocData {
5001 content: "\thello\n\tworld\n".to_string(),
5002 source_body: "\thello\n\tworld\n".to_string(),
5003 delimiter: "EOF".to_string(),
5004 literal: false,
5005 strip_tabs: true,
5006 body_start_offset: 11,
5007 }),
5008 Token::Newline,
5009 ]);
5010 }
5011
5012 // ═══════════════════════════════════════════════════════════════════
5013 // Arithmetic expression tests
5014 // ═══════════════════════════════════════════════════════════════════
5015
5016 #[test]
5017 fn arithmetic_simple() {
5018 let source = "$((1 + 2))";
5019 let tokens = lex(source);
5020 assert_eq!(tokens, vec![Token::Arithmetic("1 + 2".to_string())]);
5021 }
5022
5023 #[test]
5024 fn arithmetic_in_assignment() {
5025 let source = "X=$((5 * 3))";
5026 let tokens = lex(source);
5027 assert_eq!(tokens, vec![
5028 Token::Ident("X".to_string()),
5029 Token::Eq,
5030 Token::Arithmetic("5 * 3".to_string()),
5031 ]);
5032 }
5033
5034 #[test]
5035 fn arithmetic_with_nested_parens() {
5036 let source = "$((2 * (3 + 4)))";
5037 let tokens = lex(source);
5038 assert_eq!(tokens, vec![Token::Arithmetic("2 * (3 + 4)".to_string())]);
5039 }
5040
5041 #[test]
5042 fn arithmetic_with_variable() {
5043 let source = "$((X + 1))";
5044 let tokens = lex(source);
5045 assert_eq!(tokens, vec![Token::Arithmetic("X + 1".to_string())]);
5046 }
5047
5048 #[test]
5049 fn arithmetic_command_subst_not_confused() {
5050 // $( should not be treated as arithmetic
5051 let source = "$(echo hello)";
5052 let tokens = lex(source);
5053 assert_eq!(tokens, vec![
5054 Token::CmdSubstStart,
5055 Token::Ident("echo".to_string()),
5056 Token::Ident("hello".to_string()),
5057 Token::RParen,
5058 ]);
5059 }
5060
5061 #[test]
5062 fn arithmetic_nesting_limit() {
5063 // Create deeply nested parens that exceed MAX_PAREN_DEPTH (256)
5064 let open_parens = "(".repeat(300);
5065 let close_parens = ")".repeat(300);
5066 let source = format!("$(({}1{}))", open_parens, close_parens);
5067 let result = tokenize(&source);
5068 assert!(result.is_err());
5069 let errors = result.unwrap_err();
5070 assert_eq!(errors.len(), 1);
5071 assert_eq!(errors[0].token, LexerError::NestingTooDeep);
5072 }
5073
5074 #[test]
5075 fn arithmetic_nesting_within_limit() {
5076 // Nesting within limit should work
5077 let source = "$((((1 + 2) * 3)))";
5078 let tokens = lex(source);
5079 assert_eq!(tokens, vec![Token::Arithmetic("((1 + 2) * 3)".to_string())]);
5080 }
5081
5082 // ═══════════════════════════════════════════════════════════════════
5083 // extract_arithmetic must be region-aware, not a flat paren counter.
5084 //
5085 // A quoted `(` or `)` inside a `$(…)` nested in `$(( ))` used to
5086 // increment/decrement the SAME flat depth counter that decides where
5087 // the real `))` is, so `$(( $(echo "(" ) + 1 ))` read the quoted `(`
5088 // as arithmetic grouping and never found its true close. These hand
5089 // the nested `$(…)` to `copy_substitution_verbatim` — the region-stack
5090 // scanner `scan`'s double-quoted-string arm already uses for the same
5091 // problem — so its interior never touches `depth`.
5092 // ═══════════════════════════════════════════════════════════════════
5093
5094 #[test]
5095 fn arithmetic_dquote_open_paren_in_nested_cmdsubst() {
5096 let source = r#"$(( $(echo "(" >/dev/null; echo 5) + 1 ))"#;
5097 let tokens = lex(source);
5098 assert_eq!(
5099 tokens,
5100 vec![Token::Arithmetic(
5101 r#" $(echo "(" >/dev/null; echo 5) + 1 "#.to_string()
5102 )]
5103 );
5104 }
5105
5106 #[test]
5107 fn arithmetic_dquote_close_paren_in_nested_cmdsubst() {
5108 let source = r#"$(( $(echo ")" >/dev/null; echo 5) + 1 ))"#;
5109 let tokens = lex(source);
5110 assert_eq!(
5111 tokens,
5112 vec![Token::Arithmetic(
5113 r#" $(echo ")" >/dev/null; echo 5) + 1 "#.to_string()
5114 )]
5115 );
5116 }
5117
5118 #[test]
5119 fn arithmetic_squote_open_paren_in_nested_cmdsubst() {
5120 let source = "$(( $(echo '(' >/dev/null; echo 5) + 1 ))";
5121 let tokens = lex(source);
5122 assert_eq!(
5123 tokens,
5124 vec![Token::Arithmetic(
5125 " $(echo '(' >/dev/null; echo 5) + 1 ".to_string()
5126 )]
5127 );
5128 }
5129
5130 #[test]
5131 fn arithmetic_squote_close_paren_in_nested_cmdsubst() {
5132 let source = "$(( $(echo ')' >/dev/null; echo 5) + 1 ))";
5133 let tokens = lex(source);
5134 assert_eq!(
5135 tokens,
5136 vec![Token::Arithmetic(
5137 " $(echo ')' >/dev/null; echo 5) + 1 ".to_string()
5138 )]
5139 );
5140 }
5141
5142 #[test]
5143 fn bare_arith_dquote_paren_in_nested_cmdsubst() {
5144 // Same defect, bare `(( ))` condition form.
5145 let source = r#"(( $(echo "(" >/dev/null; echo 5) > 1 ))"#;
5146 let tokens = lex(source);
5147 assert_eq!(
5148 tokens,
5149 vec![Token::ArithCond(
5150 r#" $(echo "(" >/dev/null; echo 5) > 1 "#.to_string()
5151 )]
5152 );
5153 }
5154
5155 #[test]
5156 fn arithmetic_escaped_open_paren_in_body_does_not_count() {
5157 // Today's flat counter treats the escaped `(` as a real depth
5158 // increment, so the real `))` never arrives and the expression is
5159 // reported unterminated.
5160 let source = r"$(( \( 1 + 1 ))";
5161 let tokens = lex(source);
5162 assert_eq!(tokens, vec![Token::Arithmetic(r" \( 1 + 1 ".to_string())]);
5163 }
5164
5165 #[test]
5166 fn arithmetic_escaped_close_paren_in_body_does_not_count() {
5167 // An escaped `)` immediately followed by a real `)` used to read
5168 // as the `))` pair that closes arithmetic, truncating the
5169 // expression early and leaking the remainder as program text.
5170 let source = r"$(( 1 \)) + 1 ))";
5171 let tokens = lex(source);
5172 assert_eq!(
5173 tokens,
5174 vec![Token::Arithmetic(r" 1 \)) + 1 ".to_string())]
5175 );
5176 }
5177
5178 #[test]
5179 fn arithmetic_two_levels_of_nested_cmdsubst_with_quoted_paren() {
5180 // $(...) inside $(...) inside $(( )), with the quoted paren at the
5181 // innermost level — the hand-off must recurse.
5182 let source = r#"$(( $(echo $(echo "(" >/dev/null; echo 3)) + 1 ))"#;
5183 let tokens = lex(source);
5184 assert_eq!(
5185 tokens,
5186 vec![Token::Arithmetic(
5187 r#" $(echo $(echo "(" >/dev/null; echo 3)) + 1 "#.to_string()
5188 )]
5189 );
5190 }
5191
5192 #[test]
5193 fn arithmetic_comment_paren_in_nested_cmdsubst() {
5194 // `#` inside the nested $(...) is a comment; the `)` on its line
5195 // is not structure. Matches the kernel-level
5196 // `cmdsubst_comment_inside_arith_should_be_honored`.
5197 let source = "$(( $(true # )\necho 1) ))";
5198 let tokens = lex(source);
5199 assert_eq!(
5200 tokens,
5201 vec![Token::Arithmetic(" $(true # )\necho 1) ".to_string())]
5202 );
5203 }
5204
5205 #[test]
5206 fn arithmetic_control_still_unterminated_without_close() {
5207 // Genuinely unterminated: no `))` anywhere. Must still error —
5208 // region-awareness must not make the scanner permissive.
5209 let source = "$(( 1 + 2";
5210 let result = tokenize(source);
5211 assert!(result.is_err());
5212 let errors = result.unwrap_err();
5213 assert_eq!(errors[0].token, LexerError::UnterminatedArithmetic);
5214 }
5215
5216 #[test]
5217 fn arithmetic_control_still_unterminated_with_unbalanced_paren() {
5218 // A genuinely unbalanced, unquoted, unescaped `(` still consumes
5219 // the trailing `))` as its own close and leaves nothing to end
5220 // the expression.
5221 let source = "$(( ( 1 + 2 ))";
5222 let result = tokenize(source);
5223 assert!(result.is_err());
5224 let errors = result.unwrap_err();
5225 assert_eq!(errors[0].token, LexerError::UnterminatedArithmetic);
5226 }
5227
5228 // ═══════════════════════════════════════════════════════════════════
5229 // Arithmetic preprocessor + comment interaction
5230 //
5231 // The preprocessor used to walk raw characters tracking only quote
5232 // state. An apostrophe inside a `#` comment would open single-quote
5233 // mode and swallow real `$((..))` later in the file; `$((..))` *inside*
5234 // a comment would itself be preprocessed into a marker, misplacing
5235 // tokens. Surfaced from kaijutsu's seed scripts (see gotcha memory
5236 // `gotcha-kaish-comment-arithmetic`).
5237 // ═══════════════════════════════════════════════════════════════════
5238
5239 #[test]
5240 fn arithmetic_after_apostrophe_in_comment() {
5241 // The bare apostrophe in "doesn't" used to open single-quote mode
5242 // in the preprocessor and swallow the $((..)) below.
5243 let source = "# this doesn't work\necho $((1+2))";
5244 let tokens = lex(source);
5245 assert_eq!(tokens, vec![
5246 Token::Newline,
5247 Token::Ident("echo".to_string()),
5248 Token::Arithmetic("1+2".to_string()),
5249 ]);
5250 }
5251
5252 #[test]
5253 fn arithmetic_inside_comment_is_not_expanded() {
5254 // `$((y))` inside a `#` comment must stay comment text.
5255 let source = "# the $((y)) syntax explained\necho hello";
5256 let tokens = lex(source);
5257 assert_eq!(tokens, vec![
5258 Token::Newline,
5259 Token::Ident("echo".to_string()),
5260 Token::Ident("hello".to_string()),
5261 ]);
5262 }
5263
5264 #[test]
5265 fn backticked_arithmetic_in_comment_is_not_expanded() {
5266 // The original kaijutsu repro: `$((x))` inside a comment.
5267 // Backticks-in-comments used to leak the inner $((..)) to the
5268 // preprocessor; with comment-skip they stay inert.
5269 let source = "# the `$((x))` syntax explained\necho $((3+4))";
5270 let tokens = lex(source);
5271 assert_eq!(tokens, vec![
5272 Token::Newline,
5273 Token::Ident("echo".to_string()),
5274 Token::Arithmetic("3+4".to_string()),
5275 ]);
5276 }
5277
5278 #[test]
5279 fn arithmetic_still_works_outside_comments() {
5280 // Regression guard: comment-skip must not shrink the arithmetic
5281 // preprocessor's scope on normal `$((..))` usages.
5282 let source = "X=$((1+2)); Y=$((3*4))";
5283 let tokens = lex(source);
5284 assert_eq!(tokens, vec![
5285 Token::Ident("X".to_string()),
5286 Token::Eq,
5287 Token::Arithmetic("1+2".to_string()),
5288 Token::Semi,
5289 Token::Ident("Y".to_string()),
5290 Token::Eq,
5291 Token::Arithmetic("3*4".to_string()),
5292 ]);
5293 }
5294
5295 #[test]
5296 fn arithmetic_inside_double_quotes_still_expands() {
5297 // `#` inside a double-quoted string is a literal character, not a
5298 // comment introducer — arithmetic must still expand around it.
5299 let source = "echo \"# $((1+2))\"";
5300 let tokens = lex(source);
5301 // The string token contains the `#` and the arithmetic marker;
5302 // the exact post-processing happens at interpret time. What we
5303 // assert here is that lexing succeeds and produces a String token
5304 // (i.e. the comment skip didn't trigger inside the string).
5305 assert_eq!(tokens.len(), 2);
5306 assert!(matches!(tokens[0], Token::Ident(_)));
5307 assert!(matches!(tokens[1], Token::String(_)));
5308 }
5309
5310 // ═══════════════════════════════════════════════════════════════════
5311 // Backtick rejection
5312 //
5313 // Backticks are an explicitly dropped feature (see CLAUDE.md,
5314 // docs/LANGUAGE.md, help/limits.md, help/overview.md). We surface a
5315 // dedicated error rather than the generic `UnexpectedCharacter` so
5316 // users get a hint to use `$(cmd)`. Comments, single-quoted strings,
5317 // double-quoted strings, and heredoc bodies are all matched as single
5318 // tokens (or extracted before logos runs), so the rejection only
5319 // fires on bare backticks in source code.
5320 // ═══════════════════════════════════════════════════════════════════
5321
5322 #[test]
5323 fn backtick_in_source_is_rejected() {
5324 let result = tokenize("echo `date`");
5325 assert!(result.is_err());
5326 let errors = result.unwrap_err();
5327 assert!(errors.iter().any(|e| e.token == LexerError::BackticksNotSupported));
5328 }
5329
5330 #[test]
5331 fn backtick_in_comment_is_just_comment_text() {
5332 // Backticks are only rejected when they reach the top-level
5333 // lexer. Inside a comment they're part of the comment body.
5334 let source = "# use `date` here\necho hi";
5335 let tokens = lex(source);
5336 assert_eq!(tokens, vec![
5337 Token::Newline,
5338 Token::Ident("echo".to_string()),
5339 Token::Ident("hi".to_string()),
5340 ]);
5341 }
5342
5343 #[test]
5344 fn backtick_in_single_quoted_string_is_literal() {
5345 // Single-quoted strings are matched as one token by logos; the
5346 // backticks inside never reach the rejecting matcher.
5347 let source = "echo '`date`'";
5348 let tokens = lex(source);
5349 assert_eq!(tokens, vec![
5350 Token::Ident("echo".to_string()),
5351 Token::SingleString("`date`".to_string()),
5352 ]);
5353 }
5354
5355 #[test]
5356 fn backtick_in_double_quoted_string_is_literal() {
5357 // Kaish does not activate command substitution from backticks
5358 // inside double-quoted strings either — clear divergence from
5359 // POSIX but matches the "backticks don't exist" stance. The
5360 // double-quoted string token absorbs them as literal characters.
5361 let source = "echo \"`date`\"";
5362 let tokens = lex(source);
5363 assert_eq!(tokens.len(), 2);
5364 assert!(matches!(tokens[0], Token::Ident(_)));
5365 match &tokens[1] {
5366 Token::String(s) => assert!(s.contains('`')),
5367 other => panic!("expected Token::String, got {:?}", other),
5368 }
5369 }
5370
5371 #[test]
5372 fn backtick_in_heredoc_body_is_preserved() {
5373 // Heredoc bodies are extracted by the scanner before logos
5374 // runs, so backticks inside them survive as content.
5375 let source = "cat <<EOF\n`date`\nEOF\n";
5376 let tokens = lex(source);
5377 let heredoc = tokens.iter().find(|t| matches!(t, Token::HereDoc(_)));
5378 assert!(heredoc.is_some(), "expected a HereDoc token");
5379 if let Some(Token::HereDoc(d)) = heredoc {
5380 assert!(d.content.contains('`'));
5381 }
5382 }
5383
5384 // ═══════════════════════════════════════════════════════════════════
5385 // Token category tests
5386 // ═══════════════════════════════════════════════════════════════════
5387
5388 #[test]
5389 fn token_categories() {
5390 // Keywords
5391 assert_eq!(Token::If.category(), TokenCategory::Keyword);
5392 assert_eq!(Token::Then.category(), TokenCategory::Keyword);
5393 assert_eq!(Token::For.category(), TokenCategory::Keyword);
5394 assert_eq!(Token::Function.category(), TokenCategory::Keyword);
5395 assert_eq!(Token::True.category(), TokenCategory::Keyword);
5396 assert_eq!(Token::TypeString.category(), TokenCategory::Keyword);
5397
5398 // Operators
5399 assert_eq!(Token::Pipe.category(), TokenCategory::Operator);
5400 assert_eq!(Token::And.category(), TokenCategory::Operator);
5401 assert_eq!(Token::Or.category(), TokenCategory::Operator);
5402 assert_eq!(Token::StderrToStdout.category(), TokenCategory::Operator);
5403 assert_eq!(Token::GtGt.category(), TokenCategory::Operator);
5404
5405 // Strings
5406 assert_eq!(Token::String("test".to_string()).category(), TokenCategory::String);
5407 assert_eq!(Token::SingleString("test".to_string()).category(), TokenCategory::String);
5408 assert_eq!(
5409 Token::HereDoc(HereDocData {
5410 content: "test".to_string(),
5411 source_body: "test".to_string(),
5412 delimiter: "EOF".to_string(),
5413 literal: false,
5414 strip_tabs: false,
5415 body_start_offset: 0,
5416 }).category(),
5417 TokenCategory::String,
5418 );
5419
5420 // Numbers
5421 assert_eq!(Token::Int(42).category(), TokenCategory::Number);
5422 assert_eq!(Token::Float(3.14).category(), TokenCategory::Number);
5423 assert_eq!(Token::Arithmetic("1+2".to_string()).category(), TokenCategory::Number);
5424
5425 // Variables
5426 assert_eq!(Token::SimpleVarRef("X".to_string()).category(), TokenCategory::Variable);
5427 assert_eq!(Token::VarRef("${X}".to_string()).category(), TokenCategory::Variable);
5428 assert_eq!(Token::Positional(1).category(), TokenCategory::Variable);
5429 assert_eq!(Token::AllArgs.category(), TokenCategory::Variable);
5430 assert_eq!(Token::ArgCount.category(), TokenCategory::Variable);
5431 assert_eq!(Token::LastExitCode.category(), TokenCategory::Variable);
5432 assert_eq!(Token::CurrentPid.category(), TokenCategory::Variable);
5433
5434 // Flags
5435 assert_eq!(Token::ShortFlag("l".to_string()).category(), TokenCategory::Flag);
5436 assert_eq!(Token::LongFlag("verbose".to_string()).category(), TokenCategory::Flag);
5437 assert_eq!(Token::PlusFlag("e".to_string()).category(), TokenCategory::Flag);
5438 assert_eq!(Token::DoubleDash.category(), TokenCategory::Flag);
5439
5440 // Punctuation
5441 assert_eq!(Token::Semi.category(), TokenCategory::Punctuation);
5442 assert_eq!(Token::LParen.category(), TokenCategory::Punctuation);
5443 assert_eq!(Token::LBracket.category(), TokenCategory::Punctuation);
5444 assert_eq!(Token::Newline.category(), TokenCategory::Punctuation);
5445
5446 // Comments
5447 assert_eq!(Token::Comment.category(), TokenCategory::Comment);
5448
5449 // Paths
5450 assert_eq!(Token::Path("/tmp/file".to_string()).category(), TokenCategory::Path);
5451
5452 // Commands
5453 assert_eq!(Token::Ident("echo".to_string()).category(), TokenCategory::Command);
5454 assert_eq!(Token::NumberIdent("019dda1c".to_string()).category(), TokenCategory::Command);
5455 assert_eq!(Token::DottedIdent(".gitignore".to_string()).category(), TokenCategory::Command);
5456
5457 // Errors
5458 assert_eq!(Token::InvalidFloatNoLeading.category(), TokenCategory::Error);
5459 assert_eq!(Token::InvalidFloatNoTrailing.category(), TokenCategory::Error);
5460 }
5461
5462 #[test]
5463 fn test_heredoc_piped_to_command() {
5464 // Bug 4: "cat <<EOF | jq" should produce: cat <<heredoc | jq
5465 // Not: cat | jq <<heredoc
5466 let tokens = tokenize("cat <<EOF | jq\n{\"key\": \"val\"}\nEOF").unwrap();
5467 let heredoc_pos = tokens.iter().position(|t| matches!(t.token, Token::HereDoc(_)));
5468 let pipe_pos = tokens.iter().position(|t| matches!(t.token, Token::Pipe));
5469 assert!(heredoc_pos.is_some(), "should have a heredoc token");
5470 assert!(pipe_pos.is_some(), "should have a pipe token");
5471 assert!(
5472 pipe_pos.unwrap() > heredoc_pos.unwrap(),
5473 "Pipe must come after heredoc, got heredoc at {}, pipe at {}. Tokens: {:?}",
5474 heredoc_pos.unwrap(), pipe_pos.unwrap(), tokens,
5475 );
5476 }
5477
5478 #[test]
5479 fn test_heredoc_standalone_still_works() {
5480 // Regression: standalone heredoc (no pipe) must still work
5481 let tokens = tokenize("cat <<EOF\nhello\nEOF").unwrap();
5482 assert!(tokens.iter().any(|t| matches!(t.token, Token::HereDoc(_))));
5483 assert!(!tokens.iter().any(|t| matches!(t.token, Token::Pipe)));
5484 }
5485
5486 #[test]
5487 fn test_heredoc_preserves_leading_empty_lines() {
5488 // Bug B: heredoc starting with a blank line must preserve it
5489 let tokens = tokenize("cat <<EOF\n\nhello\nEOF").unwrap();
5490 let heredoc = tokens.iter().find_map(|t| {
5491 if let Token::HereDoc(data) = &t.token {
5492 Some(data.clone())
5493 } else {
5494 None
5495 }
5496 });
5497 assert!(heredoc.is_some(), "should have a heredoc token");
5498 let data = heredoc.unwrap();
5499 assert!(data.content.starts_with('\n'), "leading empty line must be preserved, got: {:?}", data.content);
5500 assert_eq!(data.content, "\nhello\n");
5501 }
5502
5503 #[test]
5504 fn test_heredoc_quoted_delimiter_sets_literal() {
5505 // Bug N: quoted delimiter (<<'EOF') should set literal=true
5506 let tokens = tokenize("cat <<'EOF'\nhello $HOME\nEOF").unwrap();
5507 let heredoc = tokens.iter().find_map(|t| {
5508 if let Token::HereDoc(data) = &t.token {
5509 Some(data.clone())
5510 } else {
5511 None
5512 }
5513 });
5514 assert!(heredoc.is_some(), "should have a heredoc token");
5515 let data = heredoc.unwrap();
5516 assert!(data.literal, "quoted delimiter should set literal=true");
5517 assert_eq!(data.content, "hello $HOME\n");
5518 }
5519
5520 #[test]
5521 fn test_heredoc_unquoted_delimiter_not_literal() {
5522 // Bug N: unquoted delimiter (<<EOF) should have literal=false
5523 let tokens = tokenize("cat <<EOF\nhello $HOME\nEOF").unwrap();
5524 let heredoc = tokens.iter().find_map(|t| {
5525 if let Token::HereDoc(data) = &t.token {
5526 Some(data.clone())
5527 } else {
5528 None
5529 }
5530 });
5531 assert!(heredoc.is_some(), "should have a heredoc token");
5532 let data = heredoc.unwrap();
5533 assert!(!data.literal, "unquoted delimiter should have literal=false");
5534 }
5535
5536 // ═══════════════════════════════════════════════════════════════════
5537 // Colon merge tests
5538 // ═══════════════════════════════════════════════════════════════════
5539
5540 #[test]
5541 fn colon_double_in_word() {
5542 assert_eq!(lex("foo::bar"), vec![Token::Ident("foo::bar".into())]);
5543 }
5544
5545 #[test]
5546 fn colon_single_in_word() {
5547 assert_eq!(lex("a:b:c"), vec![Token::Ident("a:b:c".into())]);
5548 }
5549
5550 #[test]
5551 fn colon_with_port() {
5552 assert_eq!(lex("host:8080"), vec![Token::Ident("host:8080".into())]);
5553 }
5554
5555 #[test]
5556 fn colon_standalone() {
5557 assert_eq!(lex(":"), vec![Token::Colon]);
5558 }
5559
5560 #[test]
5561 fn colon_spaced_no_merge() {
5562 assert_eq!(
5563 lex("foo : bar"),
5564 vec![
5565 Token::Ident("foo".into()),
5566 Token::Colon,
5567 Token::Ident("bar".into()),
5568 ]
5569 );
5570 }
5571
5572 #[test]
5573 fn colon_in_command_arg() {
5574 assert_eq!(
5575 lex("echo foo::bar"),
5576 vec![
5577 Token::Ident("echo".into()),
5578 Token::Ident("foo::bar".into()),
5579 ]
5580 );
5581 }
5582
5583 #[test]
5584 fn colon_trailing() {
5585 // Trailing colon merges with preceding ident
5586 assert_eq!(lex("foo:"), vec![Token::Ident("foo:".into())]);
5587 }
5588
5589 #[test]
5590 fn colon_leading() {
5591 // Leading colon merges with following ident
5592 assert_eq!(lex(":foo"), vec![Token::Ident(":foo".into())]);
5593 }
5594
5595 #[test]
5596 fn colon_with_path() {
5597 // Path token + colon + int
5598 assert_eq!(
5599 lex("/usr/bin:8080"),
5600 vec![Token::Ident("/usr/bin:8080".into())]
5601 );
5602 }
5603
5604 // ═══════════════════════════════════════════════════════════════════
5605 // Token predicate coverage (is_keyword / starts_statement)
5606 // ═══════════════════════════════════════════════════════════════════
5607
5608 #[test]
5609 fn is_keyword_covers_control_flow() {
5610 for t in [
5611 Token::While,
5612 Token::Return,
5613 Token::Break,
5614 Token::Continue,
5615 Token::Exit,
5616 ] {
5617 assert!(t.is_keyword(), "{t:?} should be a keyword");
5618 }
5619 }
5620
5621 #[test]
5622 fn starts_statement_covers_while() {
5623 assert!(Token::While.starts_statement());
5624 }
5625
5626 #[test]
5627 fn is_keyword_rejects_operators() {
5628 for t in [Token::Pipe, Token::Amp, Token::Eq, Token::LBrace] {
5629 assert!(!t.is_keyword(), "{t:?} should not be a keyword");
5630 }
5631 }
5632
5633 // ═══════════════════════════════════════════════════════════════════
5634 // Comma significance: only inside a `[...]`/`{...}` literal or pattern
5635 // (see `run_has_bare_comma`, `compute_bracket_depth`). Outside brackets
5636 // a comma folds into the surrounding bareword like any other ordinary
5637 // character. Kernel-level (`echo`/`sed`/`cut`/`sort` output) coverage
5638 // lives in `tests/bareword_comma_tests.rs`, `tests/builtin_fidelity_tests.rs`,
5639 // and `tests/sort_key_tests.rs`; these are the precise token-shape
5640 // assertions that don't fit an integration test.
5641 // ═══════════════════════════════════════════════════════════════════
5642
5643 #[test]
5644 fn bare_comma_run_folds_to_ident() {
5645 // sed -n 1,3p / cut -f 1,3 / sort -k 2,2n: no brackets anywhere, so
5646 // the comma has no grammatical role and folds into one bareword.
5647 assert_eq!(lex("1,3p"), vec![Token::Ident("1,3p".into())]);
5648 assert_eq!(lex("1,3"), vec![Token::Ident("1,3".into())]);
5649 assert_eq!(lex("2,2n"), vec![Token::Ident("2,2n".into())]);
5650 assert_eq!(lex("a,b"), vec![Token::Ident("a,b".into())]);
5651 assert_eq!(lex("1,2,3"), vec![Token::Ident("1,2,3".into())]);
5652 }
5653
5654 #[test]
5655 fn standalone_comma_stays_a_token() {
5656 // Whitespace on both sides: nothing to fold into, stays `Comma` —
5657 // this is the `cut -d , -f2` idiom (see `bareword_comma_tests.rs`).
5658 assert_eq!(
5659 lex("cut -d , -f2"),
5660 vec![
5661 Token::Ident("cut".into()),
5662 Token::ShortFlag("d".into()),
5663 Token::Comma,
5664 Token::ShortFlag("f2".into()),
5665 ]
5666 );
5667 }
5668
5669 #[test]
5670 fn case_pattern_brace_comma_stays_significant() {
5671 // `{js,ts}` has no `*`/`?`, so it never reaches the has_star_or_question
5672 // glob-fuse path either — the comma must stay a separate token for
5673 // `case_parser`'s brace-expansion grammar (parser.rs `case_parser`).
5674 assert_eq!(
5675 lex("{js,ts}"),
5676 vec![
5677 Token::LBrace,
5678 Token::Ident("js".into()),
5679 Token::Comma,
5680 Token::Ident("ts".into()),
5681 Token::RBrace,
5682 ]
5683 );
5684 }
5685
5686 #[test]
5687 fn case_pattern_paren_inside_list_literal_cmd_subst_does_not_leak_list_frame() {
5688 // `compute_value_context`'s Frame stack has no `Case` variant, so a
5689 // case-branch pattern's unpaired `)` (`case b in b) …`, no leading
5690 // `(`) used to fall through the `RParen` handler's dangling-frame
5691 // sweep and pop the nearest `Subst`/`Paren` — here the `$(...)`
5692 // that the case is actually inside. When that `$(...)` sits inside
5693 // an outer `[...]` list literal, popping it early exposes the
5694 // outer `List` frame, and the (still relative) floor check reads
5695 // it as still open: a glob argument inside the case body then gets
5696 // misread as being at value/list-literal position and its
5697 // `[...]` bracket pair does not glob-fuse. Compare against the
5698 // same case body with no outer list literal, where it fuses.
5699 assert_eq!(
5700 lex("x=[a $(case b in b) echo [dog];; esac) c]"),
5701 vec![
5702 Token::Ident("x".into()),
5703 Token::Eq,
5704 Token::LBracket,
5705 Token::Ident("a".into()),
5706 Token::CmdSubstStart,
5707 Token::Case,
5708 Token::Ident("b".into()),
5709 Token::In,
5710 Token::Ident("b".into()),
5711 Token::RParen,
5712 Token::Ident("echo".into()),
5713 Token::GlobWord("[dog]".into()),
5714 Token::DoubleSemi,
5715 Token::Esac,
5716 Token::RParen,
5717 Token::Ident("c".into()),
5718 Token::RBracket,
5719 ]
5720 );
5721 }
5722
5723 #[test]
5724 fn esac_bareword_inside_still_open_case_does_not_leak_list_frame() {
5725 // The sharper form of the previous test: an `esac` bareword INSIDE
5726 // a case that is genuinely still open (`y=esac` is branch `v)`'s
5727 // whole body; the case's real closer comes two branches later).
5728 // Popping the `Case` frame whenever it's merely innermost — rather
5729 // than only while `awaiting_pattern` (right after `case … in` or a
5730 // `;;`) — treats this bareword as the closer too, exposing the
5731 // outer `List` frame early the same way the unpaired-`)` case did,
5732 // so `[dog]` in the LAST branch does not glob-fuse either.
5733 assert_eq!(
5734 lex("x=[a $(case v in v) y=esac;; w) echo [dog];; esac) c]"),
5735 vec![
5736 Token::Ident("x".into()),
5737 Token::Eq,
5738 Token::LBracket,
5739 Token::Ident("a".into()),
5740 Token::CmdSubstStart,
5741 Token::Case,
5742 Token::Ident("v".into()),
5743 Token::In,
5744 Token::Ident("v".into()),
5745 Token::RParen,
5746 Token::Ident("y".into()),
5747 Token::Eq,
5748 Token::Esac,
5749 Token::DoubleSemi,
5750 Token::Ident("w".into()),
5751 Token::RParen,
5752 Token::Ident("echo".into()),
5753 Token::GlobWord("[dog]".into()),
5754 Token::DoubleSemi,
5755 Token::Esac,
5756 Token::RParen,
5757 Token::Ident("c".into()),
5758 Token::RBracket,
5759 ]
5760 );
5761 }
5762
5763 #[test]
5764 fn case_pattern_parenthesized_paren_inside_list_literal_cmd_subst_does_not_leak_list_frame() {
5765 // The parenthesized twin of `esac_bareword_inside_still_open_case_
5766 // does_not_leak_list_frame` above: the FIRST branch's pattern is
5767 // spelled `(v)` instead of bare `v)`. Popping the `Paren` frame the
5768 // leading `(` pushed used to leave the `Case` frame beneath stuck at
5769 // `awaiting_pattern: true` (the docstring's contract — "false once a
5770 // pattern's `)` has been consumed" — went unmet for this spelling),
5771 // so the bareword `esac` in `y=esac` (branch `v)`'s whole body) then
5772 // read as the case's own closer, popping the frame early and
5773 // exposing the outer `List` frame the same way an unpaired `)`
5774 // does — `[dog]` in the LAST branch fails to glob-fuse.
5775 assert_eq!(
5776 lex("x=[a $(case v in (v) y=esac;; w) echo [dog];; esac) c]"),
5777 vec![
5778 Token::Ident("x".into()),
5779 Token::Eq,
5780 Token::LBracket,
5781 Token::Ident("a".into()),
5782 Token::CmdSubstStart,
5783 Token::Case,
5784 Token::Ident("v".into()),
5785 Token::In,
5786 Token::LParen,
5787 Token::Ident("v".into()),
5788 Token::RParen,
5789 Token::Ident("y".into()),
5790 Token::Eq,
5791 Token::Esac,
5792 Token::DoubleSemi,
5793 Token::Ident("w".into()),
5794 Token::RParen,
5795 Token::Ident("echo".into()),
5796 Token::GlobWord("[dog]".into()),
5797 Token::DoubleSemi,
5798 Token::Esac,
5799 Token::RParen,
5800 Token::Ident("c".into()),
5801 Token::RBracket,
5802 ]
5803 );
5804 }
5805
5806 #[test]
5807 fn case_eq_argv_key_inside_cmd_subst_does_not_leak_open_scope() {
5808 // `case` is a valid `key=value` argv key (`case=x`, same as `in=a`/
5809 // `do=b` — see `keyword_key_argv_assignment_parses` in
5810 // parser_tests.rs), but `case` is the only one of those keywords
5811 // that pushes a structural frame. Pushing one unconditionally on
5812 // every `Token::Case` — including `case=x`'s — leaves a phantom
5813 // `Case` frame that nothing but a stray `)` or bareword `esac` ever
5814 // touches again. Here that phantom frame swallows the `$(...)`'s
5815 // own closing `)` (an `RParen` while a `Case` frame is innermost
5816 // only clears `awaiting_pattern`; it never pops), so the `Subst`
5817 // scope never closes and the outer list literal's own closing `]`
5818 // gets fused into a bogus glob token along with the unrelated
5819 // `[dog]` that follows — proof the corruption reaches past the
5820 // substitution's own boundary, not just within it.
5821 assert_eq!(
5822 lex("x=[a $(echo case=x) echo [dog]]"),
5823 vec![
5824 Token::Ident("x".into()),
5825 Token::Eq,
5826 Token::LBracket,
5827 Token::Ident("a".into()),
5828 Token::CmdSubstStart,
5829 Token::Ident("echo".into()),
5830 Token::Case,
5831 Token::Eq,
5832 Token::Ident("x".into()),
5833 Token::RParen,
5834 Token::Ident("echo".into()),
5835 Token::LBracket,
5836 Token::Ident("dog".into()),
5837 Token::RBracket,
5838 Token::RBracket,
5839 ]
5840 );
5841 }
5842
5843 #[test]
5844 fn glob_brace_expansion_with_star_still_fuses() {
5845 // A `*` elsewhere in the word triggers the EXISTING glob-fuse path
5846 // (unrelated to the new bare-comma fold) — the whole thing becomes
5847 // one `GlobWord`, comma included, for the glob engine to expand.
5848 assert_eq!(lex("*.{js,ts}"), vec![Token::GlobWord("*.{js,ts}".into())]);
5849 assert_eq!(
5850 lex("src/*.{rs,toml}"),
5851 vec![Token::GlobWord("src/*.{rs,toml}".into())]
5852 );
5853 }
5854
5855 #[test]
5856 fn bracket_list_with_spaces_keeps_comma_significant() {
5857 // `[1, 2, 3]` splits into three whitespace-bounded runs ("[1,", "2,",
5858 // "3]") — the opening `[` is in the FIRST run, not the run that owns
5859 // the middle comma, so this only works with the cross-run
5860 // `compute_bracket_depth` seed (a per-run-only counter would
5861 // wrongly fold "2," into one bareword — see PR discussion / GH
5862 // regression this test pins).
5863 assert_eq!(
5864 lex("[1, 2, 3]"),
5865 vec![
5866 Token::LBracket,
5867 Token::Int(1),
5868 Token::Comma,
5869 Token::Int(2),
5870 Token::Comma,
5871 Token::Int(3),
5872 Token::RBracket,
5873 ]
5874 );
5875 }
5876
5877 // These use `x=...` (assignment/value position) rather than a bare
5878 // statement: a bare non-value-position `[...]` run with a real bracket
5879 // PAIR already fuses whole into one `GlobWord` regardless of comma (an
5880 // existing, comma-unrelated rule — see `flush_glob_run`'s
5881 // `has_bracket_pair` branch); list/record literals are only ever
5882 // legal at value position anyway (`docs/LANGUAGE.md`, "Construction"),
5883 // so that's the realistic shape to pin here.
5884
5885 #[test]
5886 fn nested_list_of_lists_keeps_commas_significant() {
5887 assert_eq!(
5888 lex("x=[[1,2],[3,4]]"),
5889 vec![
5890 Token::Ident("x".into()),
5891 Token::Eq,
5892 Token::LBracket,
5893 Token::LBracket,
5894 Token::Int(1),
5895 Token::Comma,
5896 Token::Int(2),
5897 Token::RBracket,
5898 Token::Comma,
5899 Token::LBracket,
5900 Token::Int(3),
5901 Token::Comma,
5902 Token::Int(4),
5903 Token::RBracket,
5904 Token::RBracket,
5905 ]
5906 );
5907 }
5908
5909 #[test]
5910 fn nested_record_in_list_keeps_commas_significant() {
5911 assert_eq!(
5912 lex("x=[{a:1},{b:2}]"),
5913 vec![
5914 Token::Ident("x".into()),
5915 Token::Eq,
5916 Token::LBracket,
5917 Token::LBrace,
5918 Token::Ident("a".into()),
5919 Token::Colon,
5920 Token::Int(1),
5921 Token::RBrace,
5922 Token::Comma,
5923 Token::LBrace,
5924 Token::Ident("b".into()),
5925 Token::Colon,
5926 Token::Int(2),
5927 Token::RBrace,
5928 Token::RBracket,
5929 ]
5930 );
5931 }
5932
5933 #[test]
5934 fn stray_unclosed_bracket_does_not_wedge_past_the_line() {
5935 // A stray/unmatched `[` (no closing `]` anywhere) must not leave
5936 // the bracket-depth counter elevated for the rest of the line, let
5937 // alone the rest of the script — `compute_bracket_depth` resets at
5938 // every `is_statement_boundary` token, including `Newline`. The
5939 // comma on line 2 has no enclosing bracket of its own and must
5940 // still fold into a bareword.
5941 assert_eq!(
5942 lex("[dog\nsed -n 1,3p"),
5943 vec![
5944 Token::LBracket,
5945 Token::Ident("dog".into()),
5946 Token::Newline,
5947 Token::Ident("sed".into()),
5948 Token::ShortFlag("n".into()),
5949 Token::Ident("1,3p".into()),
5950 ]
5951 );
5952 }
5953
5954 #[test]
5955 fn stray_unmatched_closing_bracket_does_not_underflow() {
5956 // A stray `]`/`}` with no opener must clamp depth at 0, not go
5957 // negative (which would otherwise require an impossibly deep nest
5958 // of real opens to ever recover comma significance). `RBracket` is
5959 // itself glob-mergeable (character-class runs like `[0-9]*` need
5960 // it to fuse), so a leading stray `]` joins the same run as the
5961 // comma that follows it — the whole glued word folds into one
5962 // bareword, which is exactly the safe, self-contained outcome the
5963 // depth clamp is for.
5964 assert_eq!(lex("]a,b"), vec![Token::Ident("]a,b".into())]);
5965 }
5966
5967 #[test]
5968 fn comma_in_double_quoted_string_is_string_content() {
5969 // Quoted content never reaches `Token::Comma` at all — the whole
5970 // thing lexes as one `String` token before any fusion pass runs.
5971 assert_eq!(lex(r#""a,b""#), vec![Token::String("a,b".into())]);
5972 }
5973
5974 #[test]
5975 fn comma_in_single_quoted_string_is_string_content() {
5976 assert_eq!(lex("'a,b'"), vec![Token::SingleString("a,b".into())]);
5977 }
5978
5979 #[test]
5980 fn comma_in_var_ref_braces_is_not_tokenized_separately() {
5981 // `${...}` is captured as ONE token by `lex_varref` (balanced-brace
5982 // scan) — a comma inside never reaches the fusion passes as its own
5983 // `Token::Comma` at all.
5984 assert_eq!(
5985 lex("${X:-1,3}"),
5986 vec![Token::VarRef("${X:-1,3}".into())]
5987 );
5988 }
5989
5990 #[test]
5991 fn comma_inside_cmd_subst_folds_like_top_level() {
5992 // `$(...)` bodies are ordinary tokens in the main stream (not
5993 // extracted like heredocs/arithmetic), so a comma inside gets the
5994 // same bracket-depth treatment as top-level source.
5995 assert_eq!(
5996 lex("$(sed -n 1,3p)"),
5997 vec![
5998 Token::CmdSubstStart,
5999 Token::Ident("sed".into()),
6000 Token::ShortFlag("n".into()),
6001 Token::Ident("1,3p".into()),
6002 Token::RParen,
6003 ]
6004 );
6005 }
6006
6007 #[test]
6008 fn non_comma_glued_pasting_is_unaffected() {
6009 // The general no-token-pasting guard (`reject_glued_args`, GH #189)
6010 // must still see these as separate glued fragments — this fix only
6011 // changes comma, nothing else. (The parser-level rejection is
6012 // covered by `builtin_fidelity_tests::non_comma_pasting_keeps_generic_message`;
6013 // this pins the lexer's token shape underneath it.)
6014 assert_eq!(
6015 lex("--flag$(echo x)"),
6016 vec![
6017 Token::LongFlag("flag".into()),
6018 Token::CmdSubstStart,
6019 Token::Ident("echo".into()),
6020 Token::Ident("x".into()),
6021 Token::RParen,
6022 ]
6023 );
6024 }
6025}