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