Skip to main content

kaish_kernel/
lexer.rs

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