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};
28
29/// Default maximum AST depth (matches ExecutionLimits default)
30const DEFAULT_MAX_AST_DEPTH: usize = 100;
31
32/// Hard cap on AST depth to prevent stack overflow even if caller misconfigures limits.
33/// THREAT[TM-DOS-022]: Protects against deeply nested input attacks where
34/// a large max_depth setting allows recursion deep enough to overflow the native stack.
35/// This cap cannot be overridden by the caller.
36///
37/// Set conservatively to avoid stack overflow on tokio's blocking threads (default 2MB
38/// stack in debug builds). Each parser recursion level uses ~4-8KB of stack in debug
39/// mode. 100 levels × ~8KB = ~800KB, well within 2MB.
40/// In release builds this could safely be higher, but we use one value for consistency.
41const HARD_MAX_AST_DEPTH: usize = 100;
42
43/// Default maximum parser operations (matches ExecutionLimits default)
44const DEFAULT_MAX_PARSER_OPERATIONS: usize = 100_000;
45
46/// Parser for bash scripts.
47pub struct Parser<'a> {
48    lexer: Lexer<'a>,
49    current_token: Option<tokens::Token>,
50    /// Span of the current token
51    current_span: Span,
52    /// Lookahead token for function parsing
53    peeked_token: Option<SpannedToken>,
54    /// Maximum allowed AST nesting depth
55    max_depth: usize,
56    /// Current nesting depth
57    current_depth: usize,
58    /// Remaining fuel for parsing operations
59    fuel: usize,
60    /// Maximum fuel (for error reporting)
61    max_fuel: usize,
62}
63
64impl<'a> Parser<'a> {
65    /// Create a new parser for the given input.
66    pub fn new(input: &'a str) -> Self {
67        Self::with_limits(input, DEFAULT_MAX_AST_DEPTH, DEFAULT_MAX_PARSER_OPERATIONS)
68    }
69
70    /// Create a new parser with a custom maximum AST depth.
71    pub fn with_max_depth(input: &'a str, max_depth: usize) -> Self {
72        Self::with_limits(input, max_depth, DEFAULT_MAX_PARSER_OPERATIONS)
73    }
74
75    /// Create a new parser with a custom fuel limit.
76    pub fn with_fuel(input: &'a str, max_fuel: usize) -> Self {
77        Self::with_limits(input, DEFAULT_MAX_AST_DEPTH, max_fuel)
78    }
79
80    /// Create a new parser with custom depth and fuel limits.
81    ///
82    /// THREAT[TM-DOS-022]: `max_depth` is clamped to `HARD_MAX_AST_DEPTH` (500)
83    /// to prevent stack overflow from misconfiguration. Even if the caller passes
84    /// `max_depth = 1_000_000`, the parser will cap it at 500.
85    pub fn with_limits(input: &'a str, max_depth: usize, max_fuel: usize) -> Self {
86        let mut lexer = Lexer::with_max_subst_depth(input, max_depth.min(HARD_MAX_AST_DEPTH));
87        let spanned = lexer.next_spanned_token();
88        let (current_token, current_span) = match spanned {
89            Some(st) => (Some(st.token), st.span),
90            None => (None, Span::new()),
91        };
92        Self {
93            lexer,
94            current_token,
95            current_span,
96            peeked_token: None,
97            max_depth: max_depth.min(HARD_MAX_AST_DEPTH),
98            current_depth: 0,
99            fuel: max_fuel,
100            max_fuel,
101        }
102    }
103
104    /// Get the current token's span.
105    pub fn current_span(&self) -> Span {
106        self.current_span
107    }
108
109    /// Parse a string as a word (handling $var, $((expr)), ${...}, etc.).
110    /// Used by the interpreter to expand operands in parameter expansions lazily.
111    pub fn parse_word_string(input: &str) -> Word {
112        let parser = Parser::new(input);
113        parser.parse_word(input.to_string())
114    }
115
116    /// THREAT[TM-DOS-050]: Parse a word string with caller-configured limits.
117    /// Prevents bypass of parser limits in parameter expansion contexts.
118    pub fn parse_word_string_with_limits(input: &str, max_depth: usize, max_fuel: usize) -> Word {
119        let parser = Parser::with_limits(input, max_depth, max_fuel);
120        parser.parse_word(input.to_string())
121    }
122
123    /// Create a parse error with the current position.
124    fn error(&self, message: impl Into<String>) -> Error {
125        Error::parse_at(
126            message,
127            self.current_span.start.line,
128            self.current_span.start.column,
129        )
130    }
131
132    /// Consume one unit of fuel, returning an error if exhausted
133    fn tick(&mut self) -> Result<()> {
134        if self.fuel == 0 {
135            let used = self.max_fuel;
136            return Err(Error::parse(format!(
137                "parser fuel exhausted ({} operations, max {})",
138                used, self.max_fuel
139            )));
140        }
141        self.fuel -= 1;
142        Ok(())
143    }
144
145    /// Push nesting depth and check limit
146    fn push_depth(&mut self) -> Result<()> {
147        self.current_depth += 1;
148        if self.current_depth > self.max_depth {
149            return Err(Error::parse(format!(
150                "AST nesting too deep ({} levels, max {})",
151                self.current_depth, self.max_depth
152            )));
153        }
154        Ok(())
155    }
156
157    /// Pop nesting depth
158    fn pop_depth(&mut self) {
159        if self.current_depth > 0 {
160            self.current_depth -= 1;
161        }
162    }
163
164    /// Check if current token is an error token and return the error if so
165    fn check_error_token(&self) -> Result<()> {
166        if let Some(tokens::Token::Error(msg)) = &self.current_token {
167            return Err(self.error(format!("syntax error: {}", msg)));
168        }
169        Ok(())
170    }
171
172    /// Parse the input and return the AST.
173    pub fn parse(mut self) -> Result<Script> {
174        // Check if the very first token is an error
175        self.check_error_token()?;
176
177        let start_span = self.current_span;
178        let mut commands = Vec::new();
179
180        while self.current_token.is_some() {
181            self.tick()?;
182            self.skip_newlines()?;
183            self.check_error_token()?;
184            if self.current_token.is_none() {
185                break;
186            }
187            if let Some(cmd) = self.parse_command_list()? {
188                commands.push(cmd);
189            }
190        }
191
192        let end_span = self.current_span;
193        Ok(Script {
194            commands,
195            span: start_span.merge(end_span),
196        })
197    }
198
199    fn advance(&mut self) {
200        if let Some(peeked) = self.peeked_token.take() {
201            self.current_token = Some(peeked.token);
202            self.current_span = peeked.span;
203        } else {
204            match self.lexer.next_spanned_token() {
205                Some(st) => {
206                    self.current_token = Some(st.token);
207                    self.current_span = st.span;
208                }
209                None => {
210                    self.current_token = None;
211                    // Keep the last span for error reporting
212                }
213            }
214        }
215    }
216
217    /// Peek at the next token without consuming the current one
218    fn peek_next(&mut self) -> Option<&tokens::Token> {
219        if self.peeked_token.is_none() {
220            self.peeked_token = self.lexer.next_spanned_token();
221        }
222        self.peeked_token.as_ref().map(|st| &st.token)
223    }
224
225    fn skip_newlines(&mut self) -> Result<()> {
226        while matches!(self.current_token, Some(tokens::Token::Newline)) {
227            self.tick()?;
228            self.advance();
229        }
230        Ok(())
231    }
232
233    /// Parse a command list (commands connected by && or ||)
234    fn parse_command_list(&mut self) -> Result<Option<Command>> {
235        self.tick()?;
236        let start_span = self.current_span;
237        let first = match self.parse_pipeline()? {
238            Some(cmd) => cmd,
239            None => return Ok(None),
240        };
241
242        let mut rest = Vec::new();
243
244        loop {
245            let op = match &self.current_token {
246                Some(tokens::Token::And) => {
247                    self.advance();
248                    ListOperator::And
249                }
250                Some(tokens::Token::Or) => {
251                    self.advance();
252                    ListOperator::Or
253                }
254                Some(tokens::Token::Semicolon) => {
255                    self.advance();
256                    self.skip_newlines()?;
257                    // Check if there's more to parse
258                    if self.current_token.is_none()
259                        || matches!(self.current_token, Some(tokens::Token::Newline))
260                    {
261                        break;
262                    }
263                    ListOperator::Semicolon
264                }
265                Some(tokens::Token::Background) => {
266                    self.advance();
267                    self.skip_newlines()?;
268                    // Check if there's more to parse after &
269                    if self.current_token.is_none()
270                        || matches!(self.current_token, Some(tokens::Token::Newline))
271                    {
272                        // Just & at end - return as background
273                        rest.push((
274                            ListOperator::Background,
275                            Command::Simple(SimpleCommand {
276                                name: Word::literal(""),
277                                args: vec![],
278                                redirects: vec![],
279                                assignments: vec![],
280                                span: self.current_span,
281                            }),
282                        ));
283                        break;
284                    }
285                    ListOperator::Background
286                }
287                _ => break,
288            };
289
290            self.skip_newlines()?;
291
292            if let Some(cmd) = self.parse_pipeline()? {
293                rest.push((op, cmd));
294            } else {
295                break;
296            }
297        }
298
299        if rest.is_empty() {
300            Ok(Some(first))
301        } else {
302            Ok(Some(Command::List(CommandList {
303                first: Box::new(first),
304                rest,
305                span: start_span.merge(self.current_span),
306            })))
307        }
308    }
309
310    /// Parse a pipeline (commands connected by |)
311    ///
312    /// Handles `!` pipeline negation: `! cmd | cmd2` negates the exit code.
313    fn parse_pipeline(&mut self) -> Result<Option<Command>> {
314        let start_span = self.current_span;
315
316        // Check for pipeline negation: `! command`
317        let negated = match &self.current_token {
318            Some(tokens::Token::Word(w)) if w == "!" => {
319                self.advance();
320                true
321            }
322            _ => false,
323        };
324
325        let first = match self.parse_command()? {
326            Some(cmd) => cmd,
327            None => {
328                if negated {
329                    return Err(self.error("expected command after !"));
330                }
331                return Ok(None);
332            }
333        };
334
335        let mut commands = vec![first];
336
337        while matches!(self.current_token, Some(tokens::Token::Pipe)) {
338            self.advance();
339            self.skip_newlines()?;
340
341            if let Some(cmd) = self.parse_command()? {
342                commands.push(cmd);
343            } else {
344                return Err(self.error("expected command after |"));
345            }
346        }
347
348        if commands.len() == 1 && !negated {
349            Ok(Some(commands.remove(0)))
350        } else {
351            Ok(Some(Command::Pipeline(Pipeline {
352                negated,
353                commands,
354                span: start_span.merge(self.current_span),
355            })))
356        }
357    }
358
359    /// Parse redirections that follow a compound command (>, >>, 2>, etc.)
360    fn parse_trailing_redirects(&mut self) -> Vec<Redirect> {
361        let mut redirects = Vec::new();
362        loop {
363            match &self.current_token {
364                Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => {
365                    let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) {
366                        RedirectKind::Clobber
367                    } else {
368                        RedirectKind::Output
369                    };
370                    self.advance();
371                    if let Ok(target) = self.expect_word() {
372                        redirects.push(Redirect {
373                            fd: None,
374                            kind,
375                            target,
376                        });
377                    }
378                }
379                Some(tokens::Token::RedirectAppend) => {
380                    self.advance();
381                    if let Ok(target) = self.expect_word() {
382                        redirects.push(Redirect {
383                            fd: None,
384                            kind: RedirectKind::Append,
385                            target,
386                        });
387                    }
388                }
389                Some(tokens::Token::RedirectIn) => {
390                    self.advance();
391                    if let Ok(target) = self.expect_word() {
392                        redirects.push(Redirect {
393                            fd: None,
394                            kind: RedirectKind::Input,
395                            target,
396                        });
397                    }
398                }
399                Some(tokens::Token::RedirectBoth) => {
400                    self.advance();
401                    if let Ok(target) = self.expect_word() {
402                        redirects.push(Redirect {
403                            fd: None,
404                            kind: RedirectKind::OutputBoth,
405                            target,
406                        });
407                    }
408                }
409                Some(tokens::Token::DupOutput) => {
410                    self.advance();
411                    if let Ok(target) = self.expect_word() {
412                        redirects.push(Redirect {
413                            fd: Some(1),
414                            kind: RedirectKind::DupOutput,
415                            target,
416                        });
417                    }
418                }
419                Some(tokens::Token::RedirectFd(fd)) => {
420                    let fd = *fd;
421                    self.advance();
422                    if let Ok(target) = self.expect_word() {
423                        redirects.push(Redirect {
424                            fd: Some(fd),
425                            kind: RedirectKind::Output,
426                            target,
427                        });
428                    }
429                }
430                Some(tokens::Token::RedirectFdAppend(fd)) => {
431                    let fd = *fd;
432                    self.advance();
433                    if let Ok(target) = self.expect_word() {
434                        redirects.push(Redirect {
435                            fd: Some(fd),
436                            kind: RedirectKind::Append,
437                            target,
438                        });
439                    }
440                }
441                Some(tokens::Token::DupFd(src_fd, dst_fd)) => {
442                    let src_fd = *src_fd;
443                    let dst_fd = *dst_fd;
444                    self.advance();
445                    redirects.push(Redirect {
446                        fd: Some(src_fd),
447                        kind: RedirectKind::DupOutput,
448                        target: Word::literal(dst_fd.to_string()),
449                    });
450                }
451                Some(tokens::Token::DupInput) => {
452                    self.advance();
453                    if let Ok(target) = self.expect_word() {
454                        redirects.push(Redirect {
455                            fd: Some(0),
456                            kind: RedirectKind::DupInput,
457                            target,
458                        });
459                    }
460                }
461                Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => {
462                    let src_fd = *src_fd;
463                    let dst_fd = *dst_fd;
464                    self.advance();
465                    redirects.push(Redirect {
466                        fd: Some(src_fd),
467                        kind: RedirectKind::DupInput,
468                        target: Word::literal(dst_fd.to_string()),
469                    });
470                }
471                Some(tokens::Token::DupFdClose(fd)) => {
472                    let fd = *fd;
473                    self.advance();
474                    redirects.push(Redirect {
475                        fd: Some(fd),
476                        kind: RedirectKind::DupInput,
477                        target: Word::literal("-"),
478                    });
479                }
480                Some(tokens::Token::RedirectFdIn(fd)) => {
481                    let fd = *fd;
482                    self.advance();
483                    if let Ok(target) = self.expect_word() {
484                        redirects.push(Redirect {
485                            fd: Some(fd),
486                            kind: RedirectKind::Input,
487                            target,
488                        });
489                    }
490                }
491                Some(tokens::Token::HereString) => {
492                    self.advance();
493                    if let Ok(target) = self.expect_word() {
494                        redirects.push(Redirect {
495                            fd: None,
496                            kind: RedirectKind::HereString,
497                            target,
498                        });
499                    }
500                }
501                Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => {
502                    let strip_tabs =
503                        matches!(self.current_token, Some(tokens::Token::HereDocStrip));
504                    self.advance();
505                    let (delimiter, quoted) = match &self.current_token {
506                        Some(tokens::Token::Word(w)) => (w.clone(), false),
507                        Some(tokens::Token::LiteralWord(w)) => (w.clone(), true),
508                        Some(tokens::Token::QuotedWord(w)) => (w.clone(), true),
509                        _ => break,
510                    };
511                    let content = self.lexer.read_heredoc(&delimiter);
512                    let content = if strip_tabs {
513                        let had_trailing_newline = content.ends_with('\n');
514                        let mut stripped: String = content
515                            .lines()
516                            .map(|l| l.trim_start_matches('\t'))
517                            .collect::<Vec<_>>()
518                            .join("\n");
519                        if had_trailing_newline {
520                            stripped.push('\n');
521                        }
522                        stripped
523                    } else {
524                        content
525                    };
526                    self.advance();
527                    let target = if quoted {
528                        Word::quoted_literal(content)
529                    } else {
530                        self.parse_word(content)
531                    };
532                    let kind = if strip_tabs {
533                        RedirectKind::HereDocStrip
534                    } else {
535                        RedirectKind::HereDoc
536                    };
537                    redirects.push(Redirect {
538                        fd: None,
539                        kind,
540                        target,
541                    });
542                    // Rest-of-line tokens re-injected by lexer; break so callers
543                    // can see pipes/semicolons.
544                    break;
545                }
546                _ => break,
547            }
548        }
549        redirects
550    }
551
552    /// Parse a compound command and any trailing redirections
553    fn parse_compound_with_redirects(
554        &mut self,
555        parser: impl FnOnce(&mut Self) -> Result<CompoundCommand>,
556    ) -> Result<Option<Command>> {
557        let compound = parser(self)?;
558        let redirects = self.parse_trailing_redirects();
559        Ok(Some(Command::Compound(compound, redirects)))
560    }
561
562    /// Parse a single command (simple or compound)
563    fn parse_command(&mut self) -> Result<Option<Command>> {
564        self.skip_newlines()?;
565        self.check_error_token()?;
566
567        // Check for compound commands and function keyword
568        if let Some(tokens::Token::Word(w)) = &self.current_token {
569            let word = w.clone();
570            match word.as_str() {
571                "if" => return self.parse_compound_with_redirects(|s| s.parse_if()),
572                "for" => return self.parse_compound_with_redirects(|s| s.parse_for()),
573                "while" => return self.parse_compound_with_redirects(|s| s.parse_while()),
574                "until" => return self.parse_compound_with_redirects(|s| s.parse_until()),
575                "case" => return self.parse_compound_with_redirects(|s| s.parse_case()),
576                "select" => return self.parse_compound_with_redirects(|s| s.parse_select()),
577                "time" => return self.parse_compound_with_redirects(|s| s.parse_time()),
578                "coproc" => return self.parse_compound_with_redirects(|s| s.parse_coproc()),
579                "function" => return self.parse_function_keyword().map(Some),
580                _ => {
581                    // Check for POSIX-style function: name() { body }
582                    // Don't match if word contains '=' (that's an assignment like arr=(a b c))
583                    if !word.contains('=')
584                        && matches!(self.peek_next(), Some(tokens::Token::LeftParen))
585                    {
586                        return self.parse_function_posix().map(Some);
587                    }
588                }
589            }
590        }
591
592        // Check for conditional expression [[ ... ]]
593        if matches!(self.current_token, Some(tokens::Token::DoubleLeftBracket)) {
594            return self.parse_compound_with_redirects(|s| s.parse_conditional());
595        }
596
597        // Check for arithmetic command ((expression))
598        if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
599            return self.parse_compound_with_redirects(|s| s.parse_arithmetic_command());
600        }
601
602        // Check for subshell
603        if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
604            return self.parse_compound_with_redirects(|s| s.parse_subshell());
605        }
606
607        // Check for brace group
608        if matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
609            return self.parse_compound_with_redirects(|s| s.parse_brace_group());
610        }
611
612        // Default to simple command
613        match self.parse_simple_command()? {
614            Some(cmd) => Ok(Some(Command::Simple(cmd))),
615            None => Ok(None),
616        }
617    }
618
619    /// Parse an if statement
620    fn parse_if(&mut self) -> Result<CompoundCommand> {
621        let start_span = self.current_span;
622        self.push_depth()?;
623        self.advance(); // consume 'if'
624        self.skip_newlines()?;
625
626        // Parse condition
627        let condition = self.parse_compound_list("then")?;
628
629        // Expect 'then'
630        self.expect_keyword("then")?;
631        self.skip_newlines()?;
632
633        // Parse then branch
634        let then_branch = self.parse_compound_list_until(&["elif", "else", "fi"])?;
635
636        // Bash requires at least one command in then branch
637        if then_branch.is_empty() {
638            self.pop_depth();
639            return Err(self.error("syntax error: empty then clause"));
640        }
641
642        // Parse elif branches
643        let mut elif_branches = Vec::new();
644        while self.is_keyword("elif") {
645            self.advance(); // consume 'elif'
646            self.skip_newlines()?;
647
648            let elif_condition = self.parse_compound_list("then")?;
649            self.expect_keyword("then")?;
650            self.skip_newlines()?;
651
652            let elif_body = self.parse_compound_list_until(&["elif", "else", "fi"])?;
653
654            // Bash requires at least one command in elif branch
655            if elif_body.is_empty() {
656                self.pop_depth();
657                return Err(self.error("syntax error: empty elif clause"));
658            }
659
660            elif_branches.push((elif_condition, elif_body));
661        }
662
663        // Parse else branch
664        let else_branch = if self.is_keyword("else") {
665            self.advance(); // consume 'else'
666            self.skip_newlines()?;
667            let branch = self.parse_compound_list("fi")?;
668
669            // Bash requires at least one command in else branch
670            if branch.is_empty() {
671                self.pop_depth();
672                return Err(self.error("syntax error: empty else clause"));
673            }
674
675            Some(branch)
676        } else {
677            None
678        };
679
680        // Expect 'fi'
681        self.expect_keyword("fi")?;
682
683        self.pop_depth();
684        Ok(CompoundCommand::If(IfCommand {
685            condition,
686            then_branch,
687            elif_branches,
688            else_branch,
689            span: start_span.merge(self.current_span),
690        }))
691    }
692
693    /// Parse a for loop
694    fn parse_for(&mut self) -> Result<CompoundCommand> {
695        let start_span = self.current_span;
696        self.push_depth()?;
697        self.advance(); // consume 'for'
698        self.skip_newlines()?;
699
700        // Check for C-style for loop: for ((init; cond; step))
701        if matches!(self.current_token, Some(tokens::Token::DoubleLeftParen)) {
702            let result = self.parse_arithmetic_for_inner(start_span);
703            self.pop_depth();
704            return result;
705        }
706
707        // Expect variable name
708        let variable = match &self.current_token {
709            Some(tokens::Token::Word(w))
710            | Some(tokens::Token::LiteralWord(w))
711            | Some(tokens::Token::QuotedWord(w)) => w.clone(),
712            _ => {
713                self.pop_depth();
714                return Err(Error::parse(
715                    "expected variable name in for loop".to_string(),
716                ));
717            }
718        };
719        self.advance();
720
721        // Check for 'in' keyword
722        let words = if self.is_keyword("in") {
723            self.advance(); // consume 'in'
724
725            // Parse word list until do/newline/;
726            let mut words = Vec::new();
727            loop {
728                match &self.current_token {
729                    Some(tokens::Token::Word(w)) if w == "do" => break,
730                    Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => {
731                        let is_quoted =
732                            matches!(&self.current_token, Some(tokens::Token::QuotedWord(_)));
733                        let mut word = self.parse_word(w.clone());
734                        if is_quoted {
735                            word.quoted = true;
736                        }
737                        words.push(word);
738                        self.advance();
739                    }
740                    Some(tokens::Token::LiteralWord(w)) => {
741                        words.push(Word {
742                            parts: vec![WordPart::Literal(w.clone())],
743                            quoted: true,
744                        });
745                        self.advance();
746                    }
747                    Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) => {
748                        self.advance();
749                        break;
750                    }
751                    _ => break,
752                }
753            }
754            Some(words)
755        } else {
756            // for var; do ... (iterates over positional params)
757            // Consume optional semicolon before 'do'
758            if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
759                self.advance();
760            }
761            None
762        };
763
764        self.skip_newlines()?;
765
766        // Expect 'do'
767        self.expect_keyword("do")?;
768        self.skip_newlines()?;
769
770        // Parse body
771        let body = self.parse_compound_list("done")?;
772
773        // Bash requires at least one command in loop body
774        if body.is_empty() {
775            self.pop_depth();
776            return Err(self.error("syntax error: empty for loop body"));
777        }
778
779        // Expect 'done'
780        self.expect_keyword("done")?;
781
782        self.pop_depth();
783        Ok(CompoundCommand::For(ForCommand {
784            variable,
785            words,
786            body,
787            span: start_span.merge(self.current_span),
788        }))
789    }
790
791    /// Parse select loop: select var in list; do body; done
792    fn parse_select(&mut self) -> Result<CompoundCommand> {
793        let start_span = self.current_span;
794        self.push_depth()?;
795        self.advance(); // consume 'select'
796        self.skip_newlines()?;
797
798        // Expect variable name
799        let variable = match &self.current_token {
800            Some(tokens::Token::Word(w))
801            | Some(tokens::Token::LiteralWord(w))
802            | Some(tokens::Token::QuotedWord(w)) => w.clone(),
803            _ => {
804                self.pop_depth();
805                return Err(Error::parse("expected variable name in select".to_string()));
806            }
807        };
808        self.advance();
809
810        // Expect 'in' keyword
811        if !self.is_keyword("in") {
812            self.pop_depth();
813            return Err(Error::parse("expected 'in' in select".to_string()));
814        }
815        self.advance(); // consume 'in'
816
817        // Parse word list until do/newline/;
818        let mut words = Vec::new();
819        loop {
820            match &self.current_token {
821                Some(tokens::Token::Word(w)) if w == "do" => break,
822                Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => {
823                    let is_quoted =
824                        matches!(&self.current_token, Some(tokens::Token::QuotedWord(_)));
825                    let mut word = self.parse_word(w.clone());
826                    if is_quoted {
827                        word.quoted = true;
828                    }
829                    words.push(word);
830                    self.advance();
831                }
832                Some(tokens::Token::LiteralWord(w)) => {
833                    words.push(Word {
834                        parts: vec![WordPart::Literal(w.clone())],
835                        quoted: true,
836                    });
837                    self.advance();
838                }
839                Some(tokens::Token::Newline) | Some(tokens::Token::Semicolon) => {
840                    self.advance();
841                    break;
842                }
843                _ => break,
844            }
845        }
846
847        self.skip_newlines()?;
848
849        // Expect 'do'
850        self.expect_keyword("do")?;
851        self.skip_newlines()?;
852
853        // Parse body
854        let body = self.parse_compound_list("done")?;
855
856        // Bash requires at least one command in loop body
857        if body.is_empty() {
858            self.pop_depth();
859            return Err(self.error("syntax error: empty select loop body"));
860        }
861
862        // Expect 'done'
863        self.expect_keyword("done")?;
864
865        self.pop_depth();
866        Ok(CompoundCommand::Select(SelectCommand {
867            variable,
868            words,
869            body,
870            span: start_span.merge(self.current_span),
871        }))
872    }
873
874    /// Parse C-style arithmetic for loop inner: for ((init; cond; step)); do body; done
875    /// Note: depth tracking is done by parse_for which calls this
876    fn parse_arithmetic_for_inner(&mut self, start_span: Span) -> Result<CompoundCommand> {
877        self.advance(); // consume '(('
878
879        // Read the three expressions separated by semicolons
880        let mut parts: Vec<String> = Vec::new();
881        let mut current_expr = String::new();
882        let mut paren_depth = 0;
883
884        loop {
885            match &self.current_token {
886                Some(tokens::Token::DoubleRightParen) => {
887                    // End of the (( )) section
888                    parts.push(current_expr.trim().to_string());
889                    self.advance();
890                    break;
891                }
892                Some(tokens::Token::LeftParen) => {
893                    paren_depth += 1;
894                    current_expr.push('(');
895                    self.advance();
896                }
897                Some(tokens::Token::RightParen) => {
898                    if paren_depth > 0 {
899                        paren_depth -= 1;
900                        current_expr.push(')');
901                        self.advance();
902                    } else {
903                        // Unexpected - probably error
904                        self.advance();
905                    }
906                }
907                Some(tokens::Token::Semicolon) => {
908                    if paren_depth == 0 {
909                        // Separator between init, cond, step
910                        parts.push(current_expr.trim().to_string());
911                        current_expr.clear();
912                    } else {
913                        current_expr.push(';');
914                    }
915                    self.advance();
916                }
917                Some(tokens::Token::Word(w))
918                | Some(tokens::Token::LiteralWord(w))
919                | Some(tokens::Token::QuotedWord(w)) => {
920                    // Don't add space when joining operator pairs like < + =3 → <=3
921                    let skip_space = current_expr.ends_with('<')
922                        || current_expr.ends_with('>')
923                        || current_expr.ends_with(' ')
924                        || current_expr.ends_with('(')
925                        || current_expr.is_empty();
926                    if !skip_space {
927                        current_expr.push(' ');
928                    }
929                    current_expr.push_str(w);
930                    self.advance();
931                }
932                Some(tokens::Token::Newline) => {
933                    self.advance();
934                }
935                // Handle operators that are normally special tokens but valid in arithmetic
936                Some(tokens::Token::RedirectIn) => {
937                    current_expr.push('<');
938                    self.advance();
939                }
940                Some(tokens::Token::RedirectOut) => {
941                    current_expr.push('>');
942                    self.advance();
943                }
944                Some(tokens::Token::And) => {
945                    current_expr.push_str("&&");
946                    self.advance();
947                }
948                Some(tokens::Token::Or) => {
949                    current_expr.push_str("||");
950                    self.advance();
951                }
952                Some(tokens::Token::Pipe) => {
953                    current_expr.push('|');
954                    self.advance();
955                }
956                Some(tokens::Token::Background) => {
957                    current_expr.push('&');
958                    self.advance();
959                }
960                None => {
961                    return Err(Error::parse(
962                        "unexpected end of input in for loop".to_string(),
963                    ));
964                }
965                _ => {
966                    self.advance();
967                }
968            }
969        }
970
971        // Ensure we have exactly 3 parts
972        while parts.len() < 3 {
973            parts.push(String::new());
974        }
975
976        let init = parts.first().cloned().unwrap_or_default();
977        let condition = parts.get(1).cloned().unwrap_or_default();
978        let step = parts.get(2).cloned().unwrap_or_default();
979
980        self.skip_newlines()?;
981
982        // Skip optional semicolon after ))
983        if matches!(self.current_token, Some(tokens::Token::Semicolon)) {
984            self.advance();
985        }
986        self.skip_newlines()?;
987
988        // Expect 'do'
989        self.expect_keyword("do")?;
990        self.skip_newlines()?;
991
992        // Parse body
993        let body = self.parse_compound_list("done")?;
994
995        // Bash requires at least one command in loop body
996        if body.is_empty() {
997            return Err(self.error("syntax error: empty for loop body"));
998        }
999
1000        // Expect 'done'
1001        self.expect_keyword("done")?;
1002
1003        Ok(CompoundCommand::ArithmeticFor(ArithmeticForCommand {
1004            init,
1005            condition,
1006            step,
1007            body,
1008            span: start_span.merge(self.current_span),
1009        }))
1010    }
1011
1012    /// Parse a while loop
1013    fn parse_while(&mut self) -> Result<CompoundCommand> {
1014        let start_span = self.current_span;
1015        self.push_depth()?;
1016        self.advance(); // consume 'while'
1017        self.skip_newlines()?;
1018
1019        // Parse condition
1020        let condition = self.parse_compound_list("do")?;
1021
1022        // Expect 'do'
1023        self.expect_keyword("do")?;
1024        self.skip_newlines()?;
1025
1026        // Parse body
1027        let body = self.parse_compound_list("done")?;
1028
1029        // Bash requires at least one command in loop body
1030        if body.is_empty() {
1031            self.pop_depth();
1032            return Err(self.error("syntax error: empty while loop body"));
1033        }
1034
1035        // Expect 'done'
1036        self.expect_keyword("done")?;
1037
1038        self.pop_depth();
1039        Ok(CompoundCommand::While(WhileCommand {
1040            condition,
1041            body,
1042            span: start_span.merge(self.current_span),
1043        }))
1044    }
1045
1046    /// Parse an until loop
1047    fn parse_until(&mut self) -> Result<CompoundCommand> {
1048        let start_span = self.current_span;
1049        self.push_depth()?;
1050        self.advance(); // consume 'until'
1051        self.skip_newlines()?;
1052
1053        // Parse condition
1054        let condition = self.parse_compound_list("do")?;
1055
1056        // Expect 'do'
1057        self.expect_keyword("do")?;
1058        self.skip_newlines()?;
1059
1060        // Parse body
1061        let body = self.parse_compound_list("done")?;
1062
1063        // Bash requires at least one command in loop body
1064        if body.is_empty() {
1065            self.pop_depth();
1066            return Err(self.error("syntax error: empty until loop body"));
1067        }
1068
1069        // Expect 'done'
1070        self.expect_keyword("done")?;
1071
1072        self.pop_depth();
1073        Ok(CompoundCommand::Until(UntilCommand {
1074            condition,
1075            body,
1076            span: start_span.merge(self.current_span),
1077        }))
1078    }
1079
1080    /// Parse a case statement: case WORD in pattern) commands ;; ... esac
1081    fn parse_case(&mut self) -> Result<CompoundCommand> {
1082        let start_span = self.current_span;
1083        self.push_depth()?;
1084        self.advance(); // consume 'case'
1085        self.skip_newlines()?;
1086
1087        // Get the word to match against
1088        let word = self.expect_word()?;
1089        self.skip_newlines()?;
1090
1091        // Expect 'in'
1092        self.expect_keyword("in")?;
1093        self.skip_newlines()?;
1094
1095        // Parse case items
1096        let mut cases = Vec::new();
1097        while !self.is_keyword("esac") && self.current_token.is_some() {
1098            self.skip_newlines()?;
1099            if self.is_keyword("esac") {
1100                break;
1101            }
1102
1103            // Parse patterns (pattern1 | pattern2 | ...)
1104            // Optional leading (
1105            if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1106                self.advance();
1107            }
1108
1109            let mut patterns = Vec::new();
1110            while matches!(
1111                &self.current_token,
1112                Some(tokens::Token::Word(_))
1113                    | Some(tokens::Token::LiteralWord(_))
1114                    | Some(tokens::Token::QuotedWord(_))
1115            ) {
1116                let w = match &self.current_token {
1117                    Some(tokens::Token::Word(w))
1118                    | Some(tokens::Token::LiteralWord(w))
1119                    | Some(tokens::Token::QuotedWord(w)) => w.clone(),
1120                    _ => unreachable!(),
1121                };
1122                patterns.push(self.parse_word(w));
1123                self.advance();
1124
1125                // Check for | between patterns
1126                if matches!(self.current_token, Some(tokens::Token::Pipe)) {
1127                    self.advance();
1128                } else {
1129                    break;
1130                }
1131            }
1132
1133            // Expect )
1134            if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1135                self.pop_depth();
1136                return Err(self.error("expected ')' after case pattern"));
1137            }
1138            self.advance();
1139            self.skip_newlines()?;
1140
1141            // Parse commands until ;; or esac
1142            let mut commands = Vec::new();
1143            while !self.is_case_terminator()
1144                && !self.is_keyword("esac")
1145                && self.current_token.is_some()
1146            {
1147                if let Some(cmd) = self.parse_command_list()? {
1148                    commands.push(cmd);
1149                }
1150                self.skip_newlines()?;
1151            }
1152
1153            let terminator = self.parse_case_terminator();
1154            cases.push(CaseItem {
1155                patterns,
1156                commands,
1157                terminator,
1158            });
1159            self.skip_newlines()?;
1160        }
1161
1162        // Expect 'esac'
1163        self.expect_keyword("esac")?;
1164
1165        self.pop_depth();
1166        Ok(CompoundCommand::Case(CaseCommand {
1167            word,
1168            cases,
1169            span: start_span.merge(self.current_span),
1170        }))
1171    }
1172
1173    /// Parse a time command: time [-p] [command]
1174    ///
1175    /// The time keyword measures execution time of the following command.
1176    /// Note: Bashkit only tracks wall-clock time, not CPU user/sys time.
1177    fn parse_time(&mut self) -> Result<CompoundCommand> {
1178        let start_span = self.current_span;
1179        self.advance(); // consume 'time'
1180        self.skip_newlines()?;
1181
1182        // Check for -p flag (POSIX format)
1183        let posix_format = if let Some(tokens::Token::Word(w)) = &self.current_token {
1184            if w == "-p" {
1185                self.advance();
1186                self.skip_newlines()?;
1187                true
1188            } else {
1189                false
1190            }
1191        } else {
1192            false
1193        };
1194
1195        // Parse the command to time (if any)
1196        // time with no command is valid in bash (just outputs timing header)
1197        let command = self.parse_pipeline()?;
1198
1199        Ok(CompoundCommand::Time(TimeCommand {
1200            posix_format,
1201            command: command.map(Box::new),
1202            span: start_span.merge(self.current_span),
1203        }))
1204    }
1205
1206    /// Parse a coproc command: `coproc [NAME] command`
1207    ///
1208    /// If the token after `coproc` is a simple word followed by a compound
1209    /// command (`{`, `(`, `while`, `for`, etc.), it is treated as the coproc
1210    /// name. Otherwise the command starts immediately and the default name
1211    /// "COPROC" is used.
1212    fn parse_coproc(&mut self) -> Result<CompoundCommand> {
1213        let start_span = self.current_span;
1214        self.advance(); // consume 'coproc'
1215        self.skip_newlines()?;
1216
1217        // Determine if next token is a NAME (simple word that is NOT a compound-
1218        // command keyword and is followed by a compound command start).
1219        let (name, consumed_name) = if let Some(tokens::Token::Word(w)) = &self.current_token {
1220            let word = w.clone();
1221            let is_compound_keyword = matches!(
1222                word.as_str(),
1223                "if" | "for" | "while" | "until" | "case" | "select" | "time" | "coproc"
1224            );
1225            let next_is_compound_start = matches!(
1226                self.peek_next(),
1227                Some(tokens::Token::LeftBrace) | Some(tokens::Token::LeftParen)
1228            );
1229            if !is_compound_keyword && next_is_compound_start {
1230                self.advance(); // consume the NAME
1231                self.skip_newlines()?;
1232                (word, true)
1233            } else {
1234                ("COPROC".to_string(), false)
1235            }
1236        } else {
1237            ("COPROC".to_string(), false)
1238        };
1239
1240        let _ = consumed_name;
1241
1242        // Parse the command body (could be simple, compound, or pipeline)
1243        let body = self.parse_pipeline()?;
1244        let body = body.ok_or_else(|| self.error("coproc: missing command"))?;
1245
1246        Ok(CompoundCommand::Coproc(ast::CoprocCommand {
1247            name,
1248            body: Box::new(body),
1249            span: start_span.merge(self.current_span),
1250        }))
1251    }
1252
1253    /// Check if current token is ;; (case terminator)
1254    fn is_case_terminator(&self) -> bool {
1255        matches!(
1256            self.current_token,
1257            Some(tokens::Token::DoubleSemicolon)
1258                | Some(tokens::Token::SemiAmp)
1259                | Some(tokens::Token::DoubleSemiAmp)
1260        )
1261    }
1262
1263    /// Parse case terminator: `;;` (break), `;&` (fallthrough), `;;&` (continue matching)
1264    fn parse_case_terminator(&mut self) -> ast::CaseTerminator {
1265        match self.current_token {
1266            Some(tokens::Token::SemiAmp) => {
1267                self.advance();
1268                ast::CaseTerminator::FallThrough
1269            }
1270            Some(tokens::Token::DoubleSemiAmp) => {
1271                self.advance();
1272                ast::CaseTerminator::Continue
1273            }
1274            Some(tokens::Token::DoubleSemicolon) => {
1275                self.advance();
1276                ast::CaseTerminator::Break
1277            }
1278            _ => ast::CaseTerminator::Break,
1279        }
1280    }
1281
1282    /// Parse a subshell (commands in parentheses)
1283    fn parse_subshell(&mut self) -> Result<CompoundCommand> {
1284        self.push_depth()?;
1285        self.advance(); // consume '('
1286        self.skip_newlines()?;
1287
1288        let mut commands = Vec::new();
1289        while !matches!(
1290            self.current_token,
1291            Some(tokens::Token::RightParen) | Some(tokens::Token::DoubleRightParen) | None
1292        ) {
1293            self.skip_newlines()?;
1294            if matches!(
1295                self.current_token,
1296                Some(tokens::Token::RightParen) | Some(tokens::Token::DoubleRightParen)
1297            ) {
1298                break;
1299            }
1300            if let Some(cmd) = self.parse_command_list()? {
1301                commands.push(cmd);
1302            }
1303        }
1304
1305        if matches!(self.current_token, Some(tokens::Token::DoubleRightParen)) {
1306            // `))` at end of nested subshells: consume as single `)`, leave `)` for parent
1307            self.current_token = Some(tokens::Token::RightParen);
1308        } else if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1309            self.pop_depth();
1310            return Err(Error::parse("expected ')' to close subshell".to_string()));
1311        } else {
1312            self.advance(); // consume ')'
1313        }
1314
1315        self.pop_depth();
1316        Ok(CompoundCommand::Subshell(commands))
1317    }
1318
1319    /// Parse a brace group
1320    fn parse_brace_group(&mut self) -> Result<CompoundCommand> {
1321        self.push_depth()?;
1322        self.advance(); // consume '{'
1323        self.skip_newlines()?;
1324
1325        let mut commands = Vec::new();
1326        while !matches!(self.current_token, Some(tokens::Token::RightBrace) | None) {
1327            self.skip_newlines()?;
1328            if matches!(self.current_token, Some(tokens::Token::RightBrace)) {
1329                break;
1330            }
1331            if let Some(cmd) = self.parse_command_list()? {
1332                commands.push(cmd);
1333            }
1334        }
1335
1336        if !matches!(self.current_token, Some(tokens::Token::RightBrace)) {
1337            self.pop_depth();
1338            return Err(Error::parse(
1339                "expected '}' to close brace group".to_string(),
1340            ));
1341        }
1342
1343        // Bash requires at least one command in a brace group
1344        if commands.is_empty() {
1345            self.pop_depth();
1346            return Err(self.error("syntax error: empty brace group"));
1347        }
1348
1349        self.advance(); // consume '}'
1350
1351        self.pop_depth();
1352        Ok(CompoundCommand::BraceGroup(commands))
1353    }
1354
1355    /// Parse arithmetic command ((expression))
1356    /// Parse [[ conditional expression ]]
1357    fn parse_conditional(&mut self) -> Result<CompoundCommand> {
1358        self.advance(); // consume '[['
1359
1360        let mut words = Vec::new();
1361        let mut saw_regex_op = false;
1362
1363        loop {
1364            match &self.current_token {
1365                Some(tokens::Token::DoubleRightBracket) => {
1366                    self.advance(); // consume ']]'
1367                    break;
1368                }
1369                Some(tokens::Token::Word(w))
1370                | Some(tokens::Token::LiteralWord(w))
1371                | Some(tokens::Token::QuotedWord(w)) => {
1372                    let w_clone = w.clone();
1373                    let is_quoted =
1374                        matches!(self.current_token, Some(tokens::Token::QuotedWord(_)));
1375                    let is_literal =
1376                        matches!(self.current_token, Some(tokens::Token::LiteralWord(_)));
1377
1378                    // After =~, handle regex pattern.
1379                    // If the pattern contains $ (variable reference), parse it as a
1380                    // normal word so variables expand. Otherwise collect as literal
1381                    // regex to preserve parens, backslashes, etc.
1382                    if saw_regex_op {
1383                        if w_clone.contains('$') && !is_quoted {
1384                            // Variable reference — parse normally for expansion
1385                            let parsed = self.parse_word(w_clone);
1386                            words.push(parsed);
1387                            self.advance();
1388                        } else {
1389                            let pattern = self.collect_conditional_regex_pattern(&w_clone);
1390                            words.push(Word::literal(&pattern));
1391                        }
1392                        saw_regex_op = false;
1393                        continue;
1394                    }
1395
1396                    if w_clone == "=~" {
1397                        saw_regex_op = true;
1398                    }
1399
1400                    let word = if is_literal {
1401                        Word {
1402                            parts: vec![WordPart::Literal(w_clone)],
1403                            quoted: true,
1404                        }
1405                    } else {
1406                        let mut parsed = self.parse_word(w_clone);
1407                        if is_quoted {
1408                            parsed.quoted = true;
1409                        }
1410                        parsed
1411                    };
1412                    words.push(word);
1413                    self.advance();
1414                }
1415                // Operators that the lexer tokenizes separately
1416                Some(tokens::Token::And) => {
1417                    words.push(Word::literal("&&"));
1418                    self.advance();
1419                }
1420                Some(tokens::Token::Or) => {
1421                    words.push(Word::literal("||"));
1422                    self.advance();
1423                }
1424                Some(tokens::Token::LeftParen) => {
1425                    if saw_regex_op {
1426                        // Regex pattern starts with '(' — collect it
1427                        let pattern = self.collect_conditional_regex_pattern("(");
1428                        words.push(Word::literal(&pattern));
1429                        saw_regex_op = false;
1430                        continue;
1431                    }
1432                    words.push(Word::literal("("));
1433                    self.advance();
1434                }
1435                Some(tokens::Token::RightParen) => {
1436                    words.push(Word::literal(")"));
1437                    self.advance();
1438                }
1439                None => {
1440                    return Err(crate::error::Error::parse(
1441                        "unexpected end of input in [[ ]]".to_string(),
1442                    ));
1443                }
1444                _ => {
1445                    // Skip unknown tokens
1446                    self.advance();
1447                }
1448            }
1449        }
1450
1451        Ok(CompoundCommand::Conditional(words))
1452    }
1453
1454    /// Collect a regex pattern after =~ in [[ ]], handling parens and special chars.
1455    fn collect_conditional_regex_pattern(&mut self, first_word: &str) -> String {
1456        let mut pattern = first_word.to_string();
1457        self.advance(); // consume the first word
1458
1459        // Concatenate adjacent tokens that are part of the regex pattern
1460        loop {
1461            match &self.current_token {
1462                Some(tokens::Token::DoubleRightBracket) => break,
1463                Some(tokens::Token::And) | Some(tokens::Token::Or) => break,
1464                Some(tokens::Token::LeftParen) => {
1465                    pattern.push('(');
1466                    self.advance();
1467                }
1468                Some(tokens::Token::RightParen) => {
1469                    pattern.push(')');
1470                    self.advance();
1471                }
1472                Some(tokens::Token::Word(w))
1473                | Some(tokens::Token::LiteralWord(w))
1474                | Some(tokens::Token::QuotedWord(w)) => {
1475                    pattern.push_str(w);
1476                    self.advance();
1477                }
1478                _ => break,
1479            }
1480        }
1481
1482        pattern
1483    }
1484
1485    fn parse_arithmetic_command(&mut self) -> Result<CompoundCommand> {
1486        self.advance(); // consume '(('
1487
1488        // Read expression until we find ))
1489        let mut expr = String::new();
1490        let mut depth = 1;
1491
1492        loop {
1493            match &self.current_token {
1494                Some(tokens::Token::DoubleLeftParen) => {
1495                    depth += 1;
1496                    expr.push_str("((");
1497                    self.advance();
1498                }
1499                Some(tokens::Token::DoubleRightParen) => {
1500                    depth -= 1;
1501                    if depth == 0 {
1502                        self.advance(); // consume '))'
1503                        break;
1504                    }
1505                    expr.push_str("))");
1506                    self.advance();
1507                }
1508                Some(tokens::Token::LeftParen) => {
1509                    expr.push('(');
1510                    self.advance();
1511                }
1512                Some(tokens::Token::RightParen) => {
1513                    expr.push(')');
1514                    self.advance();
1515                }
1516                Some(tokens::Token::Word(w))
1517                | Some(tokens::Token::LiteralWord(w))
1518                | Some(tokens::Token::QuotedWord(w)) => {
1519                    if !expr.is_empty() && !expr.ends_with(' ') && !expr.ends_with('(') {
1520                        expr.push(' ');
1521                    }
1522                    expr.push_str(w);
1523                    self.advance();
1524                }
1525                Some(tokens::Token::Semicolon) => {
1526                    expr.push(';');
1527                    self.advance();
1528                }
1529                Some(tokens::Token::Newline) => {
1530                    self.advance();
1531                }
1532                // Handle operators that are normally special tokens but valid in arithmetic
1533                Some(tokens::Token::RedirectIn) => {
1534                    expr.push('<');
1535                    self.advance();
1536                }
1537                Some(tokens::Token::RedirectOut) => {
1538                    expr.push('>');
1539                    self.advance();
1540                }
1541                Some(tokens::Token::And) => {
1542                    expr.push_str("&&");
1543                    self.advance();
1544                }
1545                Some(tokens::Token::Or) => {
1546                    expr.push_str("||");
1547                    self.advance();
1548                }
1549                Some(tokens::Token::Pipe) => {
1550                    expr.push('|');
1551                    self.advance();
1552                }
1553                Some(tokens::Token::Background) => {
1554                    expr.push('&');
1555                    self.advance();
1556                }
1557                None => {
1558                    return Err(Error::parse(
1559                        "unexpected end of input in arithmetic command".to_string(),
1560                    ));
1561                }
1562                _ => {
1563                    self.advance();
1564                }
1565            }
1566        }
1567
1568        Ok(CompoundCommand::Arithmetic(expr.trim().to_string()))
1569    }
1570
1571    /// Parse function definition with 'function' keyword: function name { body }
1572    fn parse_function_keyword(&mut self) -> Result<Command> {
1573        let start_span = self.current_span;
1574        self.advance(); // consume 'function'
1575        self.skip_newlines()?;
1576
1577        // Get function name
1578        let name = match &self.current_token {
1579            Some(tokens::Token::Word(w)) => w.clone(),
1580            _ => return Err(self.error("expected function name")),
1581        };
1582        self.advance();
1583        self.skip_newlines()?;
1584
1585        // Optional () after name
1586        if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1587            self.advance(); // consume '('
1588            if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1589                return Err(Error::parse(
1590                    "expected ')' in function definition".to_string(),
1591                ));
1592            }
1593            self.advance(); // consume ')'
1594            self.skip_newlines()?;
1595        }
1596
1597        // Expect { for body
1598        if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
1599            return Err(Error::parse("expected '{' for function body".to_string()));
1600        }
1601
1602        // Parse body as brace group
1603        let body = self.parse_brace_group()?;
1604
1605        Ok(Command::Function(FunctionDef {
1606            name,
1607            body: Box::new(Command::Compound(body, Vec::new())),
1608            span: start_span.merge(self.current_span),
1609        }))
1610    }
1611
1612    /// Parse POSIX-style function definition: name() { body }
1613    fn parse_function_posix(&mut self) -> Result<Command> {
1614        let start_span = self.current_span;
1615        // Get function name
1616        let name = match &self.current_token {
1617            Some(tokens::Token::Word(w)) => w.clone(),
1618            _ => return Err(self.error("expected function name")),
1619        };
1620        self.advance();
1621
1622        // Consume ()
1623        if !matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1624            return Err(self.error("expected '(' in function definition"));
1625        }
1626        self.advance(); // consume '('
1627
1628        if !matches!(self.current_token, Some(tokens::Token::RightParen)) {
1629            return Err(self.error("expected ')' in function definition"));
1630        }
1631        self.advance(); // consume ')'
1632        self.skip_newlines()?;
1633
1634        // Expect { for body
1635        if !matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
1636            return Err(self.error("expected '{' for function body"));
1637        }
1638
1639        // Parse body as brace group
1640        let body = self.parse_brace_group()?;
1641
1642        Ok(Command::Function(FunctionDef {
1643            name,
1644            body: Box::new(Command::Compound(body, Vec::new())),
1645            span: start_span.merge(self.current_span),
1646        }))
1647    }
1648
1649    /// Parse commands until a terminating keyword
1650    fn parse_compound_list(&mut self, terminator: &str) -> Result<Vec<Command>> {
1651        self.parse_compound_list_until(&[terminator])
1652    }
1653
1654    /// Parse commands until one of the terminating keywords
1655    fn parse_compound_list_until(&mut self, terminators: &[&str]) -> Result<Vec<Command>> {
1656        let mut commands = Vec::new();
1657
1658        loop {
1659            self.skip_newlines()?;
1660
1661            // Check for terminators
1662            if let Some(tokens::Token::Word(w)) = &self.current_token
1663                && terminators.contains(&w.as_str())
1664            {
1665                break;
1666            }
1667
1668            if self.current_token.is_none() {
1669                break;
1670            }
1671
1672            if let Some(cmd) = self.parse_command_list()? {
1673                commands.push(cmd);
1674            } else {
1675                break;
1676            }
1677        }
1678
1679        Ok(commands)
1680    }
1681
1682    /// Reserved words that cannot start a simple command.
1683    /// These words are only special in command position, not as arguments.
1684    const NON_COMMAND_WORDS: &'static [&'static str] =
1685        &["then", "else", "elif", "fi", "do", "done", "esac", "in"];
1686
1687    /// Check if a word cannot start a command
1688    fn is_non_command_word(word: &str) -> bool {
1689        Self::NON_COMMAND_WORDS.contains(&word)
1690    }
1691
1692    /// Check if current token is a specific keyword
1693    fn is_keyword(&self, keyword: &str) -> bool {
1694        matches!(&self.current_token, Some(tokens::Token::Word(w)) if w == keyword)
1695    }
1696
1697    /// Expect a specific keyword
1698    fn expect_keyword(&mut self, keyword: &str) -> Result<()> {
1699        if self.is_keyword(keyword) {
1700            self.advance();
1701            Ok(())
1702        } else {
1703            Err(self.error(format!("expected '{}'", keyword)))
1704        }
1705    }
1706
1707    /// Strip surrounding quotes from a string value
1708    fn strip_quotes(s: &str) -> &str {
1709        if s.len() >= 2
1710            && ((s.starts_with('"') && s.ends_with('"'))
1711                || (s.starts_with('\'') && s.ends_with('\'')))
1712        {
1713            return &s[1..s.len() - 1];
1714        }
1715        s
1716    }
1717
1718    /// Check if a word is an assignment (NAME=value, NAME+=value, or NAME[index]=value)
1719    /// Returns (name, optional_index, value, is_append)
1720    fn is_assignment(word: &str) -> Option<(&str, Option<&str>, &str, bool)> {
1721        // Check for += append operator first
1722        let (eq_pos, is_append) = if let Some(pos) = word.find("+=") {
1723            (pos, true)
1724        } else if let Some(pos) = word.find('=') {
1725            (pos, false)
1726        } else {
1727            return None;
1728        };
1729
1730        let lhs = &word[..eq_pos];
1731        let value = &word[eq_pos + if is_append { 2 } else { 1 }..];
1732
1733        // Check for array subscript: name[index]
1734        if let Some(bracket_pos) = lhs.find('[') {
1735            let name = &lhs[..bracket_pos];
1736            // Validate name
1737            if name.is_empty() {
1738                return None;
1739            }
1740            let mut chars = name.chars();
1741            let first = chars.next().unwrap();
1742            if !first.is_ascii_alphabetic() && first != '_' {
1743                return None;
1744            }
1745            for c in chars {
1746                if !c.is_ascii_alphanumeric() && c != '_' {
1747                    return None;
1748                }
1749            }
1750            // Extract index (everything between [ and ])
1751            if lhs.ends_with(']') {
1752                let index = &lhs[bracket_pos + 1..lhs.len() - 1];
1753                return Some((name, Some(index), value, is_append));
1754            }
1755        } else {
1756            // Name must be valid identifier: starts with letter or _, followed by alnum or _
1757            if lhs.is_empty() {
1758                return None;
1759            }
1760            let mut chars = lhs.chars();
1761            let first = chars.next().unwrap();
1762            if !first.is_ascii_alphabetic() && first != '_' {
1763                return None;
1764            }
1765            for c in chars {
1766                if !c.is_ascii_alphanumeric() && c != '_' {
1767                    return None;
1768                }
1769            }
1770            return Some((lhs, None, value, is_append));
1771        }
1772        None
1773    }
1774
1775    /// Parse a simple command with redirections
1776    /// Collect array elements between `(` and `)` tokens into a `Vec<Word>`.
1777    fn collect_array_elements(&mut self) -> Vec<Word> {
1778        let mut elements = Vec::new();
1779        loop {
1780            match &self.current_token {
1781                Some(tokens::Token::RightParen) => {
1782                    self.advance();
1783                    break;
1784                }
1785                Some(tokens::Token::Word(elem))
1786                | Some(tokens::Token::LiteralWord(elem))
1787                | Some(tokens::Token::QuotedWord(elem)) => {
1788                    let elem_clone = elem.clone();
1789                    let word = if matches!(&self.current_token, Some(tokens::Token::LiteralWord(_)))
1790                    {
1791                        Word {
1792                            parts: vec![WordPart::Literal(elem_clone)],
1793                            quoted: true,
1794                        }
1795                    } else {
1796                        self.parse_word(elem_clone)
1797                    };
1798                    elements.push(word);
1799                    self.advance();
1800                }
1801                None => break,
1802                _ => {
1803                    self.advance();
1804                }
1805            }
1806        }
1807        elements
1808    }
1809
1810    /// Parse the value side of an assignment (`VAR=value`).
1811    /// Returns `Some((Assignment, needs_advance))` if the current word is an assignment.
1812    /// The bool indicates whether the caller must call `self.advance()` afterward.
1813    fn try_parse_assignment(&mut self, w: &str) -> Option<(Assignment, bool)> {
1814        let (name, index, value, is_append) = Self::is_assignment(w)?;
1815        let name = name.to_string();
1816        let index = index.map(|s| s.to_string());
1817        let value_str = value.to_string();
1818
1819        // Array literal in the token itself: arr=(a b c)
1820        if value_str.starts_with('(') && value_str.ends_with(')') {
1821            let inner = &value_str[1..value_str.len() - 1];
1822            let elements: Vec<Word> = inner
1823                .split_whitespace()
1824                .map(|s| self.parse_word(s.to_string()))
1825                .collect();
1826            return Some((
1827                Assignment {
1828                    name,
1829                    index,
1830                    value: AssignmentValue::Array(elements),
1831                    append: is_append,
1832                },
1833                true,
1834            ));
1835        }
1836
1837        // Empty value — check for arr=(...) syntax with separate tokens
1838        if value_str.is_empty() {
1839            self.advance();
1840            if matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1841                self.advance(); // consume '('
1842                let elements = self.collect_array_elements();
1843                return Some((
1844                    Assignment {
1845                        name,
1846                        index,
1847                        value: AssignmentValue::Array(elements),
1848                        append: is_append,
1849                    },
1850                    false,
1851                ));
1852            }
1853            // Empty assignment: VAR=
1854            return Some((
1855                Assignment {
1856                    name,
1857                    index,
1858                    value: AssignmentValue::Scalar(Word::literal("")),
1859                    append: is_append,
1860                },
1861                false,
1862            ));
1863        }
1864
1865        // Quoted or plain scalar value
1866        let value_word = if value_str.starts_with('"') && value_str.ends_with('"') {
1867            let inner = Self::strip_quotes(&value_str);
1868            let mut w = self.parse_word(inner.to_string());
1869            w.quoted = true;
1870            w
1871        } else if value_str.starts_with('\'') && value_str.ends_with('\'') {
1872            let inner = Self::strip_quotes(&value_str);
1873            Word {
1874                parts: vec![WordPart::Literal(inner.to_string())],
1875                quoted: true,
1876            }
1877        } else {
1878            self.parse_word(value_str)
1879        };
1880        Some((
1881            Assignment {
1882                name,
1883                index,
1884                value: AssignmentValue::Scalar(value_word),
1885                append: is_append,
1886            },
1887            true,
1888        ))
1889    }
1890
1891    /// Parse a compound array argument in arg position (e.g. `declare -a arr=(x y z)`).
1892    /// Called when the current word ends with `=` and the next token is `(`.
1893    /// Returns the compound word if successful, or `None` if not a compound assignment.
1894    fn try_parse_compound_array_arg(&mut self, saved_w: String) -> Option<Word> {
1895        if !matches!(self.current_token, Some(tokens::Token::LeftParen)) {
1896            return None;
1897        }
1898        self.advance(); // consume '('
1899        let mut compound = saved_w;
1900        compound.push('(');
1901        loop {
1902            match &self.current_token {
1903                Some(tokens::Token::RightParen) => {
1904                    compound.push(')');
1905                    self.advance();
1906                    break;
1907                }
1908                Some(tokens::Token::Word(elem))
1909                | Some(tokens::Token::LiteralWord(elem))
1910                | Some(tokens::Token::QuotedWord(elem)) => {
1911                    if !compound.ends_with('(') {
1912                        compound.push(' ');
1913                    }
1914                    compound.push_str(elem);
1915                    self.advance();
1916                }
1917                None => break,
1918                _ => {
1919                    self.advance();
1920                }
1921            }
1922        }
1923        Some(self.parse_word(compound))
1924    }
1925
1926    /// Parse a heredoc redirect (`<<` or `<<-`) and any trailing redirects on the same line.
1927    fn parse_heredoc_redirect(
1928        &mut self,
1929        strip_tabs: bool,
1930        redirects: &mut Vec<Redirect>,
1931    ) -> Result<()> {
1932        self.advance();
1933        // Get the delimiter word and track if it was quoted
1934        let (delimiter, quoted) = match &self.current_token {
1935            Some(tokens::Token::Word(w)) => (w.clone(), false),
1936            Some(tokens::Token::LiteralWord(w)) => (w.clone(), true),
1937            Some(tokens::Token::QuotedWord(w)) => (w.clone(), true),
1938            _ => return Err(Error::parse("expected delimiter after <<".to_string())),
1939        };
1940
1941        let content = self.lexer.read_heredoc(&delimiter);
1942
1943        // Strip leading tabs for <<-
1944        let content = if strip_tabs {
1945            let had_trailing_newline = content.ends_with('\n');
1946            let mut stripped: String = content
1947                .lines()
1948                .map(|l: &str| l.trim_start_matches('\t'))
1949                .collect::<Vec<_>>()
1950                .join("\n");
1951            if had_trailing_newline {
1952                stripped.push('\n');
1953            }
1954            stripped
1955        } else {
1956            content
1957        };
1958
1959        let target = if quoted {
1960            Word::quoted_literal(content)
1961        } else {
1962            self.parse_word(content)
1963        };
1964
1965        let kind = if strip_tabs {
1966            RedirectKind::HereDocStrip
1967        } else {
1968            RedirectKind::HereDoc
1969        };
1970
1971        redirects.push(Redirect {
1972            fd: None,
1973            kind,
1974            target,
1975        });
1976
1977        // Advance so re-injected rest-of-line tokens are picked up
1978        self.advance();
1979
1980        // Consume any trailing redirects on the same line (e.g. `cat <<EOF > file`)
1981        self.collect_trailing_redirects(redirects);
1982        Ok(())
1983    }
1984
1985    /// Consume redirect tokens that follow a heredoc on the same line.
1986    fn collect_trailing_redirects(&mut self, redirects: &mut Vec<Redirect>) {
1987        while let Some(tok) = &self.current_token {
1988            match tok {
1989                tokens::Token::RedirectOut | tokens::Token::Clobber => {
1990                    let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) {
1991                        RedirectKind::Clobber
1992                    } else {
1993                        RedirectKind::Output
1994                    };
1995                    self.advance();
1996                    if let Ok(target) = self.expect_word() {
1997                        redirects.push(Redirect {
1998                            fd: None,
1999                            kind,
2000                            target,
2001                        });
2002                    }
2003                }
2004                tokens::Token::RedirectAppend => {
2005                    self.advance();
2006                    if let Ok(target) = self.expect_word() {
2007                        redirects.push(Redirect {
2008                            fd: None,
2009                            kind: RedirectKind::Append,
2010                            target,
2011                        });
2012                    }
2013                }
2014                tokens::Token::RedirectFd(fd) => {
2015                    let fd = *fd;
2016                    self.advance();
2017                    if let Ok(target) = self.expect_word() {
2018                        redirects.push(Redirect {
2019                            fd: Some(fd),
2020                            kind: RedirectKind::Output,
2021                            target,
2022                        });
2023                    }
2024                }
2025                tokens::Token::DupInput => {
2026                    self.advance();
2027                    if let Ok(target) = self.expect_word() {
2028                        redirects.push(Redirect {
2029                            fd: Some(0),
2030                            kind: RedirectKind::DupInput,
2031                            target,
2032                        });
2033                    }
2034                }
2035                tokens::Token::DupFdIn(src_fd, dst_fd) => {
2036                    let src_fd = *src_fd;
2037                    let dst_fd = *dst_fd;
2038                    self.advance();
2039                    redirects.push(Redirect {
2040                        fd: Some(src_fd),
2041                        kind: RedirectKind::DupInput,
2042                        target: Word::literal(dst_fd.to_string()),
2043                    });
2044                }
2045                tokens::Token::DupFdClose(fd) => {
2046                    let fd = *fd;
2047                    self.advance();
2048                    redirects.push(Redirect {
2049                        fd: Some(fd),
2050                        kind: RedirectKind::DupInput,
2051                        target: Word::literal("-"),
2052                    });
2053                }
2054                tokens::Token::RedirectFdIn(fd) => {
2055                    let fd = *fd;
2056                    self.advance();
2057                    if let Ok(target) = self.expect_word() {
2058                        redirects.push(Redirect {
2059                            fd: Some(fd),
2060                            kind: RedirectKind::Input,
2061                            target,
2062                        });
2063                    }
2064                }
2065                _ => break,
2066            }
2067        }
2068    }
2069
2070    fn parse_simple_command(&mut self) -> Result<Option<SimpleCommand>> {
2071        self.tick()?;
2072        self.skip_newlines()?;
2073        self.check_error_token()?;
2074        let start_span = self.current_span;
2075
2076        let mut assignments = Vec::new();
2077        let mut words = Vec::new();
2078        let mut redirects = Vec::new();
2079
2080        loop {
2081            match &self.current_token {
2082                Some(tokens::Token::Word(w))
2083                | Some(tokens::Token::LiteralWord(w))
2084                | Some(tokens::Token::QuotedWord(w)) => {
2085                    let is_literal =
2086                        matches!(&self.current_token, Some(tokens::Token::LiteralWord(_)));
2087                    let is_quoted =
2088                        matches!(&self.current_token, Some(tokens::Token::QuotedWord(_)));
2089                    // Clone early to release borrow on self.current_token
2090                    let w = w.clone();
2091
2092                    // Stop if this word cannot start a command (like 'then', 'fi', etc.)
2093                    if words.is_empty() && Self::is_non_command_word(&w) {
2094                        break;
2095                    }
2096
2097                    // Check for assignment (only before the command name, not for literal words)
2098                    if words.is_empty()
2099                        && !is_literal
2100                        && let Some((assignment, needs_advance)) = self.try_parse_assignment(&w)
2101                    {
2102                        if needs_advance {
2103                            self.advance();
2104                        }
2105                        assignments.push(assignment);
2106                        continue;
2107                    }
2108
2109                    // Handle compound array assignment in arg position:
2110                    // declare -a arr=(x y z) → arr=(x y z) as single arg
2111                    if w.ends_with('=') && !words.is_empty() {
2112                        self.advance();
2113                        if let Some(word) = self.try_parse_compound_array_arg(w.clone()) {
2114                            words.push(word);
2115                            continue;
2116                        }
2117                        // Not a compound assignment — treat as regular word
2118                        let word = if is_literal {
2119                            Word {
2120                                parts: vec![WordPart::Literal(w)],
2121                                quoted: true,
2122                            }
2123                        } else {
2124                            let mut word = self.parse_word(w);
2125                            if is_quoted {
2126                                word.quoted = true;
2127                            }
2128                            word
2129                        };
2130                        words.push(word);
2131                        continue;
2132                    }
2133
2134                    let word = if is_literal {
2135                        Word {
2136                            parts: vec![WordPart::Literal(w)],
2137                            quoted: true,
2138                        }
2139                    } else {
2140                        let mut word = self.parse_word(w);
2141                        if is_quoted {
2142                            word.quoted = true;
2143                        }
2144                        word
2145                    };
2146                    words.push(word);
2147                    self.advance();
2148                }
2149                Some(tokens::Token::RedirectOut) | Some(tokens::Token::Clobber) => {
2150                    let kind = if matches!(&self.current_token, Some(tokens::Token::Clobber)) {
2151                        RedirectKind::Clobber
2152                    } else {
2153                        RedirectKind::Output
2154                    };
2155                    self.advance();
2156                    let target = self.expect_word()?;
2157                    redirects.push(Redirect {
2158                        fd: None,
2159                        kind,
2160                        target,
2161                    });
2162                }
2163                Some(tokens::Token::RedirectAppend) => {
2164                    self.advance();
2165                    let target = self.expect_word()?;
2166                    redirects.push(Redirect {
2167                        fd: None,
2168                        kind: RedirectKind::Append,
2169                        target,
2170                    });
2171                }
2172                Some(tokens::Token::RedirectIn) => {
2173                    self.advance();
2174                    let target = self.expect_word()?;
2175                    redirects.push(Redirect {
2176                        fd: None,
2177                        kind: RedirectKind::Input,
2178                        target,
2179                    });
2180                }
2181                Some(tokens::Token::HereString) => {
2182                    self.advance();
2183                    let target = self.expect_word()?;
2184                    redirects.push(Redirect {
2185                        fd: None,
2186                        kind: RedirectKind::HereString,
2187                        target,
2188                    });
2189                }
2190                Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => {
2191                    let strip_tabs =
2192                        matches!(self.current_token, Some(tokens::Token::HereDocStrip));
2193                    self.parse_heredoc_redirect(strip_tabs, &mut redirects)?;
2194                    break;
2195                }
2196                Some(tokens::Token::ProcessSubIn) | Some(tokens::Token::ProcessSubOut) => {
2197                    let word = self.expect_word()?;
2198                    words.push(word);
2199                }
2200                Some(tokens::Token::RedirectBoth) => {
2201                    self.advance();
2202                    let target = self.expect_word()?;
2203                    redirects.push(Redirect {
2204                        fd: None,
2205                        kind: RedirectKind::OutputBoth,
2206                        target,
2207                    });
2208                }
2209                Some(tokens::Token::DupOutput) => {
2210                    self.advance();
2211                    let target = self.expect_word()?;
2212                    redirects.push(Redirect {
2213                        fd: Some(1),
2214                        kind: RedirectKind::DupOutput,
2215                        target,
2216                    });
2217                }
2218                Some(tokens::Token::RedirectFd(fd)) => {
2219                    let fd = *fd;
2220                    self.advance();
2221                    let target = self.expect_word()?;
2222                    redirects.push(Redirect {
2223                        fd: Some(fd),
2224                        kind: RedirectKind::Output,
2225                        target,
2226                    });
2227                }
2228                Some(tokens::Token::RedirectFdAppend(fd)) => {
2229                    let fd = *fd;
2230                    self.advance();
2231                    let target = self.expect_word()?;
2232                    redirects.push(Redirect {
2233                        fd: Some(fd),
2234                        kind: RedirectKind::Append,
2235                        target,
2236                    });
2237                }
2238                Some(tokens::Token::DupFd(src_fd, dst_fd)) => {
2239                    let src_fd = *src_fd;
2240                    let dst_fd = *dst_fd;
2241                    self.advance();
2242                    redirects.push(Redirect {
2243                        fd: Some(src_fd),
2244                        kind: RedirectKind::DupOutput,
2245                        target: Word::literal(dst_fd.to_string()),
2246                    });
2247                }
2248                Some(tokens::Token::DupInput) => {
2249                    self.advance();
2250                    let target = self.expect_word()?;
2251                    redirects.push(Redirect {
2252                        fd: Some(0),
2253                        kind: RedirectKind::DupInput,
2254                        target,
2255                    });
2256                }
2257                Some(tokens::Token::DupFdIn(src_fd, dst_fd)) => {
2258                    let src_fd = *src_fd;
2259                    let dst_fd = *dst_fd;
2260                    self.advance();
2261                    redirects.push(Redirect {
2262                        fd: Some(src_fd),
2263                        kind: RedirectKind::DupInput,
2264                        target: Word::literal(dst_fd.to_string()),
2265                    });
2266                }
2267                Some(tokens::Token::DupFdClose(fd)) => {
2268                    let fd = *fd;
2269                    self.advance();
2270                    redirects.push(Redirect {
2271                        fd: Some(fd),
2272                        kind: RedirectKind::DupInput,
2273                        target: Word::literal("-"),
2274                    });
2275                }
2276                Some(tokens::Token::RedirectFdIn(fd)) => {
2277                    let fd = *fd;
2278                    self.advance();
2279                    let target = self.expect_word()?;
2280                    redirects.push(Redirect {
2281                        fd: Some(fd),
2282                        kind: RedirectKind::Input,
2283                        target,
2284                    });
2285                }
2286                // { and } as arguments (not in command position) are literal words
2287                Some(tokens::Token::LeftBrace) | Some(tokens::Token::RightBrace)
2288                    if !words.is_empty() =>
2289                {
2290                    let sym = if matches!(self.current_token, Some(tokens::Token::LeftBrace)) {
2291                        "{"
2292                    } else {
2293                        "}"
2294                    };
2295                    words.push(Word::literal(sym));
2296                    self.advance();
2297                }
2298                Some(tokens::Token::Newline)
2299                | Some(tokens::Token::Semicolon)
2300                | Some(tokens::Token::Pipe)
2301                | Some(tokens::Token::And)
2302                | Some(tokens::Token::Or)
2303                | None => break,
2304                _ => break,
2305            }
2306        }
2307
2308        // Handle assignment-only commands (VAR=value with no command)
2309        if words.is_empty() && !assignments.is_empty() {
2310            return Ok(Some(SimpleCommand {
2311                name: Word::literal(""),
2312                args: Vec::new(),
2313                redirects,
2314                assignments,
2315                span: start_span.merge(self.current_span),
2316            }));
2317        }
2318
2319        if words.is_empty() {
2320            return Ok(None);
2321        }
2322
2323        let name = words.remove(0);
2324        let args = words;
2325
2326        Ok(Some(SimpleCommand {
2327            name,
2328            args,
2329            redirects,
2330            assignments,
2331            span: start_span.merge(self.current_span),
2332        }))
2333    }
2334
2335    /// Expect a word token and return it as a Word
2336    fn expect_word(&mut self) -> Result<Word> {
2337        match &self.current_token {
2338            Some(tokens::Token::Word(w)) => {
2339                let word = self.parse_word(w.clone());
2340                self.advance();
2341                Ok(word)
2342            }
2343            Some(tokens::Token::LiteralWord(w)) => {
2344                // Single-quoted: no variable expansion
2345                let word = Word {
2346                    parts: vec![WordPart::Literal(w.clone())],
2347                    quoted: true,
2348                };
2349                self.advance();
2350                Ok(word)
2351            }
2352            Some(tokens::Token::QuotedWord(w)) => {
2353                // Double-quoted: parse for variable expansion
2354                let word = self.parse_word(w.clone());
2355                self.advance();
2356                Ok(word)
2357            }
2358            Some(tokens::Token::ProcessSubIn) | Some(tokens::Token::ProcessSubOut) => {
2359                // Process substitution <(cmd) or >(cmd)
2360                let is_input = matches!(self.current_token, Some(tokens::Token::ProcessSubIn));
2361                self.advance();
2362
2363                // Parse commands until we hit a closing paren
2364                let mut cmd_str = String::new();
2365                let mut depth = 1;
2366                loop {
2367                    match &self.current_token {
2368                        Some(tokens::Token::LeftParen) => {
2369                            depth += 1;
2370                            cmd_str.push('(');
2371                            self.advance();
2372                        }
2373                        Some(tokens::Token::RightParen) => {
2374                            depth -= 1;
2375                            if depth == 0 {
2376                                self.advance();
2377                                break;
2378                            }
2379                            cmd_str.push(')');
2380                            self.advance();
2381                        }
2382                        Some(tokens::Token::Word(w)) => {
2383                            if !cmd_str.is_empty() {
2384                                cmd_str.push(' ');
2385                            }
2386                            cmd_str.push_str(w);
2387                            self.advance();
2388                        }
2389                        Some(tokens::Token::QuotedWord(w)) => {
2390                            if !cmd_str.is_empty() {
2391                                cmd_str.push(' ');
2392                            }
2393                            cmd_str.push('"');
2394                            cmd_str.push_str(w);
2395                            cmd_str.push('"');
2396                            self.advance();
2397                        }
2398                        Some(tokens::Token::LiteralWord(w)) => {
2399                            if !cmd_str.is_empty() {
2400                                cmd_str.push(' ');
2401                            }
2402                            cmd_str.push('\'');
2403                            cmd_str.push_str(w);
2404                            cmd_str.push('\'');
2405                            self.advance();
2406                        }
2407                        Some(tokens::Token::Pipe) => {
2408                            cmd_str.push_str(" | ");
2409                            self.advance();
2410                        }
2411                        Some(tokens::Token::Semicolon) => {
2412                            cmd_str.push_str("; ");
2413                            self.advance();
2414                        }
2415                        Some(tokens::Token::And) => {
2416                            cmd_str.push_str(" && ");
2417                            self.advance();
2418                        }
2419                        Some(tokens::Token::Or) => {
2420                            cmd_str.push_str(" || ");
2421                            self.advance();
2422                        }
2423                        Some(tokens::Token::Background) => {
2424                            cmd_str.push_str(" & ");
2425                            self.advance();
2426                        }
2427                        Some(tokens::Token::RedirectOut) => {
2428                            cmd_str.push_str(" > ");
2429                            self.advance();
2430                        }
2431                        Some(tokens::Token::RedirectAppend) => {
2432                            cmd_str.push_str(" >> ");
2433                            self.advance();
2434                        }
2435                        Some(tokens::Token::RedirectIn) => {
2436                            cmd_str.push_str(" < ");
2437                            self.advance();
2438                        }
2439                        Some(tokens::Token::HereString) => {
2440                            cmd_str.push_str(" <<< ");
2441                            self.advance();
2442                        }
2443                        Some(tokens::Token::DupOutput) => {
2444                            cmd_str.push_str(" >&");
2445                            self.advance();
2446                        }
2447                        Some(tokens::Token::RedirectFd(fd)) => {
2448                            cmd_str.push_str(&format!(" {}> ", fd));
2449                            self.advance();
2450                        }
2451                        Some(tokens::Token::Newline) => {
2452                            cmd_str.push('\n');
2453                            self.advance();
2454                        }
2455                        None => {
2456                            return Err(Error::parse(
2457                                "unexpected end of input in process substitution".to_string(),
2458                            ));
2459                        }
2460                        _ => {
2461                            // Skip unknown tokens but don't silently lose them
2462                            self.advance();
2463                        }
2464                    }
2465                }
2466
2467                // THREAT[TM-DOS-021]: Propagate parent parser limits to child parser
2468                // to prevent depth limit bypass via nested process substitution.
2469                // Child inherits remaining depth budget and fuel from parent.
2470                let remaining_depth = self.max_depth.saturating_sub(self.current_depth);
2471                let inner_parser = Parser::with_limits(&cmd_str, remaining_depth, self.fuel);
2472                let commands = match inner_parser.parse() {
2473                    Ok(script) => script.commands,
2474                    Err(_) => Vec::new(),
2475                };
2476
2477                Ok(Word {
2478                    parts: vec![WordPart::ProcessSubstitution { commands, is_input }],
2479                    quoted: false,
2480                })
2481            }
2482            _ => Err(self.error("expected word")),
2483        }
2484    }
2485
2486    // Helper methods for word handling - kept for potential future use
2487    #[allow(dead_code)]
2488    /// Convert current word token to Word (handles Word, LiteralWord, QuotedWord)
2489    fn current_word_to_word(&self) -> Option<Word> {
2490        match &self.current_token {
2491            Some(tokens::Token::Word(w)) | Some(tokens::Token::QuotedWord(w)) => {
2492                Some(self.parse_word(w.clone()))
2493            }
2494            Some(tokens::Token::LiteralWord(w)) => Some(Word {
2495                parts: vec![WordPart::Literal(w.clone())],
2496                quoted: true,
2497            }),
2498            _ => None,
2499        }
2500    }
2501
2502    #[allow(dead_code)]
2503    /// Check if current token is a word (Word, LiteralWord, or QuotedWord)
2504    fn is_current_word(&self) -> bool {
2505        matches!(
2506            &self.current_token,
2507            Some(tokens::Token::Word(_))
2508                | Some(tokens::Token::LiteralWord(_))
2509                | Some(tokens::Token::QuotedWord(_))
2510        )
2511    }
2512
2513    #[allow(dead_code)]
2514    /// Get the string content if current token is a word
2515    fn current_word_str(&self) -> Option<String> {
2516        match &self.current_token {
2517            Some(tokens::Token::Word(w))
2518            | Some(tokens::Token::LiteralWord(w))
2519            | Some(tokens::Token::QuotedWord(w)) => Some(w.clone()),
2520            _ => None,
2521        }
2522    }
2523
2524    /// Parse a word string into a Word with proper parts (variables, literals)
2525    fn parse_word(&self, s: String) -> Word {
2526        let mut parts = Vec::new();
2527        let mut chars = s.chars().peekable();
2528        let mut current = String::new();
2529
2530        while let Some(ch) = chars.next() {
2531            if ch == '$' {
2532                // Flush current literal
2533                if !current.is_empty() {
2534                    parts.push(WordPart::Literal(std::mem::take(&mut current)));
2535                }
2536
2537                // Check for $( - command substitution or arithmetic
2538                if chars.peek() == Some(&'(') {
2539                    chars.next(); // consume first '('
2540
2541                    // Check for $(( - arithmetic expansion
2542                    if chars.peek() == Some(&'(') {
2543                        chars.next(); // consume second '('
2544                        let mut expr = String::new();
2545                        let mut depth = 2;
2546                        for c in chars.by_ref() {
2547                            if c == '(' {
2548                                depth += 1;
2549                                expr.push(c);
2550                            } else if c == ')' {
2551                                depth -= 1;
2552                                if depth == 0 {
2553                                    break;
2554                                }
2555                                expr.push(c);
2556                            } else {
2557                                expr.push(c);
2558                            }
2559                        }
2560                        // Remove trailing ) if present
2561                        if expr.ends_with(')') {
2562                            expr.pop();
2563                        }
2564                        parts.push(WordPart::ArithmeticExpansion(expr));
2565                    } else {
2566                        // Command substitution $(...)
2567                        let mut cmd_str = String::new();
2568                        let mut depth = 1;
2569                        for c in chars.by_ref() {
2570                            if c == '(' {
2571                                depth += 1;
2572                                cmd_str.push(c);
2573                            } else if c == ')' {
2574                                depth -= 1;
2575                                if depth == 0 {
2576                                    break;
2577                                }
2578                                cmd_str.push(c);
2579                            } else {
2580                                cmd_str.push(c);
2581                            }
2582                        }
2583                        // THREAT[TM-DOS-021]: Propagate parent parser limits to child parser
2584                        // to prevent depth limit bypass via nested command substitution.
2585                        let remaining_depth = self.max_depth.saturating_sub(self.current_depth);
2586                        let inner_parser =
2587                            Parser::with_limits(&cmd_str, remaining_depth, self.fuel);
2588                        if let Ok(script) = inner_parser.parse() {
2589                            parts.push(WordPart::CommandSubstitution(script.commands));
2590                        }
2591                    }
2592                } else if chars.peek() == Some(&'{') {
2593                    // ${VAR} format with possible parameter expansion
2594                    chars.next(); // consume '{'
2595
2596                    // Check for ${#var} or ${#arr[@]} - length expansion
2597                    if chars.peek() == Some(&'#') {
2598                        chars.next(); // consume '#'
2599                        let mut var_name = String::new();
2600                        while let Some(&c) = chars.peek() {
2601                            if c == '}' || c == '[' {
2602                                break;
2603                            }
2604                            var_name.push(chars.next().unwrap());
2605                        }
2606                        // Check for array length ${#arr[@]} or ${#arr[*]}
2607                        if chars.peek() == Some(&'[') {
2608                            chars.next(); // consume '['
2609                            let mut index = String::new();
2610                            while let Some(&c) = chars.peek() {
2611                                if c == ']' {
2612                                    chars.next();
2613                                    break;
2614                                }
2615                                index.push(chars.next().unwrap());
2616                            }
2617                            // Consume closing }
2618                            if chars.peek() == Some(&'}') {
2619                                chars.next();
2620                            }
2621                            if index == "@" || index == "*" {
2622                                parts.push(WordPart::ArrayLength(var_name));
2623                            } else {
2624                                // ${#arr[n]} - length of element (same as ${#arr[n]})
2625                                parts.push(WordPart::Length(format!("{}[{}]", var_name, index)));
2626                            }
2627                        } else {
2628                            // Consume closing }
2629                            if chars.peek() == Some(&'}') {
2630                                chars.next();
2631                            }
2632                            parts.push(WordPart::Length(var_name));
2633                        }
2634                    } else if chars.peek() == Some(&'!') {
2635                        // Check for ${!arr[@]} or ${!arr[*]} - array indices
2636                        // or ${!var} - indirect expansion
2637                        chars.next(); // consume '!'
2638                        let mut var_name = String::new();
2639                        while let Some(&c) = chars.peek() {
2640                            if c == '}' || c == '[' || c == '*' || c == '@' {
2641                                break;
2642                            }
2643                            var_name.push(chars.next().unwrap());
2644                        }
2645                        // Check for array indices ${!arr[@]} or ${!arr[*]}
2646                        if chars.peek() == Some(&'[') {
2647                            chars.next(); // consume '['
2648                            let mut index = String::new();
2649                            while let Some(&c) = chars.peek() {
2650                                if c == ']' {
2651                                    chars.next();
2652                                    break;
2653                                }
2654                                index.push(chars.next().unwrap());
2655                            }
2656                            // Consume closing }
2657                            if chars.peek() == Some(&'}') {
2658                                chars.next();
2659                            }
2660                            if index == "@" || index == "*" {
2661                                parts.push(WordPart::ArrayIndices(var_name));
2662                            } else {
2663                                // ${!arr[n]} - not standard, treat as variable
2664                                parts.push(WordPart::Variable(format!("!{}[{}]", var_name, index)));
2665                            }
2666                        } else if chars.peek() == Some(&'}') {
2667                            // ${!var} - indirect expansion
2668                            chars.next(); // consume '}'
2669                            parts.push(WordPart::IndirectExpansion(var_name));
2670                        } else {
2671                            // ${!prefix*} or ${!prefix@} - prefix matching
2672                            let mut suffix = String::new();
2673                            while let Some(&c) = chars.peek() {
2674                                if c == '}' {
2675                                    chars.next();
2676                                    break;
2677                                }
2678                                suffix.push(chars.next().unwrap());
2679                            }
2680                            // Strip trailing * or @
2681                            if suffix.ends_with('*') || suffix.ends_with('@') {
2682                                let full_prefix =
2683                                    format!("{}{}", var_name, &suffix[..suffix.len() - 1]);
2684                                parts.push(WordPart::PrefixMatch(full_prefix));
2685                            } else {
2686                                parts.push(WordPart::Variable(format!("!{}{}", var_name, suffix)));
2687                            }
2688                        }
2689                    } else {
2690                        // Read variable name
2691                        let mut var_name = String::new();
2692                        while let Some(&c) = chars.peek() {
2693                            if c.is_ascii_alphanumeric() || c == '_' {
2694                                var_name.push(chars.next().unwrap());
2695                            } else {
2696                                break;
2697                            }
2698                        }
2699
2700                        // Handle special parameters: ${@...}, ${*...}
2701                        if var_name.is_empty()
2702                            && let Some(&c) = chars.peek()
2703                            && matches!(c, '@' | '*')
2704                        {
2705                            var_name.push(chars.next().unwrap());
2706                        }
2707
2708                        // Check for array access ${arr[index]} or ${arr[@]:offset:length}
2709                        if chars.peek() == Some(&'[') {
2710                            chars.next(); // consume '['
2711                            let mut index = String::new();
2712                            // Track nesting so nested ${...} containing
2713                            // brackets (e.g. ${#arr[@]}) don't prematurely
2714                            // close the subscript.
2715                            let mut bracket_depth: i32 = 0;
2716                            let mut brace_depth: i32 = 0;
2717                            while let Some(&c) = chars.peek() {
2718                                if c == ']' && bracket_depth == 0 && brace_depth == 0 {
2719                                    chars.next();
2720                                    break;
2721                                }
2722                                match c {
2723                                    '[' => bracket_depth += 1,
2724                                    ']' => bracket_depth -= 1,
2725                                    '$' => {
2726                                        index.push(chars.next().unwrap());
2727                                        if chars.peek() == Some(&'{') {
2728                                            brace_depth += 1;
2729                                            index.push(chars.next().unwrap());
2730                                            continue;
2731                                        }
2732                                        continue;
2733                                    }
2734                                    '{' => brace_depth += 1,
2735                                    '}' => {
2736                                        if brace_depth > 0 {
2737                                            brace_depth -= 1;
2738                                        }
2739                                    }
2740                                    _ => {}
2741                                }
2742                                index.push(chars.next().unwrap());
2743                            }
2744                            // Strip surrounding quotes from index (e.g. "foo" -> foo)
2745                            if index.len() >= 2
2746                                && ((index.starts_with('"') && index.ends_with('"'))
2747                                    || (index.starts_with('\'') && index.ends_with('\'')))
2748                            {
2749                                index = index[1..index.len() - 1].to_string();
2750                            }
2751                            // After ], check for operators on array subscripts
2752                            if let Some(&next_c) = chars.peek() {
2753                                if next_c == ':' {
2754                                    // Peek ahead to distinguish param ops (:- := :+ :?) from slice (:N)
2755                                    let mut lookahead = chars.clone();
2756                                    lookahead.next(); // skip ':'
2757                                    let is_param_op = matches!(
2758                                        lookahead.peek(),
2759                                        Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?')
2760                                    );
2761                                    if is_param_op {
2762                                        chars.next(); // consume ':'
2763                                        let arr_name = format!("{}[{}]", var_name, index);
2764                                        let op_char = chars.next().unwrap();
2765                                        let operand = self.read_brace_operand(&mut chars);
2766                                        let operator = match op_char {
2767                                            '-' => ParameterOp::UseDefault,
2768                                            '=' => ParameterOp::AssignDefault,
2769                                            '+' => ParameterOp::UseReplacement,
2770                                            '?' => ParameterOp::Error,
2771                                            _ => unreachable!(),
2772                                        };
2773                                        parts.push(WordPart::ParameterExpansion {
2774                                            name: arr_name,
2775                                            operator,
2776                                            operand,
2777                                            colon_variant: true,
2778                                        });
2779                                    } else {
2780                                        // Array slice ${arr[@]:offset:length}
2781                                        chars.next(); // consume ':'
2782                                        let mut offset = String::new();
2783                                        while let Some(&c) = chars.peek() {
2784                                            if c == ':' || c == '}' {
2785                                                break;
2786                                            }
2787                                            offset.push(chars.next().unwrap());
2788                                        }
2789                                        let length = if chars.peek() == Some(&':') {
2790                                            chars.next();
2791                                            let mut len = String::new();
2792                                            while let Some(&c) = chars.peek() {
2793                                                if c == '}' {
2794                                                    break;
2795                                                }
2796                                                len.push(chars.next().unwrap());
2797                                            }
2798                                            Some(len)
2799                                        } else {
2800                                            None
2801                                        };
2802                                        if chars.peek() == Some(&'}') {
2803                                            chars.next();
2804                                        }
2805                                        parts.push(WordPart::ArraySlice {
2806                                            name: var_name,
2807                                            offset,
2808                                            length,
2809                                        });
2810                                    }
2811                                } else if matches!(next_c, '-' | '+' | '=' | '?') {
2812                                    // Non-colon operators on array: ${arr[@]-default}
2813                                    let arr_name = format!("{}[{}]", var_name, index);
2814                                    let op_char = chars.next().unwrap();
2815                                    let operand = self.read_brace_operand(&mut chars);
2816                                    let operator = match op_char {
2817                                        '-' => ParameterOp::UseDefault,
2818                                        '=' => ParameterOp::AssignDefault,
2819                                        '+' => ParameterOp::UseReplacement,
2820                                        '?' => ParameterOp::Error,
2821                                        _ => unreachable!(),
2822                                    };
2823                                    parts.push(WordPart::ParameterExpansion {
2824                                        name: arr_name,
2825                                        operator,
2826                                        operand,
2827                                        colon_variant: false,
2828                                    });
2829                                } else {
2830                                    // Plain array access ${arr[index]}
2831                                    if chars.peek() == Some(&'}') {
2832                                        chars.next();
2833                                    }
2834                                    parts.push(WordPart::ArrayAccess {
2835                                        name: var_name,
2836                                        index,
2837                                    });
2838                                }
2839                            } else {
2840                                parts.push(WordPart::ArrayAccess {
2841                                    name: var_name,
2842                                    index,
2843                                });
2844                            }
2845                        } else if let Some(&c) = chars.peek() {
2846                            // Check for operator
2847                            match c {
2848                                ':' => {
2849                                    chars.next(); // consume ':'
2850                                    match chars.peek() {
2851                                        Some(&'-') | Some(&'=') | Some(&'+') | Some(&'?') => {
2852                                            let op_char = chars.next().unwrap();
2853                                            let operand = self.read_brace_operand(&mut chars);
2854                                            let operator = match op_char {
2855                                                '-' => ParameterOp::UseDefault,
2856                                                '=' => ParameterOp::AssignDefault,
2857                                                '+' => ParameterOp::UseReplacement,
2858                                                '?' => ParameterOp::Error,
2859                                                _ => unreachable!(),
2860                                            };
2861                                            parts.push(WordPart::ParameterExpansion {
2862                                                name: var_name,
2863                                                operator,
2864                                                operand,
2865                                                colon_variant: true,
2866                                            });
2867                                        }
2868                                        _ => {
2869                                            // Substring extraction ${var:offset} or ${var:offset:length}
2870                                            let mut offset = String::new();
2871                                            while let Some(&ch) = chars.peek() {
2872                                                if ch == ':' || ch == '}' {
2873                                                    break;
2874                                                }
2875                                                offset.push(chars.next().unwrap());
2876                                            }
2877                                            let length = if chars.peek() == Some(&':') {
2878                                                chars.next(); // consume ':'
2879                                                let mut len = String::new();
2880                                                while let Some(&ch) = chars.peek() {
2881                                                    if ch == '}' {
2882                                                        break;
2883                                                    }
2884                                                    len.push(chars.next().unwrap());
2885                                                }
2886                                                Some(len)
2887                                            } else {
2888                                                None
2889                                            };
2890                                            if chars.peek() == Some(&'}') {
2891                                                chars.next();
2892                                            }
2893                                            parts.push(WordPart::Substring {
2894                                                name: var_name,
2895                                                offset,
2896                                                length,
2897                                            });
2898                                        }
2899                                    }
2900                                }
2901                                // Non-colon test operators: ${var-default}, ${var+alt}, ${var=assign}, ${var?err}
2902                                '-' | '=' | '+' | '?' => {
2903                                    let op_char = chars.next().unwrap();
2904                                    let operand = self.read_brace_operand(&mut chars);
2905                                    let operator = match op_char {
2906                                        '-' => ParameterOp::UseDefault,
2907                                        '=' => ParameterOp::AssignDefault,
2908                                        '+' => ParameterOp::UseReplacement,
2909                                        '?' => ParameterOp::Error,
2910                                        _ => unreachable!(),
2911                                    };
2912                                    parts.push(WordPart::ParameterExpansion {
2913                                        name: var_name,
2914                                        operator,
2915                                        operand,
2916                                        colon_variant: false,
2917                                    });
2918                                }
2919                                '#' => {
2920                                    chars.next();
2921                                    if chars.peek() == Some(&'#') {
2922                                        chars.next();
2923                                        let op = self.read_brace_operand(&mut chars);
2924                                        parts.push(WordPart::ParameterExpansion {
2925                                            name: var_name,
2926                                            operator: ParameterOp::RemovePrefixLong,
2927                                            operand: op,
2928                                            colon_variant: false,
2929                                        });
2930                                    } else {
2931                                        let op = self.read_brace_operand(&mut chars);
2932                                        parts.push(WordPart::ParameterExpansion {
2933                                            name: var_name,
2934                                            operator: ParameterOp::RemovePrefixShort,
2935                                            operand: op,
2936                                            colon_variant: false,
2937                                        });
2938                                    }
2939                                }
2940                                '%' => {
2941                                    chars.next();
2942                                    if chars.peek() == Some(&'%') {
2943                                        chars.next();
2944                                        let op = self.read_brace_operand(&mut chars);
2945                                        parts.push(WordPart::ParameterExpansion {
2946                                            name: var_name,
2947                                            operator: ParameterOp::RemoveSuffixLong,
2948                                            operand: op,
2949                                            colon_variant: false,
2950                                        });
2951                                    } else {
2952                                        let op = self.read_brace_operand(&mut chars);
2953                                        parts.push(WordPart::ParameterExpansion {
2954                                            name: var_name,
2955                                            operator: ParameterOp::RemoveSuffixShort,
2956                                            operand: op,
2957                                            colon_variant: false,
2958                                        });
2959                                    }
2960                                }
2961                                '/' => {
2962                                    chars.next();
2963                                    let replace_all = if chars.peek() == Some(&'/') {
2964                                        chars.next();
2965                                        true
2966                                    } else {
2967                                        false
2968                                    };
2969                                    let mut pattern = String::new();
2970                                    while let Some(&ch) = chars.peek() {
2971                                        if ch == '/' || ch == '}' {
2972                                            break;
2973                                        }
2974                                        if ch == '\\' {
2975                                            chars.next();
2976                                            if let Some(&next) = chars.peek()
2977                                                && next == '/'
2978                                            {
2979                                                pattern.push(chars.next().unwrap());
2980                                                continue;
2981                                            }
2982                                            pattern.push('\\');
2983                                            continue;
2984                                        }
2985                                        pattern.push(chars.next().unwrap());
2986                                    }
2987                                    let replacement = if chars.peek() == Some(&'/') {
2988                                        chars.next();
2989                                        let mut repl = String::new();
2990                                        while let Some(&ch) = chars.peek() {
2991                                            if ch == '}' {
2992                                                break;
2993                                            }
2994                                            repl.push(chars.next().unwrap());
2995                                        }
2996                                        repl
2997                                    } else {
2998                                        String::new()
2999                                    };
3000                                    if chars.peek() == Some(&'}') {
3001                                        chars.next();
3002                                    }
3003                                    let op = if replace_all {
3004                                        ParameterOp::ReplaceAll {
3005                                            pattern,
3006                                            replacement,
3007                                        }
3008                                    } else {
3009                                        ParameterOp::ReplaceFirst {
3010                                            pattern,
3011                                            replacement,
3012                                        }
3013                                    };
3014                                    parts.push(WordPart::ParameterExpansion {
3015                                        name: var_name,
3016                                        operator: op,
3017                                        operand: String::new(),
3018                                        colon_variant: false,
3019                                    });
3020                                }
3021                                '^' => {
3022                                    chars.next();
3023                                    let op = if chars.peek() == Some(&'^') {
3024                                        chars.next();
3025                                        ParameterOp::UpperAll
3026                                    } else {
3027                                        ParameterOp::UpperFirst
3028                                    };
3029                                    if chars.peek() == Some(&'}') {
3030                                        chars.next();
3031                                    }
3032                                    parts.push(WordPart::ParameterExpansion {
3033                                        name: var_name,
3034                                        operator: op,
3035                                        operand: String::new(),
3036                                        colon_variant: false,
3037                                    });
3038                                }
3039                                ',' => {
3040                                    chars.next();
3041                                    let op = if chars.peek() == Some(&',') {
3042                                        chars.next();
3043                                        ParameterOp::LowerAll
3044                                    } else {
3045                                        ParameterOp::LowerFirst
3046                                    };
3047                                    if chars.peek() == Some(&'}') {
3048                                        chars.next();
3049                                    }
3050                                    parts.push(WordPart::ParameterExpansion {
3051                                        name: var_name,
3052                                        operator: op,
3053                                        operand: String::new(),
3054                                        colon_variant: false,
3055                                    });
3056                                }
3057                                '@' => {
3058                                    chars.next();
3059                                    if let Some(&op) = chars.peek() {
3060                                        chars.next();
3061                                        if chars.peek() == Some(&'}') {
3062                                            chars.next();
3063                                        }
3064                                        parts.push(WordPart::Transformation {
3065                                            name: var_name,
3066                                            operator: op,
3067                                        });
3068                                    } else {
3069                                        if chars.peek() == Some(&'}') {
3070                                            chars.next();
3071                                        }
3072                                        parts.push(WordPart::Variable(var_name));
3073                                    }
3074                                }
3075                                '}' => {
3076                                    chars.next();
3077                                    if !var_name.is_empty() {
3078                                        parts.push(WordPart::Variable(var_name));
3079                                    }
3080                                }
3081                                _ => {
3082                                    while let Some(&ch) = chars.peek() {
3083                                        if ch == '}' {
3084                                            chars.next();
3085                                            break;
3086                                        }
3087                                        chars.next();
3088                                    }
3089                                    if !var_name.is_empty() {
3090                                        parts.push(WordPart::Variable(var_name));
3091                                    }
3092                                }
3093                            }
3094                        } else if !var_name.is_empty() {
3095                            parts.push(WordPart::Variable(var_name));
3096                        }
3097                    }
3098                } else if let Some(&c) = chars.peek() {
3099                    // Check for special single-character variables ($?, $#, $@, $*, $!, $$, $-, $0-$9)
3100                    if matches!(c, '?' | '#' | '@' | '*' | '!' | '$' | '-') || c.is_ascii_digit() {
3101                        parts.push(WordPart::Variable(chars.next().unwrap().to_string()));
3102                    } else {
3103                        // $VAR format
3104                        let mut var_name = String::new();
3105                        while let Some(&c) = chars.peek() {
3106                            if c.is_ascii_alphanumeric() || c == '_' {
3107                                var_name.push(chars.next().unwrap());
3108                            } else {
3109                                break;
3110                            }
3111                        }
3112                        if !var_name.is_empty() {
3113                            parts.push(WordPart::Variable(var_name));
3114                        } else {
3115                            // Just a literal $
3116                            current.push('$');
3117                        }
3118                    }
3119                } else {
3120                    // Just a literal $ at end
3121                    current.push('$');
3122                }
3123            } else {
3124                current.push(ch);
3125            }
3126        }
3127
3128        // Flush remaining literal
3129        if !current.is_empty() {
3130            parts.push(WordPart::Literal(current));
3131        }
3132
3133        // If no parts, create an empty literal
3134        if parts.is_empty() {
3135            parts.push(WordPart::Literal(String::new()));
3136        }
3137
3138        Word {
3139            parts,
3140            quoted: false,
3141        }
3142    }
3143
3144    /// Read operand for brace expansion (everything until closing brace)
3145    fn read_brace_operand(&self, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
3146        let mut operand = String::new();
3147        let mut depth = 1; // Track nested braces
3148        while let Some(&c) = chars.peek() {
3149            if c == '{' {
3150                depth += 1;
3151                operand.push(chars.next().unwrap());
3152            } else if c == '}' {
3153                depth -= 1;
3154                if depth == 0 {
3155                    chars.next(); // consume closing }
3156                    break;
3157                }
3158                operand.push(chars.next().unwrap());
3159            } else {
3160                operand.push(chars.next().unwrap());
3161            }
3162        }
3163        operand
3164    }
3165}
3166
3167#[cfg(test)]
3168mod tests {
3169    use super::*;
3170
3171    #[test]
3172    fn test_parse_simple_command() {
3173        let parser = Parser::new("echo hello");
3174        let script = parser.parse().unwrap();
3175
3176        assert_eq!(script.commands.len(), 1);
3177
3178        if let Command::Simple(cmd) = &script.commands[0] {
3179            assert_eq!(cmd.name.to_string(), "echo");
3180            assert_eq!(cmd.args.len(), 1);
3181            assert_eq!(cmd.args[0].to_string(), "hello");
3182        } else {
3183            panic!("expected simple command");
3184        }
3185    }
3186
3187    #[test]
3188    fn test_parse_multiple_args() {
3189        let parser = Parser::new("echo hello world");
3190        let script = parser.parse().unwrap();
3191
3192        if let Command::Simple(cmd) = &script.commands[0] {
3193            assert_eq!(cmd.name.to_string(), "echo");
3194            assert_eq!(cmd.args.len(), 2);
3195            assert_eq!(cmd.args[0].to_string(), "hello");
3196            assert_eq!(cmd.args[1].to_string(), "world");
3197        } else {
3198            panic!("expected simple command");
3199        }
3200    }
3201
3202    #[test]
3203    fn test_parse_variable() {
3204        let parser = Parser::new("echo $HOME");
3205        let script = parser.parse().unwrap();
3206
3207        if let Command::Simple(cmd) = &script.commands[0] {
3208            assert_eq!(cmd.args.len(), 1);
3209            assert_eq!(cmd.args[0].parts.len(), 1);
3210            assert!(matches!(&cmd.args[0].parts[0], WordPart::Variable(v) if v == "HOME"));
3211        } else {
3212            panic!("expected simple command");
3213        }
3214    }
3215
3216    #[test]
3217    fn test_parse_pipeline() {
3218        let parser = Parser::new("echo hello | cat");
3219        let script = parser.parse().unwrap();
3220
3221        assert_eq!(script.commands.len(), 1);
3222        assert!(matches!(&script.commands[0], Command::Pipeline(_)));
3223
3224        if let Command::Pipeline(pipeline) = &script.commands[0] {
3225            assert_eq!(pipeline.commands.len(), 2);
3226        }
3227    }
3228
3229    #[test]
3230    fn test_parse_redirect_out() {
3231        let parser = Parser::new("echo hello > /tmp/out");
3232        let script = parser.parse().unwrap();
3233
3234        if let Command::Simple(cmd) = &script.commands[0] {
3235            assert_eq!(cmd.redirects.len(), 1);
3236            assert_eq!(cmd.redirects[0].kind, RedirectKind::Output);
3237            assert_eq!(cmd.redirects[0].target.to_string(), "/tmp/out");
3238        } else {
3239            panic!("expected simple command");
3240        }
3241    }
3242
3243    #[test]
3244    fn test_parse_redirect_append() {
3245        let parser = Parser::new("echo hello >> /tmp/out");
3246        let script = parser.parse().unwrap();
3247
3248        if let Command::Simple(cmd) = &script.commands[0] {
3249            assert_eq!(cmd.redirects.len(), 1);
3250            assert_eq!(cmd.redirects[0].kind, RedirectKind::Append);
3251        } else {
3252            panic!("expected simple command");
3253        }
3254    }
3255
3256    #[test]
3257    fn test_parse_redirect_in() {
3258        let parser = Parser::new("cat < /tmp/in");
3259        let script = parser.parse().unwrap();
3260
3261        if let Command::Simple(cmd) = &script.commands[0] {
3262            assert_eq!(cmd.redirects.len(), 1);
3263            assert_eq!(cmd.redirects[0].kind, RedirectKind::Input);
3264        } else {
3265            panic!("expected simple command");
3266        }
3267    }
3268
3269    #[test]
3270    fn test_parse_command_list_and() {
3271        let parser = Parser::new("true && echo success");
3272        let script = parser.parse().unwrap();
3273
3274        assert!(matches!(&script.commands[0], Command::List(_)));
3275    }
3276
3277    #[test]
3278    fn test_parse_command_list_or() {
3279        let parser = Parser::new("false || echo fallback");
3280        let script = parser.parse().unwrap();
3281
3282        assert!(matches!(&script.commands[0], Command::List(_)));
3283    }
3284
3285    #[test]
3286    fn test_heredoc_pipe() {
3287        let parser = Parser::new("cat <<EOF | sort\nc\na\nb\nEOF\n");
3288        let script = parser.parse().unwrap();
3289        assert!(
3290            matches!(&script.commands[0], Command::Pipeline(_)),
3291            "heredoc with pipe should parse as Pipeline"
3292        );
3293    }
3294
3295    #[test]
3296    fn test_heredoc_multiple_on_line() {
3297        let input = "while cat <<E1 && cat <<E2; do cat <<E3; break; done\n1\nE1\n2\nE2\n3\nE3\n";
3298        let parser = Parser::new(input);
3299        let script = parser.parse().unwrap();
3300        assert_eq!(script.commands.len(), 1);
3301        if let Command::Compound(comp, _) = &script.commands[0] {
3302            if let CompoundCommand::While(w) = comp {
3303                assert!(
3304                    !w.condition.is_empty(),
3305                    "while condition should be non-empty"
3306                );
3307                assert!(!w.body.is_empty(), "while body should be non-empty");
3308            } else {
3309                panic!("expected While compound command");
3310            }
3311        } else {
3312            panic!("expected Compound command");
3313        }
3314    }
3315
3316    #[test]
3317    fn test_empty_function_body_rejected() {
3318        let parser = Parser::new("f() { }");
3319        assert!(
3320            parser.parse().is_err(),
3321            "empty function body should be rejected"
3322        );
3323    }
3324
3325    #[test]
3326    fn test_empty_while_body_rejected() {
3327        let parser = Parser::new("while true; do\ndone");
3328        assert!(
3329            parser.parse().is_err(),
3330            "empty while body should be rejected"
3331        );
3332    }
3333
3334    #[test]
3335    fn test_empty_for_body_rejected() {
3336        let parser = Parser::new("for i in 1 2 3; do\ndone");
3337        assert!(parser.parse().is_err(), "empty for body should be rejected");
3338    }
3339
3340    #[test]
3341    fn test_empty_if_then_rejected() {
3342        let parser = Parser::new("if true; then\nfi");
3343        assert!(
3344            parser.parse().is_err(),
3345            "empty then clause should be rejected"
3346        );
3347    }
3348
3349    #[test]
3350    fn test_empty_else_rejected() {
3351        let parser = Parser::new("if false; then echo yes; else\nfi");
3352        assert!(
3353            parser.parse().is_err(),
3354            "empty else clause should be rejected"
3355        );
3356    }
3357
3358    #[test]
3359    fn test_unterminated_single_quote_rejected() {
3360        let parser = Parser::new("echo 'unterminated");
3361        assert!(
3362            parser.parse().is_err(),
3363            "unterminated single quote should be rejected"
3364        );
3365    }
3366
3367    #[test]
3368    fn test_unterminated_double_quote_rejected() {
3369        let parser = Parser::new("echo \"unterminated");
3370        assert!(
3371            parser.parse().is_err(),
3372            "unterminated double quote should be rejected"
3373        );
3374    }
3375
3376    #[test]
3377    fn test_nonempty_function_body_accepted() {
3378        let parser = Parser::new("f() { echo hi; }");
3379        assert!(
3380            parser.parse().is_ok(),
3381            "non-empty function body should be accepted"
3382        );
3383    }
3384
3385    #[test]
3386    fn test_nonempty_while_body_accepted() {
3387        let parser = Parser::new("while true; do echo hi; done");
3388        assert!(
3389            parser.parse().is_ok(),
3390            "non-empty while body should be accepted"
3391        );
3392    }
3393
3394    /// Issue #600: Subscript reader must handle nested ${...} containing brackets.
3395    #[test]
3396    fn test_nested_expansion_in_array_subscript() {
3397        // ${arr[$RANDOM % ${#arr[@]}]} must parse without error.
3398        // The subscript contains ${#arr[@]} which has its own [ and ].
3399        let parser = Parser::new("echo ${arr[$RANDOM % ${#arr[@]}]}");
3400        let script = parser.parse().unwrap();
3401        assert_eq!(script.commands.len(), 1);
3402        if let Command::Simple(cmd) = &script.commands[0] {
3403            assert_eq!(cmd.name.to_string(), "echo");
3404            assert_eq!(cmd.args.len(), 1);
3405            // The arg should contain an ArrayAccess with the full nested index
3406            let arg = &cmd.args[0];
3407            let has_array_access = arg.parts.iter().any(|p| {
3408                matches!(
3409                    p,
3410                    WordPart::ArrayAccess { name, index }
3411                    if name == "arr" && index.contains("${#arr[@]}")
3412                )
3413            });
3414            assert!(
3415                has_array_access,
3416                "expected ArrayAccess with nested index, got: {:?}",
3417                arg.parts
3418            );
3419        } else {
3420            panic!("expected simple command");
3421        }
3422    }
3423
3424    /// Assignment with nested subscript must parse (previously caused fuel exhaustion).
3425    #[test]
3426    fn test_assignment_nested_subscript_parses() {
3427        let parser = Parser::new("x=${arr[$RANDOM % ${#arr[@]}]}");
3428        assert!(
3429            parser.parse().is_ok(),
3430            "assignment with nested subscript should parse"
3431        );
3432    }
3433}