Skip to main content

bashkit/parser/
mod.rs

1//! Parser module for Bashkit
2//!
3//! Implements a recursive descent parser for bash scripts.
4//!
5//! # Design Notes
6//!
7//! Reserved words (like `done`, `fi`, `then`) are only treated as special in command
8//! position - when they would start a command. In argument position, they are regular
9//! words. The termination of compound commands is handled by `parse_compound_list_until`
10//! which checks for terminators BEFORE parsing each command.
11
12// Parser uses chars().next().unwrap() after validating character presence.
13// This is safe because we check bounds before accessing.
14#![allow(clippy::unwrap_used)]
15
16mod ast;
17pub mod budget;
18mod lexer;
19mod span;
20mod tokens;
21
22pub use ast::*;
23pub use budget::{BudgetError, validate as validate_budget};
24pub use lexer::{Lexer, SpannedToken};
25pub use span::{Position, Span};
26
27use crate::error::{Error, Result};
28use crate::limits::LimitExceeded;
29use crate::time_compat::Instant;
30use std::time::Duration;
31
32/// Default maximum AST depth (matches ExecutionLimits default)
33const DEFAULT_MAX_AST_DEPTH: usize = 100;
34
35/// Hard cap on AST depth to prevent stack overflow even if caller misconfigures limits.
36/// THREAT[TM-DOS-022]: Protects against deeply nested input attacks where
37/// a large max_depth setting allows recursion deep enough to overflow the native stack.
38/// This cap cannot be overridden by the caller.
39///
40/// Set conservatively to avoid stack overflow on tokio's blocking threads (default 2MB
41/// stack in debug builds). Each parser recursion level uses ~4-8KB of stack in debug
42/// mode. 100 levels × ~8KB = ~800KB, well within 2MB.
43/// In release builds this could safely be higher, but we use one value for consistency.
44const HARD_MAX_AST_DEPTH: usize = 100;
45
46/// Default maximum parser operations (matches ExecutionLimits default)
47const DEFAULT_MAX_PARSER_OPERATIONS: usize = 100_000;
48
49/// Parser for bash scripts.
50pub struct Parser<'a> {
51    input: &'a str,
52    lexer: Lexer<'a>,
53    current_token: Option<tokens::Token>,
54    /// Span of the current token
55    current_span: Span,
56    /// Lookahead token for function parsing
57    peeked_token: Option<SpannedToken>,
58    /// Maximum allowed AST nesting depth
59    max_depth: usize,
60    /// Current nesting depth
61    current_depth: usize,
62    /// Remaining fuel for parsing operations
63    fuel: usize,
64    /// Maximum fuel (for error reporting)
65    max_fuel: usize,
66    /// Optional parser timeout enforced via cooperative checks in `tick`.
67    timeout: Option<Duration>,
68    /// Parse start time used with `timeout`.
69    started_at: Instant,
70}
71
72impl<'a> Parser<'a> {
73    /// Create a new parser for the given input.
74    pub fn new(input: &'a str) -> Self {
75        Self::with_limits(input, DEFAULT_MAX_AST_DEPTH, DEFAULT_MAX_PARSER_OPERATIONS)
76    }
77
78    /// Create a new parser with a custom maximum AST depth.
79    pub fn with_max_depth(input: &'a str, max_depth: usize) -> Self {
80        Self::with_limits(input, max_depth, DEFAULT_MAX_PARSER_OPERATIONS)
81    }
82
83    /// Create a new parser with a custom fuel limit.
84    pub fn with_fuel(input: &'a str, max_fuel: usize) -> Self {
85        Self::with_limits(input, DEFAULT_MAX_AST_DEPTH, max_fuel)
86    }
87
88    /// Create a new parser with custom depth and fuel limits.
89    ///
90    /// THREAT[TM-DOS-022]: `max_depth` is clamped to `HARD_MAX_AST_DEPTH` (500)
91    /// to prevent stack overflow from misconfiguration. Even if the caller passes
92    /// `max_depth = 1_000_000`, the parser will cap it at 500.
93    pub fn with_limits(input: &'a str, max_depth: usize, max_fuel: usize) -> Self {
94        Self::with_limits_and_timeout(input, max_depth, max_fuel, None)
95    }
96
97    /// Create a new parser with custom limits and optional timeout.
98    pub fn with_limits_and_timeout(
99        input: &'a str,
100        max_depth: usize,
101        max_fuel: usize,
102        timeout: Option<Duration>,
103    ) -> Self {
104        let mut lexer = Lexer::with_max_subst_depth(input, max_depth.min(HARD_MAX_AST_DEPTH));
105        let spanned = lexer.next_spanned_token();
106        let (current_token, current_span) = match spanned {
107            Some(st) => (Some(st.token), st.span),
108            None => (None, Span::new()),
109        };
110        Self {
111            input,
112            lexer,
113            current_token,
114            current_span,
115            peeked_token: None,
116            max_depth: max_depth.min(HARD_MAX_AST_DEPTH),
117            current_depth: 0,
118            fuel: max_fuel,
119            max_fuel,
120            timeout,
121            started_at: Instant::now(),
122        }
123    }
124
125    /// Get the current token's span.
126    pub fn current_span(&self) -> Span {
127        self.current_span
128    }
129
130    /// Parse a string as a word (handling $var, $((expr)), ${...}, etc.).
131    /// Used by the interpreter to expand operands in parameter expansions lazily.
132    pub fn parse_word_string(input: &str) -> Word {
133        let parser = Parser::new(input);
134        parser.parse_word(input.to_string())
135    }
136
137    /// THREAT[TM-DOS-050]: Parse a word string with caller-configured limits.
138    /// Prevents bypass of parser limits in parameter expansion contexts.
139    pub fn parse_word_string_with_limits(input: &str, max_depth: usize, max_fuel: usize) -> Word {
140        let parser = Parser::with_limits(input, max_depth, max_fuel);
141        parser.parse_word(input.to_string())
142    }
143
144    /// Create a parse error with the current position.
145    fn error(&self, message: impl Into<String>) -> Error {
146        Error::parse_at(
147            message,
148            self.current_span.start.line,
149            self.current_span.start.column,
150        )
151    }
152
153    fn current_command_end_offset(&self) -> usize {
154        if self.current_token.is_some() {
155            self.current_span.start.offset
156        } else {
157            // Important decision: EOF keeps `current_span` on the last real token;
158            // use that token end so skipped trailing comments are not retained in
159            // persistent function source snapshots.
160            self.current_span.end.offset
161        }
162    }
163
164    fn source_slice(&self, start_offset: usize, end_offset: usize) -> Option<String> {
165        self.input.get(start_offset..end_offset).map(str::to_owned)
166    }
167
168    /// Consume one unit of fuel, returning an error if exhausted
169    fn tick(&mut self) -> Result<()> {
170        if let Some(timeout) = self.timeout
171            && self.started_at.elapsed() > timeout
172        {
173            return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(timeout)));
174        }
175        if self.fuel == 0 {
176            let used = self.max_fuel;
177            return Err(Error::parse(format!(
178                "parser fuel exhausted ({} operations, max {})",
179                used, self.max_fuel
180            )));
181        }
182        self.fuel -= 1;
183        Ok(())
184    }
185
186    /// Consume multiple parser fuel units for lexer work that can scale with input size.
187    /// THREAT[TM-DOS-064]: Heredoc rest-of-line re-injection copies command suffixes;
188    /// charge each copied character so repeated heredocs cannot hide quadratic work
189    /// outside parser fuel accounting.
190    fn tick_units(&mut self, units: usize) -> Result<()> {
191        if let Some(timeout) = self.timeout
192            && self.started_at.elapsed() > timeout
193        {
194            return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(timeout)));
195        }
196        if self.fuel < units {
197            let used = self.max_fuel;
198            return Err(Error::parse(format!(
199                "parser fuel exhausted ({} operations, max {})",
200                used, self.max_fuel
201            )));
202        }
203        self.fuel -= units;
204        Ok(())
205    }
206
207    /// Push nesting depth and check limit
208    fn push_depth(&mut self) -> Result<()> {
209        self.current_depth += 1;
210        if self.current_depth > self.max_depth {
211            return Err(Error::parse(format!(
212                "AST nesting too deep ({} levels, max {})",
213                self.current_depth, self.max_depth
214            )));
215        }
216        Ok(())
217    }
218
219    /// Pop nesting depth
220    fn pop_depth(&mut self) {
221        if self.current_depth > 0 {
222            self.current_depth -= 1;
223        }
224    }
225
226    /// Check if current token is an error token and return the error if so
227    fn check_error_token(&self) -> Result<()> {
228        if let Some(tokens::Token::Error(msg)) = &self.current_token {
229            return Err(self.error(format!("syntax error: {}", msg)));
230        }
231        Ok(())
232    }
233
234    /// Parse the input and return the AST.
235    pub fn parse(mut self) -> Result<Script> {
236        self.parse_script()
237    }
238
239    fn parse_script(&mut self) -> Result<Script> {
240        // Check if the very first token is an error
241        self.check_error_token()?;
242
243        let start_span = self.current_span;
244        let mut commands = Vec::new();
245
246        while self.current_token.is_some() {
247            self.tick()?;
248            self.skip_newlines()?;
249            self.check_error_token()?;
250            if self.current_token.is_none() {
251                break;
252            }
253            let start_offset = self.current_span.start.offset;
254            if let Some(cmd) = self.parse_command_list()? {
255                commands.push(cmd);
256            } else if self.current_token.is_some() && self.current_span.start.offset == start_offset
257            {
258                return Err(self.error("unexpected token"));
259            }
260        }
261
262        let end_span = self.current_span;
263        Ok(Script {
264            commands,
265            span: start_span.merge(end_span),
266        })
267    }
268
269    fn advance(&mut self) {
270        if let Some(peeked) = self.peeked_token.take() {
271            self.current_token = Some(peeked.token);
272            self.current_span = peeked.span;
273        } else {
274            match self.lexer.next_spanned_token() {
275                Some(st) => {
276                    self.current_token = Some(st.token);
277                    self.current_span = st.span;
278                }
279                None => {
280                    self.current_token = None;
281                    // Keep the last span for error reporting
282                }
283            }
284        }
285    }
286
287    /// Peek at the next token without consuming the current one
288    fn peek_next(&mut self) -> Option<&tokens::Token> {
289        if self.peeked_token.is_none() {
290            self.peeked_token = self.lexer.next_spanned_token();
291        }
292        self.peeked_token.as_ref().map(|st| &st.token)
293    }
294
295    fn skip_newlines(&mut self) -> Result<()> {
296        while matches!(self.current_token, Some(tokens::Token::Newline)) {
297            self.tick()?;
298            self.advance();
299        }
300        Ok(())
301    }
302
303    /// Parse a command list (commands connected by && or ||)
304    fn parse_command_list(&mut self) -> Result<Option<Command>> {
305        self.tick()?;
306        match self.current_token {
307            Some(tokens::Token::Pipe) => return Err(self.error("unexpected token: |")),
308            Some(tokens::Token::And) => return Err(self.error("unexpected token: &&")),
309            Some(tokens::Token::Or) => return Err(self.error("unexpected token: ||")),
310            _ => {}
311        }
312        let start_span = self.current_span;
313        let first = match self.parse_pipeline()? {
314            Some(cmd) => cmd,
315            None => return Ok(None),
316        };
317
318        let mut rest = Vec::new();
319
320        loop {
321            let op = match &self.current_token {
322                Some(tokens::Token::And) => {
323                    self.advance();
324                    ListOperator::And
325                }
326                Some(tokens::Token::Or) => {
327                    self.advance();
328                    ListOperator::Or
329                }
330                Some(tokens::Token::Semicolon) => {
331                    self.advance();
332                    self.skip_newlines()?;
333                    // Check if there's more to parse
334                    if self.current_token.is_none()
335                        || matches!(self.current_token, Some(tokens::Token::Newline))
336                    {
337                        break;
338                    }
339                    ListOperator::Semicolon
340                }
341                Some(tokens::Token::Background) => {
342                    self.advance();
343                    self.skip_newlines()?;
344                    // Check if there's more to parse after &
345                    if self.current_token.is_none()
346                        || matches!(self.current_token, Some(tokens::Token::Newline))
347                    {
348                        // Just & at end - return as background
349                        rest.push((
350                            ListOperator::Background,
351                            Command::Simple(SimpleCommand {
352                                name: Word::literal(""),
353                                args: vec![],
354                                redirects: vec![],
355                                assignments: vec![],
356                                span: self.current_span,
357                            }),
358                        ));
359                        break;
360                    }
361                    ListOperator::Background
362                }
363                _ => break,
364            };
365
366            self.skip_newlines()?;
367
368            if let Some(cmd) = self.parse_pipeline()? {
369                rest.push((op, cmd));
370            } else {
371                break;
372            }
373        }
374
375        if rest.is_empty() {
376            Ok(Some(first))
377        } else {
378            Ok(Some(Command::List(CommandList {
379                first: Box::new(first),
380                rest,
381                span: start_span.merge(self.current_span),
382            })))
383        }
384    }
385
386    /// Parse a pipeline (commands connected by |)
387    ///
388    /// Handles `!` pipeline negation: `! cmd | cmd2` negates the exit code.
389    fn parse_pipeline(&mut self) -> Result<Option<Command>> {
390        let start_span = self.current_span;
391
392        // Check for pipeline negation: `! command`
393        let negated = match &self.current_token {
394            Some(tokens::Token::Word(w)) if w == "!" => {
395                self.advance();
396                true
397            }
398            _ => false,
399        };
400
401        let first = match self.parse_command()? {
402            Some(cmd) => cmd,
403            None => {
404                if negated {
405                    return Err(self.error("expected command after !"));
406                }
407                return Ok(None);
408            }
409        };
410
411        let mut commands = vec![first];
412
413        while matches!(self.current_token, Some(tokens::Token::Pipe)) {
414            self.advance();
415            self.skip_newlines()?;
416
417            if let Some(cmd) = self.parse_command()? {
418                commands.push(cmd);
419            } else {
420                return Err(self.error("expected command after |"));
421            }
422        }
423
424        if commands.len() == 1 && !negated {
425            Ok(Some(commands.remove(0)))
426        } else {
427            Ok(Some(Command::Pipeline(Pipeline {
428                negated,
429                commands,
430                span: start_span.merge(self.current_span),
431            })))
432        }
433    }
434
435    /// Parse redirections that follow a compound command (>, >>, 2>, etc.)
436    fn parse_trailing_redirects(&mut self) -> Result<Vec<Redirect>> {
437        let mut redirects = Vec::new();
438        loop {
439            match &self.current_token {
440                Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => {
441                    let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) {
442                        RedirectKind::Clobber
443                    } else {
444                        RedirectKind::Output
445                    };
446                    self.advance();
447                    if let Ok(target) = self.expect_word() {
448                        redirects.push(Redirect {
449                            fd: None,
450                            fd_var: None,
451                            kind,
452                            target,
453                        });
454                    }
455                }
456                Some(tokens::Token::RedirectAppend) => {
457                    self.advance();
458                    if let Ok(target) = self.expect_word() {
459                        redirects.push(Redirect {
460                            fd: None,
461                            fd_var: None,
462                            kind: RedirectKind::Append,
463                            target,
464                        });
465                    }
466                }
467                Some(tokens::Token::RedirectIn) => {
468                    self.advance();
469                    if let Ok(target) = self.expect_word() {
470                        redirects.push(Redirect {
471                            fd: None,
472                            fd_var: None,
473                            kind: RedirectKind::Input,
474                            target,
475                        });
476                    }
477                }
478                Some(tokens::Token::RedirectBoth) => {
479                    self.advance();
480                    if let Ok(target) = self.expect_word() {
481                        redirects.push(Redirect {
482                            fd: None,
483                            fd_var: None,
484                            kind: RedirectKind::OutputBoth,
485                            target,
486                        });
487                    }
488                }
489                Some(tokens::Token::DupOutput) => {
490                    self.advance();
491                    if let Ok(target) = self.expect_word() {
492                        redirects.push(Redirect {
493                            fd: Some(1),
494                            fd_var: None,
495                            kind: RedirectKind::DupOutput,
496                            target,
497                        });
498                    }
499                }
500                Some(tokens::Token::RedirectFd(fd)) => {
501                    let fd = *fd;
502                    self.advance();
503                    if let Ok(target) = self.expect_word() {
504                        redirects.push(Redirect {
505                            fd: Some(fd),
506                            fd_var: None,
507                            kind: RedirectKind::Output,
508                            target,
509                        });
510                    }
511                }
512                Some(tokens::Token::RedirectFdAppend(fd)) => {
513                    let fd = *fd;
514                    self.advance();
515                    if let Ok(target) = self.expect_word() {
516                        redirects.push(Redirect {
517                            fd: Some(fd),
518                            fd_var: None,
519                            kind: RedirectKind::Append,
520                            target,
521                        });
522                    }
523                }
524                Some(tokens::Token::DupFd(src_fd, dst_fd)) => {
525                    let src_fd = *src_fd;
526                    let dst_fd = *dst_fd;
527                    self.advance();
528                    redirects.push(Redirect {
529                        fd: Some(src_fd),
530                        fd_var: None,
531                        kind: RedirectKind::DupOutput,
532                        target: Word::literal(dst_fd.to_string()),
533                    });
534                }
535                Some(tokens::Token::DupFdCloseOut(fd)) => {
536                    let fd = *fd;
537                    self.advance();
538                    redirects.push(Redirect {
539                        fd: Some(fd),
540                        fd_var: None,
541                        kind: RedirectKind::DupOutput,
542                        target: Word::literal("-"),
543                    });
544                }
545                Some(tokens::Token::DupInput) => {
546                    self.advance();
547                    if let Ok(target) = self.expect_word() {
548                        redirects.push(Redirect {
549                            fd: Some(0),
550                            fd_var: None,
551                            kind: RedirectKind::DupInput,
552                            target,
553                        });
554                    }
555                }
556                Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => {
557                    let src_fd = *src_fd;
558                    let dst_fd = *dst_fd;
559                    self.advance();
560                    redirects.push(Redirect {
561                        fd: Some(src_fd),
562                        fd_var: None,
563                        kind: RedirectKind::DupInput,
564                        target: Word::literal(dst_fd.to_string()),
565                    });
566                }
567                Some(tokens::Token::DupFdClose(fd)) => {
568                    let fd = *fd;
569                    self.advance();
570                    redirects.push(Redirect {
571                        fd: Some(fd),
572                        fd_var: None,
573                        kind: RedirectKind::DupInput,
574                        target: Word::literal("-"),
575                    });
576                }
577                Some(tokens::Token::RedirectFdIn(fd)) => {
578                    let fd = *fd;
579                    self.advance();
580                    if let Ok(target) = self.expect_word() {
581                        redirects.push(Redirect {
582                            fd: Some(fd),
583                            fd_var: None,
584                            kind: RedirectKind::Input,
585                            target,
586                        });
587                    }
588                }
589                Some(tokens::Token::HereString) => {
590                    self.advance();
591                    if let Ok(target) = self.expect_word() {
592                        redirects.push(Redirect {
593                            fd: None,
594                            fd_var: None,
595                            kind: RedirectKind::HereString,
596                            target,
597                        });
598                    }
599                }
600                Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => {
601                    let strip_tabs =
602                        matches!(self.current_token, Some(tokens::Token::HereDocStrip));
603                    self.advance();
604                    let (delimiter, quoted) = match &self.current_token {
605                        Some(tokens::Token::Word(w)) => (w.clone(), false),
606                        Some(tokens::Token::LiteralWord(w)) => (w.clone(), true),
607                        Some(tokens::Token::QuotedWord(w))
608                        | Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), true),
609                        _ => break,
610                    };
611                    let (content, rest_of_line_chars) = self
612                        .lexer
613                        .read_heredoc_with_strip_metered(&delimiter, strip_tabs);
614                    self.tick_units(rest_of_line_chars)?;
615                    let content = if strip_tabs {
616                        let had_trailing_newline = content.ends_with('\n');
617                        let mut stripped: String = content
618                            .lines()
619                            .map(|l| l.trim_start_matches('\t'))
620                            .collect::<Vec<_>>()
621                            .join("\n");
622                        if had_trailing_newline {
623                            stripped.push('\n');
624                        }
625                        stripped
626                    } else {
627                        content
628                    };
629                    self.advance();
630                    let target = if quoted {
631                        Word::quoted_literal(content)
632                    } else {
633                        self.parse_word(content)
634                    };
635                    let kind = if strip_tabs {
636                        RedirectKind::HereDocStrip
637                    } else {
638                        RedirectKind::HereDoc
639                    };
640                    redirects.push(Redirect {
641                        fd: None,
642                        fd_var: None,
643                        kind,
644                        target,
645                    });
646                    // Rest-of-line tokens re-injected by lexer; break so callers
647                    // can see pipes/semicolons.
648                    break;
649                }
650                _ => break,
651            }
652        }
653        Ok(redirects)
654    }
655
656    /// Parse a compound command and any trailing redirections
657    fn parse_compound_with_redirects(
658        &mut self,
659        parser: impl FnOnce(&mut Self) -> Result<CompoundCommand>,
660    ) -> Result<Option<Command>> {
661        let compound = parser(self)?;
662        let redirects = self.parse_trailing_redirects()?;
663        Ok(Some(Command::Compound(compound, redirects)))
664    }
665
666    /// Parse a single command (simple or compound)
667    fn parse_command(&mut self) -> Result<Option<Command>> {
668        self.skip_newlines()?;
669        self.check_error_token()?;
670
671        // Check for compound commands and function keyword
672        if let Some(tokens::Token::Word(w)) = &self.current_token {
673            let word = w.clone();
674            match word.as_str() {
675                "if" => return self.parse_compound_with_redirects(|s| s.parse_if()),
676                "for" => return self.parse_compound_with_redirects(|s| s.parse_for()),
677                "while" => return self.parse_compound_with_redirects(|s| s.parse_while()),
678                "until" => return self.parse_compound_with_redirects(|s| s.parse_until()),
679                "case" => return self.parse_compound_with_redirects(|s| s.parse_case()),
680                "select" => return self.parse_compound_with_redirects(|s| s.parse_select()),
681                "time" => return self.parse_compound_with_redirects(|s| s.parse_time()),
682                "coproc" => return self.parse_compound_with_redirects(|s| s.parse_coproc()),
683                "function" => return self.parse_function_keyword().map(Some),
684                _ => {
685                    // Check for POSIX-style function: name() { body }
686                    // Don't match if word contains '=' (that's an assignment like arr=(a b c))
687                    if !word.contains('=')
688                        && matches!(self.peek_next(), Some(tokens::Token::LeftParen))
689                    {
690                        return self.parse_function_posix().map(Some);
691                    }
692                }
693            }
694        }
695
696        // Check for conditional expression [[ ... ]]
697        if matches!(self.current_token, Some(tokens::Token::DoubleLeftBracket)) {
698            return self.parse_compound_with_redirects(|s| s.parse_conditional());
699        }
700
701        // Check for arithmetic command ((expression))
702        if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
703            return self.parse_compound_with_redirects(|s| s.parse_arithmetic_command());
704        }
705
706        // Check for subshell
707        if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
708            return self.parse_compound_with_redirects(|s| s.parse_subshell());
709        }
710
711        // Check for brace group
712        if matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
713            return self.parse_compound_with_redirects(|s| s.parse_brace_group());
714        }
715
716        // Default to simple command
717        match self.parse_simple_command()? {
718            Some(cmd) => Ok(Some(Command::Simple(cmd))),
719            None => Ok(None),
720        }
721    }
722
723    /// Parse an if statement
724    fn parse_if(&mut self) -> Result<CompoundCommand> {
725        let start_span = self.current_span;
726        self.push_depth()?;
727        self.advance(); // consume 'if'
728        self.skip_newlines()?;
729
730        // Parse condition
731        let condition = self.parse_compound_list("then")?;
732
733        // Expect 'then'
734        self.expect_keyword("then")?;
735        self.skip_newlines()?;
736
737        // Parse then branch
738        let then_branch = self.parse_compound_list_until(&["elif", "else", "fi"])?;
739
740        // Bash requires at least one command in then branch
741        if then_branch.is_empty() {
742            self.pop_depth();
743            return Err(self.error("syntax error: empty then clause"));
744        }
745
746        // Parse elif branches
747        let mut elif_branches = Vec::new();
748        while self.is_keyword("elif") {
749            self.advance(); // consume 'elif'
750            self.skip_newlines()?;
751
752            let elif_condition = self.parse_compound_list("then")?;
753            self.expect_keyword("then")?;
754            self.skip_newlines()?;
755
756            let elif_body = self.parse_compound_list_until(&["elif", "else", "fi"])?;
757
758            // Bash requires at least one command in elif branch
759            if elif_body.is_empty() {
760                self.pop_depth();
761                return Err(self.error("syntax error: empty elif clause"));
762            }
763
764            elif_branches.push((elif_condition, elif_body));
765        }
766
767        // Parse else branch
768        let else_branch = if self.is_keyword("else") {
769            self.advance(); // consume 'else'
770            self.skip_newlines()?;
771            let branch = self.parse_compound_list("fi")?;
772
773            // Bash requires at least one command in else branch
774            if branch.is_empty() {
775                self.pop_depth();
776                return Err(self.error("syntax error: empty else clause"));
777            }
778
779            Some(branch)
780        } else {
781            None
782        };
783
784        // Expect 'fi'
785        self.expect_keyword("fi")?;
786
787        self.pop_depth();
788        Ok(CompoundCommand::If(IfCommand {
789            condition,
790            then_branch,
791            elif_branches,
792            else_branch,
793            span: start_span.merge(self.current_span),
794        }))
795    }
796
797    /// Parse a for loop
798    fn parse_for(&mut self) -> Result<CompoundCommand> {
799        let start_span = self.current_span;
800        self.push_depth()?;
801        self.advance(); // consume 'for'
802        self.skip_newlines()?;
803
804        // Check for C-style for loop: for ((init; cond; step))
805        if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
806            let result = self.parse_arithmetic_for_inner(start_span);
807            self.pop_depth();
808            return result;
809        }
810
811        // Expect variable name
812        let variable = match &self.current_token {
813            Some(tokens::Token::Word(w))
814            | Some(tokens::Token::LiteralWord(w))
815            | Some(tokens::Token::QuotedWord(w))
816            | Some(tokens::Token::QuotedGlobWord(w)) => w.clone(),
817            _ => {
818                self.pop_depth();
819                return Err(Error::parse(
820                    "expected variable name in for loop".to_string(),
821                ));
822            }
823        };
824        self.advance();
825
826        // Check for 'in' keyword
827        let words = if self.is_keyword("in") {
828            self.advance(); // consume 'in'
829
830            // Parse word list until a list terminator (newline/;)
831            let mut words = Vec::new();
832            loop {
833                match &self.current_token {
834                    // `do`/`done` are reserved words only in command position.
835                    // Inside the `in` list they are ordinary words until a list
836                    // terminator (`;`/newline), matching bash: `for a in do; do
837                    // echo $a; done` iterates over the single word `do`.
838                    Some(tokens::Token::Word(w))
839                    | Some(tokens::Token::QuotedWord(w))
840                    | Some(tokens::Token::QuotedGlobWord(w)) => {
841                        let is_quoted = matches!(
842                            &self.current_token,
843                            Some(tokens::Token::QuotedWord(_))
844                                | Some(tokens::Token::QuotedGlobWord(_))
845                        );
846                        let mut word = self.parse_word(w.clone());
847                        if is_quoted {
848                            word.quoted = true;
849                        }
850                        if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) {
851                            word.has_unquoted_glob = true;
852                        }
853                        words.push(word);
854                        self.advance();
855                    }
856                    Some(tokens::Token::LiteralWord(w)) => {
857                        words.push(Word {
858                            parts: vec![WordPart::Literal(w.clone())],
859                            quoted: true,
860                            has_unquoted_glob: false,
861                            part_quoted: Vec::new(),
862                        });
863                        self.advance();
864                    }
865                    Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) => {
866                        self.advance();
867                        break;
868                    }
869                    _ => break,
870                }
871            }
872            Some(words)
873        } else {
874            // for var; do ... (iterates over positional params)
875            // Consume optional semicolon before 'do'
876            if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
877                self.advance();
878            }
879            None
880        };
881
882        self.skip_newlines()?;
883
884        // Expect 'do'
885        self.expect_keyword("do")?;
886        self.skip_newlines()?;
887
888        // Parse body
889        let body = self.parse_compound_list("done")?;
890
891        // Bash requires at least one command in loop body
892        if body.is_empty() {
893            self.pop_depth();
894            return Err(self.error("syntax error: empty for loop body"));
895        }
896
897        // Expect 'done'
898        self.expect_keyword("done")?;
899
900        self.pop_depth();
901        Ok(CompoundCommand::For(ForCommand {
902            variable,
903            words,
904            body,
905            span: start_span.merge(self.current_span),
906        }))
907    }
908
909    /// Parse select loop: select var in list; do body; done
910    fn parse_select(&mut self) -> Result<CompoundCommand> {
911        let start_span = self.current_span;
912        self.push_depth()?;
913        self.advance(); // consume 'select'
914        self.skip_newlines()?;
915
916        // Expect variable name
917        let variable = match &self.current_token {
918            Some(tokens::Token::Word(w))
919            | Some(tokens::Token::LiteralWord(w))
920            | Some(tokens::Token::QuotedWord(w))
921            | Some(tokens::Token::QuotedGlobWord(w)) => w.clone(),
922            _ => {
923                self.pop_depth();
924                return Err(Error::parse("expected variable name in select".to_string()));
925            }
926        };
927        self.advance();
928
929        // Expect 'in' keyword
930        if !self.is_keyword("in") {
931            self.pop_depth();
932            return Err(Error::parse("expected 'in' in select".to_string()));
933        }
934        self.advance(); // consume 'in'
935
936        // Parse word list until a list terminator (newline/;)
937        let mut words = Vec::new();
938        loop {
939            match &self.current_token {
940                // `do`/`done` are reserved words only in command position.
941                // Inside the `in` list they are ordinary words until a list
942                // terminator (`;`/newline), matching bash.
943                Some(tokens::Token::Word(w))
944                | Some(tokens::Token::QuotedWord(w))
945                | Some(tokens::Token::QuotedGlobWord(w)) => {
946                    let is_quoted = matches!(
947                        &self.current_token,
948                        Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_))
949                    );
950                    let mut word = self.parse_word(w.clone());
951                    if is_quoted {
952                        word.quoted = true;
953                    }
954                    if matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_))) {
955                        word.has_unquoted_glob = true;
956                    }
957                    words.push(word);
958                    self.advance();
959                }
960                Some(tokens::Token::LiteralWord(w)) => {
961                    words.push(Word {
962                        parts: vec![WordPart::Literal(w.clone())],
963                        quoted: true,
964                        has_unquoted_glob: false,
965                        part_quoted: Vec::new(),
966                    });
967                    self.advance();
968                }
969                Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) => {
970                    self.advance();
971                    break;
972                }
973                _ => break,
974            }
975        }
976
977        self.skip_newlines()?;
978
979        // Expect 'do'
980        self.expect_keyword("do")?;
981        self.skip_newlines()?;
982
983        // Parse body
984        let body = self.parse_compound_list("done")?;
985
986        // Bash requires at least one command in loop body
987        if body.is_empty() {
988            self.pop_depth();
989            return Err(self.error("syntax error: empty select loop body"));
990        }
991
992        // Expect 'done'
993        self.expect_keyword("done")?;
994
995        self.pop_depth();
996        Ok(CompoundCommand::Select(SelectCommand {
997            variable,
998            words,
999            body,
1000            span: start_span.merge(self.current_span),
1001        }))
1002    }
1003
1004    /// Parse C-style arithmetic for loop inner: for ((init; cond; step)); do body; done
1005    /// Note: depth tracking is done by parse_for which calls this
1006    fn parse_arithmetic_for_inner(&mut self, start_span: Span) -> Result<CompoundCommand> {
1007        self.advance(); // consume '(('
1008
1009        // Read the three expressions separated by semicolons
1010        let mut parts: Vec<String> = Vec::new();
1011        let mut current_expr = String::new();
1012        let mut paren_depth = 0;
1013
1014        loop {
1015            match &self.current_token {
1016                Some(tokens::Token::DoubleRightParen) => {
1017                    // End of the (( )) section
1018                    parts.push(current_expr.trim().to_string());
1019                    self.advance();
1020                    break;
1021                }
1022                Some(tokens::Token::LeftParen) => {
1023                    paren_depth += 1;
1024                    current_expr.push('(');
1025                    self.advance();
1026                }
1027                Some(tokens::Token::RightParen) => {
1028                    if paren_depth > 0 {
1029                        paren_depth -= 1;
1030                        current_expr.push(')');
1031                        self.advance();
1032                    } else {
1033                        // Unexpected - probably error
1034                        self.advance();
1035                    }
1036                }
1037                Some(tokens::Token::Semicolon) => {
1038                    if paren_depth == 0 {
1039                        // Separator between init, cond, step
1040                        parts.push(current_expr.trim().to_string());
1041                        current_expr.clear();
1042                    } else {
1043                        current_expr.push(';');
1044                    }
1045                    self.advance();
1046                }
1047                Some(tokens::Token::Word(w))
1048                | Some(tokens::Token::LiteralWord(w))
1049                | Some(tokens::Token::QuotedWord(w))
1050                | Some(tokens::Token::QuotedGlobWord(w)) => {
1051                    // Don't add space when joining operator pairs like < + =3 → <=3
1052                    let skip_space = current_expr.ends_with('<')
1053                        || current_expr.ends_with('>')
1054                        || current_expr.ends_with(' ')
1055                        || current_expr.ends_with('(')
1056                        || current_expr.is_empty();
1057                    if !skip_space {
1058                        current_expr.push(' ');
1059                    }
1060                    current_expr.push_str(w);
1061                    self.advance();
1062                }
1063                Some(tokens::Token::Newline) => {
1064                    self.advance();
1065                }
1066                // Handle operators that are normally special tokens but valid in arithmetic
1067                Some(tokens::Token::RedirectIn) => {
1068                    current_expr.push('<');
1069                    self.advance();
1070                }
1071                Some(tokens::Token::RedirectOut) => {
1072                    current_expr.push('>');
1073                    self.advance();
1074                }
1075                Some(tokens::Token::And) => {
1076                    current_expr.push_str("&&");
1077                    self.advance();
1078                }
1079                Some(tokens::Token::Or) => {
1080                    current_expr.push_str("||");
1081                    self.advance();
1082                }
1083                Some(tokens::Token::Pipe) => {
1084                    current_expr.push('|');
1085                    self.advance();
1086                }
1087                Some(tokens::Token::Background) => {
1088                    current_expr.push('&');
1089                    self.advance();
1090                }
1091                None => {
1092                    return Err(Error::parse(
1093                        "unexpected end of input in for loop".to_string(),
1094                    ));
1095                }
1096                _ => {
1097                    self.advance();
1098                }
1099            }
1100        }
1101
1102        // Ensure we have exactly 3 parts
1103        while parts.len() < 3 {
1104            parts.push(String::new());
1105        }
1106
1107        let init = parts.first().cloned().unwrap_or_default();
1108        let condition = parts.get(1).cloned().unwrap_or_default();
1109        let step = parts.get(2).cloned().unwrap_or_default();
1110
1111        self.skip_newlines()?;
1112
1113        // Skip optional semicolon after ))
1114        if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
1115            self.advance();
1116        }
1117        self.skip_newlines()?;
1118
1119        // Expect 'do'
1120        self.expect_keyword("do")?;
1121        self.skip_newlines()?;
1122
1123        // Parse body
1124        let body = self.parse_compound_list("done")?;
1125
1126        // Bash requires at least one command in loop body
1127        if body.is_empty() {
1128            return Err(self.error("syntax error: empty for loop body"));
1129        }
1130
1131        // Expect 'done'
1132        self.expect_keyword("done")?;
1133
1134        Ok(CompoundCommand::ArithmeticFor(ArithmeticForCommand {
1135            init,
1136            condition,
1137            step,
1138            body,
1139            span: start_span.merge(self.current_span),
1140        }))
1141    }
1142
1143    /// Parse a while loop
1144    fn parse_while(&mut self) -> Result<CompoundCommand> {
1145        let start_span = self.current_span;
1146        self.push_depth()?;
1147        self.advance(); // consume 'while'
1148        self.skip_newlines()?;
1149
1150        // Parse condition
1151        let condition = self.parse_compound_list("do")?;
1152
1153        // Expect 'do'
1154        self.expect_keyword("do")?;
1155        self.skip_newlines()?;
1156
1157        // Parse body
1158        let body = self.parse_compound_list("done")?;
1159
1160        // Bash requires at least one command in loop body
1161        if body.is_empty() {
1162            self.pop_depth();
1163            return Err(self.error("syntax error: empty while loop body"));
1164        }
1165
1166        // Expect 'done'
1167        self.expect_keyword("done")?;
1168
1169        self.pop_depth();
1170        Ok(CompoundCommand::While(WhileCommand {
1171            condition,
1172            body,
1173            span: start_span.merge(self.current_span),
1174        }))
1175    }
1176
1177    /// Parse an until loop
1178    fn parse_until(&mut self) -> Result<CompoundCommand> {
1179        let start_span = self.current_span;
1180        self.push_depth()?;
1181        self.advance(); // consume 'until'
1182        self.skip_newlines()?;
1183
1184        // Parse condition
1185        let condition = self.parse_compound_list("do")?;
1186
1187        // Expect 'do'
1188        self.expect_keyword("do")?;
1189        self.skip_newlines()?;
1190
1191        // Parse body
1192        let body = self.parse_compound_list("done")?;
1193
1194        // Bash requires at least one command in loop body
1195        if body.is_empty() {
1196            self.pop_depth();
1197            return Err(self.error("syntax error: empty until loop body"));
1198        }
1199
1200        // Expect 'done'
1201        self.expect_keyword("done")?;
1202
1203        self.pop_depth();
1204        Ok(CompoundCommand::Until(UntilCommand {
1205            condition,
1206            body,
1207            span: start_span.merge(self.current_span),
1208        }))
1209    }
1210
1211    /// Parse a case statement: case WORD in pattern) commands ;; ... esac
1212    fn parse_case(&mut self) -> Result<CompoundCommand> {
1213        let start_span = self.current_span;
1214        self.push_depth()?;
1215        self.advance(); // consume 'case'
1216        self.skip_newlines()?;
1217
1218        // Get the word to match against
1219        let word = self.expect_word()?;
1220        self.skip_newlines()?;
1221
1222        // Expect 'in'
1223        self.expect_keyword("in")?;
1224        self.skip_newlines()?;
1225
1226        // Parse case items
1227        let mut cases = Vec::new();
1228        while !self.is_keyword("esac") && self.current_token.is_some() {
1229            self.skip_newlines()?;
1230            if self.is_keyword("esac") {
1231                break;
1232            }
1233
1234            // Parse patterns (pattern1 | pattern2 | ...)
1235            // Optional leading (
1236            if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1237                self.advance();
1238            }
1239
1240            let mut patterns = Vec::new();
1241            while matches!(
1242                &self.current_token,
1243                Some(tokens::Token::Word(_))
1244                    | Some(tokens::Token::LiteralWord(_))
1245                    | Some(tokens::Token::QuotedWord(_))
1246                    | Some(tokens::Token::QuotedGlobWord(_))
1247            ) {
1248                let pattern = match &self.current_token {
1249                    Some(tokens::Token::LiteralWord(w)) => Word {
1250                        // LiteralWord already decoded lexer-only sentinels and must not
1251                        // be reparsed; otherwise escaped dollars can become expansions.
1252                        parts: vec![WordPart::Literal(w.clone())],
1253                        quoted: true,
1254                        has_unquoted_glob: false,
1255                        part_quoted: Vec::new(),
1256                    },
1257                    Some(tokens::Token::Word(w))
1258                    | Some(tokens::Token::QuotedWord(w))
1259                    | Some(tokens::Token::QuotedGlobWord(w)) => self.parse_word(w.clone()),
1260                    _ => unreachable!(),
1261                };
1262                patterns.push(pattern);
1263                self.advance();
1264
1265                // Check for | between patterns
1266                if matches!(self.current_token, Some(tokens::Token::Pipe)) {
1267                    self.advance();
1268                } else {
1269                    break;
1270                }
1271            }
1272
1273            // Expect )
1274            if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1275                self.pop_depth();
1276                return Err(self.error("expected ')' after case pattern"));
1277            }
1278            self.advance();
1279            self.skip_newlines()?;
1280
1281            // Parse commands until ;; or esac
1282            let mut commands = Vec::new();
1283            while !self.is_case_terminator()
1284                && !self.is_keyword("esac")
1285                && self.current_token.is_some()
1286            {
1287                if let Some(cmd) = self.parse_command_list()? {
1288                    commands.push(cmd);
1289                }
1290                self.skip_newlines()?;
1291            }
1292
1293            let terminator = self.parse_case_terminator();
1294            cases.push(CaseItem {
1295                patterns,
1296                commands,
1297                terminator,
1298            });
1299            self.skip_newlines()?;
1300        }
1301
1302        // Expect 'esac'
1303        self.expect_keyword("esac")?;
1304
1305        self.pop_depth();
1306        Ok(CompoundCommand::Case(CaseCommand {
1307            word,
1308            cases,
1309            span: start_span.merge(self.current_span),
1310        }))
1311    }
1312
1313    /// Parse a time command: time [-p] [command]
1314    ///
1315    /// The time keyword measures execution time of the following command.
1316    /// Note: Bashkit only tracks wall-clock time, not CPU user/sys time.
1317    fn parse_time(&mut self) -> Result<CompoundCommand> {
1318        let start_span = self.current_span;
1319        self.advance(); // consume 'time'
1320        self.skip_newlines()?;
1321
1322        // Check for -p flag (POSIX format)
1323        let posix_format = if let Some(tokens::Token::Word(w)) = &self.current_token {
1324            if w == "-p" {
1325                self.advance();
1326                self.skip_newlines()?;
1327                true
1328            } else {
1329                false
1330            }
1331        } else {
1332            false
1333        };
1334
1335        // Parse the command to time (if any)
1336        // time with no command is valid in bash (just outputs timing header)
1337        let command = self.parse_pipeline()?;
1338
1339        Ok(CompoundCommand::Time(TimeCommand {
1340            posix_format,
1341            command: command.map(Box::new),
1342            span: start_span.merge(self.current_span),
1343        }))
1344    }
1345
1346    /// Parse a coproc command: `coproc [NAME] command`
1347    ///
1348    /// If the token after `coproc` is a simple word followed by a compound
1349    /// command (`{`, `(`, `while`, `for`, etc.), it is treated as the coproc
1350    /// name. Otherwise the command starts immediately and the default name
1351    /// "COPROC" is used.
1352    fn parse_coproc(&mut self) -> Result<CompoundCommand> {
1353        self.tick()?;
1354        self.push_depth()?;
1355
1356        let result = (|| {
1357            let start_span = self.current_span;
1358            self.advance(); // consume 'coproc'
1359            self.skip_newlines()?;
1360
1361            // Determine if next token is a NAME (simple word that is NOT a compound-
1362            // command keyword and is followed by a compound command start).
1363            let (name, consumed_name) = if let Some(tokens::Token::Word(w)) = &self.current_token {
1364                let word = w.clone();
1365                let is_compound_keyword = matches!(
1366                    word.as_str(),
1367                    "if" | "for" | "while" | "until" | "case" | "select" | "time" | "coproc"
1368                );
1369                let next_is_compound_start = matches!(
1370                    self.peek_next(),
1371                    Some(tokens::Token::LeftBrace) | Some(tokens::Token::LeftParen)
1372                );
1373                if !is_compound_keyword && next_is_compound_start {
1374                    self.advance(); // consume the NAME
1375                    self.skip_newlines()?;
1376                    (word, true)
1377                } else {
1378                    ("COPROC".to_string(), false)
1379                }
1380            } else {
1381                ("COPROC".to_string(), false)
1382            };
1383
1384            let _ = consumed_name;
1385
1386            // Parse the command body (could be simple, compound, or pipeline)
1387            let body = self.parse_pipeline()?;
1388            let body = body.ok_or_else(|| self.error("coproc: missing command"))?;
1389
1390            Ok(CompoundCommand::Coproc(ast::CoprocCommand {
1391                name,
1392                body: Box::new(body),
1393                span: start_span.merge(self.current_span),
1394            }))
1395        })();
1396
1397        self.pop_depth();
1398        result
1399    }
1400
1401    /// Check if current token is ;; (case terminator)
1402    fn is_case_terminator(&self) -> bool {
1403        matches!(
1404            self.current_token,
1405            Some(tokens::Token::DoubleSemicolon)
1406                | Some(tokens::Token::SemiAmp)
1407                | Some(tokens::Token::DoubleSemiAmp)
1408        )
1409    }
1410
1411    /// Parse case terminator: `;;` (break), `;&` (fallthrough), `;;&` (continue matching)
1412    fn parse_case_terminator(&mut self) -> ast::CaseTerminator {
1413        match self.current_token {
1414            Some(tokens::Token::SemiAmp) => {
1415                self.advance();
1416                ast::CaseTerminator::FallThrough
1417            }
1418            Some(tokens::Token::DoubleSemiAmp) => {
1419                self.advance();
1420                ast::CaseTerminator::Continue
1421            }
1422            Some(tokens::Token::DoubleSemicolon) => {
1423                self.advance();
1424                ast::CaseTerminator::Break
1425            }
1426            _ => ast::CaseTerminator::Break,
1427        }
1428    }
1429
1430    /// Parse a subshell (commands in parentheses)
1431    fn parse_subshell(&mut self) -> Result<CompoundCommand> {
1432        self.push_depth()?;
1433        self.advance(); // consume '('
1434        self.skip_newlines()?;
1435
1436        let mut commands = Vec::new();
1437        while !matches!(
1438            self.current_token,
1439            Some(tokens::Token::RightParen) | Some(tokens::Token::DoubleRightParen) | None
1440        ) {
1441            self.skip_newlines()?;
1442            if matches!(
1443                self.current_token,
1444                Some(tokens::Token::RightParen) | Some(tokens::Token::DoubleRightParen)
1445            ) {
1446                break;
1447            }
1448            if let Some(cmd) = self.parse_command_list()? {
1449                commands.push(cmd);
1450            }
1451        }
1452
1453        if matches!(self.current_token, Some(tokens::Token::DoubleRightParen)) {
1454            // `))` at end of nested subshells: consume as single `)`, leave `)` for parent
1455            self.current_token = Some(tokens::Token::RightParen);
1456        } else if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1457            self.pop_depth();
1458            return Err(Error::parse("expected ')' to close subshell".to_string()));
1459        } else {
1460            self.advance(); // consume ')'
1461        }
1462
1463        self.pop_depth();
1464        Ok(CompoundCommand::Subshell(commands))
1465    }
1466
1467    /// Parse a brace group
1468    fn parse_brace_group(&mut self) -> Result<CompoundCommand> {
1469        self.push_depth()?;
1470        self.advance(); // consume '{'
1471        self.skip_newlines()?;
1472
1473        let mut commands = Vec::new();
1474        while !matches!(self.current_token, Some(tokens::Token::RightBrace) | None) {
1475            self.skip_newlines()?;
1476            if matches!(self.current_token, Some(tokens::Token::RightBrace)) {
1477                break;
1478            }
1479            if let Some(cmd) = self.parse_command_list()? {
1480                commands.push(cmd);
1481            }
1482        }
1483
1484        if !matches!(self.current_token, Some(tokens::Token::RightBrace)) {
1485            self.pop_depth();
1486            return Err(Error::parse(
1487                "expected '}' to close brace group".to_string(),
1488            ));
1489        }
1490
1491        // Bash requires at least one command in a brace group
1492        if commands.is_empty() {
1493            self.pop_depth();
1494            return Err(self.error("syntax error: empty brace group"));
1495        }
1496
1497        self.advance(); // consume '}'
1498
1499        self.pop_depth();
1500        Ok(CompoundCommand::BraceGroup(commands))
1501    }
1502
1503    /// Parse arithmetic command ((expression))
1504    /// Parse [[ conditional expression ]]
1505    fn parse_conditional(&mut self) -> Result<CompoundCommand> {
1506        self.advance(); // consume '[['
1507
1508        let mut words = Vec::new();
1509        let mut saw_regex_op = false;
1510
1511        loop {
1512            match &self.current_token {
1513                Some(tokens::Token::DoubleRightBracket) => {
1514                    self.advance(); // consume ']]'
1515                    break;
1516                }
1517                Some(tokens::Token::Word(w))
1518                | Some(tokens::Token::LiteralWord(w))
1519                | Some(tokens::Token::QuotedWord(w))
1520                | Some(tokens::Token::QuotedGlobWord(w)) => {
1521                    let w_clone = w.clone();
1522                    let is_quoted = matches!(
1523                        self.current_token,
1524                        Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_))
1525                    );
1526                    let is_literal =
1527                        matches!(self.current_token, Some(tokens::Token::LiteralWord(_)));
1528
1529                    // After =~, handle regex pattern.
1530                    // If the pattern contains $ (variable reference), parse it as a
1531                    // normal word so variables expand. Keep quoted/literal tokens as
1532                    // literal regex patterns to preserve shell quoting semantics.
1533                    if saw_regex_op {
1534                        if w_clone.contains('$') && !is_quoted && !is_literal {
1535                            // Variable reference — parse normally for expansion
1536                            let parsed = self.parse_word(w_clone);
1537                            words.push(parsed);
1538                            self.advance();
1539                        } else {
1540                            let pattern = self.collect_conditional_regex_pattern(&w_clone);
1541                            words.push(Word::literal(&pattern));
1542                        }
1543                        saw_regex_op = false;
1544                        continue;
1545                    }
1546
1547                    if w_clone == "=~" {
1548                        saw_regex_op = true;
1549                    }
1550
1551                    let word = if is_literal {
1552                        Word {
1553                            parts: vec![WordPart::Literal(w_clone)],
1554                            quoted: true,
1555                            has_unquoted_glob: false,
1556                            part_quoted: Vec::new(),
1557                        }
1558                    } else {
1559                        let mut parsed = self.parse_word(w_clone);
1560                        if is_quoted {
1561                            parsed.quoted = true;
1562                        }
1563                        if matches!(self.current_token, Some(tokens::Token::QuotedGlobWord(_))) {
1564                            parsed.has_unquoted_glob = true;
1565                        }
1566                        parsed
1567                    };
1568                    words.push(word);
1569                    self.advance();
1570                }
1571                // Operators that the lexer tokenizes separately
1572                Some(tokens::Token::And) => {
1573                    words.push(Word::literal("&&"));
1574                    self.advance();
1575                }
1576                Some(tokens::Token::Or) => {
1577                    words.push(Word::literal("||"));
1578                    self.advance();
1579                }
1580                Some(tokens::Token::LeftParen) => {
1581                    if saw_regex_op {
1582                        // Regex pattern starts with '(' — collect it
1583                        let pattern = self.collect_conditional_regex_pattern("(");
1584                        words.push(Word::literal(&pattern));
1585                        saw_regex_op = false;
1586                        continue;
1587                    }
1588                    words.push(Word::literal("("));
1589                    self.advance();
1590                }
1591                Some(tokens::Token::RightParen) => {
1592                    words.push(Word::literal(")"));
1593                    self.advance();
1594                }
1595                None => {
1596                    return Err(crate::error::Error::parse(
1597                        "unexpected end of input in [[ ]]".to_string(),
1598                    ));
1599                }
1600                _ => {
1601                    // Skip unknown tokens
1602                    self.advance();
1603                }
1604            }
1605        }
1606
1607        Ok(CompoundCommand::Conditional(words))
1608    }
1609
1610    /// Collect a regex pattern after =~ in [[ ]], handling parens and special chars.
1611    fn collect_conditional_regex_pattern(&mut self, first_word: &str) -> String {
1612        let mut pattern = first_word.to_string();
1613        self.advance(); // consume the first word
1614
1615        // Concatenate adjacent tokens that are part of the regex pattern
1616        loop {
1617            match &self.current_token {
1618                Some(tokens::Token::DoubleRightBracket) => break,
1619                Some(tokens::Token::And) | Some(tokens::Token::Or) => break,
1620                Some(tokens::Token::LeftParen) => {
1621                    pattern.push('(');
1622                    self.advance();
1623                }
1624                Some(tokens::Token::RightParen) => {
1625                    pattern.push(')');
1626                    self.advance();
1627                }
1628                Some(tokens::Token::Word(w))
1629                | Some(tokens::Token::LiteralWord(w))
1630                | Some(tokens::Token::QuotedWord(w))
1631                | Some(tokens::Token::QuotedGlobWord(w)) => {
1632                    pattern.push_str(w);
1633                    self.advance();
1634                }
1635                _ => break,
1636            }
1637        }
1638
1639        pattern
1640    }
1641
1642    /// Check if current token starts with `=` (e.g., Word("=5") from `>=5`).
1643    /// If so, return the rest of the word after `=`.
1644    fn current_token_starts_with_eq(&self) -> Option<String> {
1645        match &self.current_token {
1646            Some(tokens::Token::Assignment) => Some(String::new()),
1647            Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) => {
1648                w.strip_prefix('=').map(|rest| rest.to_string())
1649            }
1650            _ => None,
1651        }
1652    }
1653
1654    fn parse_arithmetic_command(&mut self) -> Result<CompoundCommand> {
1655        self.advance(); // consume '(('
1656
1657        // Read expression until we find ))
1658        let mut expr = String::new();
1659        let mut depth = 1;
1660
1661        loop {
1662            match &self.current_token {
1663                Some(tokens::Token::DoubleLeftParen) => {
1664                    depth += 1;
1665                    expr.push_str("((");
1666                    self.advance();
1667                }
1668                Some(tokens::Token::DoubleRightParen) => {
1669                    depth -= 1;
1670                    if depth == 0 {
1671                        self.advance(); // consume '))'
1672                        break;
1673                    }
1674                    expr.push_str("))");
1675                    self.advance();
1676                }
1677                Some(tokens::Token::LeftParen) => {
1678                    expr.push('(');
1679                    self.advance();
1680                }
1681                Some(tokens::Token::RightParen) => {
1682                    expr.push(')');
1683                    self.advance();
1684                }
1685                Some(tokens::Token::Word(w))
1686                | Some(tokens::Token::LiteralWord(w))
1687                | Some(tokens::Token::QuotedWord(w))
1688                | Some(tokens::Token::QuotedGlobWord(w)) => {
1689                    if !expr.is_empty() && !expr.ends_with(' ') && !expr.ends_with('(') {
1690                        expr.push(' ');
1691                    }
1692                    expr.push_str(w);
1693                    self.advance();
1694                }
1695                Some(tokens::Token::Semicolon) => {
1696                    expr.push(';');
1697                    self.advance();
1698                }
1699                Some(tokens::Token::Newline) => {
1700                    self.advance();
1701                }
1702                // Handle operators that are normally special tokens but valid in arithmetic
1703                Some(tokens::Token::RedirectIn) => {
1704                    self.advance();
1705                    // Check if next token starts with '=' to form '<='
1706                    if let Some(rest) = self.current_token_starts_with_eq() {
1707                        expr.push_str("<=");
1708                        if !rest.is_empty() {
1709                            expr.push_str(&rest);
1710                        }
1711                        self.advance();
1712                    } else {
1713                        expr.push('<');
1714                    }
1715                }
1716                Some(tokens::Token::RedirectOut) => {
1717                    self.advance();
1718                    // Check if next token starts with '=' to form '>='
1719                    if let Some(rest) = self.current_token_starts_with_eq() {
1720                        expr.push_str(">=");
1721                        if !rest.is_empty() {
1722                            expr.push_str(&rest);
1723                        }
1724                        self.advance();
1725                    } else {
1726                        expr.push('>');
1727                    }
1728                }
1729                Some(tokens::Token::And) => {
1730                    expr.push_str("&&");
1731                    self.advance();
1732                }
1733                Some(tokens::Token::Or) => {
1734                    expr.push_str("||");
1735                    self.advance();
1736                }
1737                Some(tokens::Token::Pipe) => {
1738                    expr.push('|');
1739                    self.advance();
1740                }
1741                Some(tokens::Token::Background) => {
1742                    expr.push('&');
1743                    self.advance();
1744                }
1745                Some(tokens::Token::Assignment) => {
1746                    expr.push('=');
1747                    self.advance();
1748                }
1749                // In arithmetic context, N> is a number followed by >, not a fd redirect
1750                Some(tokens::Token::RedirectFd(fd)) => {
1751                    let fd = *fd;
1752                    self.advance();
1753                    if let Some(rest) = self.current_token_starts_with_eq() {
1754                        // N>= → number >= ...
1755                        expr.push_str(&format!("{}>=", fd));
1756                        if !rest.is_empty() {
1757                            expr.push_str(&rest);
1758                        }
1759                        self.advance();
1760                    } else {
1761                        expr.push_str(&format!("{}>", fd));
1762                    }
1763                }
1764                Some(tokens::Token::RedirectFdAppend(fd)) => {
1765                    // N>> in arithmetic is N >> (right shift)
1766                    let fd = *fd;
1767                    expr.push_str(&format!("{}>>", fd));
1768                    self.advance();
1769                }
1770                Some(tokens::Token::RedirectFdIn(fd)) => {
1771                    let fd = *fd;
1772                    self.advance();
1773                    if let Some(rest) = self.current_token_starts_with_eq() {
1774                        expr.push_str(&format!("{}<=", fd));
1775                        if !rest.is_empty() {
1776                            expr.push_str(&rest);
1777                        }
1778                        self.advance();
1779                    } else {
1780                        expr.push_str(&format!("{}<", fd));
1781                    }
1782                }
1783                Some(tokens::Token::RedirectAppend) => {
1784                    // >> in arithmetic is right shift
1785                    expr.push_str(">>");
1786                    self.advance();
1787                }
1788                None => {
1789                    return Err(Error::parse(
1790                        "unexpected end of input in arithmetic command".to_string(),
1791                    ));
1792                }
1793                _ => {
1794                    self.advance();
1795                }
1796            }
1797        }
1798
1799        Ok(CompoundCommand::Arithmetic(expr.trim().to_string()))
1800    }
1801
1802    /// Parse function definition with 'function' keyword: function name { body }
1803    fn parse_function_keyword(&mut self) -> Result<Command> {
1804        let start_span = self.current_span;
1805        self.advance(); // consume 'function'
1806        self.skip_newlines()?;
1807
1808        // Get function name
1809        let name = match &self.current_token {
1810            Some(tokens::Token::Word(w)) => w.clone(),
1811            _ => return Err(self.error("expected function name")),
1812        };
1813        self.advance();
1814        self.skip_newlines()?;
1815
1816        // Optional () after name
1817        if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1818            self.advance(); // consume '('
1819            if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1820                return Err(Error::parse(
1821                    "expected ')' in function definition".to_string(),
1822                ));
1823            }
1824            self.advance(); // consume ')'
1825            self.skip_newlines()?;
1826        }
1827
1828        // Expect { for body
1829        if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
1830            return Err(Error::parse("expected '{' for function body".to_string()));
1831        }
1832
1833        // Parse body as brace group
1834        let body = self.parse_brace_group()?;
1835        let end_offset = self.current_command_end_offset();
1836
1837        Ok(Command::Function(FunctionDef {
1838            name,
1839            body: Box::new(Command::Compound(body, Vec::new())),
1840            source: self.source_slice(start_span.start.offset, end_offset),
1841            span: start_span.merge(self.current_span),
1842        }))
1843    }
1844
1845    /// Parse POSIX-style function definition: name() { body }
1846    fn parse_function_posix(&mut self) -> Result<Command> {
1847        let start_span = self.current_span;
1848        // Get function name
1849        let name = match &self.current_token {
1850            Some(tokens::Token::Word(w)) => w.clone(),
1851            _ => return Err(self.error("expected function name")),
1852        };
1853        self.advance();
1854
1855        // Consume ()
1856        if !matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1857            return Err(self.error("expected '(' in function definition"));
1858        }
1859        self.advance(); // consume '('
1860
1861        if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1862            return Err(self.error("expected ')' in function definition"));
1863        }
1864        self.advance(); // consume ')'
1865        self.skip_newlines()?;
1866
1867        // Expect { for body
1868        if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
1869            return Err(self.error("expected '{' for function body"));
1870        }
1871
1872        // Parse body as brace group
1873        let body = self.parse_brace_group()?;
1874        let end_offset = self.current_command_end_offset();
1875
1876        Ok(Command::Function(FunctionDef {
1877            name,
1878            body: Box::new(Command::Compound(body, Vec::new())),
1879            source: self.source_slice(start_span.start.offset, end_offset),
1880            span: start_span.merge(self.current_span),
1881        }))
1882    }
1883
1884    /// Parse commands until a terminating keyword
1885    fn parse_compound_list(&mut self, terminator: &str) -> Result<Vec<Command>> {
1886        self.parse_compound_list_until(&[terminator])
1887    }
1888
1889    /// Parse commands until one of the terminating keywords
1890    fn parse_compound_list_until(&mut self, terminators: &[&str]) -> Result<Vec<Command>> {
1891        let mut commands = Vec::new();
1892
1893        loop {
1894            self.skip_newlines()?;
1895
1896            // Check for terminators
1897            if let Some(tokens::Token::Word(w)) = &self.current_token
1898                && terminators.contains(&w.as_str())
1899            {
1900                break;
1901            }
1902
1903            if self.current_token.is_none() {
1904                break;
1905            }
1906
1907            if let Some(cmd) = self.parse_command_list()? {
1908                commands.push(cmd);
1909            } else {
1910                break;
1911            }
1912        }
1913
1914        Ok(commands)
1915    }
1916
1917    /// Reserved words that cannot start a simple command.
1918    /// These words are only special in command position, not as arguments.
1919    const NON_COMMAND_WORDS: &'static [&'static str] =
1920        &["then", "else", "elif", "fi", "do", "done", "esac", "in"];
1921
1922    /// Check if a word cannot start a command
1923    fn is_non_command_word(word: &str) -> bool {
1924        Self::NON_COMMAND_WORDS.contains(&word)
1925    }
1926
1927    /// Check if current token is a specific keyword
1928    fn is_keyword(&self, keyword: &str) -> bool {
1929        matches!(&self.current_token, Some(tokens::Token::Word(w)) if w == keyword)
1930    }
1931
1932    /// Expect a specific keyword
1933    fn expect_keyword(&mut self, keyword: &str) -> Result<()> {
1934        if self.is_keyword(keyword) {
1935            self.advance();
1936            Ok(())
1937        } else {
1938            Err(self.error(format!("expected '{}'", keyword)))
1939        }
1940    }
1941
1942    /// Strip surrounding quotes from a string value
1943    /// Split array element text respecting single and double quotes.
1944    /// Returns Vec of (element_text, was_quoted).
1945    /// Quoted elements have their outer quotes stripped.
1946    fn split_array_elements(s: &str) -> Vec<(String, bool)> {
1947        let mut result = Vec::new();
1948        let mut current = String::new();
1949        let mut chars = s.chars().peekable();
1950        let mut in_double_quote = false;
1951        let mut in_single_quote = false;
1952        let mut is_quoted = false;
1953
1954        while let Some(c) = chars.next() {
1955            match c {
1956                '"' if !in_single_quote => {
1957                    in_double_quote = !in_double_quote;
1958                    is_quoted = true;
1959                    // Don't include the quote character in output
1960                }
1961                '\'' if !in_double_quote => {
1962                    in_single_quote = !in_single_quote;
1963                    is_quoted = true;
1964                    // Don't include the quote character in output
1965                }
1966                '\\' if in_double_quote => {
1967                    // In double quotes, backslash escapes certain chars
1968                    if let Some(&next) = chars.peek() {
1969                        if matches!(next, '$' | '`' | '"' | '\\' | '\n') {
1970                            current.push(chars.next().unwrap());
1971                        } else {
1972                            current.push(c);
1973                        }
1974                    } else {
1975                        current.push(c);
1976                    }
1977                }
1978                c if c.is_ascii_whitespace() && !in_double_quote && !in_single_quote => {
1979                    if !current.is_empty() {
1980                        result.push((current.clone(), is_quoted));
1981                        current.clear();
1982                        is_quoted = false;
1983                    }
1984                }
1985                _ => {
1986                    current.push(c);
1987                }
1988            }
1989        }
1990        if !current.is_empty() {
1991            result.push((current, is_quoted));
1992        }
1993        result
1994    }
1995
1996    fn strip_quotes(s: &str) -> &str {
1997        if s.len() >= 2
1998            && ((s.starts_with('"') && s.ends_with('"'))
1999                || (s.starts_with('\'') && s.ends_with('\'')))
2000        {
2001            return &s[1..s.len() - 1];
2002        }
2003        s
2004    }
2005
2006    /// Find the assignment operator, ignoring `=` characters inside array subscripts.
2007    fn assignment_operator_pos(word: &str) -> Option<usize> {
2008        let mut bracket_depth = 0usize;
2009        let mut in_single_quote = false;
2010        let mut in_double_quote = false;
2011        let mut escaped = false;
2012
2013        for (pos, c) in word.char_indices() {
2014            if escaped {
2015                escaped = false;
2016                continue;
2017            }
2018
2019            if bracket_depth > 0 && c == '\\' {
2020                escaped = true;
2021                continue;
2022            }
2023
2024            match c {
2025                '\'' if bracket_depth > 0 && !in_double_quote => {
2026                    in_single_quote = !in_single_quote;
2027                }
2028                '"' if bracket_depth > 0 && !in_single_quote => {
2029                    in_double_quote = !in_double_quote;
2030                }
2031                '[' if !in_single_quote && !in_double_quote => {
2032                    bracket_depth += 1;
2033                }
2034                ']' if bracket_depth > 0 && !in_single_quote && !in_double_quote => {
2035                    bracket_depth -= 1;
2036                }
2037                '=' if bracket_depth == 0 => return Some(pos),
2038                _ => {}
2039            }
2040        }
2041
2042        None
2043    }
2044
2045    /// Check if a word is an assignment (NAME=value, NAME+=value, or NAME[index]=value)
2046    /// Returns (name, optional_index, value, is_append)
2047    fn is_assignment(word: &str) -> Option<(&str, Option<&str>, &str, bool)> {
2048        let eq_pos = Self::assignment_operator_pos(word)?;
2049        let mut lhs = &word[..eq_pos];
2050        let is_append = lhs.ends_with('+');
2051        if is_append {
2052            lhs = &lhs[..lhs.len() - 1];
2053        }
2054        let value = &word[eq_pos + 1..];
2055
2056        // Check for array subscript: name[index]
2057        if let Some(bracket_pos) = lhs.find('[') {
2058            let name = &lhs[..bracket_pos];
2059            // Validate name
2060            if name.is_empty() {
2061                return None;
2062            }
2063            let mut chars = name.chars();
2064            let first = chars.next().unwrap();
2065            if !first.is_ascii_alphabetic() && first != '_' {
2066                return None;
2067            }
2068            for c in chars {
2069                if !c.is_ascii_alphanumeric() && c != '_' {
2070                    return None;
2071                }
2072            }
2073            // Extract index (everything between [ and ])
2074            if lhs.ends_with(']') {
2075                let index = &lhs[bracket_pos + 1..lhs.len() - 1];
2076                return Some((name, Some(index), value, is_append));
2077            }
2078        } else {
2079            // Name must be valid identifier: starts with letter or _, followed by alnum or _
2080            if lhs.is_empty() {
2081                return None;
2082            }
2083            let mut chars = lhs.chars();
2084            let first = chars.next().unwrap();
2085            if !first.is_ascii_alphabetic() && first != '_' {
2086                return None;
2087            }
2088            for c in chars {
2089                if !c.is_ascii_alphanumeric() && c != '_' {
2090                    return None;
2091                }
2092            }
2093            return Some((lhs, None, value, is_append));
2094        }
2095        None
2096    }
2097
2098    /// Parse a simple command with redirections
2099    /// Collect array elements between `(` and `)` tokens into a `Vec<Word>`.
2100    fn collect_array_elements(&mut self) -> Vec<Word> {
2101        let mut elements = Vec::new();
2102        loop {
2103            match &self.current_token {
2104                Some(tokens::Token::RightParen) => {
2105                    self.advance();
2106                    break;
2107                }
2108                Some(tokens::Token::Word(elem))
2109                | Some(tokens::Token::LiteralWord(elem))
2110                | Some(tokens::Token::QuotedWord(elem))
2111                | Some(tokens::Token::QuotedGlobWord(elem)) => {
2112                    let elem_clone = elem.clone();
2113                    let word = if matches!(&self.current_token, Some(tokens::Token::LiteralWord(_)))
2114                    {
2115                        Word {
2116                            parts: vec![WordPart::Literal(elem_clone)],
2117                            quoted: true,
2118                            has_unquoted_glob: false,
2119                            part_quoted: Vec::new(),
2120                        }
2121                    } else if matches!(
2122                        &self.current_token,
2123                        Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_))
2124                    ) {
2125                        let mut w = self.parse_word(elem_clone);
2126                        w.quoted = true;
2127                        w
2128                    } else {
2129                        self.parse_word(elem_clone)
2130                    };
2131                    elements.push(word);
2132                    self.advance();
2133                }
2134                None => break,
2135                _ => {
2136                    self.advance();
2137                }
2138            }
2139        }
2140        elements
2141    }
2142
2143    /// Parse the value side of an assignment (`VAR=value`).
2144    /// Returns `Some((Assignment, needs_advance))` if the current word is an assignment.
2145    /// The bool indicates whether the caller must call `self.advance()` afterward.
2146    fn try_parse_assignment(&mut self, w: &str) -> Option<(Assignment, bool)> {
2147        let (name, index, value, is_append) = Self::is_assignment(w)?;
2148        let name = name.to_string();
2149        let index = index.map(|s| s.to_string());
2150        let value_str = value.to_string();
2151
2152        // Array literal in the token itself: arr=(a b c)
2153        if value_str.starts_with('(') && value_str.ends_with(')') {
2154            let inner = &value_str[1..value_str.len() - 1];
2155            let elements: Vec<Word> = Self::split_array_elements(inner)
2156                .into_iter()
2157                .map(|(s, quoted)| {
2158                    if quoted {
2159                        let mut w = self.parse_word(s);
2160                        w.quoted = true;
2161                        w
2162                    } else {
2163                        self.parse_word(s)
2164                    }
2165                })
2166                .collect();
2167            return Some((
2168                Assignment {
2169                    name,
2170                    index,
2171                    value: AssignmentValue::Array(elements),
2172                    append: is_append,
2173                },
2174                true,
2175            ));
2176        }
2177
2178        // Empty value — check for arr=(...) syntax with separate tokens
2179        if value_str.is_empty() {
2180            self.advance();
2181            if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
2182                self.advance(); // consume '('
2183                let elements = self.collect_array_elements();
2184                return Some((
2185                    Assignment {
2186                        name,
2187                        index,
2188                        value: AssignmentValue::Array(elements),
2189                        append: is_append,
2190                    },
2191                    false,
2192                ));
2193            }
2194            // Empty assignment: VAR=
2195            return Some((
2196                Assignment {
2197                    name,
2198                    index,
2199                    value: AssignmentValue::Scalar(Word::literal("")),
2200                    append: is_append,
2201                },
2202                false,
2203            ));
2204        }
2205
2206        // Quoted or plain scalar value
2207        let value_word = if value_str.starts_with('"') && value_str.ends_with('"') {
2208            let inner = Self::strip_quotes(&value_str);
2209            let mut w = self.parse_word(inner.to_string());
2210            w.quoted = true;
2211            w
2212        } else if value_str.starts_with('\'') && value_str.ends_with('\'') {
2213            let inner = Self::strip_quotes(&value_str);
2214            Word {
2215                parts: vec![WordPart::Literal(inner.to_string())],
2216                quoted: true,
2217                has_unquoted_glob: false,
2218                part_quoted: Vec::new(),
2219            }
2220        } else {
2221            self.parse_word(value_str)
2222        };
2223        Some((
2224            Assignment {
2225                name,
2226                index,
2227                value: AssignmentValue::Scalar(value_word),
2228                append: is_append,
2229            },
2230            true,
2231        ))
2232    }
2233
2234    /// Parse a compound array argument in arg position (e.g. `declare -a arr=(x y z)`).
2235    /// Called when the current word ends with `=` and the next token is `(`.
2236    /// Returns the compound word if successful, or `None` if not a compound assignment.
2237    fn try_parse_compound_array_arg(&mut self, saved_w: String) -> Option<Word> {
2238        if !matches!(self.current_token, Some(tokens::Token::LeftParen)) {
2239            return None;
2240        }
2241        self.advance(); // consume '('
2242        let mut compound = saved_w;
2243        compound.push('(');
2244        loop {
2245            match &self.current_token {
2246                Some(tokens::Token::RightParen) => {
2247                    compound.push(')');
2248                    self.advance();
2249                    break;
2250                }
2251                Some(tokens::Token::Word(elem))
2252                | Some(tokens::Token::LiteralWord(elem))
2253                | Some(tokens::Token::QuotedWord(elem))
2254                | Some(tokens::Token::QuotedGlobWord(elem)) => {
2255                    if !compound.ends_with('(') {
2256                        compound.push(' ');
2257                    }
2258                    compound.push_str(elem);
2259                    self.advance();
2260                }
2261                None => break,
2262                _ => {
2263                    self.advance();
2264                }
2265            }
2266        }
2267        Some(self.parse_word(compound))
2268    }
2269
2270    /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line.
2271    fn parse_heredoc_redirect(
2272        &mut self,
2273        strip_tabs: bool,
2274        redirects: &mut Vec<Redirect>,
2275    ) -> Result<()> {
2276        self.advance();
2277        // Get the delimiter word and track if it was quoted
2278        let (delimiter, quoted) = match &self.current_token {
2279            Some(tokens::Token::Word(w)) => (w.clone(), false),
2280            Some(tokens::Token::LiteralWord(w)) => (w.clone(), true),
2281            Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => {
2282                (w.clone(), true)
2283            }
2284            _ => return Err(Error::parse("expected delimiter after <<".to_string())),
2285        };
2286
2287        let (content, rest_of_line_chars) = self
2288            .lexer
2289            .read_heredoc_with_strip_metered(&delimiter, strip_tabs);
2290        self.tick_units(rest_of_line_chars)?;
2291
2292        // Strip leading tabs for <<-
2293        let content = if strip_tabs {
2294            let had_trailing_newline = content.ends_with('\n');
2295            let mut stripped: String = content
2296                .lines()
2297                .map(|l: &str| l.trim_start_matches('\t'))
2298                .collect::<Vec<_>>()
2299                .join("\n");
2300            if had_trailing_newline {
2301                stripped.push('\n');
2302            }
2303            stripped
2304        } else {
2305            content
2306        };
2307
2308        let target = if quoted {
2309            Word::quoted_literal(content)
2310        } else {
2311            self.parse_word(content)
2312        };
2313
2314        let kind = if strip_tabs {
2315            RedirectKind::HereDocStrip
2316        } else {
2317            RedirectKind::HereDoc
2318        };
2319
2320        redirects.push(Redirect {
2321            fd: None,
2322            fd_var: None,
2323            kind,
2324            target,
2325        });
2326
2327        // Advance so re-injected rest-of-line tokens are picked up
2328        self.advance();
2329
2330        // Consume any trailing redirects on the same line (e.g. `cat <<EOF > file`)
2331        self.collect_trailing_redirects(redirects);
2332        Ok(())
2333    }
2334
2335    /// Consume redirect tokens that follow a heredoc on the same line.
2336    fn collect_trailing_redirects(&mut self, redirects: &mut Vec<Redirect>) {
2337        while let Some(tok) = &self.current_token {
2338            match tok {
2339                tokens::Token::RedirectOut | tokens::Token::Clobber => {
2340                    let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) {
2341                        RedirectKind::Clobber
2342                    } else {
2343                        RedirectKind::Output
2344                    };
2345                    self.advance();
2346                    if let Ok(target) = self.expect_word() {
2347                        redirects.push(Redirect {
2348                            fd: None,
2349                            fd_var: None,
2350                            kind,
2351                            target,
2352                        });
2353                    }
2354                }
2355                tokens::Token::RedirectAppend => {
2356                    self.advance();
2357                    if let Ok(target) = self.expect_word() {
2358                        redirects.push(Redirect {
2359                            fd: None,
2360                            fd_var: None,
2361                            kind: RedirectKind::Append,
2362                            target,
2363                        });
2364                    }
2365                }
2366                tokens::Token::RedirectFd(fd) => {
2367                    let fd = *fd;
2368                    self.advance();
2369                    if let Ok(target) = self.expect_word() {
2370                        redirects.push(Redirect {
2371                            fd: Some(fd),
2372                            fd_var: None,
2373                            kind: RedirectKind::Output,
2374                            target,
2375                        });
2376                    }
2377                }
2378                tokens::Token::DupFdCloseOut(fd) => {
2379                    let fd = *fd;
2380                    self.advance();
2381                    redirects.push(Redirect {
2382                        fd: Some(fd),
2383                        fd_var: None,
2384                        kind: RedirectKind::DupOutput,
2385                        target: Word::literal("-"),
2386                    });
2387                }
2388                tokens::Token::DupInput => {
2389                    self.advance();
2390                    if let Ok(target) = self.expect_word() {
2391                        redirects.push(Redirect {
2392                            fd: Some(0),
2393                            fd_var: None,
2394                            kind: RedirectKind::DupInput,
2395                            target,
2396                        });
2397                    }
2398                }
2399                tokens::Token::DupFdIn(src_fd, dst_fd) => {
2400                    let src_fd = *src_fd;
2401                    let dst_fd = *dst_fd;
2402                    self.advance();
2403                    redirects.push(Redirect {
2404                        fd: Some(src_fd),
2405                        fd_var: None,
2406                        kind: RedirectKind::DupInput,
2407                        target: Word::literal(dst_fd.to_string()),
2408                    });
2409                }
2410                tokens::Token::DupFdClose(fd) => {
2411                    let fd = *fd;
2412                    self.advance();
2413                    redirects.push(Redirect {
2414                        fd: Some(fd),
2415                        fd_var: None,
2416                        kind: RedirectKind::DupInput,
2417                        target: Word::literal("-"),
2418                    });
2419                }
2420                tokens::Token::RedirectFdIn(fd) => {
2421                    let fd = *fd;
2422                    self.advance();
2423                    if let Ok(target) = self.expect_word() {
2424                        redirects.push(Redirect {
2425                            fd: Some(fd),
2426                            fd_var: None,
2427                            kind: RedirectKind::Input,
2428                            target,
2429                        });
2430                    }
2431                }
2432                _ => break,
2433            }
2434        }
2435    }
2436
2437    /// Extract fd-variable name from `{varname}` pattern in the last word.
2438    /// If the last word is a single literal `{identifier}`, pop it and return the name.
2439    /// Used for `exec {var}>file` / `exec {var}>&-` syntax.
2440    fn pop_fd_var(words: &mut Vec<Word>) -> Option<String> {
2441        if let Some(last) = words.last()
2442            && last.parts.len() == 1
2443            && let WordPart::Literal(ref s) = last.parts[0]
2444            && s.starts_with('{')
2445            && s.ends_with('}')
2446            && s.len() > 2
2447            && s[1..s.len() - 1]
2448                .chars()
2449                .all(|c| c.is_alphanumeric() || c == '_')
2450        {
2451            let var_name = s[1..s.len() - 1].to_string();
2452            words.pop();
2453            return Some(var_name);
2454        }
2455        None
2456    }
2457
2458    fn parse_simple_command(&mut self) -> Result<Option<SimpleCommand>> {
2459        self.tick()?;
2460        self.skip_newlines()?;
2461        self.check_error_token()?;
2462        let start_span = self.current_span;
2463
2464        let mut assignments = Vec::new();
2465        let mut words = Vec::new();
2466        let mut redirects = Vec::new();
2467
2468        loop {
2469            match &self.current_token {
2470                Some(tokens::Token::Word(w))
2471                | Some(tokens::Token::LiteralWord(w))
2472                | Some(tokens::Token::QuotedWord(w))
2473                | Some(tokens::Token::QuotedGlobWord(w)) => {
2474                    let is_literal =
2475                        matches!(&self.current_token, Some(tokens::Token::LiteralWord(_)));
2476                    let is_quoted = matches!(
2477                        &self.current_token,
2478                        Some(tokens::Token::QuotedWord(_)) | Some(tokens::Token::QuotedGlobWord(_))
2479                    );
2480                    let is_glob_quoted =
2481                        matches!(&self.current_token, Some(tokens::Token::QuotedGlobWord(_)));
2482                    // Clone early to release borrow on self.current_token
2483                    let w = w.clone();
2484
2485                    // Stop if this word cannot start a command (like 'then', 'fi', etc.)
2486                    if words.is_empty() && Self::is_non_command_word(&w) {
2487                        break;
2488                    }
2489
2490                    // Check for assignment (only before the command name, not for literal words)
2491                    if words.is_empty()
2492                        && !is_literal
2493                        && let Some((assignment, needs_advance)) = self.try_parse_assignment(&w)
2494                    {
2495                        if needs_advance {
2496                            self.advance();
2497                        }
2498                        assignments.push(assignment);
2499                        continue;
2500                    }
2501
2502                    // Handle compound array assignment in arg position:
2503                    // declare -a arr=(x y z) → arr=(x y z) as single arg
2504                    if w.ends_with('=') && !words.is_empty() {
2505                        self.advance();
2506                        if let Some(word) = self.try_parse_compound_array_arg(w.clone()) {
2507                            words.push(word);
2508                            continue;
2509                        }
2510                        // Not a compound assignment — treat as regular word
2511                        let word = if is_literal {
2512                            Word {
2513                                parts: vec![WordPart::Literal(w)],
2514                                quoted: true,
2515                                has_unquoted_glob: false,
2516                                part_quoted: Vec::new(),
2517                            }
2518                        } else {
2519                            let mut word = self.parse_word(w);
2520                            if is_quoted {
2521                                word.quoted = true;
2522                            }
2523                            if is_glob_quoted {
2524                                word.has_unquoted_glob = true;
2525                            }
2526                            word
2527                        };
2528                        words.push(word);
2529                        continue;
2530                    }
2531
2532                    let word = if is_literal {
2533                        Word {
2534                            parts: vec![WordPart::Literal(w)],
2535                            quoted: true,
2536                            has_unquoted_glob: false,
2537                            part_quoted: Vec::new(),
2538                        }
2539                    } else {
2540                        let mut word = self.parse_word(w);
2541                        if is_quoted {
2542                            word.quoted = true;
2543                        }
2544                        if is_glob_quoted {
2545                            word.has_unquoted_glob = true;
2546                        }
2547                        word
2548                    };
2549                    words.push(word);
2550                    self.advance();
2551                }
2552                Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => {
2553                    let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) {
2554                        RedirectKind::Clobber
2555                    } else {
2556                        RedirectKind::Output
2557                    };
2558                    let fd_var = Self::pop_fd_var(&mut words);
2559                    self.advance();
2560                    let target = self.expect_word()?;
2561                    redirects.push(Redirect {
2562                        fd: None,
2563                        fd_var,
2564                        kind,
2565                        target,
2566                    });
2567                }
2568                Some(tokens::Token::RedirectAppend) => {
2569                    let fd_var = Self::pop_fd_var(&mut words);
2570                    self.advance();
2571                    let target = self.expect_word()?;
2572                    redirects.push(Redirect {
2573                        fd: None,
2574                        fd_var,
2575                        kind: RedirectKind::Append,
2576                        target,
2577                    });
2578                }
2579                Some(tokens::Token::RedirectIn) => {
2580                    let fd_var = Self::pop_fd_var(&mut words);
2581                    self.advance();
2582                    let target = self.expect_word()?;
2583                    redirects.push(Redirect {
2584                        fd: None,
2585                        fd_var,
2586                        kind: RedirectKind::Input,
2587                        target,
2588                    });
2589                }
2590                Some(tokens::Token::HereString) => {
2591                    let fd_var = Self::pop_fd_var(&mut words);
2592                    self.advance();
2593                    let target = self.expect_word()?;
2594                    redirects.push(Redirect {
2595                        fd: None,
2596                        fd_var,
2597                        kind: RedirectKind::HereString,
2598                        target,
2599                    });
2600                }
2601                Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => {
2602                    let strip_tabs =
2603                        matches!(self.current_token, Some(tokens::Token::HereDocStrip));
2604                    self.parse_heredoc_redirect(strip_tabs, &mut redirects)?;
2605                    break;
2606                }
2607                Some(tokens::Token::ProcessSubIn) | Some(tokens::Token::ProcessSubOut) => {
2608                    let word = self.expect_word()?;
2609                    words.push(word);
2610                }
2611                Some(tokens::Token::RedirectBoth) => {
2612                    let fd_var = Self::pop_fd_var(&mut words);
2613                    self.advance();
2614                    let target = self.expect_word()?;
2615                    redirects.push(Redirect {
2616                        fd: None,
2617                        fd_var,
2618                        kind: RedirectKind::OutputBoth,
2619                        target,
2620                    });
2621                }
2622                Some(tokens::Token::DupOutput) => {
2623                    let fd_var = Self::pop_fd_var(&mut words);
2624                    self.advance();
2625                    let target = self.expect_word()?;
2626                    redirects.push(Redirect {
2627                        fd: if fd_var.is_some() { None } else { Some(1) },
2628                        fd_var,
2629                        kind: RedirectKind::DupOutput,
2630                        target,
2631                    });
2632                }
2633                Some(tokens::Token::RedirectFd(fd)) => {
2634                    let fd = *fd;
2635                    self.advance();
2636                    let target = self.expect_word()?;
2637                    redirects.push(Redirect {
2638                        fd: Some(fd),
2639                        fd_var: None,
2640                        kind: RedirectKind::Output,
2641                        target,
2642                    });
2643                }
2644                Some(tokens::Token::RedirectFdAppend(fd)) => {
2645                    let fd = *fd;
2646                    self.advance();
2647                    let target = self.expect_word()?;
2648                    redirects.push(Redirect {
2649                        fd: Some(fd),
2650                        fd_var: None,
2651                        kind: RedirectKind::Append,
2652                        target,
2653                    });
2654                }
2655                Some(tokens::Token::DupFd(src_fd, dst_fd)) => {
2656                    let src_fd = *src_fd;
2657                    let dst_fd = *dst_fd;
2658                    self.advance();
2659                    redirects.push(Redirect {
2660                        fd: Some(src_fd),
2661                        fd_var: None,
2662                        kind: RedirectKind::DupOutput,
2663                        target: Word::literal(dst_fd.to_string()),
2664                    });
2665                }
2666                Some(tokens::Token::DupFdCloseOut(fd)) => {
2667                    let fd = *fd;
2668                    self.advance();
2669                    redirects.push(Redirect {
2670                        fd: Some(fd),
2671                        fd_var: None,
2672                        kind: RedirectKind::DupOutput,
2673                        target: Word::literal("-"),
2674                    });
2675                }
2676                Some(tokens::Token::DupInput) => {
2677                    let fd_var = Self::pop_fd_var(&mut words);
2678                    self.advance();
2679                    let target = self.expect_word()?;
2680                    redirects.push(Redirect {
2681                        fd: if fd_var.is_some() { None } else { Some(0) },
2682                        fd_var,
2683                        kind: RedirectKind::DupInput,
2684                        target,
2685                    });
2686                }
2687                Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => {
2688                    let src_fd = *src_fd;
2689                    let dst_fd = *dst_fd;
2690                    self.advance();
2691                    redirects.push(Redirect {
2692                        fd: Some(src_fd),
2693                        fd_var: None,
2694                        kind: RedirectKind::DupInput,
2695                        target: Word::literal(dst_fd.to_string()),
2696                    });
2697                }
2698                Some(tokens::Token::DupFdClose(fd)) => {
2699                    let fd = *fd;
2700                    self.advance();
2701                    redirects.push(Redirect {
2702                        fd: Some(fd),
2703                        fd_var: None,
2704                        kind: RedirectKind::DupInput,
2705                        target: Word::literal("-"),
2706                    });
2707                }
2708                Some(tokens::Token::RedirectFdIn(fd)) => {
2709                    let fd = *fd;
2710                    self.advance();
2711                    let target = self.expect_word()?;
2712                    redirects.push(Redirect {
2713                        fd: Some(fd),
2714                        fd_var: None,
2715                        kind: RedirectKind::Input,
2716                        target,
2717                    });
2718                }
2719                // { and } as arguments (not in command position) are literal words
2720                Some(tokens::Token::LeftBrace) | Some(tokens::Token::RightBrace)
2721                    if !words.is_empty() =>
2722                {
2723                    let sym = if matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
2724                        "{"
2725                    } else {
2726                        "}"
2727                    };
2728                    words.push(Word::literal(sym));
2729                    self.advance();
2730                }
2731                Some(tokens::Token::Newline)
2732                | Some(tokens::Token::Semicolon)
2733                | Some(tokens::Token::Pipe)
2734                | Some(tokens::Token::And)
2735                | Some(tokens::Token::Or)
2736                | None => break,
2737                _ => break,
2738            }
2739        }
2740
2741        // Handle assignment-only commands (VAR=value with no command)
2742        if words.is_empty() && !assignments.is_empty() {
2743            return Ok(Some(SimpleCommand {
2744                name: Word::literal(""),
2745                args: Vec::new(),
2746                redirects,
2747                assignments,
2748                span: start_span.merge(self.current_span),
2749            }));
2750        }
2751
2752        if words.is_empty() {
2753            return Ok(None);
2754        }
2755
2756        let name = words.remove(0);
2757        let args = words;
2758
2759        Ok(Some(SimpleCommand {
2760            name,
2761            args,
2762            redirects,
2763            assignments,
2764            span: start_span.merge(self.current_span),
2765        }))
2766    }
2767
2768    /// Expect a word token and return it as a Word
2769    fn expect_word(&mut self) -> Result<Word> {
2770        match &self.current_token {
2771            Some(tokens::Token::Word(w)) => {
2772                let word = self.parse_word(w.clone());
2773                self.advance();
2774                Ok(word)
2775            }
2776            Some(tokens::Token::LiteralWord(w)) => {
2777                // Single-quoted: no variable expansion
2778                let word = Word {
2779                    parts: vec![WordPart::Literal(w.clone())],
2780                    quoted: true,
2781                    has_unquoted_glob: false,
2782                    part_quoted: Vec::new(),
2783                };
2784                self.advance();
2785                Ok(word)
2786            }
2787            Some(tokens::Token::QuotedWord(w)) | Some(tokens::Token::QuotedGlobWord(w)) => {
2788                // Double-quoted: parse for variable expansion
2789                let word = self.parse_word(w.clone());
2790                self.advance();
2791                Ok(word)
2792            }
2793            Some(tokens::Token::ProcessSubIn) | Some(tokens::Token::ProcessSubOut) => {
2794                // Process substitution <(cmd) or >(cmd).
2795                //
2796                // Issue #1333: extract the body from the original source via span
2797                // offsets rather than reconstructing a string from the token stream.
2798                // Token-string reconstruction wraps `QuotedGlobWord` in `"..."`,
2799                // erasing the unquoted-glob boundary and breaking glob expansion
2800                // for patterns like `./"$var"*.ext` inside `<(...)`.
2801                let is_input = matches!(self.current_token, Some(tokens::Token::ProcessSubIn));
2802                // Span end of the `<(` / `>(` token is exactly the start of the body.
2803                let body_start_offset = self.current_span.end.offset;
2804                self.advance();
2805
2806                let mut depth = 1;
2807                let body_end_offset;
2808                loop {
2809                    match &self.current_token {
2810                        Some(tokens::Token::LeftParen) => {
2811                            depth += 1;
2812                            self.advance();
2813                        }
2814                        Some(tokens::Token::RightParen) => {
2815                            depth -= 1;
2816                            if depth == 0 {
2817                                // Body ends at the start of the matching `)`.
2818                                body_end_offset = self.current_span.start.offset;
2819                                self.advance();
2820                                break;
2821                            }
2822                            self.advance();
2823                        }
2824                        Some(tokens::Token::ProcessSubIn) | Some(tokens::Token::ProcessSubOut) => {
2825                            // Nested <( / >( opens another paren level.
2826                            depth += 1;
2827                            self.advance();
2828                        }
2829                        Some(tokens::Token::Error(e)) => {
2830                            let msg = e.clone();
2831                            self.advance();
2832                            return Err(Error::parse(format!(
2833                                "lexer error in process substitution: {}",
2834                                msg
2835                            )));
2836                        }
2837                        None => {
2838                            return Err(Error::parse(
2839                                "unexpected end of input in process substitution".to_string(),
2840                            ));
2841                        }
2842                        _ => {
2843                            self.advance();
2844                        }
2845                    }
2846                }
2847
2848                let body_len = body_end_offset.saturating_sub(body_start_offset);
2849                self.tick_units(body_len)?;
2850
2851                // THREAT[TM-DOS-021]: Charge nested process-substitution parsers
2852                // against the same depth/fuel/timeout budget. Borrow the original
2853                // source slice instead of cloning it so nested `<(...)` cannot retain
2854                // repeated near-full-size String bodies; charge body bytes because the
2855                // child lexer must rescan whitespace/comments that produce no tokens.
2856                if self.current_depth >= self.max_depth {
2857                    return Err(Error::parse(format!(
2858                        "AST nesting too deep ({} levels, max {})",
2859                        self.current_depth + 1,
2860                        self.max_depth
2861                    )));
2862                }
2863                let inner_result = {
2864                    let cmd_src = self
2865                        .input
2866                        .get(body_start_offset..body_end_offset)
2867                        .unwrap_or("");
2868                    let mut inner_parser = Parser::with_limits_and_timeout(
2869                        cmd_src,
2870                        self.max_depth,
2871                        self.fuel,
2872                        self.timeout,
2873                    );
2874                    inner_parser.current_depth = self.current_depth + 1;
2875                    inner_parser.started_at = self.started_at;
2876                    let result = inner_parser.parse_script();
2877                    (result, inner_parser.fuel)
2878                };
2879                let (parse_result, remaining_fuel) = inner_result;
2880                self.fuel = remaining_fuel;
2881                let commands = match parse_result {
2882                    Ok(script) => script.commands,
2883                    Err(err) if Self::is_parser_budget_error(&err) => return Err(err),
2884                    Err(_) => Vec::new(),
2885                };
2886
2887                Ok(Word {
2888                    parts: vec![WordPart::ProcessSubstitution { commands, is_input }],
2889                    quoted: false,
2890                    has_unquoted_glob: false,
2891                    part_quoted: Vec::new(),
2892                })
2893            }
2894            _ => Err(self.error("expected word")),
2895        }
2896    }
2897
2898    fn is_parser_budget_error(err: &Error) -> bool {
2899        match err {
2900            Error::ResourceLimit(_) => true,
2901            Error::Parse { message, .. } => {
2902                message.starts_with("AST nesting too deep")
2903                    || message.starts_with("parser fuel exhausted")
2904            }
2905            _ => false,
2906        }
2907    }
2908
2909    // Helper methods for word handling - kept for potential future use
2910    #[allow(dead_code)]
2911    /// Convert current word token to Word (handles Word, LiteralWord, QuotedWord)
2912    fn current_word_to_word(&self) -> Option<Word> {
2913        match &self.current_token {
2914            Some(tokens::Token::Word(w))
2915            | Some(tokens::Token::QuotedWord(w))
2916            | Some(tokens::Token::QuotedGlobWord(w)) => Some(self.parse_word(w.clone())),
2917            Some(tokens::Token::LiteralWord(w)) => Some(Word {
2918                parts: vec![WordPart::Literal(w.clone())],
2919                quoted: true,
2920                has_unquoted_glob: false,
2921                part_quoted: Vec::new(),
2922            }),
2923            _ => None,
2924        }
2925    }
2926
2927    #[allow(dead_code)]
2928    /// Check if current token is a word (Word, LiteralWord, or QuotedWord)
2929    fn is_current_word(&self) -> bool {
2930        matches!(
2931            &self.current_token,
2932            Some(tokens::Token::Word(_))
2933                | Some(tokens::Token::LiteralWord(_))
2934                | Some(tokens::Token::QuotedWord(_))
2935                | Some(tokens::Token::QuotedGlobWord(_))
2936        )
2937    }
2938
2939    #[allow(dead_code)]
2940    /// Get the string content if current token is a word
2941    fn current_word_str(&self) -> Option<String> {
2942        match &self.current_token {
2943            Some(tokens::Token::Word(w))
2944            | Some(tokens::Token::LiteralWord(w))
2945            | Some(tokens::Token::QuotedWord(w))
2946            | Some(tokens::Token::QuotedGlobWord(w)) => Some(w.clone()),
2947            _ => None,
2948        }
2949    }
2950
2951    /// Parse a word string into a Word with proper parts (variables, literals)
2952    fn parse_word(&self, s: String) -> Word {
2953        let mut parts = Vec::new();
2954        let mut part_quoted = Vec::new();
2955        let mut chars = s.chars().peekable();
2956        let mut current = String::new();
2957        let mut in_quoted_segment = false;
2958        macro_rules! push_part {
2959            ($part:expr) => {{
2960                parts.push($part);
2961                part_quoted.push(in_quoted_segment);
2962            }};
2963        }
2964
2965        while let Some(ch) = chars.next() {
2966            if ch == '\x00' {
2967                // NUL sentinel from lexer: next char is a literal (escaped in source).
2968                if let Some(literal_ch) = chars.next() {
2969                    current.push(literal_ch);
2970                }
2971            } else if ch == '\u{1e}' {
2972                in_quoted_segment = true;
2973            } else if ch == '\u{1f}' {
2974                in_quoted_segment = false;
2975            } else if ch == '$' {
2976                // Flush current literal
2977                if !current.is_empty() {
2978                    push_part!(WordPart::Literal(std::mem::take(&mut current)));
2979                }
2980
2981                // Check for $'...' - ANSI-C quoting
2982                if chars.peek() == Some(&'\'') {
2983                    chars.next(); // consume opening '
2984                    let mut ansi = String::new();
2985                    while let Some(c) = chars.next() {
2986                        if c == '\'' {
2987                            break;
2988                        }
2989                        if c == '\\' {
2990                            if let Some(esc) = chars.next() {
2991                                match esc {
2992                                    'n' => ansi.push('\n'),
2993                                    't' => ansi.push('\t'),
2994                                    'r' => ansi.push('\r'),
2995                                    'a' => ansi.push('\x07'),
2996                                    'b' => ansi.push('\x08'),
2997                                    'e' | 'E' => ansi.push('\x1B'),
2998                                    '\\' => ansi.push('\\'),
2999                                    '\'' => ansi.push('\''),
3000                                    _ => {
3001                                        ansi.push('\\');
3002                                        ansi.push(esc);
3003                                    }
3004                                }
3005                            }
3006                        } else {
3007                            ansi.push(c);
3008                        }
3009                    }
3010                    push_part!(WordPart::Literal(ansi));
3011                } else if chars.peek() == Some(&'(') {
3012                    // Check for $( - command substitution or arithmetic
3013                    chars.next(); // consume first '('
3014
3015                    // Check for $(( - arithmetic expansion
3016                    if chars.peek() == Some(&'(') {
3017                        chars.next(); // consume second '('
3018                        let mut expr = String::new();
3019                        let mut depth = 2;
3020                        for c in chars.by_ref() {
3021                            if c == '(' {
3022                                depth += 1;
3023                                expr.push(c);
3024                            } else if c == ')' {
3025                                depth -= 1;
3026                                if depth == 0 {
3027                                    break;
3028                                }
3029                                expr.push(c);
3030                            } else {
3031                                expr.push(c);
3032                            }
3033                        }
3034                        // Remove trailing ) if present
3035                        if expr.ends_with(')') {
3036                            expr.pop();
3037                        }
3038                        push_part!(WordPart::ArithmeticExpansion(expr));
3039                    } else {
3040                        // Command substitution $(...)
3041                        let mut cmd_str = String::new();
3042                        let mut depth = 1;
3043                        for c in chars.by_ref() {
3044                            if c == '(' {
3045                                depth += 1;
3046                                cmd_str.push(c);
3047                            } else if c == ')' {
3048                                depth -= 1;
3049                                if depth == 0 {
3050                                    break;
3051                                }
3052                                cmd_str.push(c);
3053                            } else {
3054                                cmd_str.push(c);
3055                            }
3056                        }
3057                        // THREAT[TM-DOS-021]: Propagate parent parser limits to child parser
3058                        // to prevent depth limit bypass via nested command substitution.
3059                        let remaining_depth = self.max_depth.saturating_sub(self.current_depth);
3060                        let inner_parser =
3061                            Parser::with_limits(&cmd_str, remaining_depth, self.fuel);
3062                        if let Ok(script) = inner_parser.parse() {
3063                            push_part!(WordPart::CommandSubstitution(script.commands));
3064                        }
3065                    }
3066                } else if chars.peek() == Some(&'{') {
3067                    // ${VAR} format with possible parameter expansion
3068                    chars.next(); // consume '{'
3069
3070                    // Check for ${#var} or ${#arr[@]} - length expansion
3071                    if chars.peek() == Some(&'#') {
3072                        chars.next(); // consume '#'
3073                        let mut var_name = String::new();
3074                        while let Some(&c) = chars.peek() {
3075                            if c == '}' || c == '[' {
3076                                break;
3077                            }
3078                            var_name.push(chars.next().unwrap());
3079                        }
3080                        // Check for array length ${#arr[@]} or ${#arr[*]}
3081                        if chars.peek() == Some(&'[') {
3082                            chars.next(); // consume '['
3083                            let mut index = String::new();
3084                            while let Some(&c) = chars.peek() {
3085                                if c == ']' {
3086                                    chars.next();
3087                                    break;
3088                                }
3089                                index.push(chars.next().unwrap());
3090                            }
3091                            // Consume closing }
3092                            if chars.peek() == Some(&'}') {
3093                                chars.next();
3094                            }
3095                            if index == "@" || index == "*" {
3096                                push_part!(WordPart::ArrayLength(var_name));
3097                            } else {
3098                                // ${#arr[n]} - length of element (same as ${#arr[n]})
3099                                push_part!(WordPart::Length(format!("{}[{}]", var_name, index)));
3100                            }
3101                        } else {
3102                            // Consume closing }
3103                            if chars.peek() == Some(&'}') {
3104                                chars.next();
3105                            }
3106                            push_part!(WordPart::Length(var_name));
3107                        }
3108                    } else if chars.peek() == Some(&'!') {
3109                        // Check for ${!arr[@]} or ${!arr[*]} - array indices
3110                        // or ${!var} - indirect expansion
3111                        chars.next(); // consume '!'
3112                        let mut var_name = String::new();
3113                        while let Some(&c) = chars.peek() {
3114                            if c == '}'
3115                                || c == '['
3116                                || c == '*'
3117                                || c == '@'
3118                                || c == ':'
3119                                || c == '-'
3120                                || c == '='
3121                                || c == '+'
3122                                || c == '?'
3123                            {
3124                                break;
3125                            }
3126                            var_name.push(chars.next().unwrap());
3127                        }
3128                        // Check for array indices ${!arr[@]} or ${!arr[*]}
3129                        if chars.peek() == Some(&'[') {
3130                            chars.next(); // consume '['
3131                            let mut index = String::new();
3132                            while let Some(&c) = chars.peek() {
3133                                if c == ']' {
3134                                    chars.next();
3135                                    break;
3136                                }
3137                                index.push(chars.next().unwrap());
3138                            }
3139                            // Consume closing }
3140                            if chars.peek() == Some(&'}') {
3141                                chars.next();
3142                            }
3143                            if index == "@" || index == "*" {
3144                                push_part!(WordPart::ArrayIndices(var_name));
3145                            } else {
3146                                // ${!arr[n]} - not standard, treat as variable
3147                                push_part!(WordPart::Variable(format!("!{}[{}]", var_name, index)));
3148                            }
3149                        } else if chars.peek() == Some(&'}') {
3150                            // ${!var} - indirect expansion (no operator)
3151                            chars.next(); // consume '}'
3152                            push_part!(WordPart::IndirectExpansion {
3153                                name: var_name,
3154                                operator: None,
3155                                operand: String::new(),
3156                                colon_variant: false,
3157                            });
3158                        } else if chars.peek() == Some(&':') {
3159                            // ${!var:op} - indirect expansion with colon operator
3160                            let mut lookahead = chars.clone();
3161                            lookahead.next(); // skip ':'
3162                            if matches!(
3163                                lookahead.peek(),
3164                                Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
3165                            ) {
3166                                chars.next(); // consume ':'
3167                                let op_char = chars.next().unwrap();
3168                                let operand = self.read_brace_operand(&mut chars);
3169                                let operator = match op_char {
3170                                    '-' => ParameterOp::UseDefault,
3171                                    '=' => ParameterOp::AssignDefault,
3172                                    '+' => ParameterOp::UseReplacement,
3173                                    '?' => ParameterOp::Error,
3174                                    _ => unreachable!(),
3175                                };
3176                                push_part!(WordPart::IndirectExpansion {
3177                                    name: var_name,
3178                                    operator: Some(operator),
3179                                    operand,
3180                                    colon_variant: true,
3181                                });
3182                            } else {
3183                                // Not a param op after ':', treat as prefix match fallback
3184                                let mut suffix = String::new();
3185                                while let Some(&c) = chars.peek() {
3186                                    if c == '}' {
3187                                        chars.next();
3188                                        break;
3189                                    }
3190                                    suffix.push(chars.next().unwrap());
3191                                }
3192                                push_part!(WordPart::Variable(format!("!{}{}", var_name, suffix)));
3193                            }
3194                        } else if matches!(
3195                            chars.peek(),
3196                            Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
3197                        ) {
3198                            // ${!var-op} - indirect expansion with non-colon operator
3199                            let op_char = chars.next().unwrap();
3200                            let operand = self.read_brace_operand(&mut chars);
3201                            let operator = match op_char {
3202                                '-' => ParameterOp::UseDefault,
3203                                '=' => ParameterOp::AssignDefault,
3204                                '+' => ParameterOp::UseReplacement,
3205                                '?' => ParameterOp::Error,
3206                                _ => unreachable!(),
3207                            };
3208                            push_part!(WordPart::IndirectExpansion {
3209                                name: var_name,
3210                                operator: Some(operator),
3211                                operand,
3212                                colon_variant: false,
3213                            });
3214                        } else {
3215                            // ${!prefix*} or ${!prefix@} - prefix matching
3216                            let mut suffix = String::new();
3217                            while let Some(&c) = chars.peek() {
3218                                if c == '}' {
3219                                    chars.next();
3220                                    break;
3221                                }
3222                                suffix.push(chars.next().unwrap());
3223                            }
3224                            // Strip trailing * or @
3225                            if suffix.ends_with('*') || suffix.ends_with('@') {
3226                                let full_prefix =
3227                                    format!("{}{}", var_name, &suffix[..suffix.len() - 1]);
3228                                push_part!(WordPart::PrefixMatch(full_prefix));
3229                            } else {
3230                                push_part!(WordPart::Variable(format!("!{}{}", var_name, suffix)));
3231                            }
3232                        }
3233                    } else {
3234                        // Read variable name
3235                        let mut var_name = String::new();
3236                        while let Some(&c) = chars.peek() {
3237                            if c.is_ascii_alphanumeric() || c == '_' {
3238                                var_name.push(chars.next().unwrap());
3239                            } else {
3240                                break;
3241                            }
3242                        }
3243
3244                        // Handle special parameters: ${@...}, ${*...}
3245                        if var_name.is_empty()
3246                            && let Some(&c) = chars.peek()
3247                            && matches!(c, '@' | '*')
3248                        {
3249                            var_name.push(chars.next().unwrap());
3250                        }
3251
3252                        // Check for array access ${arr[index]} or ${arr[@]:offset:length}
3253                        if chars.peek() == Some(&'[') {
3254                            chars.next(); // consume '['
3255                            let mut index = String::new();
3256                            // Track nesting so nested ${...} containing
3257                            // brackets (e.g. ${#arr[@]}) don't prematurely
3258                            // close the subscript.
3259                            let mut bracket_depth: i32 = 0;
3260                            let mut brace_depth: i32 = 0;
3261                            while let Some(&c) = chars.peek() {
3262                                if c == ']' && bracket_depth == 0 && brace_depth == 0 {
3263                                    chars.next();
3264                                    break;
3265                                }
3266                                match c {
3267                                    '[' => bracket_depth += 1,
3268                                    ']' => bracket_depth -= 1,
3269                                    '$' => {
3270                                        index.push(chars.next().unwrap());
3271                                        if chars.peek() == Some(&'{') {
3272                                            brace_depth += 1;
3273                                            index.push(chars.next().unwrap());
3274                                            continue;
3275                                        }
3276                                        continue;
3277                                    }
3278                                    '{' => brace_depth += 1,
3279                                    '}' if brace_depth > 0 => brace_depth -= 1,
3280                                    '}' => {}
3281                                    _ => {}
3282                                }
3283                                index.push(chars.next().unwrap());
3284                            }
3285                            // Strip surrounding quotes from index (e.g. "foo" -> foo)
3286                            if index.len() >= 2
3287                                && ((index.starts_with('"') && index.ends_with('"'))
3288                                    || (index.starts_with('\'') && index.ends_with('\'')))
3289                            {
3290                                index = index[1..index.len() - 1].to_string();
3291                            }
3292                            // After ], check for operators on array subscripts
3293                            if let Some(&next_c) = chars.peek() {
3294                                if next_c == ':' {
3295                                    // Peek ahead to distinguish param ops (:- := :+ :?) from slice (:N)
3296                                    let mut lookahead = chars.clone();
3297                                    lookahead.next(); // skip ':'
3298                                    let is_param_op = matches!(
3299                                        lookahead.peek(),
3300                                        Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
3301                                    );
3302                                    if is_param_op {
3303                                        chars.next(); // consume ':'
3304                                        let arr_name = format!("{}[{}]", var_name, index);
3305                                        let op_char = chars.next().unwrap();
3306                                        let operand = self.read_brace_operand(&mut chars);
3307                                        let operator = match op_char {
3308                                            '-' => ParameterOp::UseDefault,
3309                                            '=' => ParameterOp::AssignDefault,
3310                                            '+' => ParameterOp::UseReplacement,
3311                                            '?' => ParameterOp::Error,
3312                                            _ => unreachable!(),
3313                                        };
3314                                        push_part!(WordPart::ParameterExpansion {
3315                                            name: arr_name,
3316                                            operator,
3317                                            operand,
3318                                            colon_variant: true,
3319                                        });
3320                                    } else {
3321                                        // Array slice ${arr[@]:offset:length}
3322                                        chars.next(); // consume ':'
3323                                        let mut offset = String::new();
3324                                        while let Some(&c) = chars.peek() {
3325                                            if c == ':' || c == '}' {
3326                                                break;
3327                                            }
3328                                            offset.push(chars.next().unwrap());
3329                                        }
3330                                        let length = if chars.peek() == Some(&':') {
3331                                            chars.next();
3332                                            let mut len = String::new();
3333                                            while let Some(&c) = chars.peek() {
3334                                                if c == '}' {
3335                                                    break;
3336                                                }
3337                                                len.push(chars.next().unwrap());
3338                                            }
3339                                            Some(len)
3340                                        } else {
3341                                            None
3342                                        };
3343                                        if chars.peek() == Some(&'}') {
3344                                            chars.next();
3345                                        }
3346                                        push_part!(WordPart::ArraySlice {
3347                                            name: var_name,
3348                                            offset,
3349                                            length,
3350                                        });
3351                                    }
3352                                } else if matches!(next_c, '-' | '+' | '=' | '?') {
3353                                    // Non-colon operators on array: ${arr[@]-default}
3354                                    let arr_name = format!("{}[{}]", var_name, index);
3355                                    let op_char = chars.next().unwrap();
3356                                    let operand = self.read_brace_operand(&mut chars);
3357                                    let operator = match op_char {
3358                                        '-' => ParameterOp::UseDefault,
3359                                        '=' => ParameterOp::AssignDefault,
3360                                        '+' => ParameterOp::UseReplacement,
3361                                        '?' => ParameterOp::Error,
3362                                        _ => unreachable!(),
3363                                    };
3364                                    push_part!(WordPart::ParameterExpansion {
3365                                        name: arr_name,
3366                                        operator,
3367                                        operand,
3368                                        colon_variant: false,
3369                                    });
3370                                } else {
3371                                    // Plain array access ${arr[index]}
3372                                    if chars.peek() == Some(&'}') {
3373                                        chars.next();
3374                                    }
3375                                    push_part!(WordPart::ArrayAccess {
3376                                        name: var_name,
3377                                        index,
3378                                    });
3379                                }
3380                            } else {
3381                                push_part!(WordPart::ArrayAccess {
3382                                    name: var_name,
3383                                    index,
3384                                });
3385                            }
3386                        } else if let Some(&c) = chars.peek() {
3387                            // Check for operator
3388                            match c {
3389                                ':' => {
3390                                    chars.next(); // consume ':'
3391                                    match chars.peek() {
3392                                        Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?') => {
3393                                            let op_char = chars.next().unwrap();
3394                                            let operand = self.read_brace_operand(&mut chars);
3395                                            let operator = match op_char {
3396                                                '-' => ParameterOp::UseDefault,
3397                                                '=' => ParameterOp::AssignDefault,
3398                                                '+' => ParameterOp::UseReplacement,
3399                                                '?' => ParameterOp::Error,
3400                                                _ => unreachable!(),
3401                                            };
3402                                            push_part!(WordPart::ParameterExpansion {
3403                                                name: var_name,
3404                                                operator,
3405                                                operand,
3406                                                colon_variant: true,
3407                                            });
3408                                        }
3409                                        _ => {
3410                                            // Substring extraction ${var:offset} or ${var:offset:length}
3411                                            let mut offset = String::new();
3412                                            while let Some(&ch) = chars.peek() {
3413                                                if ch == ':' || ch == '}' {
3414                                                    break;
3415                                                }
3416                                                offset.push(chars.next().unwrap());
3417                                            }
3418                                            let length = if chars.peek() == Some(&':') {
3419                                                chars.next(); // consume ':'
3420                                                let mut len = String::new();
3421                                                while let Some(&ch) = chars.peek() {
3422                                                    if ch == '}' {
3423                                                        break;
3424                                                    }
3425                                                    len.push(chars.next().unwrap());
3426                                                }
3427                                                Some(len)
3428                                            } else {
3429                                                None
3430                                            };
3431                                            if chars.peek() == Some(&'}') {
3432                                                chars.next();
3433                                            }
3434                                            push_part!(WordPart::Substring {
3435                                                name: var_name,
3436                                                offset,
3437                                                length,
3438                                            });
3439                                        }
3440                                    }
3441                                }
3442                                // Non-colon test operators: ${var-default}, ${var+alt}, ${var=assign}, ${var?err}
3443                                '-' | '=' | '+' | '?' => {
3444                                    let op_char = chars.next().unwrap();
3445                                    let operand = self.read_brace_operand(&mut chars);
3446                                    let operator = match op_char {
3447                                        '-' => ParameterOp::UseDefault,
3448                                        '=' => ParameterOp::AssignDefault,
3449                                        '+' => ParameterOp::UseReplacement,
3450                                        '?' => ParameterOp::Error,
3451                                        _ => unreachable!(),
3452                                    };
3453                                    push_part!(WordPart::ParameterExpansion {
3454                                        name: var_name,
3455                                        operator,
3456                                        operand,
3457                                        colon_variant: false,
3458                                    });
3459                                }
3460                                '#' => {
3461                                    chars.next();
3462                                    if chars.peek() == Some(&'#') {
3463                                        chars.next();
3464                                        let op = self.read_brace_operand(&mut chars);
3465                                        push_part!(WordPart::ParameterExpansion {
3466                                            name: var_name,
3467                                            operator: ParameterOp::RemovePrefixLong,
3468                                            operand: op,
3469                                            colon_variant: false,
3470                                        });
3471                                    } else {
3472                                        let op = self.read_brace_operand(&mut chars);
3473                                        push_part!(WordPart::ParameterExpansion {
3474                                            name: var_name,
3475                                            operator: ParameterOp::RemovePrefixShort,
3476                                            operand: op,
3477                                            colon_variant: false,
3478                                        });
3479                                    }
3480                                }
3481                                '%' => {
3482                                    chars.next();
3483                                    if chars.peek() == Some(&'%') {
3484                                        chars.next();
3485                                        let op = self.read_brace_operand(&mut chars);
3486                                        push_part!(WordPart::ParameterExpansion {
3487                                            name: var_name,
3488                                            operator: ParameterOp::RemoveSuffixLong,
3489                                            operand: op,
3490                                            colon_variant: false,
3491                                        });
3492                                    } else {
3493                                        let op = self.read_brace_operand(&mut chars);
3494                                        push_part!(WordPart::ParameterExpansion {
3495                                            name: var_name,
3496                                            operator: ParameterOp::RemoveSuffixShort,
3497                                            operand: op,
3498                                            colon_variant: false,
3499                                        });
3500                                    }
3501                                }
3502                                '/' => {
3503                                    chars.next();
3504                                    let replace_all = if chars.peek() == Some(&'/') {
3505                                        chars.next();
3506                                        true
3507                                    } else {
3508                                        false
3509                                    };
3510                                    let mut pattern = String::new();
3511                                    while let Some(&ch) = chars.peek() {
3512                                        if ch == '/' || ch == '}' {
3513                                            break;
3514                                        }
3515                                        if ch == '\\' {
3516                                            chars.next();
3517                                            if let Some(&next) = chars.peek()
3518                                                && next == '/'
3519                                            {
3520                                                pattern.push(chars.next().unwrap());
3521                                                continue;
3522                                            }
3523                                            pattern.push('\\');
3524                                            continue;
3525                                        }
3526                                        pattern.push(chars.next().unwrap());
3527                                    }
3528                                    let replacement = if chars.peek() == Some(&'/') {
3529                                        chars.next();
3530                                        let mut repl = String::new();
3531                                        while let Some(&ch) = chars.peek() {
3532                                            if ch == '}' {
3533                                                break;
3534                                            }
3535                                            repl.push(chars.next().unwrap());
3536                                        }
3537                                        repl
3538                                    } else {
3539                                        String::new()
3540                                    };
3541                                    if chars.peek() == Some(&'}') {
3542                                        chars.next();
3543                                    }
3544                                    let op = if replace_all {
3545                                        ParameterOp::ReplaceAll {
3546                                            pattern,
3547                                            replacement,
3548                                        }
3549                                    } else {
3550                                        ParameterOp::ReplaceFirst {
3551                                            pattern,
3552                                            replacement,
3553                                        }
3554                                    };
3555                                    push_part!(WordPart::ParameterExpansion {
3556                                        name: var_name,
3557                                        operator: op,
3558                                        operand: String::new(),
3559                                        colon_variant: false,
3560                                    });
3561                                }
3562                                '^' => {
3563                                    chars.next();
3564                                    let op = if chars.peek() == Some(&'^') {
3565                                        chars.next();
3566                                        ParameterOp::UpperAll
3567                                    } else {
3568                                        ParameterOp::UpperFirst
3569                                    };
3570                                    if chars.peek() == Some(&'}') {
3571                                        chars.next();
3572                                    }
3573                                    push_part!(WordPart::ParameterExpansion {
3574                                        name: var_name,
3575                                        operator: op,
3576                                        operand: String::new(),
3577                                        colon_variant: false,
3578                                    });
3579                                }
3580                                ',' => {
3581                                    chars.next();
3582                                    let op = if chars.peek() == Some(&',') {
3583                                        chars.next();
3584                                        ParameterOp::LowerAll
3585                                    } else {
3586                                        ParameterOp::LowerFirst
3587                                    };
3588                                    if chars.peek() == Some(&'}') {
3589                                        chars.next();
3590                                    }
3591                                    push_part!(WordPart::ParameterExpansion {
3592                                        name: var_name,
3593                                        operator: op,
3594                                        operand: String::new(),
3595                                        colon_variant: false,
3596                                    });
3597                                }
3598                                '@' => {
3599                                    chars.next();
3600                                    if let Some(&op) = chars.peek() {
3601                                        chars.next();
3602                                        if chars.peek() == Some(&'}') {
3603                                            chars.next();
3604                                        }
3605                                        push_part!(WordPart::Transformation {
3606                                            name: var_name,
3607                                            operator: op,
3608                                        });
3609                                    } else {
3610                                        if chars.peek() == Some(&'}') {
3611                                            chars.next();
3612                                        }
3613                                        push_part!(WordPart::Variable(var_name));
3614                                    }
3615                                }
3616                                '}' => {
3617                                    chars.next();
3618                                    if !var_name.is_empty() {
3619                                        push_part!(WordPart::Variable(var_name));
3620                                    }
3621                                }
3622                                _ => {
3623                                    while let Some(&ch) = chars.peek() {
3624                                        if ch == '}' {
3625                                            chars.next();
3626                                            break;
3627                                        }
3628                                        chars.next();
3629                                    }
3630                                    if !var_name.is_empty() {
3631                                        push_part!(WordPart::Variable(var_name));
3632                                    }
3633                                }
3634                            }
3635                        } else if !var_name.is_empty() {
3636                            push_part!(WordPart::Variable(var_name));
3637                        }
3638                    }
3639                } else if let Some(&c) = chars.peek() {
3640                    // Check for special single-character variables ($?, $#, $@, $*, $!, $$, $-, $0-$9)
3641                    if matches!(c, '?' | '#' | '@' | '*' | '!' | '$' | '-') || c.is_ascii_digit() {
3642                        push_part!(WordPart::Variable(chars.next().unwrap().to_string()));
3643                    } else {
3644                        // $VAR format
3645                        let mut var_name = String::new();
3646                        while let Some(&c) = chars.peek() {
3647                            if c.is_ascii_alphanumeric() || c == '_' {
3648                                var_name.push(chars.next().unwrap());
3649                            } else {
3650                                break;
3651                            }
3652                        }
3653                        if !var_name.is_empty() {
3654                            push_part!(WordPart::Variable(var_name));
3655                        } else {
3656                            // Just a literal $
3657                            current.push('$');
3658                        }
3659                    }
3660                } else {
3661                    // Just a literal $ at end
3662                    current.push('$');
3663                }
3664            } else {
3665                current.push(ch);
3666            }
3667        }
3668
3669        // Flush remaining literal
3670        if !current.is_empty() {
3671            push_part!(WordPart::Literal(current));
3672        }
3673
3674        // If no parts, create an empty literal
3675        if parts.is_empty() {
3676            push_part!(WordPart::Literal(String::new()));
3677        }
3678
3679        Word {
3680            parts,
3681            quoted: false,
3682            has_unquoted_glob: false,
3683            part_quoted,
3684        }
3685    }
3686
3687    /// Read operand for brace expansion (everything until closing brace)
3688    fn read_brace_operand(&self, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
3689        let mut operand = String::new();
3690        let mut depth = 1; // Track nested braces
3691        while let Some(&c) = chars.peek() {
3692            if c == '{' {
3693                depth += 1;
3694                operand.push(chars.next().unwrap());
3695            } else if c == '}' {
3696                depth -= 1;
3697                if depth == 0 {
3698                    chars.next(); // consume closing }
3699                    break;
3700                }
3701                operand.push(chars.next().unwrap());
3702            } else {
3703                operand.push(chars.next().unwrap());
3704            }
3705        }
3706        operand
3707    }
3708}
3709
3710#[cfg(test)]
3711mod tests {
3712    use super::*;
3713    use std::time::Duration;
3714
3715    #[test]
3716    fn test_parse_simple_command() {
3717        let parser = Parser::new("echo hello");
3718        let script = parser.parse().unwrap();
3719
3720        assert_eq!(script.commands.len(), 1);
3721
3722        if let Command::Simple(cmd) = &script.commands[0] {
3723            assert_eq!(cmd.name.to_string(), "echo");
3724            assert_eq!(cmd.args.len(), 1);
3725            assert_eq!(cmd.args[0].to_string(), "hello");
3726        } else {
3727            panic!("expected simple command");
3728        }
3729    }
3730
3731    #[test]
3732    fn test_parse_timeout_exceeded() {
3733        let parser =
3734            Parser::with_limits_and_timeout("echo hello", 100, 100_000, Some(Duration::ZERO));
3735        let err = parser.parse().expect_err("expected parser timeout");
3736        assert!(matches!(
3737            err,
3738            Error::ResourceLimit(LimitExceeded::ParserTimeout(timeout)) if timeout == Duration::ZERO
3739        ));
3740    }
3741
3742    #[test]
3743    fn test_parse_multiple_args() {
3744        let parser = Parser::new("echo hello world");
3745        let script = parser.parse().unwrap();
3746
3747        if let Command::Simple(cmd) = &script.commands[0] {
3748            assert_eq!(cmd.name.to_string(), "echo");
3749            assert_eq!(cmd.args.len(), 2);
3750            assert_eq!(cmd.args[0].to_string(), "hello");
3751            assert_eq!(cmd.args[1].to_string(), "world");
3752        } else {
3753            panic!("expected simple command");
3754        }
3755    }
3756
3757    #[test]
3758    fn test_parse_variable() {
3759        let parser = Parser::new("echo $HOME");
3760        let script = parser.parse().unwrap();
3761
3762        if let Command::Simple(cmd) = &script.commands[0] {
3763            assert_eq!(cmd.args.len(), 1);
3764            assert_eq!(cmd.args[0].parts.len(), 1);
3765            assert!(matches!(&cmd.args[0].parts[0], WordPart::Variable(v) if v == "HOME"));
3766        } else {
3767            panic!("expected simple command");
3768        }
3769    }
3770
3771    #[test]
3772    fn test_parse_pipeline() {
3773        let parser = Parser::new("echo hello | cat");
3774        let script = parser.parse().unwrap();
3775
3776        assert_eq!(script.commands.len(), 1);
3777        assert!(matches!(&script.commands[0], Command::Pipeline(_)));
3778
3779        if let Command::Pipeline(pipeline) = &script.commands[0] {
3780            assert_eq!(pipeline.commands.len(), 2);
3781        }
3782    }
3783
3784    #[test]
3785    fn test_parse_redirect_out() {
3786        let parser = Parser::new("echo hello > /tmp/out");
3787        let script = parser.parse().unwrap();
3788
3789        if let Command::Simple(cmd) = &script.commands[0] {
3790            assert_eq!(cmd.redirects.len(), 1);
3791            assert_eq!(cmd.redirects[0].kind, RedirectKind::Output);
3792            assert_eq!(cmd.redirects[0].target.to_string(), "/tmp/out");
3793        } else {
3794            panic!("expected simple command");
3795        }
3796    }
3797
3798    #[test]
3799    fn test_parse_redirect_append() {
3800        let parser = Parser::new("echo hello >> /tmp/out");
3801        let script = parser.parse().unwrap();
3802
3803        if let Command::Simple(cmd) = &script.commands[0] {
3804            assert_eq!(cmd.redirects.len(), 1);
3805            assert_eq!(cmd.redirects[0].kind, RedirectKind::Append);
3806        } else {
3807            panic!("expected simple command");
3808        }
3809    }
3810
3811    #[test]
3812    fn test_parse_redirect_in() {
3813        let parser = Parser::new("cat < /tmp/in");
3814        let script = parser.parse().unwrap();
3815
3816        if let Command::Simple(cmd) = &script.commands[0] {
3817            assert_eq!(cmd.redirects.len(), 1);
3818            assert_eq!(cmd.redirects[0].kind, RedirectKind::Input);
3819        } else {
3820            panic!("expected simple command");
3821        }
3822    }
3823
3824    #[test]
3825    fn test_parse_command_list_and() {
3826        let parser = Parser::new("true && echo success");
3827        let script = parser.parse().unwrap();
3828
3829        assert!(matches!(&script.commands[0], Command::List(_)));
3830    }
3831
3832    #[test]
3833    fn test_parse_command_list_or() {
3834        let parser = Parser::new("false || echo fallback");
3835        let script = parser.parse().unwrap();
3836
3837        assert!(matches!(&script.commands[0], Command::List(_)));
3838    }
3839
3840    #[test]
3841    fn test_heredoc_pipe() {
3842        let parser = Parser::new("cat <<EOF | sort\nc\na\nb\nEOF\n");
3843        let script = parser.parse().unwrap();
3844        assert!(
3845            matches!(&script.commands[0], Command::Pipeline(_)),
3846            "heredoc with pipe should parse as Pipeline"
3847        );
3848    }
3849
3850    #[test]
3851    fn test_heredoc_multiple_on_line() {
3852        let input = "while cat <<E1 && cat <<E2; do cat <<E3; break; done\n1\nE1\n2\nE2\n3\nE3\n";
3853        let parser = Parser::new(input);
3854        let script = parser.parse().unwrap();
3855        assert_eq!(script.commands.len(), 1);
3856        if let Command::Compound(comp, _) = &script.commands[0] {
3857            if let CompoundCommand::While(w) = comp {
3858                assert!(
3859                    !w.condition.is_empty(),
3860                    "while condition should be non-empty"
3861                );
3862                assert!(!w.body.is_empty(), "while body should be non-empty");
3863            } else {
3864                panic!("expected While compound command");
3865            }
3866        } else {
3867            panic!("expected Compound command");
3868        }
3869    }
3870
3871    #[test]
3872    fn test_empty_function_body_rejected() {
3873        let parser = Parser::new("f() { }");
3874        assert!(
3875            parser.parse().is_err(),
3876            "empty function body should be rejected"
3877        );
3878    }
3879
3880    #[test]
3881    fn test_empty_while_body_rejected() {
3882        let parser = Parser::new("while true; do\ndone");
3883        assert!(
3884            parser.parse().is_err(),
3885            "empty while body should be rejected"
3886        );
3887    }
3888
3889    #[test]
3890    fn test_empty_for_body_rejected() {
3891        let parser = Parser::new("for i in 1 2 3; do\ndone");
3892        assert!(parser.parse().is_err(), "empty for body should be rejected");
3893    }
3894
3895    #[test]
3896    fn test_empty_if_then_rejected() {
3897        let parser = Parser::new("if true; then\nfi");
3898        assert!(
3899            parser.parse().is_err(),
3900            "empty then clause should be rejected"
3901        );
3902    }
3903
3904    #[test]
3905    fn test_empty_else_rejected() {
3906        let parser = Parser::new("if false; then echo yes; else\nfi");
3907        assert!(
3908            parser.parse().is_err(),
3909            "empty else clause should be rejected"
3910        );
3911    }
3912
3913    #[test]
3914    fn test_unterminated_single_quote_rejected() {
3915        let parser = Parser::new("echo 'unterminated");
3916        assert!(
3917            parser.parse().is_err(),
3918            "unterminated single quote should be rejected"
3919        );
3920    }
3921
3922    #[test]
3923    fn test_unterminated_double_quote_rejected() {
3924        let parser = Parser::new("echo \"unterminated");
3925        assert!(
3926            parser.parse().is_err(),
3927            "unterminated double quote should be rejected"
3928        );
3929    }
3930
3931    #[test]
3932    fn test_leading_pipe_rejected() {
3933        let parser = Parser::new("| cat");
3934        assert!(parser.parse().is_err(), "leading | should be rejected");
3935    }
3936
3937    #[test]
3938    fn test_leading_and_rejected() {
3939        let parser = Parser::new("&& echo hi");
3940        assert!(parser.parse().is_err(), "leading && should be rejected");
3941    }
3942
3943    #[test]
3944    fn test_leading_or_rejected() {
3945        let parser = Parser::new("|| echo hi");
3946        assert!(parser.parse().is_err(), "leading || should be rejected");
3947    }
3948
3949    #[test]
3950    fn test_nonempty_function_body_accepted() {
3951        let parser = Parser::new("f() { echo hi; }");
3952        assert!(
3953            parser.parse().is_ok(),
3954            "non-empty function body should be accepted"
3955        );
3956    }
3957
3958    #[test]
3959    fn test_nonempty_while_body_accepted() {
3960        let parser = Parser::new("while true; do echo hi; done");
3961        assert!(
3962            parser.parse().is_ok(),
3963            "non-empty while body should be accepted"
3964        );
3965    }
3966
3967    /// Issue #600: Subscript reader must handle nested ${...} containing brackets.
3968    #[test]
3969    fn test_nested_expansion_in_array_subscript() {
3970        // ${arr[$RANDOM % ${#arr[@]}]} must parse without error.
3971        // The subscript contains ${#arr[@]} which has its own [ and ].
3972        let parser = Parser::new("echo ${arr[$RANDOM % ${#arr[@]}]}");
3973        let script = parser.parse().unwrap();
3974        assert_eq!(script.commands.len(), 1);
3975        if let Command::Simple(cmd) = &script.commands[0] {
3976            assert_eq!(cmd.name.to_string(), "echo");
3977            assert_eq!(cmd.args.len(), 1);
3978            // The arg should contain an ArrayAccess with the full nested index
3979            let arg = &cmd.args[0];
3980            let has_array_access = arg.parts.iter().any(|p| {
3981                matches!(
3982                    p,
3983                    WordPart::ArrayAccess { name, index }
3984                    if name == "arr" && index.contains("${#arr[@]}")
3985                )
3986            });
3987            assert!(
3988                has_array_access,
3989                "expected ArrayAccess with nested index, got: {:?}",
3990                arg.parts
3991            );
3992        } else {
3993            panic!("expected simple command");
3994        }
3995    }
3996
3997    /// Assignment with nested subscript must parse (previously caused fuel exhaustion).
3998    #[test]
3999    fn test_assignment_nested_subscript_parses() {
4000        let parser = Parser::new("x=${arr[$RANDOM % ${#arr[@]}]}");
4001        assert!(
4002            parser.parse().is_ok(),
4003            "assignment with nested subscript should parse"
4004        );
4005    }
4006
4007    #[test]
4008    fn test_ansi_c_quoted_dollar_is_literal_and_quoted() {
4009        let parser = Parser::new("echo $'$(printf pwned)'");
4010        let script = parser.parse().unwrap();
4011        if let Command::Simple(cmd) = &script.commands[0] {
4012            assert_eq!(cmd.args.len(), 1);
4013            let arg = &cmd.args[0];
4014            assert!(arg.quoted, "ANSI-C quoted argument must be quoted");
4015            assert!(
4016                arg.parts
4017                    .iter()
4018                    .all(|part| matches!(part, WordPart::Literal(_))),
4019                "ANSI-C quoted argument must remain literal, got {:?}",
4020                arg.parts
4021            );
4022            assert_eq!(arg.to_string(), "$(printf pwned)");
4023        } else {
4024            panic!("expected simple command");
4025        }
4026    }
4027
4028    #[test]
4029    fn test_ansi_c_quoted_nul_before_dollar_stays_literal() {
4030        for input in [
4031            "echo $'\\0$(printf pwned)'",
4032            "echo $'\\x00$(printf pwned)'",
4033            "echo $'\\u0000${SECRET}'",
4034            "echo $'\\U00000000${SECRET}'",
4035        ] {
4036            let parser = Parser::new(input);
4037            let script = parser.parse().unwrap();
4038            if let Command::Simple(cmd) = &script.commands[0] {
4039                assert_eq!(cmd.args.len(), 1);
4040                let arg = &cmd.args[0];
4041                assert!(arg.quoted, "ANSI-C quoted argument must be quoted: {input}");
4042                assert!(
4043                    arg.parts
4044                        .iter()
4045                        .all(|part| matches!(part, WordPart::Literal(_))),
4046                    "ANSI-C quoted NUL must not expose expansions for {input}: {:?}",
4047                    arg.parts
4048                );
4049                assert!(
4050                    arg.to_string().starts_with('\0'),
4051                    "decoded ANSI-C NUL should remain literal for {input}"
4052                );
4053            } else {
4054                panic!("expected simple command");
4055            }
4056        }
4057    }
4058
4059    #[test]
4060    fn test_single_quoted_segment_concatenation_stays_literal() {
4061        let parser = Parser::new("echo foo'$(id)'");
4062        let script = parser.parse().unwrap();
4063
4064        if let Command::Simple(cmd) = &script.commands[0] {
4065            assert_eq!(cmd.args.len(), 1);
4066            assert_eq!(cmd.args[0].to_string(), "foo$(id)");
4067            assert!(
4068                cmd.args[0]
4069                    .parts
4070                    .iter()
4071                    .all(|p| matches!(p, WordPart::Literal(_))),
4072                "single-quoted segment should not produce expansions: {:?}",
4073                cmd.args[0].parts
4074            );
4075        } else {
4076            panic!("expected simple command");
4077        }
4078    }
4079
4080    #[test]
4081    fn test_locale_quote_marks_word_as_quoted_without_expansion() {
4082        let parser = Parser::new("echo $\"*.txt\"");
4083        let script = parser.parse().unwrap();
4084        if let Command::Simple(cmd) = &script.commands[0] {
4085            assert_eq!(cmd.args.len(), 1);
4086            assert!(
4087                cmd.args[0].quoted,
4088                "locale-quoted argument must be marked quoted"
4089            );
4090            assert_eq!(cmd.args[0].to_string(), "*.txt");
4091        } else {
4092            panic!("expected simple command");
4093        }
4094    }
4095
4096    #[test]
4097    fn test_case_literal_pattern_escaped_dollar_continuation_stays_literal() {
4098        let parser =
4099            Parser::new(r#"case "xy" in 'x'"\$(echo y)") echo MATCH ;; *) echo NOMATCH ;; esac"#);
4100        let script = parser.parse().unwrap();
4101
4102        let case = match &script.commands[0] {
4103            Command::Compound(CompoundCommand::Case(case), _) => case,
4104            other => panic!("expected case command, got: {other:?}"),
4105        };
4106        let pattern = &case.cases[0].patterns[0];
4107        assert_eq!(pattern.to_string(), r#"x$(echo y)"#);
4108        assert!(
4109            pattern
4110                .parts
4111                .iter()
4112                .all(|part| matches!(part, WordPart::Literal(_))),
4113            "escaped dollar in literal case pattern must not parse as expansion: {:?}",
4114            pattern.parts
4115        );
4116    }
4117
4118    #[test]
4119    fn test_single_quoted_assignment_value_stays_literal() {
4120        let parser = Parser::new("VAR='$(id)'");
4121        let script = parser.parse().unwrap();
4122
4123        if let Command::Simple(cmd) = &script.commands[0] {
4124            assert_eq!(cmd.assignments.len(), 1);
4125            assert_eq!(cmd.assignments[0].name, "VAR");
4126            match &cmd.assignments[0].value {
4127                AssignmentValue::Scalar(word) => {
4128                    assert_eq!(word.to_string(), "$(id)");
4129                    assert!(
4130                        word.parts.iter().all(|p| matches!(p, WordPart::Literal(_))),
4131                        "single-quoted assignment should remain literal: {:?}",
4132                        word.parts
4133                    );
4134                }
4135                AssignmentValue::Array(_) => panic!("expected scalar assignment"),
4136            }
4137        } else {
4138            panic!("expected simple command");
4139        }
4140    }
4141
4142    #[test]
4143    fn test_assignment_with_plus_equal_in_value_parses_as_assignment() {
4144        let parser = Parser::new("VAR=a+=b");
4145        let script = parser.parse().expect("script should parse");
4146        let cmd = match &script.commands[0] {
4147            Command::Simple(cmd) => cmd,
4148            other => panic!("expected simple command, got: {other:?}"),
4149        };
4150        assert_eq!(cmd.assignments.len(), 1);
4151        assert_eq!(cmd.assignments[0].name, "VAR");
4152        assert!(!cmd.assignments[0].append);
4153        match &cmd.assignments[0].value {
4154            AssignmentValue::Scalar(word) => assert_eq!(word.to_string(), "a+=b"),
4155            AssignmentValue::Array(_) => panic!("expected scalar assignment"),
4156        }
4157    }
4158
4159    #[test]
4160    fn test_array_append_assignment_with_equal_in_subscript_parses_as_assignment() {
4161        let parser = Parser::new("arr[i=0]+=x");
4162        let script = parser.parse().expect("script should parse");
4163        let cmd = match &script.commands[0] {
4164            Command::Simple(cmd) => cmd,
4165            other => panic!("expected simple command, got: {other:?}"),
4166        };
4167        assert_eq!(cmd.assignments.len(), 1);
4168        assert_eq!(cmd.assignments[0].name, "arr");
4169        assert_eq!(cmd.assignments[0].index.as_deref(), Some("i=0"));
4170        assert!(cmd.assignments[0].append);
4171        match &cmd.assignments[0].value {
4172            AssignmentValue::Scalar(word) => assert_eq!(word.to_string(), "x"),
4173            AssignmentValue::Array(_) => panic!("expected scalar assignment"),
4174        }
4175    }
4176
4177    #[test]
4178    fn test_assoc_append_assignment_with_equal_in_subscript_parses_as_assignment() {
4179        let parser = Parser::new("assoc[key=value]+=x");
4180        let script = parser.parse().expect("script should parse");
4181        let cmd = match &script.commands[0] {
4182            Command::Simple(cmd) => cmd,
4183            other => panic!("expected simple command, got: {other:?}"),
4184        };
4185        assert_eq!(cmd.assignments.len(), 1);
4186        assert_eq!(cmd.assignments[0].name, "assoc");
4187        assert_eq!(cmd.assignments[0].index.as_deref(), Some("key=value"));
4188        assert!(cmd.assignments[0].append);
4189        match &cmd.assignments[0].value {
4190            AssignmentValue::Scalar(word) => assert_eq!(word.to_string(), "x"),
4191            AssignmentValue::Array(_) => panic!("expected scalar assignment"),
4192        }
4193    }
4194
4195    fn nested_process_substitution(levels: usize) -> String {
4196        let mut script = String::from("cat ");
4197        for _ in 0..levels {
4198            script.push_str("<(cat ");
4199        }
4200        script.push_str("echo x");
4201        for _ in 0..levels {
4202            script.push_str("; )");
4203        }
4204        script
4205    }
4206
4207    #[test]
4208    fn test_nested_process_substitution_within_budget_parses() {
4209        let script = nested_process_substitution(3);
4210        let parser = Parser::with_limits(&script, 8, 1_000);
4211        parser
4212            .parse()
4213            .expect("nested process substitution within budget should parse");
4214    }
4215
4216    #[test]
4217    fn test_nested_process_substitution_consumes_depth_budget() {
4218        let script = nested_process_substitution(5);
4219        let parser = Parser::with_limits(&script, 4, 10_000);
4220        let err = parser
4221            .parse()
4222            .expect_err("nested process substitution must not bypass AST depth");
4223        assert!(
4224            err.to_string().contains("AST nesting too deep"),
4225            "expected AST depth error, got: {err}"
4226        );
4227    }
4228
4229    #[test]
4230    fn test_nested_process_substitution_consumes_fuel_budget() {
4231        let script = nested_process_substitution(8);
4232        let parser = Parser::with_limits(&script, 100, 8);
4233        let err = parser
4234            .parse()
4235            .expect_err("nested process substitution must not get fresh parser fuel");
4236        assert!(
4237            err.to_string().contains("parser fuel exhausted"),
4238            "expected parser fuel error, got: {err}"
4239        );
4240    }
4241
4242    #[test]
4243    fn test_process_substitution_whitespace_body_consumes_fuel_budget() {
4244        let script = format!("cat <({}echo x)", " ".repeat(256));
4245        let parser = Parser::with_limits(&script, 100, 200);
4246        let err = parser
4247            .parse()
4248            .expect_err("process substitution body scanning must consume parser fuel");
4249        assert!(
4250            err.to_string().contains("parser fuel exhausted"),
4251            "expected parser fuel error, got: {err}"
4252        );
4253    }
4254
4255    #[test]
4256    fn test_nested_coproc_respects_ast_depth_limit() {
4257        let parser = Parser::with_limits("coproc coproc echo x", 1, usize::MAX);
4258        let err = parser.parse().unwrap_err();
4259        assert!(
4260            err.to_string().contains("AST nesting too deep"),
4261            "expected controlled AST depth error, got: {err}"
4262        );
4263    }
4264
4265    #[test]
4266    fn test_nested_coproc_consumes_parser_fuel() {
4267        let parser = Parser::with_limits("coproc coproc echo x", 100, 3);
4268        let err = parser.parse().unwrap_err();
4269        assert!(
4270            err.to_string().contains("parser fuel exhausted"),
4271            "expected controlled parser fuel error, got: {err}"
4272        );
4273    }
4274
4275    #[test]
4276    fn test_chained_heredoc_reinjection_consumes_parser_fuel() {
4277        let script = ": <<E && : <<E && : <<E\nE\nE\nE\n";
4278        let err = Parser::with_fuel(script, 12).parse().unwrap_err();
4279        assert!(
4280            err.to_string().contains("parser fuel exhausted"),
4281            "expected heredoc rest-of-line reinjection to consume parser fuel, got: {err}"
4282        );
4283    }
4284
4285    #[test]
4286    fn test_array_subscript_single_double_quote_character_does_not_panic() {
4287        let parser = Parser::new(r#"echo "${arr[\"]}""#);
4288        let result = parser.parse();
4289
4290        assert!(
4291            result.is_ok(),
4292            "single-character quoted subscript should not panic: {result:?}"
4293        );
4294    }
4295
4296    #[test]
4297    fn test_double_quoted_param_expansion_obeys_parser_depth_limit() {
4298        let parser = Parser::with_limits(r#"echo "${a:-${b:-${c}}}""#, 2, usize::MAX);
4299        let err = parser
4300            .parse()
4301            .expect_err("nested parameter expansion should be rejected");
4302
4303        assert!(
4304            err.to_string()
4305                .contains("parameter expansion nesting too deep"),
4306            "expected parameter expansion depth error, got: {err}"
4307        );
4308    }
4309
4310    #[test]
4311    fn test_top_level_reserved_word_errors_immediately() {
4312        let parser = Parser::with_fuel("fi", usize::MAX);
4313        let err = parser.parse().unwrap_err();
4314        assert!(
4315            err.to_string().contains("unexpected token"),
4316            "expected immediate syntax error, got: {err}"
4317        );
4318    }
4319}