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 generator.
4//! The lexer is designed to be unambiguous: every valid input produces exactly
5//! one token sequence, and invalid input produces clear errors.
6//!
7//! # Token Categories
8//!
9//! - **Keywords**: `set`, `tool`, `if`, `then`, `else`, `fi`, `for`, `in`, `do`, `done`
10//! - **Literals**: strings, integers, floats, booleans (`true`/`false`)
11//! - **Operators**: `=`, `|`, `&`, `>`, `>>`, `<`, `2>`, `&>`, `&&`, `||`
12//! - **Punctuation**: `;`, `:`, `,`, `.`, `{`, `}`, `[`, `]`
13//! - **Variable references**: `${...}` with nested path access
14//! - **Identifiers**: command names, variable names, parameter names
15
16use logos::{Logos, Span};
17use std::fmt;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21/// Global counter for generating unique markers across all tokenize calls.
22static MARKER_COUNTER: AtomicU64 = AtomicU64::new(0);
23
24/// Maximum nesting depth for parentheses in arithmetic expressions.
25/// Prevents stack overflow from pathologically nested inputs like $((((((...
26const MAX_PAREN_DEPTH: usize = 256;
27
28/// Tracks a text replacement for span correction.
29/// When preprocessing replaces text (like `$((1+2))` with a marker),
30/// we need to adjust subsequent spans to account for the length change.
31#[derive(Debug, Clone)]
32struct SpanReplacement {
33    /// Position in the preprocessed text where the marker starts.
34    preprocessed_pos: usize,
35    /// Length of the marker in preprocessed text.
36    marker_len: usize,
37    /// Length of the original text that was replaced.
38    original_len: usize,
39}
40
41/// Corrects a span from preprocessed-text coordinates back to original-text coordinates.
42fn correct_span(span: Span, replacements: &[SpanReplacement]) -> Span {
43    let mut start_adjustment: isize = 0;
44    let mut end_adjustment: isize = 0;
45
46    for r in replacements {
47        // Calculate the length difference (positive = original was longer, negative = marker is longer)
48        let delta = r.original_len as isize - r.marker_len as isize;
49
50        // If the span starts after this replacement, adjust the start
51        if span.start > r.preprocessed_pos + r.marker_len {
52            start_adjustment += delta;
53        } else if span.start > r.preprocessed_pos {
54            // Span starts inside the marker - map to original position
55            // (this shouldn't happen often, but handle it gracefully)
56            start_adjustment += delta;
57        }
58
59        // If the span ends after this replacement, adjust the end
60        if span.end > r.preprocessed_pos + r.marker_len {
61            end_adjustment += delta;
62        } else if span.end > r.preprocessed_pos {
63            // Span ends inside the marker - map to end of original
64            end_adjustment += delta;
65        }
66    }
67
68    let new_start = (span.start as isize + start_adjustment).max(0) as usize;
69    let new_end = (span.end as isize + end_adjustment).max(new_start as isize) as usize;
70    new_start..new_end
71}
72
73/// Generate a unique marker ID that's extremely unlikely to collide with user code.
74/// Uses a combination of timestamp, counter, and process ID.
75fn unique_marker_id() -> String {
76    let timestamp = SystemTime::now()
77        .duration_since(UNIX_EPOCH)
78        .map(|d| d.as_nanos())
79        .unwrap_or(0);
80    let counter = MARKER_COUNTER.fetch_add(1, Ordering::Relaxed);
81    #[cfg(target_os = "wasi")]
82    let pid = 0u32;
83    #[cfg(not(target_os = "wasi"))]
84    let pid = std::process::id();
85    format!("{:x}_{:x}_{:x}", timestamp, counter, pid)
86}
87
88/// A token with its span in the source text.
89#[derive(Debug, Clone, PartialEq)]
90pub struct Spanned<T> {
91    pub token: T,
92    pub span: Span,
93}
94
95impl<T> Spanned<T> {
96    pub fn new(token: T, span: Span) -> Self {
97        Self { token, span }
98    }
99}
100
101/// Lexer error types.
102#[derive(Debug, Clone, PartialEq, Default)]
103pub enum LexerError {
104    #[default]
105    UnexpectedCharacter,
106    UnterminatedString,
107    UnterminatedVarRef,
108    InvalidEscape,
109    InvalidNumber,
110    AmbiguousBoolean(String),
111    AmbiguousBooleanLike(String),
112    InvalidFloatNoLeading,
113    InvalidFloatNoTrailing,
114    /// Nesting depth exceeded (too many nested parentheses in arithmetic).
115    NestingTooDeep,
116    /// Arithmetic expansion `$((` reached end of input without a closing `))`.
117    /// Silently evaluating the partial expression would mask a typo (`$(( 1 + 2`
118    /// would compute `3`), so we surface it loudly instead.
119    UnterminatedArithmetic,
120    /// Heredoc body ended without seeing the closing delimiter on its own line.
121    /// The user almost certainly meant to type the delimiter — silently using
122    /// whatever was collected up to EOF would mask missing data.
123    UnterminatedHeredoc { delimiter: String },
124    /// Backtick command substitution. Kaish drops backticks intentionally —
125    /// they're listed in `docs/LANGUAGE.md` and the help system as not supported.
126    /// We surface this as a dedicated error (rather than `UnexpectedCharacter`)
127    /// so the message can point users at the `$(cmd)` replacement.
128    BackticksNotSupported,
129}
130
131impl fmt::Display for LexerError {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            LexerError::UnexpectedCharacter => write!(f, "unexpected character"),
135            LexerError::UnterminatedString => write!(f, "unterminated string"),
136            LexerError::UnterminatedVarRef => write!(f, "unterminated variable reference"),
137            LexerError::InvalidEscape => write!(f, "invalid escape sequence"),
138            LexerError::InvalidNumber => write!(f, "invalid number"),
139            LexerError::AmbiguousBoolean(s) => {
140                write!(f, "ambiguous boolean, use lowercase '{}'", s.to_lowercase())
141            }
142            LexerError::AmbiguousBooleanLike(s) => {
143                let suggest = if s.eq_ignore_ascii_case("yes") { "true" } else { "false" };
144                write!(f, "ambiguous boolean-like '{}', use '{}' or '\"{}\"'", s, suggest, s)
145            }
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        }
159    }
160}
161
162/// Tokens produced by the kaish lexer.
163///
164/// The order of variants matters for logos priority. More specific patterns
165/// (like keywords) should come before more general ones (like identifiers).
166///
167/// Tokens that carry semantic values (strings, numbers, identifiers) include
168/// the parsed value directly. This ensures the parser has access to actual
169/// data, not just token types.
170/// Here-doc content data.
171///
172/// - `literal` is true when the delimiter was quoted (`<<'EOF'` or `<<"EOF"`),
173///   meaning no variable expansion should occur.
174/// - `strip_tabs` is true for the `<<-EOF` form. Per POSIX, leading tabs on
175///   each body line are stripped at materialization time. Stripping happens
176///   downstream of the parser so byte offsets in `content` stay aligned with
177///   their original-source positions for span-tracking purposes.
178/// - `body_start_offset` is the byte offset of the first character of `content`
179///   in the source string fed into the lexer's `tokenize`. This lets the parser
180///   compute absolute spans for parts found inside the body during interpolation.
181///   In sources without arithmetic preprocessing rewrites, this equals the
182///   original-source offset; with arithmetic before the heredoc, line numbers
183///   may shift slightly until full preprocessing-layer composition lands.
184#[derive(Debug, Clone, PartialEq)]
185pub struct HereDocData {
186    pub content: String,
187    pub literal: bool,
188    pub strip_tabs: bool,
189    pub body_start_offset: usize,
190}
191
192#[derive(Logos, Debug, Clone, PartialEq)]
193#[logos(error = LexerError)]
194#[logos(skip r"[ \t]+")]
195pub enum Token {
196    // ═══════════════════════════════════════════════════════════════════
197    // Keywords (must come before Ident for priority)
198    // ═══════════════════════════════════════════════════════════════════
199    #[token("set")]
200    Set,
201
202    #[token("local")]
203    Local,
204
205    #[token("if")]
206    If,
207
208    #[token("then")]
209    Then,
210
211    #[token("else")]
212    Else,
213
214    #[token("elif")]
215    Elif,
216
217    #[token("fi")]
218    Fi,
219
220    #[token("for")]
221    For,
222
223    #[token("while")]
224    While,
225
226    #[token("in")]
227    In,
228
229    #[token("do")]
230    Do,
231
232    #[token("done")]
233    Done,
234
235    #[token("case")]
236    Case,
237
238    #[token("esac")]
239    Esac,
240
241    #[token("function")]
242    Function,
243
244    #[token("break")]
245    Break,
246
247    #[token("continue")]
248    Continue,
249
250    #[token("return")]
251    Return,
252
253    #[token("exit")]
254    Exit,
255
256    #[token("true")]
257    True,
258
259    #[token("false")]
260    False,
261
262    // ═══════════════════════════════════════════════════════════════════
263    // Type keywords (for tool parameters)
264    // ═══════════════════════════════════════════════════════════════════
265    #[token("string")]
266    TypeString,
267
268    #[token("int")]
269    TypeInt,
270
271    #[token("float")]
272    TypeFloat,
273
274    #[token("bool")]
275    TypeBool,
276
277    // ═══════════════════════════════════════════════════════════════════
278    // Multi-character operators (must come before single-char versions)
279    // ═══════════════════════════════════════════════════════════════════
280    #[token("&&")]
281    And,
282
283    #[token("||")]
284    Or,
285
286    #[token("==")]
287    EqEq,
288
289    #[token("!=")]
290    NotEq,
291
292    #[token("=~")]
293    Match,
294
295    #[token("!~")]
296    NotMatch,
297
298    #[token(">=")]
299    GtEq,
300
301    #[token("<=")]
302    LtEq,
303
304    #[token(">>")]
305    GtGt,
306
307    #[token("2>&1")]
308    StderrToStdout,
309
310    #[token("1>&2")]
311    StdoutToStderr,
312
313    #[token(">&2")]
314    StdoutToStderr2,
315
316    #[token("2>")]
317    Stderr,
318
319    #[token("&>")]
320    Both,
321
322    #[token("<<<")]
323    HereString,
324
325    #[token("<<")]
326    HereDocStart,
327
328    #[token(";;")]
329    DoubleSemi,
330
331    // ═══════════════════════════════════════════════════════════════════
332    // Single-character operators and punctuation
333    // ═══════════════════════════════════════════════════════════════════
334    #[token("=")]
335    Eq,
336
337    #[token("|")]
338    Pipe,
339
340    #[token("&")]
341    Amp,
342
343    #[token(">")]
344    Gt,
345
346    #[token("<")]
347    Lt,
348
349    #[token(";")]
350    Semi,
351
352    #[token(":")]
353    Colon,
354
355    #[token(",")]
356    Comma,
357
358    /// Spread operator: `[...$xs date]`. Only meaningful inside a list literal
359    /// (value context); inert everywhere else. logos resolves the `"..."` vs
360    /// `".."` (`DotDot`) ambiguity by longest match, so no explicit priority
361    /// is needed here.
362    #[token("...")]
363    DotDotDot,
364
365    #[token("..")]
366    DotDot,
367
368    #[token(".")]
369    Dot,
370
371    /// Tilde path: `~/foo`, `~user/bar` - value includes the full string
372    #[regex(r"~[a-zA-Z0-9_./+-]+", lex_tilde_path, priority = 3)]
373    TildePath(String),
374
375    /// Bare tilde: `~` alone (expands to $HOME)
376    #[token("~")]
377    Tilde,
378
379    /// Relative path: `../foo/bar`, bare `src/kaish` (ident containing `/`),
380    /// or a directory reference with a trailing slash like `dest/`. The
381    /// trailing-slash form uses `*` (not `+`) after the slash so `dest/`
382    /// lexes as one token instead of `Ident("dest")` + `Path("/")` — the
383    /// latter split silently turned `cp a b dest/` into a 4-operand command.
384    #[regex(r"\.\./[a-zA-Z0-9_./-]+", lex_relative_path, priority = 3)]
385    #[regex(r"[a-zA-Z_][a-zA-Z0-9_.-]*/[a-zA-Z0-9_./-]*", lex_relative_path, priority = 3)]
386    RelativePath(String),
387
388    /// Dot-slash path: `./foo`, `./script.sh`
389    #[regex(r"\./[a-zA-Z0-9_./-]+", lex_dot_slash_path, priority = 3)]
390    DotSlashPath(String),
391
392    /// Dot-prefixed bareword: `.parent`, `.gitignore`, `.foo.bar`.
393    /// Treated as an opaque string in argv position. Distinct from `Token::Dot`
394    /// (the POSIX `.` source alias) which only matches a bare `.` — the source
395    /// alias requires whitespace before its file argument (`. script`), so
396    /// `.parent` (no space) is unambiguously a single bareword.
397    #[regex(r"\.[a-zA-Z_][a-zA-Z0-9_.-]*", lex_dotted_ident, priority = 3)]
398    DottedIdent(String),
399
400    #[token("{")]
401    LBrace,
402
403    #[token("}")]
404    RBrace,
405
406    #[token("[")]
407    LBracket,
408
409    #[token("]")]
410    RBracket,
411
412    #[token("(")]
413    LParen,
414
415    #[token(")")]
416    RParen,
417
418    #[token("*")]
419    Star,
420
421    #[token("!")]
422    Bang,
423
424    #[token("?")]
425    Question,
426
427    /// Merged glob word: span-adjacent tokens containing `*`, `?`, or `[...]`.
428    /// Synthesized by `merge_glob_adjacent()`, never produced by logos directly.
429    GlobWord(String),
430
431    // ═══════════════════════════════════════════════════════════════════
432    // Command substitution
433    // ═══════════════════════════════════════════════════════════════════
434
435    /// Arithmetic expression content: synthesized by preprocessing.
436    /// Contains the expression string between `$((` and `))`.
437    Arithmetic(String),
438
439    /// Command substitution start: `$(` - begins a command substitution
440    #[token("$(")]
441    CmdSubstStart,
442
443    // ═══════════════════════════════════════════════════════════════════
444    // Flags (must come before Int to win over negative numbers)
445    // ═══════════════════════════════════════════════════════════════════
446
447    /// Long flag: `--name` or `--foo-bar`
448    #[regex(r"--[a-zA-Z][a-zA-Z0-9-]*", lex_long_flag, priority = 3)]
449    LongFlag(String),
450
451    /// Short flag: `-l`, `-la` (combined short flags), or a dash-word with
452    /// internal hyphens like `-not-a-flag`. Internal hyphens are part of the
453    /// single shell word — without them the word fragments into separate flag
454    /// tokens, which breaks `echo -- -not-a-flag` and the like. A leading `--`
455    /// is still `DoubleDash` (the second char must be a letter here) unless
456    /// the third char isn't a letter either, in which case it's
457    /// `DoubleDashBare` — see below — and whether the word is a flag or a
458    /// literal is the binding layer's call.
459    #[regex(r"-[a-zA-Z][a-zA-Z0-9-]*", lex_short_flag, priority = 3)]
460    ShortFlag(String),
461
462    /// Plus flag: `+e` or `+x` (for set +e to disable options)
463    #[regex(r"\+[a-zA-Z][a-zA-Z0-9]*", lex_plus_flag, priority = 3)]
464    PlusFlag(String),
465
466    /// Double dash: `--` alone marks end of flags. Only matches when nothing
467    /// else follows (a longer match always wins) — a `--`-prefixed word with
468    /// more characters after it either lexes as `LongFlag` (3rd char is a
469    /// letter) or `DoubleDashBare` (3rd char is anything else).
470    #[token("--")]
471    DoubleDash,
472
473    /// Bare word starting with `--` whose continuation isn't a valid
474    /// long-flag name: `---`, `----`, `--=x`, `--1`, etc. Without this, the
475    /// plain `--` literal above always won the length tie against a lone
476    /// `--`, silently truncating a dash-only operand to its trailing
477    /// remainder (`echo ---` printed `-` instead of `---` — GH #137). Mirrors
478    /// `MinusBare`/`PlusBare` (bare-word fallback for an unrecognized
479    /// flag-shaped prefix), just generalized to the `--` prefix. A standalone
480    /// `--` (followed by whitespace/EOF) still lexes as `DoubleDash` — this
481    /// regex requires at least one more non-whitespace character, so the two
482    /// never tie in match length and no priority tiebreak is load-bearing;
483    /// `priority = 2` is set for consistency with `PlusBare`'s tier.
484    #[regex(r"--[^a-zA-Z\s][^\s]*", lex_double_dash_bare, priority = 2)]
485    DoubleDashBare(String),
486
487    /// Bare word starting with + followed by non-letter: `+%s`, `+%Y-%m-%d`
488    /// For date format strings and similar. Lower priority than PlusFlag.
489    #[regex(r"\+[^a-zA-Z\s][^\s]*", lex_plus_bare, priority = 2)]
490    PlusBare(String),
491
492    /// Bare word starting with - followed by non-letter/digit/dash: `-%`, etc.
493    /// For rare cases. Lower priority than ShortFlag, Int, and DoubleDash.
494    /// Excludes - after first - to avoid matching --name patterns.
495    #[regex(r"-[^a-zA-Z0-9\s\-][^\s]*", lex_minus_bare, priority = 1)]
496    MinusBare(String),
497
498    /// Job specifier: `%1`, `%2` — the bash idiom for `wait`/`kill` targets.
499    /// Keeps the leading `%` (kill uses it to distinguish a job from a PID;
500    /// wait strips it). Without this token a bare `%1` is a lexer error.
501    #[regex(r"%[0-9]+", lex_job_spec)]
502    JobSpec(String),
503
504    /// Standalone - (stdin indicator for cat -, diff - -, etc.)
505    /// Only matches when followed by whitespace or end.
506    /// This is handled specially in the parser as a positional arg.
507    #[token("-")]
508    MinusAlone,
509
510    // ═══════════════════════════════════════════════════════════════════
511    // Literals (with values)
512    // ═══════════════════════════════════════════════════════════════════
513
514    /// Double-quoted string: `"..."` - value is the parsed content (quotes removed, escapes processed)
515    #[regex(r#""([^"\\]|\\.)*""#, lex_string)]
516    String(String),
517
518    /// Single-quoted string: `'...'` - literal content, no escape processing
519    #[regex(r"'[^']*'", lex_single_string)]
520    SingleString(String),
521
522    /// Braced variable reference: `${VAR}` or `${VAR.field}` - value is the raw inner content
523    #[regex(r"\$\{[^}]+\}", lex_varref)]
524    VarRef(String),
525
526    /// Simple variable reference: `$NAME` - just the identifier
527    #[regex(r"\$[a-zA-Z_][a-zA-Z0-9_]*", lex_simple_varref)]
528    SimpleVarRef(String),
529
530    /// Positional parameter: `$0` through `$9`
531    #[regex(r"\$[0-9]", lex_positional)]
532    Positional(usize),
533
534    /// All positional parameters: `$@`
535    #[token("$@")]
536    AllArgs,
537
538    /// Number of positional parameters: `$#`
539    #[token("$#")]
540    ArgCount,
541
542    /// Last exit code: `$?`
543    #[token("$?")]
544    LastExitCode,
545
546    /// Current shell PID: `$$`
547    #[token("$$")]
548    CurrentPid,
549
550    /// Variable string length: `${#VAR}` or a subscripted path `${#u[tags]}`.
551    /// The trailing `(\[[^\]]*\])*` admits chained bracket subscripts so a
552    /// length-of-path lexes in expression position, not just inside strings; the
553    /// parser turns the captured inner into a `VarPath`.
554    #[regex(r"\$\{#[a-zA-Z_][a-zA-Z0-9_]*(\[[^\]]*\])*\}", lex_var_length)]
555    VarLength(String),
556
557    /// Here-doc content: synthesized by preprocessing, not directly lexed.
558    /// Contains the full content of the here-doc (without the delimiter lines).
559    HereDoc(HereDocData),
560
561    /// Integer literal - value is the parsed i64
562    #[regex(r"-?[0-9]+", lex_int, priority = 2)]
563    Int(i64),
564
565    /// Float literal - value is the parsed f64
566    #[regex(r"-?[0-9]+\.[0-9]+", lex_float)]
567    Float(f64),
568
569    // ═══════════════════════════════════════════════════════════════════
570    // Invalid patterns (caught before valid tokens for better errors)
571    // ═══════════════════════════════════════════════════════════════════
572
573    /// Digit-leading bareword: `019dda1c` (SHA prefix), UUIDs, version-ish
574    /// strings. Distinguished from `Int` because at least one alpha character
575    /// follows the leading digits — the lexer commits to "this is a string,
576    /// not a number." Treated as a bareword string in expression position.
577    #[regex(r"[0-9]+[a-zA-Z_][a-zA-Z0-9_.-]*", lex_number_ident, priority = 3)]
578    NumberIdent(String),
579
580    /// Numeric word containing an embedded hyphen run, or a minus-led numeric
581    /// word with a non-numeric suffix. These are single contiguous shell words
582    /// the user typed — ISO dates (`2024-01-02`), `N-M` ranges (`10-20`,
583    /// `cut -f 1-3`, `tr -d 0-9`), float-dash forms (`1.5-2`), and `find`
584    /// predicate values like `-1k` (smaller than 1k). Without this token they
585    /// fragment into adjacent `Int`/`Float`/flag tokens and trip the
586    /// no-token-pasting guard. The raw slice is preserved verbatim (so leading
587    /// zeros survive). A plain `2024`/`1.5`/`-1` stays `Int`/`Float` — the
588    /// digit-hyphen form requires a `-segment`, and the minus-led form requires
589    /// an alpha after the digits.
590    #[regex(r"[0-9]+(\.[0-9]+)?(-[0-9a-zA-Z._]+)+", lex_slice_word, priority = 3)]
591    #[regex(r"-[0-9]+[a-zA-Z_][0-9a-zA-Z._-]*", lex_slice_word, priority = 3)]
592    DashNumWord(String),
593
594    /// Leading-`@` bareword: `@scope/pkg` (scoped package), `@0` (epoch in
595    /// `date -d @0`), or bare `@`. Mid-word `@` (`user@host`) is handled by
596    /// `Ident`; this covers the leading-`@` cases that would otherwise be an
597    /// "unexpected character" lexer error.
598    #[regex(r"@[a-zA-Z0-9_./@-]*", lex_slice_word, priority = 3)]
599    AtWord(String),
600
601    /// Invalid: float without leading digit (like .5)
602    #[regex(r"\.[0-9]+", lex_invalid_float_no_leading, priority = 3)]
603    InvalidFloatNoLeading,
604
605    /// Invalid: float without trailing digit (like 5.)
606    /// Logos uses longest-match, so valid floats like 5.5 will match Float pattern instead
607    #[regex(r"[0-9]+\.", lex_invalid_float_no_trailing, priority = 2)]
608    InvalidFloatNoTrailing,
609
610    // ═══════════════════════════════════════════════════════════════════
611    // Paths (absolute paths starting with /)
612    // ═══════════════════════════════════════════════════════════════════
613
614    /// Absolute path: `/tmp/out`, `/etc/hosts`, etc.
615    #[regex(r"/[a-zA-Z0-9_./+-]*", lex_path)]
616    Path(String),
617
618    // ═══════════════════════════════════════════════════════════════════
619    // Identifiers (command names, variable names, etc.)
620    // ═══════════════════════════════════════════════════════════════════
621
622    /// Identifier - value is the identifier string
623    /// Allows dots for filenames like `script.kai` and `@` for `user@host`,
624    /// `a@b.com` (bare `@` is an ordinary word character, as in bash).
625    #[regex(r"[a-zA-Z_][a-zA-Z0-9_.@-]*", lex_ident)]
626    Ident(String),
627
628    // ═══════════════════════════════════════════════════════════════════
629    // Structural tokens
630    // ═══════════════════════════════════════════════════════════════════
631
632    /// Comment: `# ...` to end of line
633    #[regex(r"#[^\n\r]*", allow_greedy = true)]
634    Comment,
635
636    /// Newline (significant in kaish - ends statements)
637    #[regex(r"\n|\r\n")]
638    Newline,
639
640    /// Line continuation: backslash at end of line
641    #[regex(r"\\[ \t]*(\n|\r\n)")]
642    LineContinuation,
643
644    /// Backtick command substitution — explicitly rejected. Kaish drops
645    /// backticks; the callback always errors so users get a dedicated
646    /// `BackticksNotSupported` message instead of the generic
647    /// `UnexpectedCharacter` they would have hit before. Backticks inside
648    /// single/double-quoted strings, heredoc bodies, and comments don't
649    /// reach this match — those tokens are matched as a single unit
650    /// (strings) or extracted before logos runs (heredocs) or skipped to
651    /// EOL (comments).
652    #[token("`", reject_backtick)]
653    BacktickRejected,
654}
655
656/// Semantic category for syntax highlighting.
657///
658/// Stable enum that groups tokens by purpose. Consumers match on categories
659/// instead of individual tokens, insulating them from lexer evolution.
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
661pub enum TokenCategory {
662    /// Keywords: if, then, else, for, while, function, return, etc.
663    Keyword,
664    /// Operators: |, &&, ||, >, >>, 2>&1, =, ==, etc.
665    Operator,
666    /// String literals: "...", '...', heredocs
667    String,
668    /// Numeric literals: 123, 3.14, arithmetic expressions
669    Number,
670    /// Variable references: $foo, ${bar}, $1, $@, $#, $?, $$
671    Variable,
672    /// Comments: # ...
673    Comment,
674    /// Punctuation: ; , . ( ) { } [ ]
675    Punctuation,
676    /// Identifiers in command position
677    Command,
678    /// Absolute paths: /foo/bar
679    Path,
680    /// Flags: --long, -s, +x
681    Flag,
682    /// Invalid tokens
683    Error,
684}
685
686impl Token {
687    /// Returns the semantic category for syntax highlighting.
688    pub fn category(&self) -> TokenCategory {
689        match self {
690            // Keywords
691            Token::If
692            | Token::Then
693            | Token::Else
694            | Token::Elif
695            | Token::Fi
696            | Token::For
697            | Token::In
698            | Token::Do
699            | Token::Done
700            | Token::While
701            | Token::Case
702            | Token::Esac
703            | Token::Function
704            | Token::Return
705            | Token::Break
706            | Token::Continue
707            | Token::Exit
708            | Token::Set
709            | Token::Local
710            | Token::True
711            | Token::False
712            | Token::TypeString
713            | Token::TypeInt
714            | Token::TypeFloat
715            | Token::TypeBool => TokenCategory::Keyword,
716
717            // Operators and redirections
718            Token::Pipe
719            | Token::And
720            | Token::Or
721            | Token::Amp
722            | Token::Eq
723            | Token::EqEq
724            | Token::NotEq
725            | Token::Match
726            | Token::NotMatch
727            | Token::Lt
728            | Token::Gt
729            | Token::LtEq
730            | Token::GtEq
731            | Token::GtGt
732            | Token::Stderr
733            | Token::Both
734            | Token::HereDocStart
735            | Token::HereString
736            | Token::StderrToStdout
737            | Token::StdoutToStderr
738            | Token::StdoutToStderr2 => TokenCategory::Operator,
739
740            // Strings
741            Token::String(_) | Token::SingleString(_) | Token::HereDoc(_) => TokenCategory::String,
742
743            // Numbers
744            Token::Int(_) | Token::Float(_) | Token::Arithmetic(_) => TokenCategory::Number,
745
746            // Variables
747            Token::VarRef(_)
748            | Token::SimpleVarRef(_)
749            | Token::Positional(_)
750            | Token::AllArgs
751            | Token::ArgCount
752            | Token::VarLength(_)
753            | Token::LastExitCode
754            | Token::CurrentPid => TokenCategory::Variable,
755
756            // Flags
757            Token::LongFlag(_)
758            | Token::ShortFlag(_)
759            | Token::PlusFlag(_)
760            | Token::DoubleDash => TokenCategory::Flag,
761
762            // Punctuation
763            Token::Semi
764            | Token::DoubleSemi
765            | Token::Colon
766            | Token::Comma
767            | Token::Dot
768            | Token::LParen
769            | Token::RParen
770            | Token::LBrace
771            | Token::RBrace
772            | Token::LBracket
773            | Token::RBracket
774            | Token::Bang
775            | Token::Question
776            | Token::Star
777            | Token::Newline
778            | Token::LineContinuation
779            | Token::CmdSubstStart
780            | Token::DotDotDot => TokenCategory::Punctuation,
781
782            // Glob words (merged tokens containing wildcards)
783            Token::GlobWord(_) => TokenCategory::Path,
784
785            // Comments
786            Token::Comment => TokenCategory::Comment,
787
788            // Paths
789            Token::Path(_)
790            | Token::TildePath(_)
791            | Token::RelativePath(_)
792            | Token::Tilde
793            | Token::DotDot
794            | Token::DotSlashPath(_) => TokenCategory::Path,
795
796            // Commands/identifiers (and bare words)
797            Token::Ident(_)
798            | Token::PlusBare(_)
799            | Token::MinusBare(_)
800            | Token::DoubleDashBare(_)
801            | Token::MinusAlone
802            | Token::NumberIdent(_)
803            | Token::DashNumWord(_)
804            | Token::AtWord(_)
805            | Token::DottedIdent(_)
806            | Token::JobSpec(_) => TokenCategory::Command,
807
808            // Errors
809            Token::InvalidFloatNoLeading
810            | Token::InvalidFloatNoTrailing
811            | Token::BacktickRejected => TokenCategory::Error,
812        }
813    }
814}
815
816/// Lex a double-quoted string literal, processing escape sequences.
817fn lex_string(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
818    parse_string_literal(lex.slice())
819}
820
821/// Lex a single-quoted string literal (no escape processing).
822fn lex_single_string(lex: &mut logos::Lexer<Token>) -> String {
823    let s = lex.slice();
824    // Strip the surrounding single quotes
825    s[1..s.len() - 1].to_string()
826}
827
828/// Lex a braced variable reference, extracting the inner content.
829fn lex_varref(lex: &mut logos::Lexer<Token>) -> String {
830    // Keep the full ${...} for later parsing of path segments
831    lex.slice().to_string()
832}
833
834/// Lex a simple variable reference: `$NAME` → `NAME`
835fn lex_simple_varref(lex: &mut logos::Lexer<Token>) -> String {
836    // Strip the leading `$`
837    lex.slice()[1..].to_string()
838}
839
840/// Lex a positional parameter: `$1` → 1
841fn lex_positional(lex: &mut logos::Lexer<Token>) -> usize {
842    // Strip the leading `$` and parse the digit
843    lex.slice()[1..].parse().unwrap_or(0)
844}
845
846/// Lex a variable length: `${#VAR}` → "VAR"
847fn lex_var_length(lex: &mut logos::Lexer<Token>) -> String {
848    // Strip the leading `${#` and trailing `}`
849    let s = lex.slice();
850    s[3..s.len() - 1].to_string()
851}
852
853/// Lex an integer literal.
854fn lex_int(lex: &mut logos::Lexer<Token>) -> Result<i64, LexerError> {
855    lex.slice().parse().map_err(|_| LexerError::InvalidNumber)
856}
857
858/// Lex a float literal.
859fn lex_float(lex: &mut logos::Lexer<Token>) -> Result<f64, LexerError> {
860    lex.slice().parse().map_err(|_| LexerError::InvalidNumber)
861}
862
863/// Lex a digit-leading bareword like `019dda1c` or `019dda1c-5b3f-7000`.
864/// Distinguished from `Int` because at least one alpha character follows the
865/// leading digits — the slice is treated as a string, not a number.
866fn lex_number_ident(lex: &mut logos::Lexer<Token>) -> String {
867    lex.slice().to_string()
868}
869
870/// Lex a dot-prefixed bareword like `.gitignore` or `.parent.parent`.
871fn lex_dotted_ident(lex: &mut logos::Lexer<Token>) -> String {
872    lex.slice().to_string()
873}
874
875/// Lex a bareword by capturing its raw slice verbatim (used by `DashNumWord`
876/// and `AtWord`, where exact characters — e.g. leading zeros — must survive).
877fn lex_slice_word(lex: &mut logos::Lexer<Token>) -> String {
878    lex.slice().to_string()
879}
880
881/// Lex an invalid float without leading digit (like .5).
882/// Always returns Err to produce a lexer error instead of a token.
883fn lex_invalid_float_no_leading(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
884    Err(LexerError::InvalidFloatNoLeading)
885}
886
887/// Reject a backtick — kaish doesn't support backtick command substitution.
888/// The dedicated error gives the user a `$(cmd)` hint instead of the generic
889/// `UnexpectedCharacter` they would have hit otherwise.
890fn reject_backtick(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
891    Err(LexerError::BackticksNotSupported)
892}
893
894/// Lex an invalid float without trailing digit (like 5.).
895/// Always returns Err to produce a lexer error instead of a token.
896fn lex_invalid_float_no_trailing(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
897    Err(LexerError::InvalidFloatNoTrailing)
898}
899
900/// Lex an identifier, rejecting ambiguous boolean-like values.
901fn lex_ident(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
902    let s = lex.slice();
903
904    // Reject ambiguous boolean variants (TRUE, FALSE, True, etc.)
905    // Only lowercase 'true' and 'false' are valid booleans (handled by Token::True/False)
906    match s.to_lowercase().as_str() {
907        "true" | "false" if s != "true" && s != "false" => {
908            return Err(LexerError::AmbiguousBoolean(s.to_string()));
909        }
910        _ => {}
911    }
912
913    // Reject yes/no/YES/NO/Yes/No as ambiguous boolean-like values
914    if s.eq_ignore_ascii_case("yes") || s.eq_ignore_ascii_case("no") {
915        return Err(LexerError::AmbiguousBooleanLike(s.to_string()));
916    }
917
918    Ok(s.to_string())
919}
920
921/// Lex a long flag: `--name` → `name`
922fn lex_long_flag(lex: &mut logos::Lexer<Token>) -> String {
923    // Strip the leading `--`
924    lex.slice()[2..].to_string()
925}
926
927/// Lex a short flag: `-l` → `l`, `-la` → `la`
928fn lex_short_flag(lex: &mut logos::Lexer<Token>) -> String {
929    // Strip the leading `-`
930    lex.slice()[1..].to_string()
931}
932
933/// Lex a plus flag: `+e` → `e`, `+ex` → `ex`
934fn lex_plus_flag(lex: &mut logos::Lexer<Token>) -> String {
935    // Strip the leading `+`
936    lex.slice()[1..].to_string()
937}
938
939/// Lex a plus bare word: `+%s` → `+%s` (keep the full string)
940fn lex_plus_bare(lex: &mut logos::Lexer<Token>) -> String {
941    lex.slice().to_string()
942}
943
944/// Lex a minus bare word: `-%` → `-%` (keep the full string)
945fn lex_minus_bare(lex: &mut logos::Lexer<Token>) -> String {
946    lex.slice().to_string()
947}
948
949/// Lex a double-dash bare word: `---` → `---`, `--=x` → `--=x` (keep the
950/// full string; see GH #137).
951fn lex_double_dash_bare(lex: &mut logos::Lexer<Token>) -> String {
952    lex.slice().to_string()
953}
954
955/// Lex a job specifier: `%1` → `%1` (keep the leading `%`).
956fn lex_job_spec(lex: &mut logos::Lexer<Token>) -> String {
957    lex.slice().to_string()
958}
959
960/// Lex an absolute path: `/tmp/out` → `/tmp/out`
961fn lex_path(lex: &mut logos::Lexer<Token>) -> String {
962    lex.slice().to_string()
963}
964
965/// Lex a tilde path: `~/foo` → `~/foo`
966fn lex_tilde_path(lex: &mut logos::Lexer<Token>) -> String {
967    lex.slice().to_string()
968}
969
970/// Lex a relative path: `../foo` → `../foo`
971fn lex_relative_path(lex: &mut logos::Lexer<Token>) -> String {
972    lex.slice().to_string()
973}
974
975/// Lex a dot-slash path: `./foo` → `./foo`
976fn lex_dot_slash_path(lex: &mut logos::Lexer<Token>) -> String {
977    lex.slice().to_string()
978}
979
980impl fmt::Display for Token {
981    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
982        match self {
983            Token::Set => write!(f, "set"),
984            Token::Local => write!(f, "local"),
985            Token::If => write!(f, "if"),
986            Token::Then => write!(f, "then"),
987            Token::Else => write!(f, "else"),
988            Token::Elif => write!(f, "elif"),
989            Token::Fi => write!(f, "fi"),
990            Token::For => write!(f, "for"),
991            Token::While => write!(f, "while"),
992            Token::In => write!(f, "in"),
993            Token::Do => write!(f, "do"),
994            Token::Done => write!(f, "done"),
995            Token::Case => write!(f, "case"),
996            Token::Esac => write!(f, "esac"),
997            Token::Function => write!(f, "function"),
998            Token::Break => write!(f, "break"),
999            Token::Continue => write!(f, "continue"),
1000            Token::Return => write!(f, "return"),
1001            Token::Exit => write!(f, "exit"),
1002            Token::True => write!(f, "true"),
1003            Token::False => write!(f, "false"),
1004            Token::TypeString => write!(f, "string"),
1005            Token::TypeInt => write!(f, "int"),
1006            Token::TypeFloat => write!(f, "float"),
1007            Token::TypeBool => write!(f, "bool"),
1008            Token::And => write!(f, "&&"),
1009            Token::Or => write!(f, "||"),
1010            Token::EqEq => write!(f, "=="),
1011            Token::NotEq => write!(f, "!="),
1012            Token::Match => write!(f, "=~"),
1013            Token::NotMatch => write!(f, "!~"),
1014            Token::GtEq => write!(f, ">="),
1015            Token::LtEq => write!(f, "<="),
1016            Token::GtGt => write!(f, ">>"),
1017            Token::StderrToStdout => write!(f, "2>&1"),
1018            Token::StdoutToStderr => write!(f, "1>&2"),
1019            Token::StdoutToStderr2 => write!(f, ">&2"),
1020            Token::Stderr => write!(f, "2>"),
1021            Token::Both => write!(f, "&>"),
1022            Token::HereDocStart => write!(f, "<<"),
1023            Token::HereString => write!(f, "<<<"),
1024            Token::DoubleSemi => write!(f, ";;"),
1025            Token::Eq => write!(f, "="),
1026            Token::Pipe => write!(f, "|"),
1027            Token::Amp => write!(f, "&"),
1028            Token::Gt => write!(f, ">"),
1029            Token::Lt => write!(f, "<"),
1030            Token::Semi => write!(f, ";"),
1031            Token::Colon => write!(f, ":"),
1032            Token::Comma => write!(f, ","),
1033            Token::Dot => write!(f, "."),
1034            Token::DotDot => write!(f, ".."),
1035            Token::DotDotDot => write!(f, "..."),
1036            Token::Tilde => write!(f, "~"),
1037            Token::TildePath(s) => write!(f, "{}", s),
1038            Token::RelativePath(s) => write!(f, "{}", s),
1039            Token::DotSlashPath(s) => write!(f, "{}", s),
1040            Token::LBrace => write!(f, "{{"),
1041            Token::RBrace => write!(f, "}}"),
1042            Token::LBracket => write!(f, "["),
1043            Token::RBracket => write!(f, "]"),
1044            Token::LParen => write!(f, "("),
1045            Token::RParen => write!(f, ")"),
1046            Token::Star => write!(f, "*"),
1047            Token::Bang => write!(f, "!"),
1048            Token::Question => write!(f, "?"),
1049            Token::GlobWord(s) => write!(f, "GLOB({})", s),
1050            Token::Arithmetic(s) => write!(f, "ARITHMETIC({})", s),
1051            Token::CmdSubstStart => write!(f, "$("),
1052            Token::LongFlag(s) => write!(f, "--{}", s),
1053            Token::ShortFlag(s) => write!(f, "-{}", s),
1054            Token::PlusFlag(s) => write!(f, "+{}", s),
1055            Token::DoubleDash => write!(f, "--"),
1056            Token::DoubleDashBare(s) => write!(f, "{}", s),
1057            Token::PlusBare(s) => write!(f, "{}", s),
1058            Token::MinusBare(s) => write!(f, "{}", s),
1059            Token::JobSpec(s) => write!(f, "{}", s),
1060            Token::MinusAlone => write!(f, "-"),
1061            Token::String(s) => write!(f, "STRING({:?})", s),
1062            Token::SingleString(s) => write!(f, "SINGLESTRING({:?})", s),
1063            Token::HereDoc(d) => write!(f, "HEREDOC({:?}, literal={})", d.content, d.literal),
1064            Token::VarRef(v) => write!(f, "VARREF({})", v),
1065            Token::SimpleVarRef(v) => write!(f, "SIMPLEVARREF({})", v),
1066            Token::Positional(n) => write!(f, "${}", n),
1067            Token::AllArgs => write!(f, "$@"),
1068            Token::ArgCount => write!(f, "$#"),
1069            Token::LastExitCode => write!(f, "$?"),
1070            Token::CurrentPid => write!(f, "$$"),
1071            Token::VarLength(v) => write!(f, "${{#{}}}", v),
1072            Token::Int(n) => write!(f, "INT({})", n),
1073            Token::Float(n) => write!(f, "FLOAT({})", n),
1074            Token::Path(s) => write!(f, "PATH({})", s),
1075            Token::Ident(s) => write!(f, "IDENT({})", s),
1076            Token::NumberIdent(s) => write!(f, "NUMIDENT({})", s),
1077            Token::DashNumWord(s) => write!(f, "DASHNUM({})", s),
1078            Token::AtWord(s) => write!(f, "ATWORD({})", s),
1079            Token::DottedIdent(s) => write!(f, "DOTIDENT({})", s),
1080            Token::Comment => write!(f, "COMMENT"),
1081            Token::Newline => write!(f, "NEWLINE"),
1082            Token::LineContinuation => write!(f, "LINECONT"),
1083            // These variants should never be produced — their callbacks always return errors
1084            Token::InvalidFloatNoLeading => write!(f, "INVALID_FLOAT_NO_LEADING"),
1085            Token::InvalidFloatNoTrailing => write!(f, "INVALID_FLOAT_NO_TRAILING"),
1086            Token::BacktickRejected => write!(f, "BACKTICK_REJECTED"),
1087        }
1088    }
1089}
1090
1091impl Token {
1092    /// Returns true if this token is a keyword.
1093    // Must match the Keyword variants in `Token::category()` (minus the
1094    // TypeX variants, which `is_type()` covers separately). Currently
1095    // uncalled — kept exhaustive so future callers don't get wrong answers.
1096    pub fn is_keyword(&self) -> bool {
1097        matches!(
1098            self,
1099            Token::Set
1100                | Token::Local
1101                | Token::If
1102                | Token::Then
1103                | Token::Else
1104                | Token::Elif
1105                | Token::Fi
1106                | Token::For
1107                | Token::In
1108                | Token::Do
1109                | Token::Done
1110                | Token::While
1111                | Token::Case
1112                | Token::Esac
1113                | Token::Function
1114                | Token::Return
1115                | Token::Break
1116                | Token::Continue
1117                | Token::Exit
1118                | Token::True
1119                | Token::False
1120        )
1121    }
1122
1123    /// Returns true if this token is a type keyword.
1124    pub fn is_type(&self) -> bool {
1125        matches!(
1126            self,
1127            Token::TypeString
1128                | Token::TypeInt
1129                | Token::TypeFloat
1130                | Token::TypeBool
1131        )
1132    }
1133
1134    /// Returns true if this token starts a statement.
1135    // Currently uncalled — kept exhaustive so future callers don't get wrong answers.
1136    pub fn starts_statement(&self) -> bool {
1137        matches!(
1138            self,
1139            Token::Set
1140                | Token::Local
1141                | Token::Function
1142                | Token::If
1143                | Token::For
1144                | Token::While
1145                | Token::Case
1146                | Token::Ident(_)
1147                | Token::LBracket
1148        )
1149    }
1150
1151    /// Returns true if this token can appear in an expression.
1152    pub fn is_value(&self) -> bool {
1153        matches!(
1154            self,
1155            Token::String(_)
1156                | Token::SingleString(_)
1157                | Token::HereDoc(_)
1158                | Token::Arithmetic(_)
1159                | Token::Int(_)
1160                | Token::Float(_)
1161                | Token::True
1162                | Token::False
1163                | Token::VarRef(_)
1164                | Token::SimpleVarRef(_)
1165                | Token::CmdSubstStart
1166                | Token::Path(_)
1167                | Token::GlobWord(_)
1168                | Token::LastExitCode
1169                | Token::CurrentPid
1170        )
1171    }
1172}
1173
1174/// Result of preprocessing arithmetic expressions.
1175struct ArithmeticPreprocessResult {
1176    /// Preprocessed source with markers replacing $((expr)).
1177    text: String,
1178    /// Vector of (marker, expression_content) pairs.
1179    arithmetics: Vec<(String, String)>,
1180    /// Span replacements for correcting token positions.
1181    replacements: Vec<SpanReplacement>,
1182}
1183
1184/// Skip a `$(...)` command substitution with quote-aware paren matching.
1185///
1186/// Copies the entire command substitution verbatim to `result`, handling
1187/// single quotes, double quotes, and backslash escapes inside the sub so
1188/// that parentheses within strings don't confuse the depth counter.
1189///
1190/// On entry, `i` points to the `$` of `$(`. On exit, `i` points past the
1191/// closing `)`.
1192fn skip_command_substitution(
1193    chars: &[char],
1194    i: &mut usize,
1195    source_pos: &mut usize,
1196    result: &mut String,
1197) {
1198    // Copy $(
1199    result.push('$');
1200    result.push('(');
1201    *i += 2;
1202    *source_pos += 2;
1203
1204    let mut depth: usize = 1;
1205    let mut in_single_quote = false;
1206    let mut in_double_quote = false;
1207
1208    while *i < chars.len() && depth > 0 {
1209        let c = chars[*i];
1210
1211        if in_single_quote {
1212            result.push(c);
1213            *source_pos += c.len_utf8();
1214            *i += 1;
1215            if c == '\'' {
1216                in_single_quote = false;
1217            }
1218            continue;
1219        }
1220
1221        if in_double_quote {
1222            if c == '\\' && *i + 1 < chars.len() {
1223                let next = chars[*i + 1];
1224                if next == '"' || next == '\\' || next == '$' || next == '`' {
1225                    result.push(c);
1226                    result.push(next);
1227                    *source_pos += c.len_utf8() + next.len_utf8();
1228                    *i += 2;
1229                    continue;
1230                }
1231            }
1232            if c == '"' {
1233                in_double_quote = false;
1234            }
1235            result.push(c);
1236            *source_pos += c.len_utf8();
1237            *i += 1;
1238            continue;
1239        }
1240
1241        // Outside quotes
1242        match c {
1243            '\'' => {
1244                in_single_quote = true;
1245                result.push(c);
1246                *source_pos += c.len_utf8();
1247                *i += 1;
1248            }
1249            '"' => {
1250                in_double_quote = true;
1251                result.push(c);
1252                *source_pos += c.len_utf8();
1253                *i += 1;
1254            }
1255            '\\' if *i + 1 < chars.len() => {
1256                result.push(c);
1257                result.push(chars[*i + 1]);
1258                *source_pos += c.len_utf8() + chars[*i + 1].len_utf8();
1259                *i += 2;
1260            }
1261            '(' => {
1262                depth += 1;
1263                result.push(c);
1264                *source_pos += c.len_utf8();
1265                *i += 1;
1266            }
1267            ')' => {
1268                depth -= 1;
1269                result.push(c);
1270                *source_pos += c.len_utf8();
1271                *i += 1;
1272            }
1273            _ => {
1274                result.push(c);
1275                *source_pos += c.len_utf8();
1276                *i += 1;
1277            }
1278        }
1279    }
1280}
1281
1282/// Preprocess arithmetic expressions in source code.
1283///
1284/// Finds `$((expr))` patterns and replaces them with markers.
1285/// Returns the preprocessed source, arithmetic contents, and span replacement info.
1286///
1287/// Example:
1288///   `X=$((1 + 2))`
1289/// Becomes:
1290///   `X=__KAISH_ARITH_{id}__`
1291/// With arithmetics[0] = ("__KAISH_ARITH_{id}__", "1 + 2")
1292///
1293/// # Errors
1294/// Returns `LexerError::NestingTooDeep` if parentheses are nested beyond MAX_PAREN_DEPTH.
1295fn preprocess_arithmetic(source: &str) -> Result<ArithmeticPreprocessResult, LexerError> {
1296    let mut result = String::with_capacity(source.len());
1297    let mut arithmetics: Vec<(String, String)> = Vec::new();
1298    let mut replacements: Vec<SpanReplacement> = Vec::new();
1299    let mut source_pos: usize = 0;
1300    let chars_vec: Vec<char> = source.chars().collect();
1301    let mut i = 0;
1302
1303    // Whether we're currently inside double quotes. Single quotes inside
1304    // double quotes are literal characters, not quote delimiters.
1305    let mut in_double_quote = false;
1306
1307    while i < chars_vec.len() {
1308        let ch = chars_vec[i];
1309
1310        // Backslash escape outside quotes — skip both chars verbatim
1311        if !in_double_quote && ch == '\\' && i + 1 < chars_vec.len() {
1312            result.push(ch);
1313            result.push(chars_vec[i + 1]);
1314            source_pos += ch.len_utf8() + chars_vec[i + 1].len_utf8();
1315            i += 2;
1316            continue;
1317        }
1318
1319        // Single quote — only starts quote mode when NOT inside double quotes
1320        if ch == '\'' && !in_double_quote {
1321            result.push(ch);
1322            i += 1;
1323            source_pos += 1;
1324            while i < chars_vec.len() && chars_vec[i] != '\'' {
1325                result.push(chars_vec[i]);
1326                source_pos += chars_vec[i].len_utf8();
1327                i += 1;
1328            }
1329            if i < chars_vec.len() {
1330                result.push(chars_vec[i]); // closing quote
1331                source_pos += 1;
1332                i += 1;
1333            }
1334            continue;
1335        }
1336
1337        // Double quote — toggle state (arithmetic is still expanded inside)
1338        if ch == '"' {
1339            in_double_quote = !in_double_quote;
1340            result.push(ch);
1341            i += 1;
1342            source_pos += 1;
1343            continue;
1344        }
1345
1346        // Backslash escape inside double quotes — only \" and \\ are special
1347        if in_double_quote && ch == '\\' && i + 1 < chars_vec.len() {
1348            let next = chars_vec[i + 1];
1349            if next == '"' || next == '\\' || next == '$' || next == '`' {
1350                result.push(ch);
1351                result.push(next);
1352                source_pos += ch.len_utf8() + next.len_utf8();
1353                i += 2;
1354                continue;
1355            }
1356        }
1357
1358        // Comment — copy verbatim from `#` through end-of-line so apostrophes
1359        // and `$((..))` inside the comment body don't get processed. Logos's
1360        // own comment regex `#[^\n\r]*` doesn't require a word boundary, so
1361        // we match that: any `#` outside double quotes (and outside single
1362        // quotes — those are consumed above as a single run) starts a comment.
1363        // The newline is left for the next iteration so newline-significance
1364        // and span tracking are preserved.
1365        if ch == '#' && !in_double_quote {
1366            while i < chars_vec.len() && chars_vec[i] != '\n' && chars_vec[i] != '\r' {
1367                result.push(chars_vec[i]);
1368                source_pos += chars_vec[i].len_utf8();
1369                i += 1;
1370            }
1371            continue;
1372        }
1373
1374        // Skip $(...) command substitutions — inner arithmetic belongs to the subcommand
1375        if ch == '$' && i + 1 < chars_vec.len() && chars_vec[i + 1] == '('
1376            && !(i + 2 < chars_vec.len() && chars_vec[i + 2] == '(')
1377        {
1378            skip_command_substitution(&chars_vec, &mut i, &mut source_pos, &mut result);
1379            continue;
1380        }
1381
1382        // Look for $(( (potential arithmetic)
1383        if ch == '$' && i + 2 < chars_vec.len() && chars_vec[i + 1] == '(' && chars_vec[i + 2] == '(' {
1384            let arith_start_pos = result.len();
1385            let original_start = source_pos;
1386
1387            // Skip $((
1388            i += 3;
1389            source_pos += 3;
1390
1391            // Collect expression until matching ))
1392            let mut expr = String::new();
1393            let mut paren_depth: usize = 0;
1394            let mut closed = false;
1395
1396            while i < chars_vec.len() {
1397                let c = chars_vec[i];
1398                match c {
1399                    '(' => {
1400                        paren_depth += 1;
1401                        if paren_depth > MAX_PAREN_DEPTH {
1402                            return Err(LexerError::NestingTooDeep);
1403                        }
1404                        expr.push('(');
1405                        i += 1;
1406                        source_pos += c.len_utf8();
1407                    }
1408                    ')' => {
1409                        if paren_depth > 0 {
1410                            paren_depth -= 1;
1411                            expr.push(')');
1412                            i += 1;
1413                            source_pos += 1;
1414                        } else if i + 1 < chars_vec.len() && chars_vec[i + 1] == ')' {
1415                            // Found closing ))
1416                            i += 2;
1417                            source_pos += 2;
1418                            closed = true;
1419                            break;
1420                        } else {
1421                            // Single ) inside - keep going
1422                            expr.push(')');
1423                            i += 1;
1424                            source_pos += 1;
1425                        }
1426                    }
1427                    _ => {
1428                        expr.push(c);
1429                        i += 1;
1430                        source_pos += c.len_utf8();
1431                    }
1432                }
1433            }
1434
1435            // Reached EOF without the closing `))` — don't silently evaluate the
1436            // partial expression (`$(( 1 + 2` must not become `3`).
1437            if !closed {
1438                return Err(LexerError::UnterminatedArithmetic);
1439            }
1440
1441            // Calculate original length: from $$(( to ))
1442            let original_len = source_pos - original_start;
1443
1444            // Create a unique marker for this arithmetic (collision-resistant)
1445            let marker = format!("__KAISH_ARITH_{}__", unique_marker_id());
1446            let marker_len = marker.len();
1447
1448            // Record the replacement for span correction
1449            replacements.push(SpanReplacement {
1450                preprocessed_pos: arith_start_pos,
1451                marker_len,
1452                original_len,
1453            });
1454
1455            arithmetics.push((marker.clone(), expr));
1456            result.push_str(&marker);
1457        } else {
1458            result.push(ch);
1459            i += 1;
1460            source_pos += ch.len_utf8();
1461        }
1462    }
1463
1464    Ok(ArithmeticPreprocessResult {
1465        text: result,
1466        arithmetics,
1467        replacements,
1468    })
1469}
1470
1471/// Per-heredoc metadata collected during preprocessing.
1472///
1473/// Stored verbatim alongside the substituted marker so the parser, validator,
1474/// and interpreter can reconstitute the body with correct semantics:
1475/// - `body` is the raw body bytes; tab stripping for `<<-` is applied later
1476///   (at materialization), so byte offsets stay aligned with the original
1477///   source for span tracking.
1478/// - `strip_tabs` records whether the `<<-` form was used.
1479/// - `literal` records whether the delimiter was quoted (no interpolation).
1480/// - `body_start_offset` is the byte offset of the first body character in
1481///   the source string passed to `preprocess_heredocs`. When heredocs are
1482///   preprocessed AFTER arithmetic, this is in arith-preprocessed coordinates;
1483///   in the common case (no arithmetic before the heredoc) this equals the
1484///   original-source offset. See span-correction notes in `tokenize`.
1485#[derive(Debug, Clone)]
1486struct HeredocReplacement {
1487    marker: String,
1488    body: String,
1489    literal: bool,
1490    strip_tabs: bool,
1491    body_start_offset: usize,
1492}
1493
1494/// Preprocess here-docs in source code.
1495///
1496/// Finds `<<WORD` patterns and collects content until the delimiter line.
1497/// Returns the preprocessed source and a vector of replacement records.
1498///
1499/// Example:
1500///   `cat <<EOF\nhello\nworld\nEOF`
1501/// Becomes:
1502///   `cat <<__HEREDOC_0__`
1503/// With heredocs[0] = HeredocReplacement { marker: "__HEREDOC_0__",
1504/// body: "hello\nworld", literal: false, strip_tabs: false }
1505fn preprocess_heredocs(source: &str) -> Result<(String, Vec<HeredocReplacement>), Spanned<LexerError>> {
1506    let mut result = String::with_capacity(source.len());
1507    let mut heredocs: Vec<HeredocReplacement> = Vec::new();
1508    let chars_vec: Vec<char> = source.chars().collect();
1509    let mut i = 0;
1510    // `pos` tracks the byte offset into `source` corresponding to chars_vec[i].
1511    // `result` accumulates output; we record body offsets in `pos` (input-side)
1512    // and emit positions via `result.len()` (output-side) where needed.
1513    let mut pos: usize = 0;
1514
1515    while i < chars_vec.len() {
1516        let ch = chars_vec[i];
1517
1518        // Pass <<< through verbatim so the logos tokenizer sees the here-string
1519        // operator. If we fell through naively, the next iteration would see
1520        // the remaining `<<` and misfire heredoc preprocessing.
1521        if ch == '<'
1522            && chars_vec.get(i + 1) == Some(&'<')
1523            && chars_vec.get(i + 2) == Some(&'<')
1524        {
1525            result.push_str("<<<");
1526            i += 3;
1527            pos += 3;
1528            continue;
1529        }
1530
1531        // Look for << (potential here-doc).
1532        if ch == '<' && chars_vec.get(i + 1) == Some(&'<') {
1533            // Remember where the `<<` started so an unterminated-heredoc
1534            // error can point back at the introducer rather than at EOF.
1535            let introducer_start = pos;
1536            i += 2; // consume both '<'
1537            pos += 2;
1538
1539            // Check for optional - (strip leading tabs)
1540            let strip_tabs = chars_vec.get(i) == Some(&'-');
1541            if strip_tabs {
1542                i += 1;
1543                pos += 1;
1544            }
1545
1546            // Skip whitespace before delimiter
1547            while let Some(&c) = chars_vec.get(i) {
1548                if c == ' ' || c == '\t' {
1549                    i += 1;
1550                    pos += 1;
1551                } else {
1552                    break;
1553                }
1554            }
1555
1556            // Collect the delimiter word
1557            let mut delimiter = String::new();
1558            let quoted = chars_vec.get(i) == Some(&'\'') || chars_vec.get(i) == Some(&'"');
1559            let quote_char = if quoted {
1560                let q = chars_vec.get(i).copied();
1561                i += 1;
1562                pos += 1;
1563                q
1564            } else {
1565                None
1566            };
1567
1568            while let Some(&c) = chars_vec.get(i) {
1569                if quoted {
1570                    if Some(c) == quote_char {
1571                        i += 1; // consume closing quote
1572                        pos += 1;
1573                        break;
1574                    }
1575                } else if c.is_whitespace() || c == '\n' || c == '\r' {
1576                    break;
1577                }
1578                delimiter.push(c);
1579                i += 1;
1580                pos += c.len_utf8();
1581            }
1582
1583            if delimiter.is_empty() {
1584                // Not a valid here-doc, output << literally
1585                result.push_str("<<");
1586                if strip_tabs {
1587                    result.push('-');
1588                }
1589                continue;
1590            }
1591
1592            // Buffer text after delimiter word (e.g., " | jq" in "cat <<EOF | jq")
1593            // This must be emitted AFTER the heredoc marker, not before.
1594            let mut after_delimiter = String::new();
1595            while let Some(&c) = chars_vec.get(i) {
1596                if c == '\n' {
1597                    i += 1;
1598                    pos += 1;
1599                    break;
1600                } else if c == '\r' {
1601                    i += 1;
1602                    pos += 1;
1603                    if chars_vec.get(i) == Some(&'\n') {
1604                        i += 1;
1605                        pos += 1;
1606                    }
1607                    break;
1608                }
1609                after_delimiter.push(c);
1610                i += 1;
1611                pos += c.len_utf8();
1612            }
1613
1614            // Collect content until delimiter on its own line.
1615            // `body_start_offset` is the byte position of the first char of
1616            // the body in the source — first char after the newline that
1617            // ended the delimiter line. See HeredocReplacement docs for
1618            // coordinate-system caveat (arith-preprocessed, not original).
1619            let body_start_offset = pos;
1620            let mut content = String::new();
1621            let mut current_line = String::new();
1622
1623            loop {
1624                let next = chars_vec.get(i).copied();
1625                match next {
1626                    Some('\n') => {
1627                        i += 1;
1628                        pos += 1;
1629                        // Check if this line is the delimiter
1630                        let trimmed = if strip_tabs {
1631                            current_line.trim_start_matches('\t')
1632                        } else {
1633                            &current_line
1634                        };
1635                        if trimmed == delimiter {
1636                            // Found end of here-doc
1637                            break;
1638                        }
1639                        // Add line to content (including empty lines)
1640                        content.push_str(&current_line);
1641                        content.push('\n');
1642                        current_line.clear();
1643                    }
1644                    Some('\r') => {
1645                        i += 1;
1646                        pos += 1;
1647                        // Detect CRLF vs bare CR. We strip the line ending
1648                        // for delimiter matching (so `EOF\r` still matches
1649                        // `EOF`) but preserve the original byte sequence in
1650                        // the body content — the user's input is honored
1651                        // verbatim.
1652                        let crlf = chars_vec.get(i) == Some(&'\n');
1653                        if crlf {
1654                            i += 1;
1655                            pos += 1;
1656                        }
1657                        let trimmed = if strip_tabs {
1658                            current_line.trim_start_matches('\t')
1659                        } else {
1660                            &current_line
1661                        };
1662                        if trimmed == delimiter {
1663                            break;
1664                        }
1665                        content.push_str(&current_line);
1666                        content.push_str(if crlf { "\r\n" } else { "\r" });
1667                        current_line.clear();
1668                    }
1669                    Some(c) => {
1670                        current_line.push(c);
1671                        i += 1;
1672                        pos += c.len_utf8();
1673                    }
1674                    None => {
1675                        // EOF — check if current line is the delimiter (matches
1676                        // when the source ends without a trailing newline).
1677                        let trimmed = if strip_tabs {
1678                            current_line.trim_start_matches('\t')
1679                        } else {
1680                            &current_line
1681                        };
1682                        if trimmed == delimiter {
1683                            break;
1684                        }
1685                        // Not a delimiter — the heredoc was never closed.
1686                        // Crash rather than silently using whatever we
1687                        // collected: missing data is exactly the failure
1688                        // mode where silent fallback masks the bug.
1689                        let span_end = introducer_start
1690                            + 2
1691                            + if strip_tabs { 1 } else { 0 }
1692                            + delimiter.len();
1693                        return Err(Spanned::new(
1694                            LexerError::UnterminatedHeredoc {
1695                                delimiter: delimiter.clone(),
1696                            },
1697                            introducer_start..span_end,
1698                        ));
1699                    }
1700                }
1701            }
1702
1703            // Create a unique marker for this here-doc (collision-resistant)
1704            let marker = format!("__KAISH_HEREDOC_{}__", unique_marker_id());
1705            heredocs.push(HeredocReplacement {
1706                marker: marker.clone(),
1707                body: content,
1708                literal: quoted,
1709                strip_tabs,
1710                body_start_offset,
1711            });
1712
1713            // Output <<marker first, then any text that followed the delimiter
1714            // (e.g., " | jq") so the heredoc attaches to the correct command.
1715            result.push_str("<<");
1716            result.push_str(&marker);
1717            result.push_str(&after_delimiter);
1718            result.push('\n');
1719        } else {
1720            result.push(ch);
1721            i += 1;
1722            pos += ch.len_utf8();
1723        }
1724    }
1725
1726    Ok((result, heredocs))
1727}
1728
1729/// Extract the text contribution of a token for colon-adjacent merging.
1730///
1731/// Returns `Some(text)` for token types that can participate in word-like
1732/// merging, `None` for everything else.
1733fn mergeable_text(token: &Token) -> Option<String> {
1734    match token {
1735        Token::Ident(s) => Some(s.clone()),
1736        Token::NumberIdent(s) => Some(s.clone()),
1737        Token::DashNumWord(s) => Some(s.clone()),
1738        Token::AtWord(s) => Some(s.clone()),
1739        Token::DottedIdent(s) => Some(s.clone()),
1740        Token::Colon => Some(":".to_string()),
1741        Token::Int(n) => Some(n.to_string()),
1742        Token::Path(p) => Some(p.clone()),
1743        Token::Float(f) => Some(f.to_string()),
1744        _ => None,
1745    }
1746}
1747
1748/// Per-token context computed ahead of the colon-merge and glob-merge passes:
1749/// is this token part of a *value*-position collection literal (assignment
1750/// RHS, `in`/`not in` RHS)? Both merge passes suppress their fusion there so
1751/// `x=[dog]` / `{port:8080}` reach the parser as primitive tokens instead of
1752/// a fused `GlobWord`/`Ident` — see docs/arrays-and-hashes.md
1753/// ("Implementation notes: Glob-merge is the big lexer collision" and the
1754/// colon-fusion note right after it).
1755///
1756/// A run is "at value position" when it opens immediately after a token that
1757/// *grammatically* introduces a value — an **assignment** `=` or a
1758/// **membership** `in`. The trap is that both of those tokens are reused for a
1759/// non-value role: `Token::Eq` is also the `[[ ]]` comparison `=`
1760/// (`[[ $x = [0-9]* ]]`), and `Token::In` is also the `for`/`case` statement
1761/// head (`for f in *.txt`, `case 5 in [0-9]*)`). In every one of those
1762/// non-value uses the following word is argv-shaped (a glob / char-class
1763/// pattern) and must keep fusing, so getting this discrimination wrong breaks
1764/// real syntax.
1765///
1766/// The discrimination is **grammar-exact by `[[ ]]` test depth**, which needs
1767/// no per-keyword special-casing:
1768/// - membership `in` occurs ONLY inside `[[ ]]`; a `for`/`case` head `in`
1769///   occurs ONLY outside. So `Token::In` opens value position **iff
1770///   `test_depth > 0`**.
1771/// - an assignment `=` occurs outside `[[ ]]`; a comparison `=` occurs inside.
1772///   So `Token::Eq` opens value position **iff `test_depth == 0`**.
1773///
1774/// Tracking `test_depth` in the same forward pass also dissolves two `$()`
1775/// leaks a hand-rolled keyword counter suffered: a bareword `in`/`for`/`case`
1776/// inside `$(…)` sits at `test_depth == 0` and never opens value position (nor
1777/// leaks any state past `RParen`), while a real sub-shell assignment
1778/// `$(x=[a b])` is still `test_depth == 0` and correctly does. This replaces
1779/// the earlier pending-`for`/`case` counter entirely.
1780///
1781/// `bracket_depth`/`brace_depth` are simple counters, not a real stack, but
1782/// that's sufficient here: the `!currently_value` guard on `[[` detection
1783/// above means a glued nested literal (`x=[[a] [b]]`) never gets mistaken for
1784/// a `[[ ]]` test opener once bracket_depth is already > 0, so deeply-nested
1785/// *glued* literals parse identically to spaced nesting (`[ [a] [b] ]`) at any
1786/// depth — see `deeply_nested_glued_list_literal_matches_spaced_nesting` in
1787/// `collection_literals_tests.rs`.
1788#[derive(Clone, Copy, Default)]
1789struct ValueContext {
1790    /// Inside (or opening) a value-position `[`/`{` literal — suppresses
1791    /// glob-merge bracket-pair fusion.
1792    in_literal: bool,
1793    /// Inside a value-position `{` literal specifically — suppresses
1794    /// colon-merge fusion. Narrower than `in_literal` on purpose: a plain
1795    /// scalar assignment like `x=foo:bar` must keep fusing into one `Ident`.
1796    in_brace: bool,
1797}
1798
1799fn compute_value_context(tokens: &[Spanned<Token>]) -> Vec<ValueContext> {
1800    let mut ctx = vec![ValueContext::default(); tokens.len()];
1801    let mut bracket_depth: usize = 0;
1802    let mut brace_depth: usize = 0;
1803    // `[[ ]]` nesting depth. `[[` is two `Token::LBracket`, `]]` two
1804    // `Token::RBracket` (kaish has no compound bracket token). This is what
1805    // tells an assignment `=` from a comparison `=`, and a membership `in`
1806    // from a `for`/`case` head `in`.
1807    let mut test_depth: usize = 0;
1808    let mut expect_value = false;
1809    // Set after consuming the first `[`/`]` of a `[[`/`]]` pair so the paired
1810    // bracket is skipped for depth accounting (it's a pure test delimiter).
1811    let mut skip_paired_bracket = false;
1812
1813    for i in 0..tokens.len() {
1814        let tok = &tokens[i].token;
1815
1816        let currently_value = expect_value || bracket_depth > 0 || brace_depth > 0;
1817        ctx[i] = ValueContext {
1818            in_literal: currently_value,
1819            in_brace: brace_depth > 0,
1820        };
1821
1822        if skip_paired_bracket {
1823            // Second `[` of `[[` or second `]` of `]]`: already accounted for
1824            // by its partner, no depth effect.
1825            skip_paired_bracket = false;
1826        } else {
1827            match tok {
1828                Token::LBracket => {
1829                    let next_is_lbracket =
1830                        matches!(tokens.get(i + 1).map(|t| &t.token), Some(Token::LBracket));
1831                    if next_is_lbracket && !currently_value {
1832                        // `[[` opens a test — NOT a value literal. The
1833                        // `!currently_value` guard keeps a glued nested value
1834                        // list (`x=[[a] [b]]`) as literal brackets, not a bogus
1835                        // test.
1836                        test_depth += 1;
1837                        skip_paired_bracket = true;
1838                    } else if currently_value {
1839                        bracket_depth += 1;
1840                    }
1841                    // A lone `[` outside any value literal (argv `ls [dog]`,
1842                    // a `[0-9]` char-class) neither opens a test nor a literal.
1843                }
1844                Token::RBracket => {
1845                    let next_is_rbracket =
1846                        matches!(tokens.get(i + 1).map(|t| &t.token), Some(Token::RBracket));
1847                    if bracket_depth > 0 {
1848                        // Close an open value-literal bracket first — in
1849                        // `[[ x in [a b] ]]` the `[a b]`'s own `]` closes here
1850                        // (bracket_depth 1→0) before the trailing `]]`.
1851                        bracket_depth -= 1;
1852                    } else if test_depth > 0 && next_is_rbracket {
1853                        test_depth -= 1;
1854                        skip_paired_bracket = true;
1855                    }
1856                }
1857                Token::LBrace if currently_value => brace_depth += 1,
1858                Token::RBrace if brace_depth > 0 => brace_depth -= 1,
1859                _ => {}
1860            }
1861        }
1862
1863        // Grammar-exact: assignment `=` (outside `[[ ]]`) and membership `in`
1864        // (inside `[[ ]]`) open value position; comparison `=` and for/case
1865        // head `in` do not. `Token::EqEq` (`==`) is a distinct token and never
1866        // opens value position at all.
1867        expect_value = match tok {
1868            Token::Eq => test_depth == 0,
1869            Token::In => test_depth > 0,
1870            _ => false,
1871        };
1872    }
1873
1874    ctx
1875}
1876
1877/// Merge span-adjacent token runs containing `Token::Colon` into single `Ident` tokens.
1878///
1879/// In bash, `:` is a regular character in unquoted words. kaish tokenizes it
1880/// separately, which breaks Rust paths (`foo::bar`), URLs (`host:8080`), etc.
1881///
1882/// This pass fuses span-adjacent mergeable tokens (Ident, Colon, Int, Path, Float)
1883/// into a single `Ident` when the run contains at least one `Colon`. Runs without
1884/// colons or standalone tokens pass through unchanged. A run that opens inside a
1885/// value-position record literal (`{port:8080}`) is exempted — see
1886/// `ValueContext`/`compute_value_context` above — so the record parser sees the
1887/// `Colon` as its own token.
1888fn merge_colon_adjacent(tokens: Vec<Spanned<Token>>) -> Vec<Spanned<Token>> {
1889    if tokens.is_empty() {
1890        return tokens;
1891    }
1892
1893    let value_ctx = compute_value_context(&tokens);
1894    let mut result = Vec::with_capacity(tokens.len());
1895    let mut run: Vec<&Spanned<Token>> = Vec::new();
1896    let mut run_start = 0usize;
1897
1898    for (idx, token) in tokens.iter().enumerate() {
1899        if run.is_empty() {
1900            if mergeable_text(&token.token).is_some() {
1901                run.push(token);
1902                run_start = idx;
1903            } else {
1904                result.push(token.clone());
1905            }
1906            continue;
1907        }
1908
1909        // Check span adjacency: previous run's last token ends where this one starts
1910        // Safety: run is non-empty (checked above)
1911        let Some(last) = run.last() else { unreachable!() };
1912        let adjacent = last.span.end == token.span.start;
1913
1914        if adjacent && mergeable_text(&token.token).is_some() {
1915            run.push(token);
1916        } else {
1917            flush_colon_run(&mut run, &mut result, value_ctx[run_start].in_brace);
1918            if mergeable_text(&token.token).is_some() {
1919                run.push(token);
1920                run_start = idx;
1921            } else {
1922                result.push(token.clone());
1923            }
1924        }
1925    }
1926
1927    flush_colon_run(&mut run, &mut result, value_ctx[run_start].in_brace);
1928
1929    result
1930}
1931
1932/// Flush a run of mergeable tokens: merge if it contains a colon, otherwise emit
1933/// individually. `suppress` (true when the run opened inside a value-position
1934/// record literal) forces individual emission even when `has_colon` would
1935/// otherwise fuse — see `ValueContext`.
1936fn flush_colon_run(run: &mut Vec<&Spanned<Token>>, result: &mut Vec<Spanned<Token>>, suppress: bool) {
1937    if run.is_empty() {
1938        return;
1939    }
1940
1941    let has_colon = run.iter().any(|t| matches!(t.token, Token::Colon));
1942
1943    if !suppress && run.len() >= 2 && has_colon {
1944        let text: String = run
1945            .iter()
1946            .filter_map(|t| mergeable_text(&t.token))
1947            .collect();
1948        // Safety: run.len() >= 2 so first/last exist
1949        let start = run.first().map(|t| t.span.start).unwrap_or(0);
1950        let end = run.last().map(|t| t.span.end).unwrap_or(0);
1951        result.push(Spanned::new(Token::Ident(text), start..end));
1952    } else {
1953        for t in run.iter() {
1954            result.push((*t).clone());
1955        }
1956    }
1957
1958    run.clear();
1959}
1960
1961/// Extract the text contribution of a token that can participate in a glob word.
1962///
1963/// Returns `Some(text)` for tokens that can be part of a glob pattern (identifiers,
1964/// wildcard chars, brackets, paths, etc.), `None` for structural tokens.
1965fn glob_mergeable_text(token: &Token) -> Option<String> {
1966    match token {
1967        Token::Star => Some("*".to_string()),
1968        Token::Question => Some("?".to_string()),
1969        Token::Dot => Some(".".to_string()),
1970        Token::DotDot => Some("..".to_string()),
1971        Token::Ident(s) => Some(s.clone()),
1972        Token::NumberIdent(s) => Some(s.clone()),
1973        Token::DashNumWord(s) => Some(s.clone()),
1974        Token::AtWord(s) => Some(s.clone()),
1975        Token::DottedIdent(s) => Some(s.clone()),
1976        Token::Path(s) => Some(s.clone()),
1977        Token::Int(n) => Some(n.to_string()),
1978        Token::LBracket => Some("[".to_string()),
1979        Token::RBracket => Some("]".to_string()),
1980        Token::Bang => Some("!".to_string()),
1981        Token::DotSlashPath(s) => Some(s.clone()),
1982        Token::RelativePath(s) => Some(s.clone()),
1983        Token::TildePath(s) => Some(s.clone()),
1984        Token::Tilde => Some("~".to_string()),
1985        Token::LBrace => Some("{".to_string()),
1986        Token::RBrace => Some("}".to_string()),
1987        Token::Comma => Some(",".to_string()),
1988        _ => None,
1989    }
1990}
1991
1992/// Merge a span-adjacent metacharacter onto a flag token.
1993///
1994/// Handles the `awk -F:` idiom: the kaish lexer emits `-F` as `ShortFlag("F")` and
1995/// `:` as `Token::Colon` (an operator). When the two tokens are span-adjacent (no
1996/// whitespace between them), the `:` is part of the flag value, not a shell operator.
1997/// This pass fuses them so `ShortFlag("F:")` reaches the arg-binding layer, which
1998/// already handles the glued-value form (the same mechanism used for `cut -f1`).
1999///
2000/// Metachars handled: `:` (Colon) only.
2001///
2002/// `;` (Semi) and `|` (Pipe) are shell operators and must NOT be fused even when
2003/// span-adjacent — `cmd -p; cmd2` and `ls -l|cat` must produce real Semi/Pipe
2004/// tokens so the shell grammar can treat them as statement separators and pipes.
2005/// In bash, `-F;` and `-F|` require quoting (`-F';'`), so kaish matches that
2006/// contract.
2007///
2008/// **Safety**: the fuse is guarded by span adjacency (`last.span.end == token.span.start`),
2009/// which is only true when there is no whitespace between the flag and the metachar.
2010/// A space-separated `cmd -F :` leaves a gap and never reaches this merge.
2011///
2012/// **Colon-run fusion**: consecutive span-adjacent colons after the flag are all
2013/// absorbed in one pass, so `-F::` becomes `ShortFlag("F::")` rather than
2014/// `ShortFlag("F:") + Colon`.
2015///
2016/// Only `ShortFlag` is handled here. `LongFlag` with a bare `=:` form (e.g.
2017/// `--field-separator=:`) is handled by the parser's `long_flag_with_value` rule
2018/// via `primary_expr_parser`, which accepts the merged `Ident` that
2019/// `merge_colon_adjacent` already produces from `=:` runs.
2020fn merge_flag_metachar_adjacent(tokens: Vec<Spanned<Token>>) -> Vec<Spanned<Token>> {
2021    if tokens.len() < 2 {
2022        return tokens;
2023    }
2024
2025    let mut result = Vec::with_capacity(tokens.len());
2026    let mut i = 0;
2027
2028    while i < tokens.len() {
2029        let token = &tokens[i];
2030
2031        // Only short flags can be followed by a glued colon.
2032        if let Token::ShortFlag(flag_name) = &token.token {
2033            // Absorb a run of span-adjacent colons into the flag name.
2034            let mut fused = flag_name.clone();
2035            let mut end_span = token.span.end;
2036            let mut j = i + 1;
2037
2038            while let Some(next) = tokens.get(j) {
2039                if next.span.start == end_span {
2040                    if let Token::Colon = &next.token {
2041                        fused.push(':');
2042                        end_span = next.span.end;
2043                        j += 1;
2044                        continue;
2045                    }
2046                }
2047                break;
2048            }
2049
2050            if j > i + 1 {
2051                // At least one colon was absorbed.
2052                let span = token.span.start..end_span;
2053                result.push(Spanned::new(Token::ShortFlag(fused), span));
2054                i = j;
2055                continue;
2056            }
2057        }
2058
2059        result.push(token.clone());
2060        i += 1;
2061    }
2062
2063    result
2064}
2065
2066/// Merge span-adjacent token runs containing glob metacharacters into `GlobWord` tokens.
2067///
2068/// A run is merged into `GlobWord` when it contains at least one `Star`, `Question`,
2069/// or a `LBracket`+`RBracket` pair. Runs without glob chars pass through unchanged.
2070///
2071/// Runs after colon merge: `foo::bar` stays as `Ident("foo::bar")` because colon merge
2072/// already fused it before this pass sees it.
2073///
2074/// A run that opens at *value position* (`x=[dog]`, `[[ $a in [dog] ]]`) is exempted
2075/// from the `[`/`]` bracket-pair fusion — see `ValueContext`/`compute_value_context`
2076/// above — so the list-literal parser sees primitive `LBracket`/`RBracket` tokens
2077/// instead of a fused `GlobWord`. Argv-position brackets (`ls [dog]`, `for x in [a]`)
2078/// are untouched.
2079///
2080/// A SEPARATE trigger suppresses fusion for an **assignment lvalue**:
2081/// `fruits[0]=kiwi`, `user[email]=x`, `services[web][port]=9090`. This run sits
2082/// at *argv/LHS position* (before `=`), not value position, so
2083/// `ValueContext::in_literal` is false for it — the lvalue grammar needs its
2084/// own peek-ahead: a bracket-pair run (no `*`/`?`) immediately followed by
2085/// `Token::Eq` is a subscripted assignment target, not a glob, so it is
2086/// suppressed the same way regardless of whitespace before the `=` (see
2087/// `flush_glob_run`'s `followed_by_eq` parameter). See
2088/// `docs/arrays-and-hashes.md` ("Assignment lvalues").
2089fn merge_glob_adjacent(tokens: Vec<Spanned<Token>>) -> Vec<Spanned<Token>> {
2090    if tokens.is_empty() {
2091        return tokens;
2092    }
2093
2094    let value_ctx = compute_value_context(&tokens);
2095    let mut result = Vec::with_capacity(tokens.len());
2096    let mut run: Vec<&Spanned<Token>> = Vec::new();
2097    let mut run_start = 0usize;
2098
2099    for (idx, token) in tokens.iter().enumerate() {
2100        if run.is_empty() {
2101            if glob_mergeable_text(&token.token).is_some() {
2102                run.push(token);
2103                run_start = idx;
2104            } else {
2105                result.push(token.clone());
2106            }
2107            continue;
2108        }
2109
2110        // Safety: run is non-empty (checked at top of loop)
2111        let Some(last) = run.last() else { unreachable!() };
2112        let adjacent = last.span.end == token.span.start;
2113
2114        if adjacent && glob_mergeable_text(&token.token).is_some() {
2115            run.push(token);
2116        } else {
2117            // `token` is whatever broke the run — an lvalue's `=` is never
2118            // glob-mergeable, so it always lands here (not in the `if` arm
2119            // above) regardless of whether whitespace separates it from the
2120            // run (`fruits[0]=kiwi` and `fruits[0] = kiwi` both see it).
2121            let followed_by_eq = matches!(token.token, Token::Eq);
2122            flush_glob_run(
2123                &mut run,
2124                &mut result,
2125                value_ctx[run_start].in_literal,
2126                followed_by_eq,
2127            );
2128            if glob_mergeable_text(&token.token).is_some() {
2129                run.push(token);
2130                run_start = idx;
2131            } else {
2132                result.push(token.clone());
2133            }
2134        }
2135    }
2136
2137    // End of input: no token follows the final run, so it can't be an lvalue
2138    // (an assignment always has a value after `=`).
2139    flush_glob_run(&mut run, &mut result, value_ctx[run_start].in_literal, false);
2140
2141    result
2142}
2143
2144/// Flush a run of glob-mergeable tokens: merge if it contains glob metacharacters.
2145///
2146/// `value_position_suppress` (true when the run opened at value position — see
2147/// `ValueContext`) forces individual emission for a run that contains an
2148/// `LBracket`, so a `[`-leading run at value position always reaches the
2149/// parser as primitive tokens for the list-literal grammar, never a
2150/// `GlobWord`. A pure `Star`/`Question` glob with no brackets (`X=*.txt`) is
2151/// untouched by `value_position_suppress` — it keeps fusing to a `GlobWord`,
2152/// which evaluates to a literal string at value position exactly as it did
2153/// before collection literals existed (see `test_glob_in_assignment_is_literal`);
2154/// only bracket-shaped runs are reinterpreted as list-literal syntax.
2155///
2156/// `followed_by_eq` (true when the token right after this run — in stream
2157/// order, whitespace or not — is `Token::Eq`) is the SEPARATE lvalue trigger:
2158/// a bracket-pair run with no `*`/`?` immediately before `=` is a subscripted
2159/// assignment target (`fruits[0]=kiwi`), not a glob, so it is suppressed too
2160/// — even though it sits at argv/LHS position, not value position. The
2161/// `has_star_or_question` guard keeps a real glob pattern that happens to
2162/// precede an `=` (vanishingly rare, but not this run's job to reinterpret)
2163/// out of the suppression.
2164fn flush_glob_run(
2165    run: &mut Vec<&Spanned<Token>>,
2166    result: &mut Vec<Spanned<Token>>,
2167    value_position_suppress: bool,
2168    followed_by_eq: bool,
2169) {
2170    if run.is_empty() {
2171        return;
2172    }
2173
2174    let has_bracket_pair = run.iter().any(|t| matches!(t.token, Token::LBracket))
2175        && run.iter().any(|t| matches!(t.token, Token::RBracket));
2176    let has_star_or_question = run
2177        .iter()
2178        .any(|t| matches!(t.token, Token::Star | Token::Question));
2179    let has_glob = has_star_or_question || has_bracket_pair;
2180
2181    // An lvalue subscript run is a ROOT IDENTIFIER followed by brackets, so the
2182    // run STARTS with an `Ident` (`arr[0]=` → run is `arr [ 0 ]`, glued, so
2183    // `arr` is the first token OF the run — a lvalue root parses via
2184    // `ident_parser()`, i.e. `Token::Ident`). A bare char-class comparison
2185    // operand starts with `[` instead (`[[ [a] = b ]]` → the suppressible run
2186    // is `[ a ]`, since the leading `[[` was flushed separately), so requiring
2187    // an `Ident`-led run keeps `[[ [a] = b ]]` fusing-and-comparing as before
2188    // while still catching every real lvalue.
2189    let run_starts_with_ident = matches!(
2190        run.first().map(|t| &t.token),
2191        Some(Token::Ident(_))
2192    );
2193    let lvalue_suppress =
2194        followed_by_eq && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
2195    let suppress = (value_position_suppress && has_bracket_pair) || lvalue_suppress;
2196
2197    if !suppress && run.len() >= 2 && has_glob {
2198        let text: String = run
2199            .iter()
2200            .filter_map(|t| glob_mergeable_text(&t.token))
2201            .collect();
2202        let start = run.first().map(|t| t.span.start).unwrap_or(0);
2203        let end = run.last().map(|t| t.span.end).unwrap_or(0);
2204        result.push(Spanned::new(Token::GlobWord(text), start..end));
2205    } else {
2206        for t in run.iter() {
2207            result.push((*t).clone());
2208        }
2209    }
2210
2211    run.clear();
2212}
2213
2214/// Tokenize source code into a vector of spanned tokens.
2215///
2216/// Skips whitespace and comments (unless you need them for formatting).
2217/// Returns errors with their positions for nice error messages.
2218///
2219/// Handles:
2220/// - Arithmetic: `$((expr))` becomes `Arithmetic("expr")`
2221/// - Here-docs: `<<EOF\nhello\nEOF` becomes `HereDocStart` + `HereDoc("hello")`
2222/// - Colon merge: span-adjacent `foo::bar` becomes `Ident("foo::bar")`
2223pub fn tokenize(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
2224    // Preprocess arithmetic first (before heredocs because heredoc content might contain $((
2225    let arith_result = preprocess_arithmetic(source)
2226        .map_err(|e| vec![Spanned::new(e, 0..source.len())])?;
2227
2228    // Then preprocess here-docs. Spans inside the heredoc preprocessor are in
2229    // arith-preprocessed coords; correct back to original-source coords before
2230    // surfacing the error to keep parser diagnostics aligned with source.
2231    let span_replacements = arith_result.replacements;
2232    let (preprocessed, heredocs) = preprocess_heredocs(&arith_result.text)
2233        .map_err(|e| {
2234            let span = correct_span(e.span, &span_replacements);
2235            vec![Spanned::new(e.token, span)]
2236        })?;
2237
2238    let lexer = Token::lexer(&preprocessed);
2239    let mut tokens = Vec::new();
2240    let mut errors = Vec::new();
2241
2242    for (result, span) in lexer.spanned() {
2243        // Correct the span from preprocessed coordinates to original coordinates
2244        let corrected_span = correct_span(span, &span_replacements);
2245        match result {
2246            Ok(token) => {
2247                // Skip comments and line continuations - they're not needed for parsing
2248                if !matches!(token, Token::Comment | Token::LineContinuation) {
2249                    tokens.push(Spanned::new(token, corrected_span));
2250                }
2251            }
2252            Err(err) => {
2253                errors.push(Spanned::new(err, corrected_span));
2254            }
2255        }
2256    }
2257
2258    if !errors.is_empty() {
2259        return Err(errors);
2260    }
2261
2262    // Post-process: replace markers with actual token content
2263    let mut final_tokens = Vec::with_capacity(tokens.len());
2264    let mut i = 0;
2265
2266    while i < tokens.len() {
2267        // Check for arithmetic marker (unique format: __KAISH_ARITH_{id}__)
2268        if let Token::Ident(ref name) = tokens[i].token
2269            && name.starts_with("__KAISH_ARITH_") && name.ends_with("__")
2270                && let Some((_, expr)) = arith_result.arithmetics.iter().find(|(marker, _)| marker == name) {
2271                    final_tokens.push(Spanned::new(Token::Arithmetic(expr.clone()), tokens[i].span.clone()));
2272                    i += 1;
2273                    continue;
2274                }
2275
2276        // Check for heredoc (unique format: __KAISH_HEREDOC_{id}__)
2277        if matches!(tokens[i].token, Token::HereDocStart) {
2278            // Check if next token is a heredoc marker
2279            if i + 1 < tokens.len()
2280                && let Token::Ident(ref name) = tokens[i + 1].token
2281                    && name.starts_with("__KAISH_HEREDOC_") && name.ends_with("__") {
2282                        // Find the corresponding content
2283                        if let Some(hd) = heredocs.iter().find(|h| h.marker == *name) {
2284                            // Re-thread arithmetic markers that the arith
2285                            // preprocessor planted in the source — without
2286                            // this, `<<EOF\n$((1+2))\nEOF` materializes the
2287                            // marker text instead of `3`. Mirrors the
2288                            // String-content translation a few lines below.
2289                            // - Literal heredocs (no expansion): restore the
2290                            //   original `$((expr))` text verbatim.
2291                            // - Interpolated heredocs: wrap as
2292                            //   `${__ARITH:expr__}` so the spanned
2293                            //   interpolation parser turns it into a
2294                            //   StringPart::Arithmetic.
2295                            let mut content = hd.body.clone();
2296                            for (marker, expr) in &arith_result.arithmetics {
2297                                if content.contains(marker) {
2298                                    let replacement = if hd.literal {
2299                                        format!("$(({}))", expr)
2300                                    } else {
2301                                        format!("${{__ARITH:{}__}}", expr)
2302                                    };
2303                                    content = content.replace(marker, &replacement);
2304                                }
2305                            }
2306                            final_tokens.push(Spanned::new(Token::HereDocStart, tokens[i].span.clone()));
2307                            final_tokens.push(Spanned::new(
2308                                Token::HereDoc(HereDocData {
2309                                    content,
2310                                    literal: hd.literal,
2311                                    strip_tabs: hd.strip_tabs,
2312                                    body_start_offset: hd.body_start_offset,
2313                                }),
2314                                tokens[i + 1].span.clone(),
2315                            ));
2316                            i += 2;
2317                            continue;
2318                        }
2319                    }
2320        }
2321
2322        // Check for arithmetic markers inside string content
2323        let token = if let Token::String(ref s) = tokens[i].token {
2324            // Check if string contains any arithmetic markers
2325            let mut new_content = s.clone();
2326            for (marker, expr) in &arith_result.arithmetics {
2327                if new_content.contains(marker) {
2328                    // Replace marker with the special format that parse_interpolated_string can detect
2329                    // Use ${__ARITH:expr__} format so it gets parsed as StringPart::Arithmetic
2330                    new_content = new_content.replace(marker, &format!("${{__ARITH:{}__}}", expr));
2331                }
2332            }
2333            if new_content != *s {
2334                Spanned::new(Token::String(new_content), tokens[i].span.clone())
2335            } else {
2336                tokens[i].clone()
2337            }
2338        } else {
2339            tokens[i].clone()
2340        };
2341        final_tokens.push(token);
2342        i += 1;
2343    }
2344
2345    Ok(merge_glob_adjacent(merge_colon_adjacent(
2346        merge_flag_metachar_adjacent(final_tokens),
2347    )))
2348}
2349
2350/// Tokenize source code, preserving comments.
2351///
2352/// Useful for pretty-printing or formatting tools that need to preserve comments.
2353pub fn tokenize_with_comments(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
2354    let lexer = Token::lexer(source);
2355    let mut tokens = Vec::new();
2356    let mut errors = Vec::new();
2357
2358    for (result, span) in lexer.spanned() {
2359        match result {
2360            Ok(token) => {
2361                tokens.push(Spanned::new(token, span));
2362            }
2363            Err(err) => {
2364                errors.push(Spanned::new(err, span));
2365            }
2366        }
2367    }
2368
2369    if errors.is_empty() {
2370        Ok(tokens)
2371    } else {
2372        Err(errors)
2373    }
2374}
2375
2376/// Extract the string content from a string token (removes quotes, processes escapes).
2377pub fn parse_string_literal(source: &str) -> Result<String, LexerError> {
2378    // Remove surrounding quotes
2379    if source.len() < 2 || !source.starts_with('"') || !source.ends_with('"') {
2380        return Err(LexerError::UnterminatedString);
2381    }
2382
2383    let inner = &source[1..source.len() - 1];
2384    let mut result = String::with_capacity(inner.len());
2385    let mut chars = inner.chars().peekable();
2386
2387    while let Some(ch) = chars.next() {
2388        if ch == '\\' {
2389            match chars.next() {
2390                Some('n') => result.push('\n'),
2391                Some('t') => result.push('\t'),
2392                Some('r') => result.push('\r'),
2393                Some('\\') => result.push('\\'),
2394                Some('"') => result.push('"'),
2395                // Use a unique marker for escaped dollar that won't be re-interpreted
2396                // parse_interpolated_string will convert this back to $
2397                Some('$') => result.push_str("__KAISH_ESCAPED_DOLLAR__"),
2398                Some('u') => {
2399                    // Unicode escape: \uXXXX
2400                    let mut hex = String::with_capacity(4);
2401                    for _ in 0..4 {
2402                        match chars.next() {
2403                            Some(h) if h.is_ascii_hexdigit() => hex.push(h),
2404                            _ => return Err(LexerError::InvalidEscape),
2405                        }
2406                    }
2407                    let codepoint = u32::from_str_radix(&hex, 16)
2408                        .map_err(|_| LexerError::InvalidEscape)?;
2409                    let ch = char::from_u32(codepoint)
2410                        .ok_or(LexerError::InvalidEscape)?;
2411                    result.push(ch);
2412                }
2413                // Unknown escapes: preserve the backslash (for regex patterns like `\.`)
2414                Some(next) => {
2415                    result.push('\\');
2416                    result.push(next);
2417                }
2418                None => return Err(LexerError::InvalidEscape),
2419            }
2420        } else {
2421            result.push(ch);
2422        }
2423    }
2424
2425    Ok(result)
2426}
2427
2428/// Parse a variable reference, extracting the path segments.
2429/// Input: "${VAR.field[0].nested}" → ["VAR", "field", "[0]", "nested"]
2430pub fn parse_var_ref(source: &str) -> Result<Vec<String>, LexerError> {
2431    // Remove ${ and }
2432    if source.len() < 4 || !source.starts_with("${") || !source.ends_with('}') {
2433        return Err(LexerError::UnterminatedVarRef);
2434    }
2435
2436    let inner = &source[2..source.len() - 1];
2437
2438    // Special case: $? (last result)
2439    if inner == "?" {
2440        return Ok(vec!["?".to_string()]);
2441    }
2442
2443    let mut segments = Vec::new();
2444    let mut current = String::new();
2445    let mut chars = inner.chars().peekable();
2446
2447    while let Some(ch) = chars.next() {
2448        match ch {
2449            '.' => {
2450                if !current.is_empty() {
2451                    segments.push(current.clone());
2452                    current.clear();
2453                }
2454            }
2455            '[' => {
2456                if !current.is_empty() {
2457                    segments.push(current.clone());
2458                    current.clear();
2459                }
2460                // Collect the index
2461                let mut index = String::from("[");
2462                while let Some(&c) = chars.peek() {
2463                    if let Some(c) = chars.next() {
2464                        index.push(c);
2465                    }
2466                    if c == ']' {
2467                        break;
2468                    }
2469                }
2470                segments.push(index);
2471            }
2472            _ => {
2473                current.push(ch);
2474            }
2475        }
2476    }
2477
2478    if !current.is_empty() {
2479        segments.push(current);
2480    }
2481
2482    Ok(segments)
2483}
2484
2485/// Parse an integer literal.
2486pub fn parse_int(source: &str) -> Result<i64, LexerError> {
2487    source.parse().map_err(|_| LexerError::InvalidNumber)
2488}
2489
2490/// Parse a float literal.
2491pub fn parse_float(source: &str) -> Result<f64, LexerError> {
2492    source.parse().map_err(|_| LexerError::InvalidNumber)
2493}
2494
2495#[cfg(test)]
2496#[allow(clippy::approx_constant)]
2497mod tests {
2498    use super::*;
2499
2500    fn lex(source: &str) -> Vec<Token> {
2501        tokenize(source)
2502            .expect("lexer should succeed")
2503            .into_iter()
2504            .map(|s| s.token)
2505            .collect()
2506    }
2507
2508    // ═══════════════════════════════════════════════════════════════════
2509    // Keyword tests
2510    // ═══════════════════════════════════════════════════════════════════
2511
2512    #[test]
2513    fn keywords() {
2514        assert_eq!(lex("set"), vec![Token::Set]);
2515        assert_eq!(lex("if"), vec![Token::If]);
2516        assert_eq!(lex("then"), vec![Token::Then]);
2517        assert_eq!(lex("else"), vec![Token::Else]);
2518        assert_eq!(lex("elif"), vec![Token::Elif]);
2519        assert_eq!(lex("fi"), vec![Token::Fi]);
2520        assert_eq!(lex("for"), vec![Token::For]);
2521        assert_eq!(lex("in"), vec![Token::In]);
2522        assert_eq!(lex("do"), vec![Token::Do]);
2523        assert_eq!(lex("done"), vec![Token::Done]);
2524        assert_eq!(lex("case"), vec![Token::Case]);
2525        assert_eq!(lex("esac"), vec![Token::Esac]);
2526        assert_eq!(lex("function"), vec![Token::Function]);
2527        assert_eq!(lex("true"), vec![Token::True]);
2528        assert_eq!(lex("false"), vec![Token::False]);
2529    }
2530
2531    #[test]
2532    fn double_semicolon() {
2533        assert_eq!(lex(";;"), vec![Token::DoubleSemi]);
2534        // In case pattern context
2535        assert_eq!(lex("echo \"hi\";;"), vec![
2536            Token::Ident("echo".to_string()),
2537            Token::String("hi".to_string()),
2538            Token::DoubleSemi,
2539        ]);
2540    }
2541
2542    #[test]
2543    fn type_keywords() {
2544        assert_eq!(lex("string"), vec![Token::TypeString]);
2545        assert_eq!(lex("int"), vec![Token::TypeInt]);
2546        assert_eq!(lex("float"), vec![Token::TypeFloat]);
2547        assert_eq!(lex("bool"), vec![Token::TypeBool]);
2548    }
2549
2550    // ═══════════════════════════════════════════════════════════════════
2551    // Operator tests
2552    // ═══════════════════════════════════════════════════════════════════
2553
2554    #[test]
2555    fn single_char_operators() {
2556        assert_eq!(lex("="), vec![Token::Eq]);
2557        assert_eq!(lex("|"), vec![Token::Pipe]);
2558        assert_eq!(lex("&"), vec![Token::Amp]);
2559        assert_eq!(lex(">"), vec![Token::Gt]);
2560        assert_eq!(lex("<"), vec![Token::Lt]);
2561        assert_eq!(lex(";"), vec![Token::Semi]);
2562        assert_eq!(lex(":"), vec![Token::Colon]);
2563        assert_eq!(lex(","), vec![Token::Comma]);
2564        assert_eq!(lex("."), vec![Token::Dot]);
2565    }
2566
2567    #[test]
2568    fn multi_char_operators() {
2569        assert_eq!(lex("&&"), vec![Token::And]);
2570        assert_eq!(lex("||"), vec![Token::Or]);
2571        assert_eq!(lex("=="), vec![Token::EqEq]);
2572        assert_eq!(lex("!="), vec![Token::NotEq]);
2573        assert_eq!(lex("=~"), vec![Token::Match]);
2574        assert_eq!(lex("!~"), vec![Token::NotMatch]);
2575        assert_eq!(lex(">="), vec![Token::GtEq]);
2576        assert_eq!(lex("<="), vec![Token::LtEq]);
2577        assert_eq!(lex(">>"), vec![Token::GtGt]);
2578        assert_eq!(lex("2>"), vec![Token::Stderr]);
2579        assert_eq!(lex("&>"), vec![Token::Both]);
2580    }
2581
2582    #[test]
2583    fn brackets() {
2584        assert_eq!(lex("{"), vec![Token::LBrace]);
2585        assert_eq!(lex("}"), vec![Token::RBrace]);
2586        assert_eq!(lex("["), vec![Token::LBracket]);
2587        assert_eq!(lex("]"), vec![Token::RBracket]);
2588        assert_eq!(lex("("), vec![Token::LParen]);
2589        assert_eq!(lex(")"), vec![Token::RParen]);
2590    }
2591
2592    // ═══════════════════════════════════════════════════════════════════
2593    // Literal tests
2594    // ═══════════════════════════════════════════════════════════════════
2595
2596    #[test]
2597    fn integers() {
2598        assert_eq!(lex("0"), vec![Token::Int(0)]);
2599        assert_eq!(lex("42"), vec![Token::Int(42)]);
2600        assert_eq!(lex("-1"), vec![Token::Int(-1)]);
2601        assert_eq!(lex("999999"), vec![Token::Int(999999)]);
2602    }
2603
2604    #[test]
2605    fn floats() {
2606        assert_eq!(lex("3.14"), vec![Token::Float(3.14)]);
2607        assert_eq!(lex("-0.5"), vec![Token::Float(-0.5)]);
2608        assert_eq!(lex("123.456"), vec![Token::Float(123.456)]);
2609    }
2610
2611    #[test]
2612    fn strings() {
2613        assert_eq!(lex(r#""hello""#), vec![Token::String("hello".to_string())]);
2614        assert_eq!(lex(r#""hello world""#), vec![Token::String("hello world".to_string())]);
2615        assert_eq!(lex(r#""""#), vec![Token::String("".to_string())]); // empty string
2616        assert_eq!(lex(r#""with \"quotes\"""#), vec![Token::String("with \"quotes\"".to_string())]);
2617        assert_eq!(lex(r#""with\nnewline""#), vec![Token::String("with\nnewline".to_string())]);
2618    }
2619
2620    #[test]
2621    fn var_refs() {
2622        assert_eq!(lex("${X}"), vec![Token::VarRef("${X}".to_string())]);
2623        assert_eq!(lex("${VAR}"), vec![Token::VarRef("${VAR}".to_string())]);
2624        assert_eq!(lex("${VAR.field}"), vec![Token::VarRef("${VAR.field}".to_string())]);
2625        assert_eq!(lex("${VAR[0]}"), vec![Token::VarRef("${VAR[0]}".to_string())]);
2626    }
2627
2628    // ═══════════════════════════════════════════════════════════════════
2629    // Identifier tests
2630    // ═══════════════════════════════════════════════════════════════════
2631
2632    #[test]
2633    fn identifiers() {
2634        assert_eq!(lex("foo"), vec![Token::Ident("foo".to_string())]);
2635        assert_eq!(lex("foo_bar"), vec![Token::Ident("foo_bar".to_string())]);
2636        assert_eq!(lex("foo-bar"), vec![Token::Ident("foo-bar".to_string())]);
2637        assert_eq!(lex("_private"), vec![Token::Ident("_private".to_string())]);
2638        assert_eq!(lex("cmd123"), vec![Token::Ident("cmd123".to_string())]);
2639    }
2640
2641    #[test]
2642    fn keyword_prefix_identifiers() {
2643        // Identifiers that start with keywords but aren't keywords
2644        assert_eq!(lex("setup"), vec![Token::Ident("setup".to_string())]);
2645        assert_eq!(lex("kaish-tools"), vec![Token::Ident("kaish-tools".to_string())]);
2646        assert_eq!(lex("iffy"), vec![Token::Ident("iffy".to_string())]);
2647        assert_eq!(lex("forked"), vec![Token::Ident("forked".to_string())]);
2648        assert_eq!(lex("done-with-it"), vec![Token::Ident("done-with-it".to_string())]);
2649    }
2650
2651    // ═══════════════════════════════════════════════════════════════════
2652    // Statement tests
2653    // ═══════════════════════════════════════════════════════════════════
2654
2655    #[test]
2656    fn assignment() {
2657        assert_eq!(
2658            lex("set X = 5"),
2659            vec![Token::Set, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
2660        );
2661    }
2662
2663    #[test]
2664    fn command_simple() {
2665        assert_eq!(lex("echo"), vec![Token::Ident("echo".to_string())]);
2666        assert_eq!(
2667            lex(r#"echo "hello""#),
2668            vec![Token::Ident("echo".to_string()), Token::String("hello".to_string())]
2669        );
2670    }
2671
2672    #[test]
2673    fn command_with_args() {
2674        assert_eq!(
2675            lex("cmd arg1 arg2"),
2676            vec![Token::Ident("cmd".to_string()), Token::Ident("arg1".to_string()), Token::Ident("arg2".to_string())]
2677        );
2678    }
2679
2680    #[test]
2681    fn command_with_named_args() {
2682        assert_eq!(
2683            lex("cmd key=value"),
2684            vec![Token::Ident("cmd".to_string()), Token::Ident("key".to_string()), Token::Eq, Token::Ident("value".to_string())]
2685        );
2686    }
2687
2688    #[test]
2689    fn pipeline() {
2690        assert_eq!(
2691            lex("a | b | c"),
2692            vec![Token::Ident("a".to_string()), Token::Pipe, Token::Ident("b".to_string()), Token::Pipe, Token::Ident("c".to_string())]
2693        );
2694    }
2695
2696    #[test]
2697    fn if_statement() {
2698        assert_eq!(
2699            lex("if true; then echo; fi"),
2700            vec![
2701                Token::If,
2702                Token::True,
2703                Token::Semi,
2704                Token::Then,
2705                Token::Ident("echo".to_string()),
2706                Token::Semi,
2707                Token::Fi
2708            ]
2709        );
2710    }
2711
2712    #[test]
2713    fn for_loop() {
2714        assert_eq!(
2715            lex("for X in items; do echo; done"),
2716            vec![
2717                Token::For,
2718                Token::Ident("X".to_string()),
2719                Token::In,
2720                Token::Ident("items".to_string()),
2721                Token::Semi,
2722                Token::Do,
2723                Token::Ident("echo".to_string()),
2724                Token::Semi,
2725                Token::Done
2726            ]
2727        );
2728    }
2729
2730    // ═══════════════════════════════════════════════════════════════════
2731    // Whitespace and newlines
2732    // ═══════════════════════════════════════════════════════════════════
2733
2734    #[test]
2735    fn whitespace_ignored() {
2736        assert_eq!(lex("   set   X   =   5   "), lex("set X = 5"));
2737    }
2738
2739    #[test]
2740    fn newlines_preserved() {
2741        let tokens = lex("a\nb");
2742        assert_eq!(
2743            tokens,
2744            vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
2745        );
2746    }
2747
2748    #[test]
2749    fn multiple_newlines() {
2750        let tokens = lex("a\n\n\nb");
2751        assert_eq!(
2752            tokens,
2753            vec![Token::Ident("a".to_string()), Token::Newline, Token::Newline, Token::Newline, Token::Ident("b".to_string())]
2754        );
2755    }
2756
2757    // ═══════════════════════════════════════════════════════════════════
2758    // Comments
2759    // ═══════════════════════════════════════════════════════════════════
2760
2761    #[test]
2762    fn comments_skipped() {
2763        assert_eq!(lex("# comment"), vec![]);
2764        assert_eq!(lex("a # comment"), vec![Token::Ident("a".to_string())]);
2765        assert_eq!(
2766            lex("a # comment\nb"),
2767            vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
2768        );
2769    }
2770
2771    #[test]
2772    fn comments_preserved_when_requested() {
2773        let tokens = tokenize_with_comments("a # comment")
2774            .expect("should succeed")
2775            .into_iter()
2776            .map(|s| s.token)
2777            .collect::<Vec<_>>();
2778        assert_eq!(tokens, vec![Token::Ident("a".to_string()), Token::Comment]);
2779    }
2780
2781    // ═══════════════════════════════════════════════════════════════════
2782    // String parsing
2783    // ═══════════════════════════════════════════════════════════════════
2784
2785    #[test]
2786    fn parse_simple_string() {
2787        assert_eq!(parse_string_literal(r#""hello""#).expect("ok"), "hello");
2788    }
2789
2790    #[test]
2791    fn parse_string_with_escapes() {
2792        assert_eq!(
2793            parse_string_literal(r#""hello\nworld""#).expect("ok"),
2794            "hello\nworld"
2795        );
2796        assert_eq!(
2797            parse_string_literal(r#""tab\there""#).expect("ok"),
2798            "tab\there"
2799        );
2800        assert_eq!(
2801            parse_string_literal(r#""quote\"here""#).expect("ok"),
2802            "quote\"here"
2803        );
2804    }
2805
2806    #[test]
2807    fn parse_string_with_unicode() {
2808        assert_eq!(
2809            parse_string_literal(r#""emoji \u2764""#).expect("ok"),
2810            "emoji ❤"
2811        );
2812    }
2813
2814    #[test]
2815    fn parse_string_with_escaped_dollar() {
2816        // \$ produces a marker that parse_interpolated_string will convert to $
2817        // The marker __KAISH_ESCAPED_DOLLAR__ is used to prevent re-interpretation
2818        assert_eq!(
2819            parse_string_literal(r#""\$VAR""#).expect("ok"),
2820            "__KAISH_ESCAPED_DOLLAR__VAR"
2821        );
2822        assert_eq!(
2823            parse_string_literal(r#""cost: \$100""#).expect("ok"),
2824            "cost: __KAISH_ESCAPED_DOLLAR__100"
2825        );
2826    }
2827
2828    // ═══════════════════════════════════════════════════════════════════
2829    // Variable reference parsing
2830    // ═══════════════════════════════════════════════════════════════════
2831
2832    #[test]
2833    fn parse_simple_var() {
2834        assert_eq!(
2835            parse_var_ref("${X}").expect("ok"),
2836            vec!["X"]
2837        );
2838    }
2839
2840    #[test]
2841    fn parse_var_with_field() {
2842        assert_eq!(
2843            parse_var_ref("${VAR.field}").expect("ok"),
2844            vec!["VAR", "field"]
2845        );
2846    }
2847
2848    #[test]
2849    fn parse_var_with_index() {
2850        assert_eq!(
2851            parse_var_ref("${VAR[0]}").expect("ok"),
2852            vec!["VAR", "[0]"]
2853        );
2854    }
2855
2856    #[test]
2857    fn parse_var_nested() {
2858        assert_eq!(
2859            parse_var_ref("${VAR.field[0].nested}").expect("ok"),
2860            vec!["VAR", "field", "[0]", "nested"]
2861        );
2862    }
2863
2864    #[test]
2865    fn parse_last_result() {
2866        assert_eq!(
2867            parse_var_ref("${?}").expect("ok"),
2868            vec!["?"]
2869        );
2870    }
2871
2872    // ═══════════════════════════════════════════════════════════════════
2873    // Number parsing
2874    // ═══════════════════════════════════════════════════════════════════
2875
2876    #[test]
2877    fn parse_integers() {
2878        assert_eq!(parse_int("0").expect("ok"), 0);
2879        assert_eq!(parse_int("42").expect("ok"), 42);
2880        assert_eq!(parse_int("-1").expect("ok"), -1);
2881    }
2882
2883    #[test]
2884    fn parse_floats() {
2885        assert!((parse_float("3.14").expect("ok") - 3.14).abs() < f64::EPSILON);
2886        assert!((parse_float("-0.5").expect("ok") - (-0.5)).abs() < f64::EPSILON);
2887    }
2888
2889    // ═══════════════════════════════════════════════════════════════════
2890    // Edge cases and errors
2891    // ═══════════════════════════════════════════════════════════════════
2892
2893    #[test]
2894    fn empty_input() {
2895        assert_eq!(lex(""), vec![]);
2896    }
2897
2898    #[test]
2899    fn only_whitespace() {
2900        assert_eq!(lex("   \t\t   "), vec![]);
2901    }
2902
2903    #[test]
2904    fn json_array() {
2905        assert_eq!(
2906            lex(r#"[1, 2, 3]"#),
2907            vec![
2908                Token::LBracket,
2909                Token::Int(1),
2910                Token::Comma,
2911                Token::Int(2),
2912                Token::Comma,
2913                Token::Int(3),
2914                Token::RBracket
2915            ]
2916        );
2917    }
2918
2919    #[test]
2920    fn json_object() {
2921        assert_eq!(
2922            lex(r#"{"key": "value"}"#),
2923            vec![
2924                Token::LBrace,
2925                Token::String("key".to_string()),
2926                Token::Colon,
2927                Token::String("value".to_string()),
2928                Token::RBrace
2929            ]
2930        );
2931    }
2932
2933    #[test]
2934    fn redirect_operators() {
2935        assert_eq!(
2936            lex("cmd > file"),
2937            vec![Token::Ident("cmd".to_string()), Token::Gt, Token::Ident("file".to_string())]
2938        );
2939        assert_eq!(
2940            lex("cmd >> file"),
2941            vec![Token::Ident("cmd".to_string()), Token::GtGt, Token::Ident("file".to_string())]
2942        );
2943        assert_eq!(
2944            lex("cmd 2> err"),
2945            vec![Token::Ident("cmd".to_string()), Token::Stderr, Token::Ident("err".to_string())]
2946        );
2947        assert_eq!(
2948            lex("cmd &> all"),
2949            vec![Token::Ident("cmd".to_string()), Token::Both, Token::Ident("all".to_string())]
2950        );
2951    }
2952
2953    #[test]
2954    fn background_job() {
2955        assert_eq!(
2956            lex("cmd &"),
2957            vec![Token::Ident("cmd".to_string()), Token::Amp]
2958        );
2959    }
2960
2961    #[test]
2962    fn command_substitution() {
2963        assert_eq!(
2964            lex("$(cmd)"),
2965            vec![Token::CmdSubstStart, Token::Ident("cmd".to_string()), Token::RParen]
2966        );
2967        assert_eq!(
2968            lex("$(cmd arg)"),
2969            vec![
2970                Token::CmdSubstStart,
2971                Token::Ident("cmd".to_string()),
2972                Token::Ident("arg".to_string()),
2973                Token::RParen
2974            ]
2975        );
2976        assert_eq!(
2977            lex("$(a | b)"),
2978            vec![
2979                Token::CmdSubstStart,
2980                Token::Ident("a".to_string()),
2981                Token::Pipe,
2982                Token::Ident("b".to_string()),
2983                Token::RParen
2984            ]
2985        );
2986    }
2987
2988    #[test]
2989    fn complex_pipeline() {
2990        assert_eq!(
2991            lex(r#"cat file | grep pattern="foo" | head count=10"#),
2992            vec![
2993                Token::Ident("cat".to_string()),
2994                Token::Ident("file".to_string()),
2995                Token::Pipe,
2996                Token::Ident("grep".to_string()),
2997                Token::Ident("pattern".to_string()),
2998                Token::Eq,
2999                Token::String("foo".to_string()),
3000                Token::Pipe,
3001                Token::Ident("head".to_string()),
3002                Token::Ident("count".to_string()),
3003                Token::Eq,
3004                Token::Int(10),
3005            ]
3006        );
3007    }
3008
3009    // ═══════════════════════════════════════════════════════════════════
3010    // Flag tests
3011    // ═══════════════════════════════════════════════════════════════════
3012
3013    #[test]
3014    fn short_flag() {
3015        assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
3016        assert_eq!(lex("-a"), vec![Token::ShortFlag("a".to_string())]);
3017        assert_eq!(lex("-v"), vec![Token::ShortFlag("v".to_string())]);
3018    }
3019
3020    #[test]
3021    fn short_flag_combined() {
3022        // Combined short flags like -la
3023        assert_eq!(lex("-la"), vec![Token::ShortFlag("la".to_string())]);
3024        assert_eq!(lex("-vvv"), vec![Token::ShortFlag("vvv".to_string())]);
3025    }
3026
3027    #[test]
3028    fn job_spec_lexes_as_one_token() {
3029        // `%N` is the bash jobspec for wait/kill — used to be a lexer error.
3030        assert_eq!(lex("%1"), vec![Token::JobSpec("%1".to_string())]);
3031        assert_eq!(lex("%12"), vec![Token::JobSpec("%12".to_string())]);
3032        assert_eq!(
3033            lex("wait %1 %2"),
3034            vec![
3035                Token::Ident("wait".to_string()),
3036                Token::JobSpec("%1".to_string()),
3037                Token::JobSpec("%2".to_string()),
3038            ]
3039        );
3040    }
3041
3042    #[test]
3043    fn short_flag_with_internal_hyphens_is_one_token() {
3044        // A dash-word with internal hyphens is ONE shell word, not three
3045        // flags — `-not-a-flag` must not fragment into `-not` `-a` `-flag`.
3046        // (Whether it's a flag or a literal is the binding layer's call.)
3047        assert_eq!(
3048            lex("-not-a-flag"),
3049            vec![Token::ShortFlag("not-a-flag".to_string())]
3050        );
3051        // The two-char terminator `--` is still DoubleDash, and a lone `-`
3052        // is still MinusAlone — the second char must be a letter to start a
3053        // short flag.
3054        assert_eq!(lex("--"), vec![Token::DoubleDash]);
3055        assert_eq!(lex("-"), vec![Token::MinusAlone]);
3056    }
3057
3058    #[test]
3059    fn long_flag() {
3060        assert_eq!(lex("--force"), vec![Token::LongFlag("force".to_string())]);
3061        assert_eq!(lex("--verbose"), vec![Token::LongFlag("verbose".to_string())]);
3062        assert_eq!(lex("--foo-bar"), vec![Token::LongFlag("foo-bar".to_string())]);
3063    }
3064
3065    #[test]
3066    fn double_dash() {
3067        // -- alone marks end of flags
3068        assert_eq!(lex("--"), vec![Token::DoubleDash]);
3069    }
3070
3071    #[test]
3072    fn flags_vs_negative_numbers() {
3073        // -123 should be a negative integer, not a flag
3074        assert_eq!(lex("-123"), vec![Token::Int(-123)]);
3075        // -l should be a flag
3076        assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
3077        // -1a is ambiguous - should be Int(-1) then Ident(a)
3078        // Actually the regex -[a-zA-Z] won't match -1a since 1 isn't a letter
3079        assert_eq!(
3080            lex("-1 a"),
3081            vec![Token::Int(-1), Token::Ident("a".to_string())]
3082        );
3083    }
3084
3085    #[test]
3086    fn command_with_flags() {
3087        assert_eq!(
3088            lex("ls -l"),
3089            vec![
3090                Token::Ident("ls".to_string()),
3091                Token::ShortFlag("l".to_string()),
3092            ]
3093        );
3094        assert_eq!(
3095            lex("git commit -m"),
3096            vec![
3097                Token::Ident("git".to_string()),
3098                Token::Ident("commit".to_string()),
3099                Token::ShortFlag("m".to_string()),
3100            ]
3101        );
3102        assert_eq!(
3103            lex("git push --force"),
3104            vec![
3105                Token::Ident("git".to_string()),
3106                Token::Ident("push".to_string()),
3107                Token::LongFlag("force".to_string()),
3108            ]
3109        );
3110    }
3111
3112    #[test]
3113    fn flag_with_value() {
3114        assert_eq!(
3115            lex(r#"git commit -m "message""#),
3116            vec![
3117                Token::Ident("git".to_string()),
3118                Token::Ident("commit".to_string()),
3119                Token::ShortFlag("m".to_string()),
3120                Token::String("message".to_string()),
3121            ]
3122        );
3123        assert_eq!(
3124            lex(r#"--message="hello""#),
3125            vec![
3126                Token::LongFlag("message".to_string()),
3127                Token::Eq,
3128                Token::String("hello".to_string()),
3129            ]
3130        );
3131    }
3132
3133    #[test]
3134    fn end_of_flags_marker() {
3135        assert_eq!(
3136            lex("git checkout -- file"),
3137            vec![
3138                Token::Ident("git".to_string()),
3139                Token::Ident("checkout".to_string()),
3140                Token::DoubleDash,
3141                Token::Ident("file".to_string()),
3142            ]
3143        );
3144    }
3145
3146    // ═══════════════════════════════════════════════════════════════════
3147    // Bash compatibility tokens
3148    // ═══════════════════════════════════════════════════════════════════
3149
3150    #[test]
3151    fn local_keyword() {
3152        assert_eq!(lex("local"), vec![Token::Local]);
3153        assert_eq!(
3154            lex("local X = 5"),
3155            vec![Token::Local, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
3156        );
3157    }
3158
3159    #[test]
3160    fn simple_var_ref() {
3161        assert_eq!(lex("$X"), vec![Token::SimpleVarRef("X".to_string())]);
3162        assert_eq!(lex("$foo"), vec![Token::SimpleVarRef("foo".to_string())]);
3163        assert_eq!(lex("$foo_bar"), vec![Token::SimpleVarRef("foo_bar".to_string())]);
3164        assert_eq!(lex("$_private"), vec![Token::SimpleVarRef("_private".to_string())]);
3165    }
3166
3167    #[test]
3168    fn simple_var_ref_in_command() {
3169        assert_eq!(
3170            lex("echo $NAME"),
3171            vec![Token::Ident("echo".to_string()), Token::SimpleVarRef("NAME".to_string())]
3172        );
3173    }
3174
3175    #[test]
3176    fn single_quoted_strings() {
3177        assert_eq!(lex("'hello'"), vec![Token::SingleString("hello".to_string())]);
3178        assert_eq!(lex("'hello world'"), vec![Token::SingleString("hello world".to_string())]);
3179        assert_eq!(lex("''"), vec![Token::SingleString("".to_string())]);
3180        // Single quotes don't process escapes or variables
3181        assert_eq!(lex(r"'no $VAR here'"), vec![Token::SingleString("no $VAR here".to_string())]);
3182        assert_eq!(lex(r"'backslash \n stays'"), vec![Token::SingleString(r"backslash \n stays".to_string())]);
3183    }
3184
3185    #[test]
3186    fn test_brackets() {
3187        // [[ and ]] are now two separate bracket tokens to avoid conflicts with nested arrays
3188        assert_eq!(lex("[["), vec![Token::LBracket, Token::LBracket]);
3189        assert_eq!(lex("]]"), vec![Token::RBracket, Token::RBracket]);
3190        assert_eq!(
3191            lex("[[ -f file ]]"),
3192            vec![
3193                Token::LBracket,
3194                Token::LBracket,
3195                Token::ShortFlag("f".to_string()),
3196                Token::Ident("file".to_string()),
3197                Token::RBracket,
3198                Token::RBracket
3199            ]
3200        );
3201    }
3202
3203    #[test]
3204    fn test_expression_syntax() {
3205        assert_eq!(
3206            lex(r#"[[ $X == "value" ]]"#),
3207            vec![
3208                Token::LBracket,
3209                Token::LBracket,
3210                Token::SimpleVarRef("X".to_string()),
3211                Token::EqEq,
3212                Token::String("value".to_string()),
3213                Token::RBracket,
3214                Token::RBracket
3215            ]
3216        );
3217    }
3218
3219    #[test]
3220    fn bash_style_assignment() {
3221        // NAME="value" (no spaces) - lexer sees IDENT EQ STRING
3222        assert_eq!(
3223            lex(r#"NAME="value""#),
3224            vec![
3225                Token::Ident("NAME".to_string()),
3226                Token::Eq,
3227                Token::String("value".to_string())
3228            ]
3229        );
3230    }
3231
3232    #[test]
3233    fn positional_params() {
3234        assert_eq!(lex("$0"), vec![Token::Positional(0)]);
3235        assert_eq!(lex("$1"), vec![Token::Positional(1)]);
3236        assert_eq!(lex("$9"), vec![Token::Positional(9)]);
3237        assert_eq!(lex("$@"), vec![Token::AllArgs]);
3238        assert_eq!(lex("$#"), vec![Token::ArgCount]);
3239    }
3240
3241    #[test]
3242    fn positional_in_context() {
3243        assert_eq!(
3244            lex("echo $1 $2"),
3245            vec![
3246                Token::Ident("echo".to_string()),
3247                Token::Positional(1),
3248                Token::Positional(2),
3249            ]
3250        );
3251    }
3252
3253    #[test]
3254    fn var_length() {
3255        assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
3256        assert_eq!(lex("${#NAME}"), vec![Token::VarLength("NAME".to_string())]);
3257        assert_eq!(lex("${#foo_bar}"), vec![Token::VarLength("foo_bar".to_string())]);
3258    }
3259
3260    #[test]
3261    fn var_length_with_subscript() {
3262        // The widened regex admits `[...]` subscripts so a length-of-path lexes
3263        // in expression position; the parser turns the inner into a VarPath.
3264        assert_eq!(lex("${#u[tags]}"), vec![Token::VarLength("u[tags]".to_string())]);
3265        assert_eq!(lex("${#a[0]}"), vec![Token::VarLength("a[0]".to_string())]);
3266        assert_eq!(lex("${#a[b][c]}"), vec![Token::VarLength("a[b][c]".to_string())]);
3267        assert_eq!(lex("${#r[$k]}"), vec![Token::VarLength("r[$k]".to_string())]);
3268    }
3269
3270    #[test]
3271    fn var_length_in_context() {
3272        assert_eq!(
3273            lex("echo ${#NAME}"),
3274            vec![
3275                Token::Ident("echo".to_string()),
3276                Token::VarLength("NAME".to_string()),
3277            ]
3278        );
3279    }
3280
3281    // ═══════════════════════════════════════════════════════════════════
3282    // Edge case tests: Flag ambiguities
3283    // ═══════════════════════════════════════════════════════════════════
3284
3285    #[test]
3286    fn plus_flag() {
3287        // Plus flags for set +e
3288        assert_eq!(lex("+e"), vec![Token::PlusFlag("e".to_string())]);
3289        assert_eq!(lex("+x"), vec![Token::PlusFlag("x".to_string())]);
3290        assert_eq!(lex("+ex"), vec![Token::PlusFlag("ex".to_string())]);
3291    }
3292
3293    #[test]
3294    fn set_with_plus_flag() {
3295        assert_eq!(
3296            lex("set +e"),
3297            vec![
3298                Token::Set,
3299                Token::PlusFlag("e".to_string()),
3300            ]
3301        );
3302    }
3303
3304    #[test]
3305    fn set_with_multiple_flags() {
3306        assert_eq!(
3307            lex("set -e -u"),
3308            vec![
3309                Token::Set,
3310                Token::ShortFlag("e".to_string()),
3311                Token::ShortFlag("u".to_string()),
3312            ]
3313        );
3314    }
3315
3316    #[test]
3317    fn flags_vs_negative_numbers_edge_cases() {
3318        // -1a should be negative int followed by ident
3319        assert_eq!(
3320            lex("-1 a"),
3321            vec![Token::Int(-1), Token::Ident("a".to_string())]
3322        );
3323        // -l is a flag
3324        assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
3325        // -123 is negative number
3326        assert_eq!(lex("-123"), vec![Token::Int(-123)]);
3327    }
3328
3329    #[test]
3330    fn single_dash_is_minus_alone() {
3331        // Single dash alone - now handled as MinusAlone for `cat -` stdin indicator
3332        let result = tokenize("-").expect("should lex");
3333        assert_eq!(result.len(), 1);
3334        assert!(matches!(result[0].token, Token::MinusAlone));
3335    }
3336
3337    #[test]
3338    fn plus_bare_for_date_format() {
3339        // `date +%s` - the +%s should be PlusBare
3340        let result = tokenize("+%s").expect("should lex");
3341        assert_eq!(result.len(), 1);
3342        assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%s"));
3343
3344        // `date +%Y-%m-%d` - format string with dashes
3345        let result = tokenize("+%Y-%m-%d").expect("should lex");
3346        assert_eq!(result.len(), 1);
3347        assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%Y-%m-%d"));
3348    }
3349
3350    #[test]
3351    fn plus_flag_still_works() {
3352        // `set +e` - should still be PlusFlag
3353        let result = tokenize("+e").expect("should lex");
3354        assert_eq!(result.len(), 1);
3355        assert!(matches!(result[0].token, Token::PlusFlag(ref s) if s == "e"));
3356    }
3357
3358    #[test]
3359    fn while_keyword_vs_while_loop() {
3360        // 'while' as keyword in loop context
3361        assert_eq!(lex("while"), vec![Token::While]);
3362        // 'while' at start followed by condition
3363        assert_eq!(
3364            lex("while true"),
3365            vec![Token::While, Token::True]
3366        );
3367    }
3368
3369    #[test]
3370    fn control_flow_keywords() {
3371        assert_eq!(lex("break"), vec![Token::Break]);
3372        assert_eq!(lex("continue"), vec![Token::Continue]);
3373        assert_eq!(lex("return"), vec![Token::Return]);
3374        assert_eq!(lex("exit"), vec![Token::Exit]);
3375    }
3376
3377    #[test]
3378    fn control_flow_with_numbers() {
3379        assert_eq!(
3380            lex("break 2"),
3381            vec![Token::Break, Token::Int(2)]
3382        );
3383        assert_eq!(
3384            lex("continue 3"),
3385            vec![Token::Continue, Token::Int(3)]
3386        );
3387        assert_eq!(
3388            lex("exit 1"),
3389            vec![Token::Exit, Token::Int(1)]
3390        );
3391    }
3392
3393    // ═══════════════════════════════════════════════════════════════════
3394    // Here-doc tests
3395    // ═══════════════════════════════════════════════════════════════════
3396
3397    #[test]
3398    fn heredoc_simple() {
3399        let source = "cat <<EOF\nhello\nworld\nEOF";
3400        let tokens = lex(source);
3401        // body_start_offset = byte offset of 'h' in "hello", i.e. just after "cat <<EOF\n"
3402        assert_eq!(tokens, vec![
3403            Token::Ident("cat".to_string()),
3404            Token::HereDocStart,
3405            Token::HereDoc(HereDocData {
3406                content: "hello\nworld\n".to_string(),
3407                literal: false,
3408                strip_tabs: false,
3409                body_start_offset: 10,
3410            }),
3411            Token::Newline,
3412        ]);
3413    }
3414
3415    #[test]
3416    fn heredoc_empty() {
3417        let source = "cat <<EOF\nEOF";
3418        let tokens = lex(source);
3419        assert_eq!(tokens, vec![
3420            Token::Ident("cat".to_string()),
3421            Token::HereDocStart,
3422            Token::HereDoc(HereDocData {
3423                content: "".to_string(),
3424                literal: false,
3425                strip_tabs: false,
3426                body_start_offset: 10,
3427            }),
3428            Token::Newline,
3429        ]);
3430    }
3431
3432    #[test]
3433    fn heredoc_with_special_chars() {
3434        let source = "cat <<EOF\n$VAR and \"quoted\" 'single'\nEOF";
3435        let tokens = lex(source);
3436        assert_eq!(tokens, vec![
3437            Token::Ident("cat".to_string()),
3438            Token::HereDocStart,
3439            Token::HereDoc(HereDocData {
3440                content: "$VAR and \"quoted\" 'single'\n".to_string(),
3441                literal: false,
3442                strip_tabs: false,
3443                body_start_offset: 10,
3444            }),
3445            Token::Newline,
3446        ]);
3447    }
3448
3449    #[test]
3450    fn heredoc_multiline() {
3451        let source = "cat <<END\nline1\nline2\nline3\nEND";
3452        let tokens = lex(source);
3453        assert_eq!(tokens, vec![
3454            Token::Ident("cat".to_string()),
3455            Token::HereDocStart,
3456            Token::HereDoc(HereDocData {
3457                content: "line1\nline2\nline3\n".to_string(),
3458                literal: false,
3459                strip_tabs: false,
3460                body_start_offset: 10,
3461            }),
3462            Token::Newline,
3463        ]);
3464    }
3465
3466    #[test]
3467    fn heredoc_in_command() {
3468        let source = "cat <<EOF\nhello\nEOF\necho goodbye";
3469        let tokens = lex(source);
3470        assert_eq!(tokens, vec![
3471            Token::Ident("cat".to_string()),
3472            Token::HereDocStart,
3473            Token::HereDoc(HereDocData {
3474                content: "hello\n".to_string(),
3475                literal: false,
3476                strip_tabs: false,
3477                body_start_offset: 10,
3478            }),
3479            Token::Newline,
3480            Token::Ident("echo".to_string()),
3481            Token::Ident("goodbye".to_string()),
3482        ]);
3483    }
3484
3485    #[test]
3486    fn heredoc_strip_tabs() {
3487        let source = "cat <<-EOF\n\thello\n\tworld\n\tEOF";
3488        let tokens = lex(source);
3489        // Content keeps tabs verbatim — strip_tabs is recorded on the token so
3490        // the interpreter can apply POSIX leading-tab stripping at materialization
3491        // without disturbing source byte offsets used for span tracking.
3492        assert_eq!(tokens, vec![
3493            Token::Ident("cat".to_string()),
3494            Token::HereDocStart,
3495            Token::HereDoc(HereDocData {
3496                content: "\thello\n\tworld\n".to_string(),
3497                literal: false,
3498                strip_tabs: true,
3499                body_start_offset: 11,
3500            }),
3501            Token::Newline,
3502        ]);
3503    }
3504
3505    // ═══════════════════════════════════════════════════════════════════
3506    // Arithmetic expression tests
3507    // ═══════════════════════════════════════════════════════════════════
3508
3509    #[test]
3510    fn arithmetic_simple() {
3511        let source = "$((1 + 2))";
3512        let tokens = lex(source);
3513        assert_eq!(tokens, vec![Token::Arithmetic("1 + 2".to_string())]);
3514    }
3515
3516    #[test]
3517    fn arithmetic_in_assignment() {
3518        let source = "X=$((5 * 3))";
3519        let tokens = lex(source);
3520        assert_eq!(tokens, vec![
3521            Token::Ident("X".to_string()),
3522            Token::Eq,
3523            Token::Arithmetic("5 * 3".to_string()),
3524        ]);
3525    }
3526
3527    #[test]
3528    fn arithmetic_with_nested_parens() {
3529        let source = "$((2 * (3 + 4)))";
3530        let tokens = lex(source);
3531        assert_eq!(tokens, vec![Token::Arithmetic("2 * (3 + 4)".to_string())]);
3532    }
3533
3534    #[test]
3535    fn arithmetic_with_variable() {
3536        let source = "$((X + 1))";
3537        let tokens = lex(source);
3538        assert_eq!(tokens, vec![Token::Arithmetic("X + 1".to_string())]);
3539    }
3540
3541    #[test]
3542    fn arithmetic_command_subst_not_confused() {
3543        // $( should not be treated as arithmetic
3544        let source = "$(echo hello)";
3545        let tokens = lex(source);
3546        assert_eq!(tokens, vec![
3547            Token::CmdSubstStart,
3548            Token::Ident("echo".to_string()),
3549            Token::Ident("hello".to_string()),
3550            Token::RParen,
3551        ]);
3552    }
3553
3554    #[test]
3555    fn arithmetic_nesting_limit() {
3556        // Create deeply nested parens that exceed MAX_PAREN_DEPTH (256)
3557        let open_parens = "(".repeat(300);
3558        let close_parens = ")".repeat(300);
3559        let source = format!("$(({}1{}))", open_parens, close_parens);
3560        let result = tokenize(&source);
3561        assert!(result.is_err());
3562        let errors = result.unwrap_err();
3563        assert_eq!(errors.len(), 1);
3564        assert_eq!(errors[0].token, LexerError::NestingTooDeep);
3565    }
3566
3567    #[test]
3568    fn arithmetic_nesting_within_limit() {
3569        // Nesting within limit should work
3570        let source = "$((((1 + 2) * 3)))";
3571        let tokens = lex(source);
3572        assert_eq!(tokens, vec![Token::Arithmetic("((1 + 2) * 3)".to_string())]);
3573    }
3574
3575    // ═══════════════════════════════════════════════════════════════════
3576    // Arithmetic preprocessor + comment interaction
3577    //
3578    // The preprocessor used to walk raw characters tracking only quote
3579    // state. An apostrophe inside a `#` comment would open single-quote
3580    // mode and swallow real `$((..))` later in the file; `$((..))` *inside*
3581    // a comment would itself be preprocessed into a marker, misplacing
3582    // tokens. Surfaced from kaijutsu's seed scripts (see gotcha memory
3583    // `gotcha-kaish-comment-arithmetic`).
3584    // ═══════════════════════════════════════════════════════════════════
3585
3586    #[test]
3587    fn arithmetic_after_apostrophe_in_comment() {
3588        // The bare apostrophe in "doesn't" used to open single-quote mode
3589        // in the preprocessor and swallow the $((..)) below.
3590        let source = "# this doesn't work\necho $((1+2))";
3591        let tokens = lex(source);
3592        assert_eq!(tokens, vec![
3593            Token::Newline,
3594            Token::Ident("echo".to_string()),
3595            Token::Arithmetic("1+2".to_string()),
3596        ]);
3597    }
3598
3599    #[test]
3600    fn arithmetic_inside_comment_is_not_expanded() {
3601        // `$((y))` inside a `#` comment must stay comment text.
3602        let source = "# the $((y)) syntax explained\necho hello";
3603        let tokens = lex(source);
3604        assert_eq!(tokens, vec![
3605            Token::Newline,
3606            Token::Ident("echo".to_string()),
3607            Token::Ident("hello".to_string()),
3608        ]);
3609    }
3610
3611    #[test]
3612    fn backticked_arithmetic_in_comment_is_not_expanded() {
3613        // The original kaijutsu repro: `$((x))` inside a comment.
3614        // Backticks-in-comments used to leak the inner $((..)) to the
3615        // preprocessor; with comment-skip they stay inert.
3616        let source = "# the `$((x))` syntax explained\necho $((3+4))";
3617        let tokens = lex(source);
3618        assert_eq!(tokens, vec![
3619            Token::Newline,
3620            Token::Ident("echo".to_string()),
3621            Token::Arithmetic("3+4".to_string()),
3622        ]);
3623    }
3624
3625    #[test]
3626    fn arithmetic_still_works_outside_comments() {
3627        // Regression guard: comment-skip must not shrink the arithmetic
3628        // preprocessor's scope on normal `$((..))` usages.
3629        let source = "X=$((1+2)); Y=$((3*4))";
3630        let tokens = lex(source);
3631        assert_eq!(tokens, vec![
3632            Token::Ident("X".to_string()),
3633            Token::Eq,
3634            Token::Arithmetic("1+2".to_string()),
3635            Token::Semi,
3636            Token::Ident("Y".to_string()),
3637            Token::Eq,
3638            Token::Arithmetic("3*4".to_string()),
3639        ]);
3640    }
3641
3642    #[test]
3643    fn arithmetic_inside_double_quotes_still_expands() {
3644        // `#` inside a double-quoted string is a literal character, not a
3645        // comment introducer — arithmetic must still expand around it.
3646        let source = "echo \"# $((1+2))\"";
3647        let tokens = lex(source);
3648        // The string token contains the `#` and the arithmetic marker;
3649        // the exact post-processing happens at interpret time. What we
3650        // assert here is that lexing succeeds and produces a String token
3651        // (i.e. the comment skip didn't trigger inside the string).
3652        assert_eq!(tokens.len(), 2);
3653        assert!(matches!(tokens[0], Token::Ident(_)));
3654        assert!(matches!(tokens[1], Token::String(_)));
3655    }
3656
3657    // ═══════════════════════════════════════════════════════════════════
3658    // Backtick rejection
3659    //
3660    // Backticks are an explicitly dropped feature (see CLAUDE.md,
3661    // docs/LANGUAGE.md, help/limits.md, help/overview.md). We surface a
3662    // dedicated error rather than the generic `UnexpectedCharacter` so
3663    // users get a hint to use `$(cmd)`. Comments, single-quoted strings,
3664    // double-quoted strings, and heredoc bodies are all matched as single
3665    // tokens (or extracted before logos runs), so the rejection only
3666    // fires on bare backticks in source code.
3667    // ═══════════════════════════════════════════════════════════════════
3668
3669    #[test]
3670    fn backtick_in_source_is_rejected() {
3671        let result = tokenize("echo `date`");
3672        assert!(result.is_err());
3673        let errors = result.unwrap_err();
3674        assert!(errors.iter().any(|e| e.token == LexerError::BackticksNotSupported));
3675    }
3676
3677    #[test]
3678    fn backtick_in_comment_is_just_comment_text() {
3679        // Backticks are only rejected when they reach the top-level
3680        // lexer. Inside a comment they're part of the comment body.
3681        let source = "# use `date` here\necho hi";
3682        let tokens = lex(source);
3683        assert_eq!(tokens, vec![
3684            Token::Newline,
3685            Token::Ident("echo".to_string()),
3686            Token::Ident("hi".to_string()),
3687        ]);
3688    }
3689
3690    #[test]
3691    fn backtick_in_single_quoted_string_is_literal() {
3692        // Single-quoted strings are matched as one token by logos; the
3693        // backticks inside never reach the rejecting matcher.
3694        let source = "echo '`date`'";
3695        let tokens = lex(source);
3696        assert_eq!(tokens, vec![
3697            Token::Ident("echo".to_string()),
3698            Token::SingleString("`date`".to_string()),
3699        ]);
3700    }
3701
3702    #[test]
3703    fn backtick_in_double_quoted_string_is_literal() {
3704        // Kaish does not activate command substitution from backticks
3705        // inside double-quoted strings either — clear divergence from
3706        // POSIX but matches the "backticks don't exist" stance. The
3707        // double-quoted string token absorbs them as literal characters.
3708        let source = "echo \"`date`\"";
3709        let tokens = lex(source);
3710        assert_eq!(tokens.len(), 2);
3711        assert!(matches!(tokens[0], Token::Ident(_)));
3712        match &tokens[1] {
3713            Token::String(s) => assert!(s.contains('`')),
3714            other => panic!("expected Token::String, got {:?}", other),
3715        }
3716    }
3717
3718    #[test]
3719    fn backtick_in_heredoc_body_is_preserved() {
3720        // Heredoc bodies are extracted by preprocess_heredocs before
3721        // logos runs, so backticks inside them survive as content.
3722        let source = "cat <<EOF\n`date`\nEOF\n";
3723        let tokens = lex(source);
3724        let heredoc = tokens.iter().find(|t| matches!(t, Token::HereDoc(_)));
3725        assert!(heredoc.is_some(), "expected a HereDoc token");
3726        if let Some(Token::HereDoc(d)) = heredoc {
3727            assert!(d.content.contains('`'));
3728        }
3729    }
3730
3731    // ═══════════════════════════════════════════════════════════════════
3732    // Token category tests
3733    // ═══════════════════════════════════════════════════════════════════
3734
3735    #[test]
3736    fn token_categories() {
3737        // Keywords
3738        assert_eq!(Token::If.category(), TokenCategory::Keyword);
3739        assert_eq!(Token::Then.category(), TokenCategory::Keyword);
3740        assert_eq!(Token::For.category(), TokenCategory::Keyword);
3741        assert_eq!(Token::Function.category(), TokenCategory::Keyword);
3742        assert_eq!(Token::True.category(), TokenCategory::Keyword);
3743        assert_eq!(Token::TypeString.category(), TokenCategory::Keyword);
3744
3745        // Operators
3746        assert_eq!(Token::Pipe.category(), TokenCategory::Operator);
3747        assert_eq!(Token::And.category(), TokenCategory::Operator);
3748        assert_eq!(Token::Or.category(), TokenCategory::Operator);
3749        assert_eq!(Token::StderrToStdout.category(), TokenCategory::Operator);
3750        assert_eq!(Token::GtGt.category(), TokenCategory::Operator);
3751
3752        // Strings
3753        assert_eq!(Token::String("test".to_string()).category(), TokenCategory::String);
3754        assert_eq!(Token::SingleString("test".to_string()).category(), TokenCategory::String);
3755        assert_eq!(
3756            Token::HereDoc(HereDocData {
3757                content: "test".to_string(),
3758                literal: false,
3759                strip_tabs: false,
3760                body_start_offset: 0,
3761            }).category(),
3762            TokenCategory::String,
3763        );
3764
3765        // Numbers
3766        assert_eq!(Token::Int(42).category(), TokenCategory::Number);
3767        assert_eq!(Token::Float(3.14).category(), TokenCategory::Number);
3768        assert_eq!(Token::Arithmetic("1+2".to_string()).category(), TokenCategory::Number);
3769
3770        // Variables
3771        assert_eq!(Token::SimpleVarRef("X".to_string()).category(), TokenCategory::Variable);
3772        assert_eq!(Token::VarRef("${X}".to_string()).category(), TokenCategory::Variable);
3773        assert_eq!(Token::Positional(1).category(), TokenCategory::Variable);
3774        assert_eq!(Token::AllArgs.category(), TokenCategory::Variable);
3775        assert_eq!(Token::ArgCount.category(), TokenCategory::Variable);
3776        assert_eq!(Token::LastExitCode.category(), TokenCategory::Variable);
3777        assert_eq!(Token::CurrentPid.category(), TokenCategory::Variable);
3778
3779        // Flags
3780        assert_eq!(Token::ShortFlag("l".to_string()).category(), TokenCategory::Flag);
3781        assert_eq!(Token::LongFlag("verbose".to_string()).category(), TokenCategory::Flag);
3782        assert_eq!(Token::PlusFlag("e".to_string()).category(), TokenCategory::Flag);
3783        assert_eq!(Token::DoubleDash.category(), TokenCategory::Flag);
3784
3785        // Punctuation
3786        assert_eq!(Token::Semi.category(), TokenCategory::Punctuation);
3787        assert_eq!(Token::LParen.category(), TokenCategory::Punctuation);
3788        assert_eq!(Token::LBracket.category(), TokenCategory::Punctuation);
3789        assert_eq!(Token::Newline.category(), TokenCategory::Punctuation);
3790
3791        // Comments
3792        assert_eq!(Token::Comment.category(), TokenCategory::Comment);
3793
3794        // Paths
3795        assert_eq!(Token::Path("/tmp/file".to_string()).category(), TokenCategory::Path);
3796
3797        // Commands
3798        assert_eq!(Token::Ident("echo".to_string()).category(), TokenCategory::Command);
3799        assert_eq!(Token::NumberIdent("019dda1c".to_string()).category(), TokenCategory::Command);
3800        assert_eq!(Token::DottedIdent(".gitignore".to_string()).category(), TokenCategory::Command);
3801
3802        // Errors
3803        assert_eq!(Token::InvalidFloatNoLeading.category(), TokenCategory::Error);
3804        assert_eq!(Token::InvalidFloatNoTrailing.category(), TokenCategory::Error);
3805    }
3806
3807    #[test]
3808    fn test_heredoc_piped_to_command() {
3809        // Bug 4: "cat <<EOF | jq" should produce: cat <<heredoc | jq
3810        // Not: cat | jq <<heredoc
3811        let tokens = tokenize("cat <<EOF | jq\n{\"key\": \"val\"}\nEOF").unwrap();
3812        let heredoc_pos = tokens.iter().position(|t| matches!(t.token, Token::HereDoc(_)));
3813        let pipe_pos = tokens.iter().position(|t| matches!(t.token, Token::Pipe));
3814        assert!(heredoc_pos.is_some(), "should have a heredoc token");
3815        assert!(pipe_pos.is_some(), "should have a pipe token");
3816        assert!(
3817            pipe_pos.unwrap() > heredoc_pos.unwrap(),
3818            "Pipe must come after heredoc, got heredoc at {}, pipe at {}. Tokens: {:?}",
3819            heredoc_pos.unwrap(), pipe_pos.unwrap(), tokens,
3820        );
3821    }
3822
3823    #[test]
3824    fn test_heredoc_standalone_still_works() {
3825        // Regression: standalone heredoc (no pipe) must still work
3826        let tokens = tokenize("cat <<EOF\nhello\nEOF").unwrap();
3827        assert!(tokens.iter().any(|t| matches!(t.token, Token::HereDoc(_))));
3828        assert!(!tokens.iter().any(|t| matches!(t.token, Token::Pipe)));
3829    }
3830
3831    #[test]
3832    fn test_heredoc_preserves_leading_empty_lines() {
3833        // Bug B: heredoc starting with a blank line must preserve it
3834        let tokens = tokenize("cat <<EOF\n\nhello\nEOF").unwrap();
3835        let heredoc = tokens.iter().find_map(|t| {
3836            if let Token::HereDoc(data) = &t.token {
3837                Some(data.clone())
3838            } else {
3839                None
3840            }
3841        });
3842        assert!(heredoc.is_some(), "should have a heredoc token");
3843        let data = heredoc.unwrap();
3844        assert!(data.content.starts_with('\n'), "leading empty line must be preserved, got: {:?}", data.content);
3845        assert_eq!(data.content, "\nhello\n");
3846    }
3847
3848    #[test]
3849    fn test_heredoc_quoted_delimiter_sets_literal() {
3850        // Bug N: quoted delimiter (<<'EOF') should set literal=true
3851        let tokens = tokenize("cat <<'EOF'\nhello $HOME\nEOF").unwrap();
3852        let heredoc = tokens.iter().find_map(|t| {
3853            if let Token::HereDoc(data) = &t.token {
3854                Some(data.clone())
3855            } else {
3856                None
3857            }
3858        });
3859        assert!(heredoc.is_some(), "should have a heredoc token");
3860        let data = heredoc.unwrap();
3861        assert!(data.literal, "quoted delimiter should set literal=true");
3862        assert_eq!(data.content, "hello $HOME\n");
3863    }
3864
3865    #[test]
3866    fn test_heredoc_unquoted_delimiter_not_literal() {
3867        // Bug N: unquoted delimiter (<<EOF) should have literal=false
3868        let tokens = tokenize("cat <<EOF\nhello $HOME\nEOF").unwrap();
3869        let heredoc = tokens.iter().find_map(|t| {
3870            if let Token::HereDoc(data) = &t.token {
3871                Some(data.clone())
3872            } else {
3873                None
3874            }
3875        });
3876        assert!(heredoc.is_some(), "should have a heredoc token");
3877        let data = heredoc.unwrap();
3878        assert!(!data.literal, "unquoted delimiter should have literal=false");
3879    }
3880
3881    // ═══════════════════════════════════════════════════════════════════
3882    // Colon merge tests
3883    // ═══════════════════════════════════════════════════════════════════
3884
3885    #[test]
3886    fn colon_double_in_word() {
3887        assert_eq!(lex("foo::bar"), vec![Token::Ident("foo::bar".into())]);
3888    }
3889
3890    #[test]
3891    fn colon_single_in_word() {
3892        assert_eq!(lex("a:b:c"), vec![Token::Ident("a:b:c".into())]);
3893    }
3894
3895    #[test]
3896    fn colon_with_port() {
3897        assert_eq!(lex("host:8080"), vec![Token::Ident("host:8080".into())]);
3898    }
3899
3900    #[test]
3901    fn colon_standalone() {
3902        assert_eq!(lex(":"), vec![Token::Colon]);
3903    }
3904
3905    #[test]
3906    fn colon_spaced_no_merge() {
3907        assert_eq!(
3908            lex("foo : bar"),
3909            vec![
3910                Token::Ident("foo".into()),
3911                Token::Colon,
3912                Token::Ident("bar".into()),
3913            ]
3914        );
3915    }
3916
3917    #[test]
3918    fn colon_in_command_arg() {
3919        assert_eq!(
3920            lex("echo foo::bar"),
3921            vec![
3922                Token::Ident("echo".into()),
3923                Token::Ident("foo::bar".into()),
3924            ]
3925        );
3926    }
3927
3928    #[test]
3929    fn colon_trailing() {
3930        // Trailing colon merges with preceding ident
3931        assert_eq!(lex("foo:"), vec![Token::Ident("foo:".into())]);
3932    }
3933
3934    #[test]
3935    fn colon_leading() {
3936        // Leading colon merges with following ident
3937        assert_eq!(lex(":foo"), vec![Token::Ident(":foo".into())]);
3938    }
3939
3940    #[test]
3941    fn colon_with_path() {
3942        // Path token + colon + int
3943        assert_eq!(
3944            lex("/usr/bin:8080"),
3945            vec![Token::Ident("/usr/bin:8080".into())]
3946        );
3947    }
3948
3949    // ═══════════════════════════════════════════════════════════════════
3950    // Token predicate coverage (is_keyword / starts_statement)
3951    // ═══════════════════════════════════════════════════════════════════
3952
3953    #[test]
3954    fn is_keyword_covers_control_flow() {
3955        for t in [
3956            Token::While,
3957            Token::Return,
3958            Token::Break,
3959            Token::Continue,
3960            Token::Exit,
3961        ] {
3962            assert!(t.is_keyword(), "{t:?} should be a keyword");
3963        }
3964    }
3965
3966    #[test]
3967    fn starts_statement_covers_while() {
3968        assert!(Token::While.starts_statement());
3969    }
3970
3971    #[test]
3972    fn is_keyword_rejects_operators() {
3973        for t in [Token::Pipe, Token::Amp, Token::Eq, Token::LBrace] {
3974            assert!(!t.is_keyword(), "{t:?} should not be a keyword");
3975        }
3976    }
3977}