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