Skip to main content

kaish_kernel/
lexer.rs

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