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