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