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