Skip to main content

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