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