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`. A fused token would reach
2116/// the parser as one word, and the literal's structure would be gone.
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 `docs/LANGUAGE.md`,
2749/// "Assignment — bracket-path 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 bracket_depth = compute_bracket_depth(&tokens);
2757    let mut result = Vec::with_capacity(tokens.len());
2758    let mut run: Vec<&Spanned<Token>> = Vec::new();
2759    let mut run_start = 0usize;
2760
2761    for (idx, token) in tokens.iter().enumerate() {
2762        if run.is_empty() {
2763            if is_glob_mergeable(&token.token) {
2764                run.push(token);
2765                run_start = idx;
2766            } else {
2767                result.push(token.clone());
2768            }
2769            continue;
2770        }
2771
2772        // Safety: run is non-empty (checked at top of loop)
2773        let Some(last) = run.last() else { unreachable!() };
2774        let adjacent = last.span.end == token.span.start;
2775
2776        if adjacent && is_glob_mergeable(&token.token) {
2777            run.push(token);
2778        } else {
2779            // `token` is whatever broke the run — an lvalue's `=` is never
2780            // glob-mergeable, so it always lands here regardless of
2781            // whitespace (`fruits[0]=kiwi` and `fruits[0] = kiwi` both).
2782            let followed_by_eq = matches!(token.token, Token::Eq);
2783            flush_glob_run(
2784                &mut run,
2785                &mut result,
2786                value_ctx[run_start].in_literal,
2787                followed_by_eq,
2788                value_ctx[run_start].push_target,
2789                bracket_depth[run_start],
2790                source,
2791            );
2792            if is_glob_mergeable(&token.token) {
2793                run.push(token);
2794                run_start = idx;
2795            } else {
2796                result.push(token.clone());
2797            }
2798        }
2799    }
2800
2801    // End of input: no token follows the final run, so it can't be an
2802    // lvalue (an assignment always has a value after `=`) — but it CAN
2803    // still be a `push` target (`push xs[0]` with nothing after it).
2804    flush_glob_run(
2805        &mut run,
2806        &mut result,
2807        value_ctx[run_start].in_literal,
2808        false,
2809        value_ctx[run_start].push_target,
2810        bracket_depth[run_start],
2811        source,
2812    );
2813
2814    result
2815}
2816
2817/// Bracket depth (count of open, unmatched `[`/`{`) in effect immediately
2818/// BEFORE each token — one entry per token, index-aligned with `tokens`.
2819/// The sole consumer is `run_has_bare_comma` below: a comma is a
2820/// literal/pattern separator while depth > 0 (`{js,ts}`, `[1, 2, 3]`,
2821/// `[{a: 1}, {b: 2}]`), an ordinary bareword character at depth 0
2822/// (`1,3p`, `a,b`).
2823///
2824/// Deliberately CRUDER than `compute_value_context`'s frame stack: it
2825/// resets to 0 at every `is_statement_boundary` token, INCLUDING
2826/// `Newline` (unlike the frame stack, which keeps a value-position
2827/// List/Record frame open across newlines for multi-line literals). That
2828/// asymmetry is safe, not a gap — multi-line value-position literals are
2829/// gated by the SEPARATE `value_position_suppress` flag in
2830/// `flush_glob_run`, computed from `compute_value_context` and unaffected
2831/// by this counter. This counter exists only to catch the constructs
2832/// `compute_value_context` doesn't track at all — argv-position brackets
2833/// and case-pattern braces — and those are always single-line, so
2834/// resetting on every newline both matches their grammar and guarantees a
2835/// stray unclosed bracket can never wedge the comma decision past the
2836/// line it's on.
2837fn compute_bracket_depth(tokens: &[Spanned<Token>]) -> Vec<usize> {
2838    let mut depths = Vec::with_capacity(tokens.len());
2839    let mut depth: i32 = 0;
2840    for t in tokens {
2841        if is_statement_boundary(&t.token) {
2842            depth = 0;
2843        }
2844        depths.push(depth.max(0) as usize);
2845        match &t.token {
2846            Token::LBracket | Token::LBrace => depth += 1,
2847            Token::RBracket | Token::RBrace => depth = (depth - 1).max(0),
2848            _ => {}
2849        }
2850    }
2851    depths
2852}
2853
2854/// True when `run` contains a `Token::Comma` sitting outside any
2855/// `[...]`/`{...}` pair — `start_depth` (from `compute_bracket_depth`,
2856/// read at the run's first token) seeds the count, since the opening
2857/// bracket of a pair often lands in an earlier, whitespace-separated run
2858/// (`[1, 2, 3]` is three runs: `[1,`, `2,`, `3]`). A comma still inside an
2859/// open pair (`{js,ts}`, a glued `[a,b]`) is left for the grammar that
2860/// consumes it — case-pattern brace expansion, or a bracket-pair run that
2861/// also flushes here via `has_bracket_pair` below; a comma with no
2862/// enclosing pair (`1,3p`, `a,b`) has no grammatical role outside a
2863/// literal/pattern.
2864fn run_has_bare_comma(run: &[&Spanned<Token>], start_depth: usize) -> bool {
2865    let mut depth = start_depth as i32;
2866    let mut found = false;
2867    for t in run.iter() {
2868        match &t.token {
2869            Token::LBracket | Token::LBrace => depth += 1,
2870            Token::RBracket | Token::RBrace => depth = (depth - 1).max(0),
2871            Token::Comma if depth == 0 => found = true,
2872            _ => {}
2873        }
2874    }
2875    found
2876}
2877
2878/// Flush a run of glob-mergeable tokens: merge to a `GlobWord` (text
2879/// sliced verbatim from the source) if it contains glob metacharacters,
2880/// or to an `Ident` if its only reason to fuse is a bare comma (see
2881/// below).
2882///
2883/// `value_position_suppress` (run opened at value position) forces
2884/// individual emission for bracket-bearing runs, so a `[`-leading run at
2885/// value position always reaches the parser as primitive tokens for the
2886/// list-literal grammar. A pure `Star`/`Question` glob with no brackets
2887/// (`X=*.txt`) keeps fusing — it evaluates to a literal string at value
2888/// position exactly as before collection literals existed. The SAME flag
2889/// also gates bare-comma folding below: a value-position run (list or
2890/// record literal, tracked across whitespace/newlines by
2891/// `compute_value_context`, unlike this function's own per-run bracket
2892/// count) must reach the parser as primitive tokens even when the run
2893/// itself contains no bracket pair — `x = [ 1,2 ]` splits into "[",
2894/// "1,2", "]" runs on the spaces, and the middle run has no bracket
2895/// token to see.
2896///
2897/// `followed_by_eq` is the SEPARATE lvalue trigger: an `Ident`-led
2898/// bracket-pair run with no `*`/`?` immediately before `=` is a
2899/// subscripted assignment target (`fruits[0]=kiwi`), not a glob.
2900///
2901/// `push_target` is a THIRD, independent trigger (see `PushTarget`):
2902/// `push`'s own bracket-path target (`push services[web][tags] item`) has
2903/// no trailing `=` to key off, so it's recognized separately and fused
2904/// verbatim into a single `Ident` (GH #183) — a path for `push` to walk,
2905/// never a glob to expand against the filesystem.
2906///
2907/// A bare comma (`1,3p`, `cut -f 1,3`, `sort -k 2,2n`) has no
2908/// grammatical role outside a `[...]`/`{...}` literal or pattern — see
2909/// `run_has_bare_comma` — so a run whose only fusion trigger is such a
2910/// comma folds into an `Ident`, never a `GlobWord`: nothing here should
2911/// reach the filesystem glob matcher, and `Expr::GlobPattern("1,3p")`
2912/// would try to `stat` a file named that and fail with "no matches".
2913fn flush_glob_run(
2914    run: &mut Vec<&Spanned<Token>>,
2915    result: &mut Vec<Spanned<Token>>,
2916    value_position_suppress: bool,
2917    followed_by_eq: bool,
2918    push_target: bool,
2919    bracket_depth_at_start: usize,
2920    source: &str,
2921) {
2922    if run.is_empty() {
2923        return;
2924    }
2925
2926    let has_bracket_pair = run.iter().any(|t| matches!(t.token, Token::LBracket))
2927        && run.iter().any(|t| matches!(t.token, Token::RBracket));
2928    let has_star_or_question = run
2929        .iter()
2930        .any(|t| matches!(t.token, Token::Star | Token::Question));
2931    let has_glob = has_star_or_question || has_bracket_pair;
2932
2933    // An lvalue subscript run is a ROOT IDENTIFIER followed by brackets
2934    // (`arr[0]=` → run is `arr [ 0 ]`). A bare char-class comparison
2935    // operand starts with `[` instead (`[[ [a] = b ]]`), so requiring an
2936    // `Ident`-led run keeps that fusing-and-comparing while still
2937    // catching every real lvalue.
2938    let run_starts_with_ident = matches!(run.first().map(|t| &t.token), Some(Token::Ident(_)));
2939    let lvalue_suppress =
2940        followed_by_eq && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
2941    let push_target_suppress =
2942        push_target && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
2943    let suppress = (value_position_suppress && has_bracket_pair) || lvalue_suppress;
2944    let has_bare_comma =
2945        !value_position_suppress && run_has_bare_comma(run, bracket_depth_at_start);
2946
2947    if push_target_suppress && run.len() >= 2 {
2948        // `push`'s target: fuse verbatim to a single `Ident` (never a
2949        // `GlobWord` — nothing here is meant to glob-expand).
2950        let start = run.first().map(|t| t.span.start).unwrap_or(0);
2951        let end = run.last().map(|t| t.span.end).unwrap_or(0);
2952        let text = source.get(start..end).unwrap_or_default().to_string();
2953        result.push(Spanned::new(Token::Ident(text), start..end));
2954    } else if !suppress && run.len() >= 2 && has_glob {
2955        let start = run.first().map(|t| t.span.start).unwrap_or(0);
2956        let end = run.last().map(|t| t.span.end).unwrap_or(0);
2957        let text = source.get(start..end).unwrap_or_default().to_string();
2958        result.push(Spanned::new(Token::GlobWord(text), start..end));
2959    } else if run.len() >= 2 && has_bare_comma {
2960        let start = run.first().map(|t| t.span.start).unwrap_or(0);
2961        let end = run.last().map(|t| t.span.end).unwrap_or(0);
2962        let text = source.get(start..end).unwrap_or_default().to_string();
2963        result.push(Spanned::new(Token::Ident(text), start..end));
2964    } else {
2965        for t in run.iter() {
2966            result.push((*t).clone());
2967        }
2968    }
2969
2970    run.clear();
2971}
2972
2973// ═══════════════════════════════════════════════════════════════════
2974// Pipeline entry points
2975// ═══════════════════════════════════════════════════════════════════
2976
2977/// Tokenize kaish source into spanned tokens.
2978///
2979/// Pipeline: one composed scan (heredocs + arithmetic extracted with full
2980/// quote/escape/comment awareness, complete replacement table) → logos →
2981/// positional marker resolution → span correction back to original
2982/// coordinates → fusion passes (flag-metachar, colon, glob) with
2983/// verbatim-slice text. All spans — including `HereDoc` tokens and
2984/// everything after them — are exact original-source byte ranges.
2985pub fn tokenize(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
2986    tokenize_impl(source, false)
2987}
2988
2989/// Tokenize, preserving `Comment` and `LineContinuation` tokens.
2990///
2991/// Runs the SAME pipeline as `tokenize` (pre-#95 this was a divergent
2992/// second pipeline with no preprocessing or merges). Useful for
2993/// pretty-printing and formatting tools.
2994pub fn tokenize_with_comments(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
2995    tokenize_impl(source, true)
2996}
2997
2998fn tokenize_impl(
2999    source: &str,
3000    keep_comments: bool,
3001) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
3002    let scan_output = scan(source).map_err(|e| vec![e])?;
3003
3004    // map_position's early `break` depends on the table being ordered by
3005    // rewritten-buffer position; the scanner appends in scan order, which
3006    // guarantees it.
3007    debug_assert!(
3008        scan_output
3009            .replacements
3010            .windows(2)
3011            .all(|w| w[0].new_start <= w[1].new_start),
3012        "replacement table must be ordered by new_start"
3013    );
3014
3015    let mut tokens = Vec::new();
3016    let mut errors = Vec::new();
3017    for (result, span) in Token::lexer(&scan_output.text).spanned() {
3018        match result {
3019            Ok(token) => {
3020                if !keep_comments
3021                    && matches!(token, Token::Comment | Token::LineContinuation)
3022                {
3023                    continue;
3024                }
3025                // Rewritten-buffer spans here; mapped to original
3026                // coordinates after marker resolution.
3027                tokens.push(Spanned::new(token, span));
3028            }
3029            Err(err) => {
3030                errors.push(Spanned::new(err, map_span(&span, &scan_output.replacements)));
3031            }
3032        }
3033    }
3034    if !errors.is_empty() {
3035        return Err(errors);
3036    }
3037
3038    let resolved = resolve_markers(tokens, &scan_output).map_err(|errs| {
3039        errs.into_iter()
3040            .map(|e| Spanned::new(e.token, map_span(&e.span, &scan_output.replacements)))
3041            .collect::<Vec<_>>()
3042    })?;
3043
3044    let mapped: Vec<Spanned<Token>> = resolved
3045        .into_iter()
3046        .map(|s| {
3047            let span = map_span(&s.span, &scan_output.replacements);
3048            Spanned::new(s.token, span)
3049        })
3050        .collect();
3051
3052    Ok(merge_glob_adjacent(
3053        merge_colon_adjacent(merge_flag_metachar_adjacent(mapped), source),
3054        source,
3055    ))
3056}
3057
3058/// Extract the string content from a string token (removes quotes, processes escapes).
3059pub fn parse_string_literal(source: &str) -> Result<String, LexerError> {
3060    // Remove surrounding quotes
3061    if source.len() < 2 || !source.starts_with('"') || !source.ends_with('"') {
3062        return Err(LexerError::UnterminatedString);
3063    }
3064
3065    let inner = &source[1..source.len() - 1];
3066    let mut result = String::with_capacity(inner.len());
3067    let mut chars = inner.chars().peekable();
3068
3069    while let Some(ch) = chars.next() {
3070        if ch == '\\' {
3071            match chars.next() {
3072                Some('n') => result.push('\n'),
3073                Some('t') => result.push('\t'),
3074                Some('r') => result.push('\r'),
3075                Some('\\') => result.push('\\'),
3076                Some('"') => result.push('"'),
3077                // Use a unique marker for escaped dollar that won't be re-interpreted
3078                // parse_interpolated_string will convert this back to $
3079                Some('$') => result.push_str("__KAISH_ESCAPED_DOLLAR__"),
3080                Some('u') => {
3081                    // Unicode escape: \uXXXX
3082                    let mut hex = String::with_capacity(4);
3083                    for _ in 0..4 {
3084                        match chars.next() {
3085                            Some(h) if h.is_ascii_hexdigit() => hex.push(h),
3086                            _ => return Err(LexerError::InvalidEscape),
3087                        }
3088                    }
3089                    let codepoint = u32::from_str_radix(&hex, 16)
3090                        .map_err(|_| LexerError::InvalidEscape)?;
3091                    let ch = char::from_u32(codepoint)
3092                        .ok_or(LexerError::InvalidEscape)?;
3093                    result.push(ch);
3094                }
3095                // Unknown escapes: preserve the backslash (for regex patterns like `\.`)
3096                Some(next) => {
3097                    result.push('\\');
3098                    result.push(next);
3099                }
3100                None => return Err(LexerError::InvalidEscape),
3101            }
3102        } else {
3103            result.push(ch);
3104        }
3105    }
3106
3107    Ok(result)
3108}
3109
3110/// Parse a variable reference, extracting the path segments.
3111/// Input: `"${VAR.field[0].nested}"` → `["VAR", "field", "[0]", "nested"]`
3112///
3113/// The `[...]` collector is quote-aware (GH #183): a subscript opening with
3114/// `"` or `'` consumes verbatim up to its OWN matching closing quote before
3115/// resuming the search for the subscript's terminating `]` — so an embedded
3116/// `]` inside a quoted key (`${r["weird]key"]}`) is just data, not the
3117/// bracket's end. Un-quoted subscripts (`[0]`, `[$k]`, `[web]`) are
3118/// unaffected — the quote check only fires when the subscript's first
3119/// character is actually a quote.
3120pub fn parse_var_ref(source: &str) -> Result<Vec<String>, LexerError> {
3121    // Remove ${ and }
3122    if source.len() < 4 || !source.starts_with("${") || !source.ends_with('}') {
3123        return Err(LexerError::UnterminatedVarRef);
3124    }
3125
3126    let inner = &source[2..source.len() - 1];
3127
3128    // Special case: $? (last result)
3129    if inner == "?" {
3130        return Ok(vec!["?".to_string()]);
3131    }
3132
3133    let mut segments = Vec::new();
3134    let mut current = String::new();
3135    let mut chars = inner.chars().peekable();
3136
3137    while let Some(ch) = chars.next() {
3138        match ch {
3139            '.' => {
3140                if !current.is_empty() {
3141                    segments.push(current.clone());
3142                    current.clear();
3143                }
3144            }
3145            '[' => {
3146                if !current.is_empty() {
3147                    segments.push(current.clone());
3148                    current.clear();
3149                }
3150                // Collect the index. Quote-aware: a quoted key's own
3151                // matching closer is consumed FIRST, verbatim, so an
3152                // embedded `]` inside it (`["weird]key"]`) can't be
3153                // mistaken for the subscript's terminator (GH #183).
3154                let mut index = String::from("[");
3155                if let Some(&quote) = chars.peek() {
3156                    if quote == '"' || quote == '\'' {
3157                        if let Some(q) = chars.next() {
3158                            index.push(q);
3159                        }
3160                        for c in chars.by_ref() {
3161                            index.push(c);
3162                            if c == quote {
3163                                break;
3164                            }
3165                        }
3166                    }
3167                }
3168                while let Some(&c) = chars.peek() {
3169                    if let Some(c) = chars.next() {
3170                        index.push(c);
3171                    }
3172                    if c == ']' {
3173                        break;
3174                    }
3175                }
3176                segments.push(index);
3177            }
3178            _ => {
3179                current.push(ch);
3180            }
3181        }
3182    }
3183
3184    if !current.is_empty() {
3185        segments.push(current);
3186    }
3187
3188    Ok(segments)
3189}
3190
3191/// Parse an integer literal.
3192pub fn parse_int(source: &str) -> Result<i64, LexerError> {
3193    source.parse().map_err(|_| LexerError::InvalidNumber)
3194}
3195
3196/// Parse a float literal.
3197pub fn parse_float(source: &str) -> Result<f64, LexerError> {
3198    source.parse().map_err(|_| LexerError::InvalidNumber)
3199}
3200
3201#[cfg(test)]
3202#[allow(clippy::approx_constant)]
3203mod tests {
3204    use super::*;
3205
3206    fn lex(source: &str) -> Vec<Token> {
3207        tokenize(source)
3208            .expect("lexer should succeed")
3209            .into_iter()
3210            .map(|s| s.token)
3211            .collect()
3212    }
3213
3214    // ═══════════════════════════════════════════════════════════════════
3215    // Keyword tests
3216    // ═══════════════════════════════════════════════════════════════════
3217
3218    #[test]
3219    fn keywords() {
3220        assert_eq!(lex("set"), vec![Token::Set]);
3221        assert_eq!(lex("if"), vec![Token::If]);
3222        assert_eq!(lex("then"), vec![Token::Then]);
3223        assert_eq!(lex("else"), vec![Token::Else]);
3224        assert_eq!(lex("elif"), vec![Token::Elif]);
3225        assert_eq!(lex("fi"), vec![Token::Fi]);
3226        assert_eq!(lex("for"), vec![Token::For]);
3227        assert_eq!(lex("in"), vec![Token::In]);
3228        assert_eq!(lex("do"), vec![Token::Do]);
3229        assert_eq!(lex("done"), vec![Token::Done]);
3230        assert_eq!(lex("case"), vec![Token::Case]);
3231        assert_eq!(lex("esac"), vec![Token::Esac]);
3232        assert_eq!(lex("function"), vec![Token::Function]);
3233        assert_eq!(lex("true"), vec![Token::True]);
3234        assert_eq!(lex("false"), vec![Token::False]);
3235    }
3236
3237    #[test]
3238    fn double_semicolon() {
3239        assert_eq!(lex(";;"), vec![Token::DoubleSemi]);
3240        // In case pattern context
3241        assert_eq!(lex("echo \"hi\";;"), vec![
3242            Token::Ident("echo".to_string()),
3243            Token::String("hi".to_string()),
3244            Token::DoubleSemi,
3245        ]);
3246    }
3247
3248    #[test]
3249    fn type_keywords() {
3250        assert_eq!(lex("string"), vec![Token::TypeString]);
3251        assert_eq!(lex("int"), vec![Token::TypeInt]);
3252        assert_eq!(lex("float"), vec![Token::TypeFloat]);
3253        assert_eq!(lex("bool"), vec![Token::TypeBool]);
3254    }
3255
3256    // ═══════════════════════════════════════════════════════════════════
3257    // Operator tests
3258    // ═══════════════════════════════════════════════════════════════════
3259
3260    #[test]
3261    fn single_char_operators() {
3262        assert_eq!(lex("="), vec![Token::Eq]);
3263        assert_eq!(lex("|"), vec![Token::Pipe]);
3264        assert_eq!(lex("&"), vec![Token::Amp]);
3265        assert_eq!(lex(">"), vec![Token::Gt]);
3266        assert_eq!(lex("<"), vec![Token::Lt]);
3267        assert_eq!(lex(";"), vec![Token::Semi]);
3268        assert_eq!(lex(":"), vec![Token::Colon]);
3269        assert_eq!(lex(","), vec![Token::Comma]);
3270        assert_eq!(lex("."), vec![Token::Dot]);
3271    }
3272
3273    #[test]
3274    fn multi_char_operators() {
3275        assert_eq!(lex("&&"), vec![Token::And]);
3276        assert_eq!(lex("||"), vec![Token::Or]);
3277        assert_eq!(lex("=="), vec![Token::EqEq]);
3278        assert_eq!(lex("!="), vec![Token::NotEq]);
3279        assert_eq!(lex("=~"), vec![Token::Match]);
3280        assert_eq!(lex("!~"), vec![Token::NotMatch]);
3281        assert_eq!(lex(">="), vec![Token::GtEq]);
3282        assert_eq!(lex("<="), vec![Token::LtEq]);
3283        assert_eq!(lex(">>"), vec![Token::GtGt]);
3284        assert_eq!(lex("2>"), vec![Token::Stderr]);
3285        assert_eq!(lex("&>"), vec![Token::Both]);
3286    }
3287
3288    #[test]
3289    fn brackets() {
3290        assert_eq!(lex("{"), vec![Token::LBrace]);
3291        assert_eq!(lex("}"), vec![Token::RBrace]);
3292        assert_eq!(lex("["), vec![Token::LBracket]);
3293        assert_eq!(lex("]"), vec![Token::RBracket]);
3294        assert_eq!(lex("("), vec![Token::LParen]);
3295        assert_eq!(lex(")"), vec![Token::RParen]);
3296    }
3297
3298    // ═══════════════════════════════════════════════════════════════════
3299    // Literal tests
3300    // ═══════════════════════════════════════════════════════════════════
3301
3302    #[test]
3303    fn integers() {
3304        assert_eq!(lex("0"), vec![Token::Int(0)]);
3305        assert_eq!(lex("42"), vec![Token::Int(42)]);
3306        assert_eq!(lex("-1"), vec![Token::Int(-1)]);
3307        assert_eq!(lex("999999"), vec![Token::Int(999999)]);
3308    }
3309
3310    #[test]
3311    fn floats() {
3312        assert_eq!(lex("3.14"), vec![Token::Float(3.14)]);
3313        assert_eq!(lex("-0.5"), vec![Token::Float(-0.5)]);
3314        assert_eq!(lex("123.456"), vec![Token::Float(123.456)]);
3315    }
3316
3317    #[test]
3318    fn strings() {
3319        assert_eq!(lex(r#""hello""#), vec![Token::String("hello".to_string())]);
3320        assert_eq!(lex(r#""hello world""#), vec![Token::String("hello world".to_string())]);
3321        assert_eq!(lex(r#""""#), vec![Token::String("".to_string())]); // empty string
3322        assert_eq!(lex(r#""with \"quotes\"""#), vec![Token::String("with \"quotes\"".to_string())]);
3323        assert_eq!(lex(r#""with\nnewline""#), vec![Token::String("with\nnewline".to_string())]);
3324    }
3325
3326    #[test]
3327    fn var_refs() {
3328        assert_eq!(lex("${X}"), vec![Token::VarRef("${X}".to_string())]);
3329        assert_eq!(lex("${VAR}"), vec![Token::VarRef("${VAR}".to_string())]);
3330        assert_eq!(lex("${VAR.field}"), vec![Token::VarRef("${VAR.field}".to_string())]);
3331        assert_eq!(lex("${VAR[0]}"), vec![Token::VarRef("${VAR[0]}".to_string())]);
3332    }
3333
3334    #[test]
3335    fn var_ref_nested_default_is_one_token() {
3336        // GH #173: the balanced-brace callback keeps a nested reference in
3337        // a default word as ONE VarRef token (the old first-`}` regex split
3338        // it into VarRef + RBrace).
3339        assert_eq!(
3340            lex("${X:-${Y}}"),
3341            vec![Token::VarRef("${X:-${Y}}".to_string())]
3342        );
3343        assert_eq!(
3344            lex("${A:-${B:-${C}}}"),
3345            vec![Token::VarRef("${A:-${B:-${C}}}".to_string())]
3346        );
3347        // VarLength still out-matches the two-character `${` opener.
3348        assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
3349    }
3350
3351    #[test]
3352    fn var_ref_unterminated_and_empty_are_errors() {
3353        assert!(tokenize("${X:-${Y}").is_err(), "unbalanced nesting is loud");
3354        assert!(tokenize("${a{b}").is_err(), "extra open brace is loud");
3355        assert!(tokenize("${}").is_err(), "empty reference is loud");
3356    }
3357
3358    #[test]
3359    fn var_ref_closes_at_first_balanced_brace() {
3360        // Trailing `b}` after the balanced close is separate tokens — the
3361        // early-close contract (kaibo review, GH #173).
3362        assert_eq!(
3363            lex("${a}b}"),
3364            vec![
3365                Token::VarRef("${a}".to_string()),
3366                Token::Ident("b".to_string()),
3367                Token::RBrace,
3368            ]
3369        );
3370    }
3371
3372    // ═══════════════════════════════════════════════════════════════════
3373    // Identifier tests
3374    // ═══════════════════════════════════════════════════════════════════
3375
3376    #[test]
3377    fn identifiers() {
3378        assert_eq!(lex("foo"), vec![Token::Ident("foo".to_string())]);
3379        assert_eq!(lex("foo_bar"), vec![Token::Ident("foo_bar".to_string())]);
3380        assert_eq!(lex("foo-bar"), vec![Token::Ident("foo-bar".to_string())]);
3381        assert_eq!(lex("_private"), vec![Token::Ident("_private".to_string())]);
3382        assert_eq!(lex("cmd123"), vec![Token::Ident("cmd123".to_string())]);
3383    }
3384
3385    #[test]
3386    fn keyword_prefix_identifiers() {
3387        // Identifiers that start with keywords but aren't keywords
3388        assert_eq!(lex("setup"), vec![Token::Ident("setup".to_string())]);
3389        assert_eq!(lex("kaish-tools"), vec![Token::Ident("kaish-tools".to_string())]);
3390        assert_eq!(lex("iffy"), vec![Token::Ident("iffy".to_string())]);
3391        assert_eq!(lex("forked"), vec![Token::Ident("forked".to_string())]);
3392        assert_eq!(lex("done-with-it"), vec![Token::Ident("done-with-it".to_string())]);
3393    }
3394
3395    // ═══════════════════════════════════════════════════════════════════
3396    // Statement tests
3397    // ═══════════════════════════════════════════════════════════════════
3398
3399    #[test]
3400    fn assignment() {
3401        assert_eq!(
3402            lex("set X = 5"),
3403            vec![Token::Set, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
3404        );
3405    }
3406
3407    #[test]
3408    fn command_simple() {
3409        assert_eq!(lex("echo"), vec![Token::Ident("echo".to_string())]);
3410        assert_eq!(
3411            lex(r#"echo "hello""#),
3412            vec![Token::Ident("echo".to_string()), Token::String("hello".to_string())]
3413        );
3414    }
3415
3416    #[test]
3417    fn command_with_args() {
3418        assert_eq!(
3419            lex("cmd arg1 arg2"),
3420            vec![Token::Ident("cmd".to_string()), Token::Ident("arg1".to_string()), Token::Ident("arg2".to_string())]
3421        );
3422    }
3423
3424    #[test]
3425    fn command_with_named_args() {
3426        assert_eq!(
3427            lex("cmd key=value"),
3428            vec![Token::Ident("cmd".to_string()), Token::Ident("key".to_string()), Token::Eq, Token::Ident("value".to_string())]
3429        );
3430    }
3431
3432    #[test]
3433    fn pipeline() {
3434        assert_eq!(
3435            lex("a | b | c"),
3436            vec![Token::Ident("a".to_string()), Token::Pipe, Token::Ident("b".to_string()), Token::Pipe, Token::Ident("c".to_string())]
3437        );
3438    }
3439
3440    #[test]
3441    fn if_statement() {
3442        assert_eq!(
3443            lex("if true; then echo; fi"),
3444            vec![
3445                Token::If,
3446                Token::True,
3447                Token::Semi,
3448                Token::Then,
3449                Token::Ident("echo".to_string()),
3450                Token::Semi,
3451                Token::Fi
3452            ]
3453        );
3454    }
3455
3456    #[test]
3457    fn for_loop() {
3458        assert_eq!(
3459            lex("for X in items; do echo; done"),
3460            vec![
3461                Token::For,
3462                Token::Ident("X".to_string()),
3463                Token::In,
3464                Token::Ident("items".to_string()),
3465                Token::Semi,
3466                Token::Do,
3467                Token::Ident("echo".to_string()),
3468                Token::Semi,
3469                Token::Done
3470            ]
3471        );
3472    }
3473
3474    // ═══════════════════════════════════════════════════════════════════
3475    // Whitespace and newlines
3476    // ═══════════════════════════════════════════════════════════════════
3477
3478    #[test]
3479    fn whitespace_ignored() {
3480        assert_eq!(lex("   set   X   =   5   "), lex("set X = 5"));
3481    }
3482
3483    #[test]
3484    fn newlines_preserved() {
3485        let tokens = lex("a\nb");
3486        assert_eq!(
3487            tokens,
3488            vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
3489        );
3490    }
3491
3492    #[test]
3493    fn multiple_newlines() {
3494        let tokens = lex("a\n\n\nb");
3495        assert_eq!(
3496            tokens,
3497            vec![Token::Ident("a".to_string()), Token::Newline, Token::Newline, Token::Newline, Token::Ident("b".to_string())]
3498        );
3499    }
3500
3501    // ═══════════════════════════════════════════════════════════════════
3502    // Comments
3503    // ═══════════════════════════════════════════════════════════════════
3504
3505    #[test]
3506    fn comments_skipped() {
3507        assert_eq!(lex("# comment"), vec![]);
3508        assert_eq!(lex("a # comment"), vec![Token::Ident("a".to_string())]);
3509        assert_eq!(
3510            lex("a # comment\nb"),
3511            vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
3512        );
3513    }
3514
3515    #[test]
3516    fn comments_preserved_when_requested() {
3517        let tokens = tokenize_with_comments("a # comment")
3518            .expect("should succeed")
3519            .into_iter()
3520            .map(|s| s.token)
3521            .collect::<Vec<_>>();
3522        assert_eq!(tokens, vec![Token::Ident("a".to_string()), Token::Comment]);
3523    }
3524
3525    // ═══════════════════════════════════════════════════════════════════
3526    // String parsing
3527    // ═══════════════════════════════════════════════════════════════════
3528
3529    #[test]
3530    fn parse_simple_string() {
3531        assert_eq!(parse_string_literal(r#""hello""#).expect("ok"), "hello");
3532    }
3533
3534    #[test]
3535    fn parse_string_with_escapes() {
3536        assert_eq!(
3537            parse_string_literal(r#""hello\nworld""#).expect("ok"),
3538            "hello\nworld"
3539        );
3540        assert_eq!(
3541            parse_string_literal(r#""tab\there""#).expect("ok"),
3542            "tab\there"
3543        );
3544        assert_eq!(
3545            parse_string_literal(r#""quote\"here""#).expect("ok"),
3546            "quote\"here"
3547        );
3548    }
3549
3550    #[test]
3551    fn parse_string_with_unicode() {
3552        assert_eq!(
3553            parse_string_literal(r#""emoji \u2764""#).expect("ok"),
3554            "emoji ❤"
3555        );
3556    }
3557
3558    #[test]
3559    fn parse_string_with_escaped_dollar() {
3560        // \$ produces a marker that parse_interpolated_string will convert to $
3561        // The marker __KAISH_ESCAPED_DOLLAR__ is used to prevent re-interpretation
3562        assert_eq!(
3563            parse_string_literal(r#""\$VAR""#).expect("ok"),
3564            "__KAISH_ESCAPED_DOLLAR__VAR"
3565        );
3566        assert_eq!(
3567            parse_string_literal(r#""cost: \$100""#).expect("ok"),
3568            "cost: __KAISH_ESCAPED_DOLLAR__100"
3569        );
3570    }
3571
3572    // ═══════════════════════════════════════════════════════════════════
3573    // Variable reference parsing
3574    // ═══════════════════════════════════════════════════════════════════
3575
3576    #[test]
3577    fn parse_simple_var() {
3578        assert_eq!(
3579            parse_var_ref("${X}").expect("ok"),
3580            vec!["X"]
3581        );
3582    }
3583
3584    #[test]
3585    fn parse_var_with_field() {
3586        assert_eq!(
3587            parse_var_ref("${VAR.field}").expect("ok"),
3588            vec!["VAR", "field"]
3589        );
3590    }
3591
3592    #[test]
3593    fn parse_var_with_index() {
3594        assert_eq!(
3595            parse_var_ref("${VAR[0]}").expect("ok"),
3596            vec!["VAR", "[0]"]
3597        );
3598    }
3599
3600    #[test]
3601    fn parse_var_nested() {
3602        assert_eq!(
3603            parse_var_ref("${VAR.field[0].nested}").expect("ok"),
3604            vec!["VAR", "field", "[0]", "nested"]
3605        );
3606    }
3607
3608    #[test]
3609    fn parse_last_result() {
3610        assert_eq!(
3611            parse_var_ref("${?}").expect("ok"),
3612            vec!["?"]
3613        );
3614    }
3615
3616    /// GH #183: a `]` inside a QUOTED subscript key must not be mistaken for
3617    /// the subscript's own terminator. Double- and single-quoted keys alike.
3618    #[test]
3619    fn parse_var_quoted_subscript_with_embedded_bracket() {
3620        assert_eq!(
3621            parse_var_ref(r#"${r["weird]key"]}"#).expect("ok"),
3622            vec!["r", r#"["weird]key"]"#]
3623        );
3624        assert_eq!(
3625            parse_var_ref("${r['weird]key']}").expect("ok"),
3626            vec!["r", "['weird]key']"]
3627        );
3628    }
3629
3630    /// A quoted key with NO embedded bracket is unaffected by the
3631    /// quote-awareness — same segment shape as before.
3632    #[test]
3633    fn parse_var_quoted_subscript_without_embedded_bracket() {
3634        assert_eq!(
3635            parse_var_ref(r#"${r["normal"]}"#).expect("ok"),
3636            vec!["r", r#"["normal"]"#]
3637        );
3638    }
3639
3640    /// Trailing content after a quoted subscript closes (a further chained
3641    /// hop) still parses — the quote-awareness only governs the ONE
3642    /// subscript it opens inside.
3643    #[test]
3644    fn parse_var_quoted_subscript_with_embedded_bracket_then_more_path() {
3645        assert_eq!(
3646            parse_var_ref(r#"${r["weird]key"][0]}"#).expect("ok"),
3647            vec!["r", r#"["weird]key"]"#, "[0]"]
3648        );
3649    }
3650
3651    // ═══════════════════════════════════════════════════════════════════
3652    // Number parsing
3653    // ═══════════════════════════════════════════════════════════════════
3654
3655    #[test]
3656    fn parse_integers() {
3657        assert_eq!(parse_int("0").expect("ok"), 0);
3658        assert_eq!(parse_int("42").expect("ok"), 42);
3659        assert_eq!(parse_int("-1").expect("ok"), -1);
3660    }
3661
3662    #[test]
3663    fn parse_floats() {
3664        assert!((parse_float("3.14").expect("ok") - 3.14).abs() < f64::EPSILON);
3665        assert!((parse_float("-0.5").expect("ok") - (-0.5)).abs() < f64::EPSILON);
3666    }
3667
3668    // ═══════════════════════════════════════════════════════════════════
3669    // Edge cases and errors
3670    // ═══════════════════════════════════════════════════════════════════
3671
3672    #[test]
3673    fn empty_input() {
3674        assert_eq!(lex(""), vec![]);
3675    }
3676
3677    #[test]
3678    fn only_whitespace() {
3679        assert_eq!(lex("   \t\t   "), vec![]);
3680    }
3681
3682    #[test]
3683    fn json_array() {
3684        assert_eq!(
3685            lex(r#"[1, 2, 3]"#),
3686            vec![
3687                Token::LBracket,
3688                Token::Int(1),
3689                Token::Comma,
3690                Token::Int(2),
3691                Token::Comma,
3692                Token::Int(3),
3693                Token::RBracket
3694            ]
3695        );
3696    }
3697
3698    #[test]
3699    fn json_object() {
3700        assert_eq!(
3701            lex(r#"{"key": "value"}"#),
3702            vec![
3703                Token::LBrace,
3704                Token::String("key".to_string()),
3705                Token::Colon,
3706                Token::String("value".to_string()),
3707                Token::RBrace
3708            ]
3709        );
3710    }
3711
3712    #[test]
3713    fn redirect_operators() {
3714        assert_eq!(
3715            lex("cmd > file"),
3716            vec![Token::Ident("cmd".to_string()), Token::Gt, Token::Ident("file".to_string())]
3717        );
3718        assert_eq!(
3719            lex("cmd >> file"),
3720            vec![Token::Ident("cmd".to_string()), Token::GtGt, Token::Ident("file".to_string())]
3721        );
3722        assert_eq!(
3723            lex("cmd 2> err"),
3724            vec![Token::Ident("cmd".to_string()), Token::Stderr, Token::Ident("err".to_string())]
3725        );
3726        assert_eq!(
3727            lex("cmd &> all"),
3728            vec![Token::Ident("cmd".to_string()), Token::Both, Token::Ident("all".to_string())]
3729        );
3730    }
3731
3732    #[test]
3733    fn background_job() {
3734        assert_eq!(
3735            lex("cmd &"),
3736            vec![Token::Ident("cmd".to_string()), Token::Amp]
3737        );
3738    }
3739
3740    #[test]
3741    fn command_substitution() {
3742        assert_eq!(
3743            lex("$(cmd)"),
3744            vec![Token::CmdSubstStart, Token::Ident("cmd".to_string()), Token::RParen]
3745        );
3746        assert_eq!(
3747            lex("$(cmd arg)"),
3748            vec![
3749                Token::CmdSubstStart,
3750                Token::Ident("cmd".to_string()),
3751                Token::Ident("arg".to_string()),
3752                Token::RParen
3753            ]
3754        );
3755        assert_eq!(
3756            lex("$(a | b)"),
3757            vec![
3758                Token::CmdSubstStart,
3759                Token::Ident("a".to_string()),
3760                Token::Pipe,
3761                Token::Ident("b".to_string()),
3762                Token::RParen
3763            ]
3764        );
3765    }
3766
3767    #[test]
3768    fn complex_pipeline() {
3769        assert_eq!(
3770            lex(r#"cat file | grep pattern="foo" | head count=10"#),
3771            vec![
3772                Token::Ident("cat".to_string()),
3773                Token::Ident("file".to_string()),
3774                Token::Pipe,
3775                Token::Ident("grep".to_string()),
3776                Token::Ident("pattern".to_string()),
3777                Token::Eq,
3778                Token::String("foo".to_string()),
3779                Token::Pipe,
3780                Token::Ident("head".to_string()),
3781                Token::Ident("count".to_string()),
3782                Token::Eq,
3783                Token::Int(10),
3784            ]
3785        );
3786    }
3787
3788    // ═══════════════════════════════════════════════════════════════════
3789    // Flag tests
3790    // ═══════════════════════════════════════════════════════════════════
3791
3792    #[test]
3793    fn short_flag() {
3794        assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
3795        assert_eq!(lex("-a"), vec![Token::ShortFlag("a".to_string())]);
3796        assert_eq!(lex("-v"), vec![Token::ShortFlag("v".to_string())]);
3797    }
3798
3799    #[test]
3800    fn short_flag_combined() {
3801        // Combined short flags like -la
3802        assert_eq!(lex("-la"), vec![Token::ShortFlag("la".to_string())]);
3803        assert_eq!(lex("-vvv"), vec![Token::ShortFlag("vvv".to_string())]);
3804    }
3805
3806    #[test]
3807    fn job_spec_lexes_as_one_token() {
3808        // `%N` is the bash jobspec for wait/kill — used to be a lexer error.
3809        assert_eq!(lex("%1"), vec![Token::JobSpec("%1".to_string())]);
3810        assert_eq!(lex("%12"), vec![Token::JobSpec("%12".to_string())]);
3811        assert_eq!(
3812            lex("wait %1 %2"),
3813            vec![
3814                Token::Ident("wait".to_string()),
3815                Token::JobSpec("%1".to_string()),
3816                Token::JobSpec("%2".to_string()),
3817            ]
3818        );
3819    }
3820
3821    #[test]
3822    fn short_flag_with_internal_hyphens_is_one_token() {
3823        // A dash-word with internal hyphens is ONE shell word, not three
3824        // flags — `-not-a-flag` must not fragment into `-not` `-a` `-flag`.
3825        // (Whether it's a flag or a literal is the binding layer's call.)
3826        assert_eq!(
3827            lex("-not-a-flag"),
3828            vec![Token::ShortFlag("not-a-flag".to_string())]
3829        );
3830        // The two-char terminator `--` is still DoubleDash, and a lone `-`
3831        // is still MinusAlone — the second char must be a letter to start a
3832        // short flag.
3833        assert_eq!(lex("--"), vec![Token::DoubleDash]);
3834        assert_eq!(lex("-"), vec![Token::MinusAlone]);
3835    }
3836
3837    #[test]
3838    fn long_flag() {
3839        assert_eq!(lex("--force"), vec![Token::LongFlag("force".to_string())]);
3840        assert_eq!(lex("--verbose"), vec![Token::LongFlag("verbose".to_string())]);
3841        assert_eq!(lex("--foo-bar"), vec![Token::LongFlag("foo-bar".to_string())]);
3842    }
3843
3844    #[test]
3845    fn double_dash() {
3846        // -- alone marks end of flags
3847        assert_eq!(lex("--"), vec![Token::DoubleDash]);
3848    }
3849
3850    #[test]
3851    fn flags_vs_negative_numbers() {
3852        // -123 should be a negative integer, not a flag
3853        assert_eq!(lex("-123"), vec![Token::Int(-123)]);
3854        // -l should be a flag
3855        assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
3856        // -1a is ambiguous - should be Int(-1) then Ident(a)
3857        // Actually the regex -[a-zA-Z] won't match -1a since 1 isn't a letter
3858        assert_eq!(
3859            lex("-1 a"),
3860            vec![Token::Int(-1), Token::Ident("a".to_string())]
3861        );
3862    }
3863
3864    #[test]
3865    fn command_with_flags() {
3866        assert_eq!(
3867            lex("ls -l"),
3868            vec![
3869                Token::Ident("ls".to_string()),
3870                Token::ShortFlag("l".to_string()),
3871            ]
3872        );
3873        assert_eq!(
3874            lex("git commit -m"),
3875            vec![
3876                Token::Ident("git".to_string()),
3877                Token::Ident("commit".to_string()),
3878                Token::ShortFlag("m".to_string()),
3879            ]
3880        );
3881        assert_eq!(
3882            lex("git push --force"),
3883            vec![
3884                Token::Ident("git".to_string()),
3885                Token::Ident("push".to_string()),
3886                Token::LongFlag("force".to_string()),
3887            ]
3888        );
3889    }
3890
3891    #[test]
3892    fn flag_with_value() {
3893        assert_eq!(
3894            lex(r#"git commit -m "message""#),
3895            vec![
3896                Token::Ident("git".to_string()),
3897                Token::Ident("commit".to_string()),
3898                Token::ShortFlag("m".to_string()),
3899                Token::String("message".to_string()),
3900            ]
3901        );
3902        assert_eq!(
3903            lex(r#"--message="hello""#),
3904            vec![
3905                Token::LongFlag("message".to_string()),
3906                Token::Eq,
3907                Token::String("hello".to_string()),
3908            ]
3909        );
3910    }
3911
3912    #[test]
3913    fn end_of_flags_marker() {
3914        assert_eq!(
3915            lex("git checkout -- file"),
3916            vec![
3917                Token::Ident("git".to_string()),
3918                Token::Ident("checkout".to_string()),
3919                Token::DoubleDash,
3920                Token::Ident("file".to_string()),
3921            ]
3922        );
3923    }
3924
3925    // ═══════════════════════════════════════════════════════════════════
3926    // Bash compatibility tokens
3927    // ═══════════════════════════════════════════════════════════════════
3928
3929    #[test]
3930    fn local_keyword() {
3931        assert_eq!(lex("local"), vec![Token::Local]);
3932        assert_eq!(
3933            lex("local X = 5"),
3934            vec![Token::Local, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
3935        );
3936    }
3937
3938    #[test]
3939    fn simple_var_ref() {
3940        assert_eq!(lex("$X"), vec![Token::SimpleVarRef("X".to_string())]);
3941        assert_eq!(lex("$foo"), vec![Token::SimpleVarRef("foo".to_string())]);
3942        assert_eq!(lex("$foo_bar"), vec![Token::SimpleVarRef("foo_bar".to_string())]);
3943        assert_eq!(lex("$_private"), vec![Token::SimpleVarRef("_private".to_string())]);
3944    }
3945
3946    #[test]
3947    fn simple_var_ref_in_command() {
3948        assert_eq!(
3949            lex("echo $NAME"),
3950            vec![Token::Ident("echo".to_string()), Token::SimpleVarRef("NAME".to_string())]
3951        );
3952    }
3953
3954    #[test]
3955    fn single_quoted_strings() {
3956        assert_eq!(lex("'hello'"), vec![Token::SingleString("hello".to_string())]);
3957        assert_eq!(lex("'hello world'"), vec![Token::SingleString("hello world".to_string())]);
3958        assert_eq!(lex("''"), vec![Token::SingleString("".to_string())]);
3959        // Single quotes don't process escapes or variables
3960        assert_eq!(lex(r"'no $VAR here'"), vec![Token::SingleString("no $VAR here".to_string())]);
3961        assert_eq!(lex(r"'backslash \n stays'"), vec![Token::SingleString(r"backslash \n stays".to_string())]);
3962    }
3963
3964    #[test]
3965    fn test_brackets() {
3966        // [[ and ]] are now two separate bracket tokens to avoid conflicts with nested arrays
3967        assert_eq!(lex("[["), vec![Token::LBracket, Token::LBracket]);
3968        assert_eq!(lex("]]"), vec![Token::RBracket, Token::RBracket]);
3969        assert_eq!(
3970            lex("[[ -f file ]]"),
3971            vec![
3972                Token::LBracket,
3973                Token::LBracket,
3974                Token::ShortFlag("f".to_string()),
3975                Token::Ident("file".to_string()),
3976                Token::RBracket,
3977                Token::RBracket
3978            ]
3979        );
3980    }
3981
3982    #[test]
3983    fn test_expression_syntax() {
3984        assert_eq!(
3985            lex(r#"[[ $X == "value" ]]"#),
3986            vec![
3987                Token::LBracket,
3988                Token::LBracket,
3989                Token::SimpleVarRef("X".to_string()),
3990                Token::EqEq,
3991                Token::String("value".to_string()),
3992                Token::RBracket,
3993                Token::RBracket
3994            ]
3995        );
3996    }
3997
3998    #[test]
3999    fn bash_style_assignment() {
4000        // NAME="value" (no spaces) - lexer sees IDENT EQ STRING
4001        assert_eq!(
4002            lex(r#"NAME="value""#),
4003            vec![
4004                Token::Ident("NAME".to_string()),
4005                Token::Eq,
4006                Token::String("value".to_string())
4007            ]
4008        );
4009    }
4010
4011    #[test]
4012    fn positional_params() {
4013        assert_eq!(lex("$0"), vec![Token::Positional(0)]);
4014        assert_eq!(lex("$1"), vec![Token::Positional(1)]);
4015        assert_eq!(lex("$9"), vec![Token::Positional(9)]);
4016        assert_eq!(lex("$@"), vec![Token::AllArgs]);
4017        assert_eq!(lex("$#"), vec![Token::ArgCount]);
4018    }
4019
4020    #[test]
4021    fn positional_in_context() {
4022        assert_eq!(
4023            lex("echo $1 $2"),
4024            vec![
4025                Token::Ident("echo".to_string()),
4026                Token::Positional(1),
4027                Token::Positional(2),
4028            ]
4029        );
4030    }
4031
4032    #[test]
4033    fn var_length() {
4034        assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
4035        assert_eq!(lex("${#NAME}"), vec![Token::VarLength("NAME".to_string())]);
4036        assert_eq!(lex("${#foo_bar}"), vec![Token::VarLength("foo_bar".to_string())]);
4037    }
4038
4039    #[test]
4040    fn var_length_with_subscript() {
4041        // The widened regex admits `[...]` subscripts so a length-of-path lexes
4042        // in expression position; the parser turns the inner into a VarPath.
4043        assert_eq!(lex("${#u[tags]}"), vec![Token::VarLength("u[tags]".to_string())]);
4044        assert_eq!(lex("${#a[0]}"), vec![Token::VarLength("a[0]".to_string())]);
4045        assert_eq!(lex("${#a[b][c]}"), vec![Token::VarLength("a[b][c]".to_string())]);
4046        assert_eq!(lex("${#r[$k]}"), vec![Token::VarLength("r[$k]".to_string())]);
4047    }
4048
4049    #[test]
4050    fn var_length_in_context() {
4051        assert_eq!(
4052            lex("echo ${#NAME}"),
4053            vec![
4054                Token::Ident("echo".to_string()),
4055                Token::VarLength("NAME".to_string()),
4056            ]
4057        );
4058    }
4059
4060    // ═══════════════════════════════════════════════════════════════════
4061    // Edge case tests: Flag ambiguities
4062    // ═══════════════════════════════════════════════════════════════════
4063
4064    #[test]
4065    fn plus_flag() {
4066        // Plus flags for set +e
4067        assert_eq!(lex("+e"), vec![Token::PlusFlag("e".to_string())]);
4068        assert_eq!(lex("+x"), vec![Token::PlusFlag("x".to_string())]);
4069        assert_eq!(lex("+ex"), vec![Token::PlusFlag("ex".to_string())]);
4070    }
4071
4072    #[test]
4073    fn set_with_plus_flag() {
4074        assert_eq!(
4075            lex("set +e"),
4076            vec![
4077                Token::Set,
4078                Token::PlusFlag("e".to_string()),
4079            ]
4080        );
4081    }
4082
4083    #[test]
4084    fn set_with_multiple_flags() {
4085        assert_eq!(
4086            lex("set -e -u"),
4087            vec![
4088                Token::Set,
4089                Token::ShortFlag("e".to_string()),
4090                Token::ShortFlag("u".to_string()),
4091            ]
4092        );
4093    }
4094
4095    #[test]
4096    fn flags_vs_negative_numbers_edge_cases() {
4097        // -1a should be negative int followed by ident
4098        assert_eq!(
4099            lex("-1 a"),
4100            vec![Token::Int(-1), Token::Ident("a".to_string())]
4101        );
4102        // -l is a flag
4103        assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
4104        // -123 is negative number
4105        assert_eq!(lex("-123"), vec![Token::Int(-123)]);
4106    }
4107
4108    #[test]
4109    fn single_dash_is_minus_alone() {
4110        // Single dash alone - now handled as MinusAlone for `cat -` stdin indicator
4111        let result = tokenize("-").expect("should lex");
4112        assert_eq!(result.len(), 1);
4113        assert!(matches!(result[0].token, Token::MinusAlone));
4114    }
4115
4116    #[test]
4117    fn plus_bare_for_date_format() {
4118        // `date +%s` - the +%s should be PlusBare
4119        let result = tokenize("+%s").expect("should lex");
4120        assert_eq!(result.len(), 1);
4121        assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%s"));
4122
4123        // `date +%Y-%m-%d` - format string with dashes
4124        let result = tokenize("+%Y-%m-%d").expect("should lex");
4125        assert_eq!(result.len(), 1);
4126        assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%Y-%m-%d"));
4127    }
4128
4129    #[test]
4130    fn plus_flag_still_works() {
4131        // `set +e` - should still be PlusFlag
4132        let result = tokenize("+e").expect("should lex");
4133        assert_eq!(result.len(), 1);
4134        assert!(matches!(result[0].token, Token::PlusFlag(ref s) if s == "e"));
4135    }
4136
4137    #[test]
4138    fn while_keyword_vs_while_loop() {
4139        // 'while' as keyword in loop context
4140        assert_eq!(lex("while"), vec![Token::While]);
4141        // 'while' at start followed by condition
4142        assert_eq!(
4143            lex("while true"),
4144            vec![Token::While, Token::True]
4145        );
4146    }
4147
4148    #[test]
4149    fn control_flow_keywords() {
4150        assert_eq!(lex("break"), vec![Token::Break]);
4151        assert_eq!(lex("continue"), vec![Token::Continue]);
4152        assert_eq!(lex("return"), vec![Token::Return]);
4153        assert_eq!(lex("exit"), vec![Token::Exit]);
4154    }
4155
4156    #[test]
4157    fn control_flow_with_numbers() {
4158        assert_eq!(
4159            lex("break 2"),
4160            vec![Token::Break, Token::Int(2)]
4161        );
4162        assert_eq!(
4163            lex("continue 3"),
4164            vec![Token::Continue, Token::Int(3)]
4165        );
4166        assert_eq!(
4167            lex("exit 1"),
4168            vec![Token::Exit, Token::Int(1)]
4169        );
4170    }
4171
4172    // ═══════════════════════════════════════════════════════════════════
4173    // Here-doc tests
4174    // ═══════════════════════════════════════════════════════════════════
4175
4176    #[test]
4177    fn heredoc_simple() {
4178        let source = "cat <<EOF\nhello\nworld\nEOF";
4179        let tokens = lex(source);
4180        // body_start_offset = byte offset of 'h' in "hello", i.e. just after "cat <<EOF\n"
4181        assert_eq!(tokens, vec![
4182            Token::Ident("cat".to_string()),
4183            Token::HereDocStart,
4184            Token::HereDoc(HereDocData {
4185                content: "hello\nworld\n".to_string(),
4186                literal: false,
4187                strip_tabs: false,
4188                body_start_offset: 10,
4189            }),
4190            Token::Newline,
4191        ]);
4192    }
4193
4194    #[test]
4195    fn heredoc_empty() {
4196        let source = "cat <<EOF\nEOF";
4197        let tokens = lex(source);
4198        assert_eq!(tokens, vec![
4199            Token::Ident("cat".to_string()),
4200            Token::HereDocStart,
4201            Token::HereDoc(HereDocData {
4202                content: "".to_string(),
4203                literal: false,
4204                strip_tabs: false,
4205                body_start_offset: 10,
4206            }),
4207            Token::Newline,
4208        ]);
4209    }
4210
4211    #[test]
4212    fn heredoc_with_special_chars() {
4213        let source = "cat <<EOF\n$VAR and \"quoted\" 'single'\nEOF";
4214        let tokens = lex(source);
4215        assert_eq!(tokens, vec![
4216            Token::Ident("cat".to_string()),
4217            Token::HereDocStart,
4218            Token::HereDoc(HereDocData {
4219                content: "$VAR and \"quoted\" 'single'\n".to_string(),
4220                literal: false,
4221                strip_tabs: false,
4222                body_start_offset: 10,
4223            }),
4224            Token::Newline,
4225        ]);
4226    }
4227
4228    #[test]
4229    fn heredoc_multiline() {
4230        let source = "cat <<END\nline1\nline2\nline3\nEND";
4231        let tokens = lex(source);
4232        assert_eq!(tokens, vec![
4233            Token::Ident("cat".to_string()),
4234            Token::HereDocStart,
4235            Token::HereDoc(HereDocData {
4236                content: "line1\nline2\nline3\n".to_string(),
4237                literal: false,
4238                strip_tabs: false,
4239                body_start_offset: 10,
4240            }),
4241            Token::Newline,
4242        ]);
4243    }
4244
4245    #[test]
4246    fn heredoc_in_command() {
4247        let source = "cat <<EOF\nhello\nEOF\necho goodbye";
4248        let tokens = lex(source);
4249        assert_eq!(tokens, vec![
4250            Token::Ident("cat".to_string()),
4251            Token::HereDocStart,
4252            Token::HereDoc(HereDocData {
4253                content: "hello\n".to_string(),
4254                literal: false,
4255                strip_tabs: false,
4256                body_start_offset: 10,
4257            }),
4258            Token::Newline,
4259            Token::Ident("echo".to_string()),
4260            Token::Ident("goodbye".to_string()),
4261        ]);
4262    }
4263
4264    #[test]
4265    fn heredoc_strip_tabs() {
4266        let source = "cat <<-EOF\n\thello\n\tworld\n\tEOF";
4267        let tokens = lex(source);
4268        // Content keeps tabs verbatim — strip_tabs is recorded on the token so
4269        // the interpreter can apply POSIX leading-tab stripping at materialization
4270        // without disturbing source byte offsets used for span tracking.
4271        assert_eq!(tokens, vec![
4272            Token::Ident("cat".to_string()),
4273            Token::HereDocStart,
4274            Token::HereDoc(HereDocData {
4275                content: "\thello\n\tworld\n".to_string(),
4276                literal: false,
4277                strip_tabs: true,
4278                body_start_offset: 11,
4279            }),
4280            Token::Newline,
4281        ]);
4282    }
4283
4284    // ═══════════════════════════════════════════════════════════════════
4285    // Arithmetic expression tests
4286    // ═══════════════════════════════════════════════════════════════════
4287
4288    #[test]
4289    fn arithmetic_simple() {
4290        let source = "$((1 + 2))";
4291        let tokens = lex(source);
4292        assert_eq!(tokens, vec![Token::Arithmetic("1 + 2".to_string())]);
4293    }
4294
4295    #[test]
4296    fn arithmetic_in_assignment() {
4297        let source = "X=$((5 * 3))";
4298        let tokens = lex(source);
4299        assert_eq!(tokens, vec![
4300            Token::Ident("X".to_string()),
4301            Token::Eq,
4302            Token::Arithmetic("5 * 3".to_string()),
4303        ]);
4304    }
4305
4306    #[test]
4307    fn arithmetic_with_nested_parens() {
4308        let source = "$((2 * (3 + 4)))";
4309        let tokens = lex(source);
4310        assert_eq!(tokens, vec![Token::Arithmetic("2 * (3 + 4)".to_string())]);
4311    }
4312
4313    #[test]
4314    fn arithmetic_with_variable() {
4315        let source = "$((X + 1))";
4316        let tokens = lex(source);
4317        assert_eq!(tokens, vec![Token::Arithmetic("X + 1".to_string())]);
4318    }
4319
4320    #[test]
4321    fn arithmetic_command_subst_not_confused() {
4322        // $( should not be treated as arithmetic
4323        let source = "$(echo hello)";
4324        let tokens = lex(source);
4325        assert_eq!(tokens, vec![
4326            Token::CmdSubstStart,
4327            Token::Ident("echo".to_string()),
4328            Token::Ident("hello".to_string()),
4329            Token::RParen,
4330        ]);
4331    }
4332
4333    #[test]
4334    fn arithmetic_nesting_limit() {
4335        // Create deeply nested parens that exceed MAX_PAREN_DEPTH (256)
4336        let open_parens = "(".repeat(300);
4337        let close_parens = ")".repeat(300);
4338        let source = format!("$(({}1{}))", open_parens, close_parens);
4339        let result = tokenize(&source);
4340        assert!(result.is_err());
4341        let errors = result.unwrap_err();
4342        assert_eq!(errors.len(), 1);
4343        assert_eq!(errors[0].token, LexerError::NestingTooDeep);
4344    }
4345
4346    #[test]
4347    fn arithmetic_nesting_within_limit() {
4348        // Nesting within limit should work
4349        let source = "$((((1 + 2) * 3)))";
4350        let tokens = lex(source);
4351        assert_eq!(tokens, vec![Token::Arithmetic("((1 + 2) * 3)".to_string())]);
4352    }
4353
4354    // ═══════════════════════════════════════════════════════════════════
4355    // Arithmetic preprocessor + comment interaction
4356    //
4357    // The preprocessor used to walk raw characters tracking only quote
4358    // state. An apostrophe inside a `#` comment would open single-quote
4359    // mode and swallow real `$((..))` later in the file; `$((..))` *inside*
4360    // a comment would itself be preprocessed into a marker, misplacing
4361    // tokens. Surfaced from kaijutsu's seed scripts (see gotcha memory
4362    // `gotcha-kaish-comment-arithmetic`).
4363    // ═══════════════════════════════════════════════════════════════════
4364
4365    #[test]
4366    fn arithmetic_after_apostrophe_in_comment() {
4367        // The bare apostrophe in "doesn't" used to open single-quote mode
4368        // in the preprocessor and swallow the $((..)) below.
4369        let source = "# this doesn't work\necho $((1+2))";
4370        let tokens = lex(source);
4371        assert_eq!(tokens, vec![
4372            Token::Newline,
4373            Token::Ident("echo".to_string()),
4374            Token::Arithmetic("1+2".to_string()),
4375        ]);
4376    }
4377
4378    #[test]
4379    fn arithmetic_inside_comment_is_not_expanded() {
4380        // `$((y))` inside a `#` comment must stay comment text.
4381        let source = "# the $((y)) syntax explained\necho hello";
4382        let tokens = lex(source);
4383        assert_eq!(tokens, vec![
4384            Token::Newline,
4385            Token::Ident("echo".to_string()),
4386            Token::Ident("hello".to_string()),
4387        ]);
4388    }
4389
4390    #[test]
4391    fn backticked_arithmetic_in_comment_is_not_expanded() {
4392        // The original kaijutsu repro: `$((x))` inside a comment.
4393        // Backticks-in-comments used to leak the inner $((..)) to the
4394        // preprocessor; with comment-skip they stay inert.
4395        let source = "# the `$((x))` syntax explained\necho $((3+4))";
4396        let tokens = lex(source);
4397        assert_eq!(tokens, vec![
4398            Token::Newline,
4399            Token::Ident("echo".to_string()),
4400            Token::Arithmetic("3+4".to_string()),
4401        ]);
4402    }
4403
4404    #[test]
4405    fn arithmetic_still_works_outside_comments() {
4406        // Regression guard: comment-skip must not shrink the arithmetic
4407        // preprocessor's scope on normal `$((..))` usages.
4408        let source = "X=$((1+2)); Y=$((3*4))";
4409        let tokens = lex(source);
4410        assert_eq!(tokens, vec![
4411            Token::Ident("X".to_string()),
4412            Token::Eq,
4413            Token::Arithmetic("1+2".to_string()),
4414            Token::Semi,
4415            Token::Ident("Y".to_string()),
4416            Token::Eq,
4417            Token::Arithmetic("3*4".to_string()),
4418        ]);
4419    }
4420
4421    #[test]
4422    fn arithmetic_inside_double_quotes_still_expands() {
4423        // `#` inside a double-quoted string is a literal character, not a
4424        // comment introducer — arithmetic must still expand around it.
4425        let source = "echo \"# $((1+2))\"";
4426        let tokens = lex(source);
4427        // The string token contains the `#` and the arithmetic marker;
4428        // the exact post-processing happens at interpret time. What we
4429        // assert here is that lexing succeeds and produces a String token
4430        // (i.e. the comment skip didn't trigger inside the string).
4431        assert_eq!(tokens.len(), 2);
4432        assert!(matches!(tokens[0], Token::Ident(_)));
4433        assert!(matches!(tokens[1], Token::String(_)));
4434    }
4435
4436    // ═══════════════════════════════════════════════════════════════════
4437    // Backtick rejection
4438    //
4439    // Backticks are an explicitly dropped feature (see CLAUDE.md,
4440    // docs/LANGUAGE.md, help/limits.md, help/overview.md). We surface a
4441    // dedicated error rather than the generic `UnexpectedCharacter` so
4442    // users get a hint to use `$(cmd)`. Comments, single-quoted strings,
4443    // double-quoted strings, and heredoc bodies are all matched as single
4444    // tokens (or extracted before logos runs), so the rejection only
4445    // fires on bare backticks in source code.
4446    // ═══════════════════════════════════════════════════════════════════
4447
4448    #[test]
4449    fn backtick_in_source_is_rejected() {
4450        let result = tokenize("echo `date`");
4451        assert!(result.is_err());
4452        let errors = result.unwrap_err();
4453        assert!(errors.iter().any(|e| e.token == LexerError::BackticksNotSupported));
4454    }
4455
4456    #[test]
4457    fn backtick_in_comment_is_just_comment_text() {
4458        // Backticks are only rejected when they reach the top-level
4459        // lexer. Inside a comment they're part of the comment body.
4460        let source = "# use `date` here\necho hi";
4461        let tokens = lex(source);
4462        assert_eq!(tokens, vec![
4463            Token::Newline,
4464            Token::Ident("echo".to_string()),
4465            Token::Ident("hi".to_string()),
4466        ]);
4467    }
4468
4469    #[test]
4470    fn backtick_in_single_quoted_string_is_literal() {
4471        // Single-quoted strings are matched as one token by logos; the
4472        // backticks inside never reach the rejecting matcher.
4473        let source = "echo '`date`'";
4474        let tokens = lex(source);
4475        assert_eq!(tokens, vec![
4476            Token::Ident("echo".to_string()),
4477            Token::SingleString("`date`".to_string()),
4478        ]);
4479    }
4480
4481    #[test]
4482    fn backtick_in_double_quoted_string_is_literal() {
4483        // Kaish does not activate command substitution from backticks
4484        // inside double-quoted strings either — clear divergence from
4485        // POSIX but matches the "backticks don't exist" stance. The
4486        // double-quoted string token absorbs them as literal characters.
4487        let source = "echo \"`date`\"";
4488        let tokens = lex(source);
4489        assert_eq!(tokens.len(), 2);
4490        assert!(matches!(tokens[0], Token::Ident(_)));
4491        match &tokens[1] {
4492            Token::String(s) => assert!(s.contains('`')),
4493            other => panic!("expected Token::String, got {:?}", other),
4494        }
4495    }
4496
4497    #[test]
4498    fn backtick_in_heredoc_body_is_preserved() {
4499        // Heredoc bodies are extracted by the scanner before logos
4500        // runs, so backticks inside them survive as content.
4501        let source = "cat <<EOF\n`date`\nEOF\n";
4502        let tokens = lex(source);
4503        let heredoc = tokens.iter().find(|t| matches!(t, Token::HereDoc(_)));
4504        assert!(heredoc.is_some(), "expected a HereDoc token");
4505        if let Some(Token::HereDoc(d)) = heredoc {
4506            assert!(d.content.contains('`'));
4507        }
4508    }
4509
4510    // ═══════════════════════════════════════════════════════════════════
4511    // Token category tests
4512    // ═══════════════════════════════════════════════════════════════════
4513
4514    #[test]
4515    fn token_categories() {
4516        // Keywords
4517        assert_eq!(Token::If.category(), TokenCategory::Keyword);
4518        assert_eq!(Token::Then.category(), TokenCategory::Keyword);
4519        assert_eq!(Token::For.category(), TokenCategory::Keyword);
4520        assert_eq!(Token::Function.category(), TokenCategory::Keyword);
4521        assert_eq!(Token::True.category(), TokenCategory::Keyword);
4522        assert_eq!(Token::TypeString.category(), TokenCategory::Keyword);
4523
4524        // Operators
4525        assert_eq!(Token::Pipe.category(), TokenCategory::Operator);
4526        assert_eq!(Token::And.category(), TokenCategory::Operator);
4527        assert_eq!(Token::Or.category(), TokenCategory::Operator);
4528        assert_eq!(Token::StderrToStdout.category(), TokenCategory::Operator);
4529        assert_eq!(Token::GtGt.category(), TokenCategory::Operator);
4530
4531        // Strings
4532        assert_eq!(Token::String("test".to_string()).category(), TokenCategory::String);
4533        assert_eq!(Token::SingleString("test".to_string()).category(), TokenCategory::String);
4534        assert_eq!(
4535            Token::HereDoc(HereDocData {
4536                content: "test".to_string(),
4537                literal: false,
4538                strip_tabs: false,
4539                body_start_offset: 0,
4540            }).category(),
4541            TokenCategory::String,
4542        );
4543
4544        // Numbers
4545        assert_eq!(Token::Int(42).category(), TokenCategory::Number);
4546        assert_eq!(Token::Float(3.14).category(), TokenCategory::Number);
4547        assert_eq!(Token::Arithmetic("1+2".to_string()).category(), TokenCategory::Number);
4548
4549        // Variables
4550        assert_eq!(Token::SimpleVarRef("X".to_string()).category(), TokenCategory::Variable);
4551        assert_eq!(Token::VarRef("${X}".to_string()).category(), TokenCategory::Variable);
4552        assert_eq!(Token::Positional(1).category(), TokenCategory::Variable);
4553        assert_eq!(Token::AllArgs.category(), TokenCategory::Variable);
4554        assert_eq!(Token::ArgCount.category(), TokenCategory::Variable);
4555        assert_eq!(Token::LastExitCode.category(), TokenCategory::Variable);
4556        assert_eq!(Token::CurrentPid.category(), TokenCategory::Variable);
4557
4558        // Flags
4559        assert_eq!(Token::ShortFlag("l".to_string()).category(), TokenCategory::Flag);
4560        assert_eq!(Token::LongFlag("verbose".to_string()).category(), TokenCategory::Flag);
4561        assert_eq!(Token::PlusFlag("e".to_string()).category(), TokenCategory::Flag);
4562        assert_eq!(Token::DoubleDash.category(), TokenCategory::Flag);
4563
4564        // Punctuation
4565        assert_eq!(Token::Semi.category(), TokenCategory::Punctuation);
4566        assert_eq!(Token::LParen.category(), TokenCategory::Punctuation);
4567        assert_eq!(Token::LBracket.category(), TokenCategory::Punctuation);
4568        assert_eq!(Token::Newline.category(), TokenCategory::Punctuation);
4569
4570        // Comments
4571        assert_eq!(Token::Comment.category(), TokenCategory::Comment);
4572
4573        // Paths
4574        assert_eq!(Token::Path("/tmp/file".to_string()).category(), TokenCategory::Path);
4575
4576        // Commands
4577        assert_eq!(Token::Ident("echo".to_string()).category(), TokenCategory::Command);
4578        assert_eq!(Token::NumberIdent("019dda1c".to_string()).category(), TokenCategory::Command);
4579        assert_eq!(Token::DottedIdent(".gitignore".to_string()).category(), TokenCategory::Command);
4580
4581        // Errors
4582        assert_eq!(Token::InvalidFloatNoLeading.category(), TokenCategory::Error);
4583        assert_eq!(Token::InvalidFloatNoTrailing.category(), TokenCategory::Error);
4584    }
4585
4586    #[test]
4587    fn test_heredoc_piped_to_command() {
4588        // Bug 4: "cat <<EOF | jq" should produce: cat <<heredoc | jq
4589        // Not: cat | jq <<heredoc
4590        let tokens = tokenize("cat <<EOF | jq\n{\"key\": \"val\"}\nEOF").unwrap();
4591        let heredoc_pos = tokens.iter().position(|t| matches!(t.token, Token::HereDoc(_)));
4592        let pipe_pos = tokens.iter().position(|t| matches!(t.token, Token::Pipe));
4593        assert!(heredoc_pos.is_some(), "should have a heredoc token");
4594        assert!(pipe_pos.is_some(), "should have a pipe token");
4595        assert!(
4596            pipe_pos.unwrap() > heredoc_pos.unwrap(),
4597            "Pipe must come after heredoc, got heredoc at {}, pipe at {}. Tokens: {:?}",
4598            heredoc_pos.unwrap(), pipe_pos.unwrap(), tokens,
4599        );
4600    }
4601
4602    #[test]
4603    fn test_heredoc_standalone_still_works() {
4604        // Regression: standalone heredoc (no pipe) must still work
4605        let tokens = tokenize("cat <<EOF\nhello\nEOF").unwrap();
4606        assert!(tokens.iter().any(|t| matches!(t.token, Token::HereDoc(_))));
4607        assert!(!tokens.iter().any(|t| matches!(t.token, Token::Pipe)));
4608    }
4609
4610    #[test]
4611    fn test_heredoc_preserves_leading_empty_lines() {
4612        // Bug B: heredoc starting with a blank line must preserve it
4613        let tokens = tokenize("cat <<EOF\n\nhello\nEOF").unwrap();
4614        let heredoc = tokens.iter().find_map(|t| {
4615            if let Token::HereDoc(data) = &t.token {
4616                Some(data.clone())
4617            } else {
4618                None
4619            }
4620        });
4621        assert!(heredoc.is_some(), "should have a heredoc token");
4622        let data = heredoc.unwrap();
4623        assert!(data.content.starts_with('\n'), "leading empty line must be preserved, got: {:?}", data.content);
4624        assert_eq!(data.content, "\nhello\n");
4625    }
4626
4627    #[test]
4628    fn test_heredoc_quoted_delimiter_sets_literal() {
4629        // Bug N: quoted delimiter (<<'EOF') should set literal=true
4630        let tokens = tokenize("cat <<'EOF'\nhello $HOME\nEOF").unwrap();
4631        let heredoc = tokens.iter().find_map(|t| {
4632            if let Token::HereDoc(data) = &t.token {
4633                Some(data.clone())
4634            } else {
4635                None
4636            }
4637        });
4638        assert!(heredoc.is_some(), "should have a heredoc token");
4639        let data = heredoc.unwrap();
4640        assert!(data.literal, "quoted delimiter should set literal=true");
4641        assert_eq!(data.content, "hello $HOME\n");
4642    }
4643
4644    #[test]
4645    fn test_heredoc_unquoted_delimiter_not_literal() {
4646        // Bug N: unquoted delimiter (<<EOF) should have literal=false
4647        let tokens = tokenize("cat <<EOF\nhello $HOME\nEOF").unwrap();
4648        let heredoc = tokens.iter().find_map(|t| {
4649            if let Token::HereDoc(data) = &t.token {
4650                Some(data.clone())
4651            } else {
4652                None
4653            }
4654        });
4655        assert!(heredoc.is_some(), "should have a heredoc token");
4656        let data = heredoc.unwrap();
4657        assert!(!data.literal, "unquoted delimiter should have literal=false");
4658    }
4659
4660    // ═══════════════════════════════════════════════════════════════════
4661    // Colon merge tests
4662    // ═══════════════════════════════════════════════════════════════════
4663
4664    #[test]
4665    fn colon_double_in_word() {
4666        assert_eq!(lex("foo::bar"), vec![Token::Ident("foo::bar".into())]);
4667    }
4668
4669    #[test]
4670    fn colon_single_in_word() {
4671        assert_eq!(lex("a:b:c"), vec![Token::Ident("a:b:c".into())]);
4672    }
4673
4674    #[test]
4675    fn colon_with_port() {
4676        assert_eq!(lex("host:8080"), vec![Token::Ident("host:8080".into())]);
4677    }
4678
4679    #[test]
4680    fn colon_standalone() {
4681        assert_eq!(lex(":"), vec![Token::Colon]);
4682    }
4683
4684    #[test]
4685    fn colon_spaced_no_merge() {
4686        assert_eq!(
4687            lex("foo : bar"),
4688            vec![
4689                Token::Ident("foo".into()),
4690                Token::Colon,
4691                Token::Ident("bar".into()),
4692            ]
4693        );
4694    }
4695
4696    #[test]
4697    fn colon_in_command_arg() {
4698        assert_eq!(
4699            lex("echo foo::bar"),
4700            vec![
4701                Token::Ident("echo".into()),
4702                Token::Ident("foo::bar".into()),
4703            ]
4704        );
4705    }
4706
4707    #[test]
4708    fn colon_trailing() {
4709        // Trailing colon merges with preceding ident
4710        assert_eq!(lex("foo:"), vec![Token::Ident("foo:".into())]);
4711    }
4712
4713    #[test]
4714    fn colon_leading() {
4715        // Leading colon merges with following ident
4716        assert_eq!(lex(":foo"), vec![Token::Ident(":foo".into())]);
4717    }
4718
4719    #[test]
4720    fn colon_with_path() {
4721        // Path token + colon + int
4722        assert_eq!(
4723            lex("/usr/bin:8080"),
4724            vec![Token::Ident("/usr/bin:8080".into())]
4725        );
4726    }
4727
4728    // ═══════════════════════════════════════════════════════════════════
4729    // Token predicate coverage (is_keyword / starts_statement)
4730    // ═══════════════════════════════════════════════════════════════════
4731
4732    #[test]
4733    fn is_keyword_covers_control_flow() {
4734        for t in [
4735            Token::While,
4736            Token::Return,
4737            Token::Break,
4738            Token::Continue,
4739            Token::Exit,
4740        ] {
4741            assert!(t.is_keyword(), "{t:?} should be a keyword");
4742        }
4743    }
4744
4745    #[test]
4746    fn starts_statement_covers_while() {
4747        assert!(Token::While.starts_statement());
4748    }
4749
4750    #[test]
4751    fn is_keyword_rejects_operators() {
4752        for t in [Token::Pipe, Token::Amp, Token::Eq, Token::LBrace] {
4753            assert!(!t.is_keyword(), "{t:?} should not be a keyword");
4754        }
4755    }
4756
4757    // ═══════════════════════════════════════════════════════════════════
4758    // Comma significance: only inside a `[...]`/`{...}` literal or pattern
4759    // (see `run_has_bare_comma`, `compute_bracket_depth`). Outside brackets
4760    // a comma folds into the surrounding bareword like any other ordinary
4761    // character. Kernel-level (`echo`/`sed`/`cut`/`sort` output) coverage
4762    // lives in `tests/bareword_comma_tests.rs`, `tests/builtin_fidelity_tests.rs`,
4763    // and `tests/sort_key_tests.rs`; these are the precise token-shape
4764    // assertions that don't fit an integration test.
4765    // ═══════════════════════════════════════════════════════════════════
4766
4767    #[test]
4768    fn bare_comma_run_folds_to_ident() {
4769        // sed -n 1,3p / cut -f 1,3 / sort -k 2,2n: no brackets anywhere, so
4770        // the comma has no grammatical role and folds into one bareword.
4771        assert_eq!(lex("1,3p"), vec![Token::Ident("1,3p".into())]);
4772        assert_eq!(lex("1,3"), vec![Token::Ident("1,3".into())]);
4773        assert_eq!(lex("2,2n"), vec![Token::Ident("2,2n".into())]);
4774        assert_eq!(lex("a,b"), vec![Token::Ident("a,b".into())]);
4775        assert_eq!(lex("1,2,3"), vec![Token::Ident("1,2,3".into())]);
4776    }
4777
4778    #[test]
4779    fn standalone_comma_stays_a_token() {
4780        // Whitespace on both sides: nothing to fold into, stays `Comma` —
4781        // this is the `cut -d , -f2` idiom (see `bareword_comma_tests.rs`).
4782        assert_eq!(
4783            lex("cut -d , -f2"),
4784            vec![
4785                Token::Ident("cut".into()),
4786                Token::ShortFlag("d".into()),
4787                Token::Comma,
4788                Token::ShortFlag("f2".into()),
4789            ]
4790        );
4791    }
4792
4793    #[test]
4794    fn case_pattern_brace_comma_stays_significant() {
4795        // `{js,ts}` has no `*`/`?`, so it never reaches the has_star_or_question
4796        // glob-fuse path either — the comma must stay a separate token for
4797        // `case_parser`'s brace-expansion grammar (parser.rs `case_parser`).
4798        assert_eq!(
4799            lex("{js,ts}"),
4800            vec![
4801                Token::LBrace,
4802                Token::Ident("js".into()),
4803                Token::Comma,
4804                Token::Ident("ts".into()),
4805                Token::RBrace,
4806            ]
4807        );
4808    }
4809
4810    #[test]
4811    fn glob_brace_expansion_with_star_still_fuses() {
4812        // A `*` elsewhere in the word triggers the EXISTING glob-fuse path
4813        // (unrelated to the new bare-comma fold) — the whole thing becomes
4814        // one `GlobWord`, comma included, for the glob engine to expand.
4815        assert_eq!(lex("*.{js,ts}"), vec![Token::GlobWord("*.{js,ts}".into())]);
4816        assert_eq!(
4817            lex("src/*.{rs,toml}"),
4818            vec![Token::GlobWord("src/*.{rs,toml}".into())]
4819        );
4820    }
4821
4822    #[test]
4823    fn bracket_list_with_spaces_keeps_comma_significant() {
4824        // `[1, 2, 3]` splits into three whitespace-bounded runs ("[1,", "2,",
4825        // "3]") — the opening `[` is in the FIRST run, not the run that owns
4826        // the middle comma, so this only works with the cross-run
4827        // `compute_bracket_depth` seed (a per-run-only counter would
4828        // wrongly fold "2," into one bareword — see PR discussion / GH
4829        // regression this test pins).
4830        assert_eq!(
4831            lex("[1, 2, 3]"),
4832            vec![
4833                Token::LBracket,
4834                Token::Int(1),
4835                Token::Comma,
4836                Token::Int(2),
4837                Token::Comma,
4838                Token::Int(3),
4839                Token::RBracket,
4840            ]
4841        );
4842    }
4843
4844    // These use `x=...` (assignment/value position) rather than a bare
4845    // statement: a bare non-value-position `[...]` run with a real bracket
4846    // PAIR already fuses whole into one `GlobWord` regardless of comma (an
4847    // existing, comma-unrelated rule — see `flush_glob_run`'s
4848    // `has_bracket_pair` branch); list/record literals are only ever
4849    // legal at value position anyway (`docs/LANGUAGE.md`, "Construction"),
4850    // so that's the realistic shape to pin here.
4851
4852    #[test]
4853    fn nested_list_of_lists_keeps_commas_significant() {
4854        assert_eq!(
4855            lex("x=[[1,2],[3,4]]"),
4856            vec![
4857                Token::Ident("x".into()),
4858                Token::Eq,
4859                Token::LBracket,
4860                Token::LBracket,
4861                Token::Int(1),
4862                Token::Comma,
4863                Token::Int(2),
4864                Token::RBracket,
4865                Token::Comma,
4866                Token::LBracket,
4867                Token::Int(3),
4868                Token::Comma,
4869                Token::Int(4),
4870                Token::RBracket,
4871                Token::RBracket,
4872            ]
4873        );
4874    }
4875
4876    #[test]
4877    fn nested_record_in_list_keeps_commas_significant() {
4878        assert_eq!(
4879            lex("x=[{a:1},{b:2}]"),
4880            vec![
4881                Token::Ident("x".into()),
4882                Token::Eq,
4883                Token::LBracket,
4884                Token::LBrace,
4885                Token::Ident("a".into()),
4886                Token::Colon,
4887                Token::Int(1),
4888                Token::RBrace,
4889                Token::Comma,
4890                Token::LBrace,
4891                Token::Ident("b".into()),
4892                Token::Colon,
4893                Token::Int(2),
4894                Token::RBrace,
4895                Token::RBracket,
4896            ]
4897        );
4898    }
4899
4900    #[test]
4901    fn stray_unclosed_bracket_does_not_wedge_past_the_line() {
4902        // A stray/unmatched `[` (no closing `]` anywhere) must not leave
4903        // the bracket-depth counter elevated for the rest of the line, let
4904        // alone the rest of the script — `compute_bracket_depth` resets at
4905        // every `is_statement_boundary` token, including `Newline`. The
4906        // comma on line 2 has no enclosing bracket of its own and must
4907        // still fold into a bareword.
4908        assert_eq!(
4909            lex("[dog\nsed -n 1,3p"),
4910            vec![
4911                Token::LBracket,
4912                Token::Ident("dog".into()),
4913                Token::Newline,
4914                Token::Ident("sed".into()),
4915                Token::ShortFlag("n".into()),
4916                Token::Ident("1,3p".into()),
4917            ]
4918        );
4919    }
4920
4921    #[test]
4922    fn stray_unmatched_closing_bracket_does_not_underflow() {
4923        // A stray `]`/`}` with no opener must clamp depth at 0, not go
4924        // negative (which would otherwise require an impossibly deep nest
4925        // of real opens to ever recover comma significance). `RBracket` is
4926        // itself glob-mergeable (character-class runs like `[0-9]*` need
4927        // it to fuse), so a leading stray `]` joins the same run as the
4928        // comma that follows it — the whole glued word folds into one
4929        // bareword, which is exactly the safe, self-contained outcome the
4930        // depth clamp is for.
4931        assert_eq!(lex("]a,b"), vec![Token::Ident("]a,b".into())]);
4932    }
4933
4934    #[test]
4935    fn comma_in_double_quoted_string_is_string_content() {
4936        // Quoted content never reaches `Token::Comma` at all — the whole
4937        // thing lexes as one `String` token before any fusion pass runs.
4938        assert_eq!(lex(r#""a,b""#), vec![Token::String("a,b".into())]);
4939    }
4940
4941    #[test]
4942    fn comma_in_single_quoted_string_is_string_content() {
4943        assert_eq!(lex("'a,b'"), vec![Token::SingleString("a,b".into())]);
4944    }
4945
4946    #[test]
4947    fn comma_in_var_ref_braces_is_not_tokenized_separately() {
4948        // `${...}` is captured as ONE token by `lex_varref` (balanced-brace
4949        // scan) — a comma inside never reaches the fusion passes as its own
4950        // `Token::Comma` at all.
4951        assert_eq!(
4952            lex("${X:-1,3}"),
4953            vec![Token::VarRef("${X:-1,3}".into())]
4954        );
4955    }
4956
4957    #[test]
4958    fn comma_inside_cmd_subst_folds_like_top_level() {
4959        // `$(...)` bodies are ordinary tokens in the main stream (not
4960        // extracted like heredocs/arithmetic), so a comma inside gets the
4961        // same bracket-depth treatment as top-level source.
4962        assert_eq!(
4963            lex("$(sed -n 1,3p)"),
4964            vec![
4965                Token::CmdSubstStart,
4966                Token::Ident("sed".into()),
4967                Token::ShortFlag("n".into()),
4968                Token::Ident("1,3p".into()),
4969                Token::RParen,
4970            ]
4971        );
4972    }
4973
4974    #[test]
4975    fn non_comma_glued_pasting_is_unaffected() {
4976        // The general no-token-pasting guard (`reject_glued_args`, GH #189)
4977        // must still see these as separate glued fragments — this fix only
4978        // changes comma, nothing else. (The parser-level rejection is
4979        // covered by `builtin_fidelity_tests::non_comma_pasting_keeps_generic_message`;
4980        // this pins the lexer's token shape underneath it.)
4981        assert_eq!(
4982            lex("--flag$(echo x)"),
4983            vec![
4984                Token::LongFlag("flag".into()),
4985                Token::CmdSubstStart,
4986                Token::Ident("echo".into()),
4987                Token::Ident("x".into()),
4988                Token::RParen,
4989            ]
4990        );
4991    }
4992}