Skip to main content

dekopon_shell/
parser.rs

1//! Hand-written recursive-descent parser: [`crate::lexer`] tokens to [`crate::ast`].
2//!
3//! Every construct the sandbox drops is rejected here by name with an actionable message. Nothing
4//! is silently ignored: a script that asks for backgrounding, a subshell, process substitution, a
5//! here-string, or a brace group fails to parse rather than quietly doing something else. The same
6//! rule reaches inside constructs that are kept: a `case` pattern that would glob-match in bash is
7//! rejected by name here rather than matched literally behind the script's back.
8
9use thiserror::Error;
10
11use crate::{
12    ast::{
13        AndOr, AndOrList, ArithBinaryOp, ArithExpr, ArithUnaryOp, Assignment, CaseClause,
14        CasePattern, CaseStatement, ForLoop, FunctionDefinition, IfStatement, Parameter, Pipeline,
15        Program, Redirect, SimpleCommand, Statement, WhileLoop, Word, WordPart,
16    },
17    lexer::{LexError, RawParameter, RawPart, RawWord, Token, TokenKind, tokenize},
18};
19
20/// How deeply the grammar may nest before parsing stops.
21///
22/// Command substitution, `if`/`for`/`while` bodies, and parenthesized arithmetic are all
23/// recursive productions, and this parser runs on the native stack before any [`crate::limits`]
24/// budget exists. Without a ceiling a few kilobytes of nested `$( $( ... ) )` overflows the stack
25/// and aborts the host process with `SIGABRT`, which is not a `ScriptOutcome` any caller can
26/// report. The bound is fixed rather than configurable because it is a property of this parser's
27/// stack usage, not of the script's resource budget; 64 is far past any hand-written nesting and
28/// far short of the depth that threatens the smallest stack this runs on.
29const MAX_NESTING_DEPTH: u32 = 64;
30
31/// How many tokens one `$(( ... ))` expansion may contain.
32///
33/// A flat `1 + 1 + 1 + ...` chain builds a left-leaning tree one node deep per term. Nothing walks
34/// it recursively at parse time, but evaluating *and dropping* it do, so the token count is what
35/// bounds that depth.
36const MAX_ARITHMETIC_TOKENS: usize = 4_096;
37
38/// Command words this shell refuses to let a script define or invoke.
39///
40/// Each one is excluded because it is sandbox-escape-shaped, ambient-authority-shaped, or would
41/// silently change the meaning of the surrounding script — not because it was left unfinished.
42pub(crate) const REJECTED_COMMANDS: &[(&str, &str)] = &[
43    (
44        "eval",
45        "`eval` is excluded: running text the script assembled at runtime is self-modifying code and defeats the point of parsing the script up front",
46    ),
47    (
48        "exec",
49        "`exec` is excluded: this shell never replaces a process image and has no processes to replace",
50    ),
51    (
52        "source",
53        "`source` is excluded: there is no filesystem to read scripts from",
54    ),
55    (
56        ".",
57        "`.` (source) is excluded: there is no filesystem to read scripts from",
58    ),
59    (
60        "trap",
61        "`trap` is excluded: this shell has no signals or job control",
62    ),
63    (
64        "wait",
65        "`wait` is excluded: this shell has no job control, so nothing can be waiting",
66    ),
67    ("jobs", "`jobs` is excluded: this shell has no job control"),
68    ("fg", "`fg` is excluded: this shell has no job control"),
69    ("bg", "`bg` is excluded: this shell has no job control"),
70    (
71        "kill",
72        "`kill` is excluded: this shell has no processes or signals",
73    ),
74    (
75        "declare",
76        "`declare` is excluded: arrays and maps are real JSON values here, so `declare -A` has nothing to declare",
77    ),
78    (
79        "export",
80        "`export` is excluded: there is no process environment to export into",
81    ),
82    (
83        "set",
84        "`set` is excluded: this shell has no shell options, so `set -e`/`set -u`/`set -o pipefail` would change nothing while looking like they had; check each command's status with `$?`, `&&`, `||`, or `exit`",
85    ),
86    (
87        "[[",
88        "`[[ ... ]]` is not part of this shell; use `[ ... ]` or `test`, which support -z, -n, =, !=, <, >, and -eq/-ne/-lt/-le/-gt/-ge",
89    ),
90];
91
92const RESERVED_WORDS: &[&str] = &[
93    "if", "then", "elif", "else", "fi", "for", "in", "do", "done", "while", "case", "esac",
94    "until", "select", "function",
95];
96
97/// A parse failure. These map to exit code `2`.
98#[derive(Clone, Debug, Eq, Error, PartialEq)]
99pub enum ParseError {
100    /// Tokenization failed.
101    #[error(transparent)]
102    Lex(#[from] LexError),
103    /// A syntax rule was violated.
104    #[error("line {line}: {message}")]
105    Syntax {
106        /// One-based source line.
107        line: usize,
108        /// Human-readable detail.
109        message: String,
110    },
111}
112
113impl ParseError {
114    fn syntax(line: usize, message: impl Into<String>) -> Self {
115        Self::Syntax {
116            line,
117            message: message.into(),
118        }
119    }
120}
121
122/// Parses one complete script.
123pub fn parse(source: &str) -> Result<Program, ParseError> {
124    parse_nested(source, 0)
125}
126
127/// Parses one script body already `depth` productions deep, for `$( ... )` re-entry.
128fn parse_nested(source: &str, depth: u32) -> Result<Program, ParseError> {
129    let tokens = tokenize(source)?;
130    let mut parser = Parser::new(tokens, depth);
131    let program = parser.parse_program(&[])?;
132    if let Some(token) = parser.peek() {
133        let line = token.line;
134        let kind = token.kind.clone();
135        return Err(ParseError::syntax(line, format!("unexpected {kind}")));
136    }
137    Ok(program)
138}
139
140/// Reports the nesting ceiling as a syntax error a script can act on.
141fn too_deep(line: usize, construct: &str) -> ParseError {
142    ParseError::syntax(
143        line,
144        format!(
145            "{construct} nested more than {MAX_NESTING_DEPTH} levels deep; this shell parses on a fixed stack and refuses rather than risking it"
146        ),
147    )
148}
149
150struct Parser {
151    tokens: Vec<Token>,
152    position: usize,
153    /// Recursive productions entered so far, checked against [`MAX_NESTING_DEPTH`].
154    depth: u32,
155}
156
157impl Parser {
158    fn new(tokens: Vec<Token>, depth: u32) -> Self {
159        Self {
160            tokens,
161            position: 0,
162            depth,
163        }
164    }
165
166    /// Enters one recursive production, refusing past the nesting ceiling.
167    fn enter(&mut self, construct: &str) -> Result<(), ParseError> {
168        if self.depth >= MAX_NESTING_DEPTH {
169            return Err(too_deep(self.line(), construct));
170        }
171        self.depth += 1;
172        Ok(())
173    }
174
175    fn leave(&mut self) {
176        self.depth = self.depth.saturating_sub(1);
177    }
178
179    fn peek(&self) -> Option<&Token> {
180        self.tokens.get(self.position)
181    }
182
183    fn peek_kind(&self) -> Option<&TokenKind> {
184        self.peek().map(|token| &token.kind)
185    }
186
187    fn line(&self) -> usize {
188        self.peek().map_or_else(
189            || self.tokens.last().map_or(1, |token| token.line),
190            |token| token.line,
191        )
192    }
193
194    fn skip_newlines(&mut self) {
195        while matches!(self.peek_kind(), Some(TokenKind::Newline)) {
196            self.position += 1;
197        }
198    }
199
200    fn skip_separators(&mut self) {
201        while matches!(
202            self.peek_kind(),
203            Some(TokenKind::Newline | TokenKind::Semicolon)
204        ) {
205            self.position += 1;
206        }
207    }
208
209    fn peek_reserved(&self) -> Option<&str> {
210        match self.peek_kind()? {
211            TokenKind::Word(word) => {
212                let literal = word.as_literal()?;
213                RESERVED_WORDS
214                    .iter()
215                    .find(|reserved| **reserved == literal)
216                    .copied()
217            }
218            _ => None,
219        }
220    }
221
222    fn eat_reserved(&mut self, expected: &str) -> bool {
223        if self.peek_reserved() == Some(expected) {
224            self.position += 1;
225            return true;
226        }
227        false
228    }
229
230    fn expect_reserved(&mut self, expected: &str, context: &str) -> Result<(), ParseError> {
231        if self.eat_reserved(expected) {
232            return Ok(());
233        }
234        let line = self.line();
235        Err(ParseError::syntax(
236            line,
237            format!("expected `{expected}` in {context}"),
238        ))
239    }
240
241    fn parse_program(&mut self, terminators: &[&str]) -> Result<Program, ParseError> {
242        self.enter("a command block")?;
243        let program = self.parse_program_body(terminators);
244        self.leave();
245        program
246    }
247
248    fn parse_program_body(&mut self, terminators: &[&str]) -> Result<Program, ParseError> {
249        let mut statements = Vec::new();
250        loop {
251            self.skip_separators();
252            match self.peek_kind() {
253                // `;;` ends a `case` clause, so a clause body stops here rather than trying to
254                // read it as another command.
255                None | Some(TokenKind::RightBrace | TokenKind::DoubleSemicolon) => break,
256                Some(_) => {}
257            }
258            if self
259                .peek_reserved()
260                .is_some_and(|reserved| terminators.contains(&reserved))
261            {
262                break;
263            }
264            statements.push(self.parse_statement()?);
265            match self.peek_kind() {
266                None
267                | Some(
268                    TokenKind::Newline
269                    | TokenKind::Semicolon
270                    | TokenKind::RightBrace
271                    | TokenKind::DoubleSemicolon,
272                ) => {}
273                Some(TokenKind::Word(_)) if self.peek_reserved().is_some() => {}
274                Some(other) => {
275                    let line = self.line();
276                    let other = other.clone();
277                    return Err(ParseError::syntax(
278                        line,
279                        format!("unexpected {other} after a command"),
280                    ));
281                }
282            }
283        }
284        Ok(Program { statements })
285    }
286
287    fn parse_statement(&mut self) -> Result<Statement, ParseError> {
288        match self.peek_reserved() {
289            Some("if") => return self.parse_if().map(Statement::If),
290            Some("for") => return self.parse_for().map(Statement::For),
291            Some("while") => return self.parse_while(false).map(Statement::While),
292            Some("until") => return self.parse_while(true).map(Statement::While),
293            Some("case") => return self.parse_case().map(Statement::Case),
294            Some("esac") => {
295                let line = self.line();
296                return Err(ParseError::syntax(line, "`esac` without a matching `case`"));
297            }
298            Some("select") => {
299                let line = self.line();
300                return Err(ParseError::syntax(
301                    line,
302                    "`select` is not part of this shell: there is no interactive terminal to prompt",
303                ));
304            }
305            Some("function") => {
306                let line = self.line();
307                return Err(ParseError::syntax(
308                    line,
309                    "the `function` keyword is not part of this shell; define functions as `name() { ... }`",
310                ));
311            }
312            Some(other) => {
313                let line = self.line();
314                return Err(ParseError::syntax(line, format!("unexpected `{other}`")));
315            }
316            None => {}
317        }
318
319        if let Some(definition) = self.try_parse_function()? {
320            return Ok(Statement::Function(definition));
321        }
322
323        self.parse_and_or_list().map(Statement::List)
324    }
325
326    fn try_parse_function(&mut self) -> Result<Option<FunctionDefinition>, ParseError> {
327        let Some(TokenKind::Word(word)) = self.peek_kind() else {
328            return Ok(None);
329        };
330        let Some(name) = word.as_literal().map(str::to_owned) else {
331            return Ok(None);
332        };
333        if !matches!(
334            self.tokens.get(self.position + 1).map(|token| &token.kind),
335            Some(TokenKind::LeftParen)
336        ) || !matches!(
337            self.tokens.get(self.position + 2).map(|token| &token.kind),
338            Some(TokenKind::RightParen)
339        ) {
340            return Ok(None);
341        }
342
343        let line = self.line();
344        if !is_valid_name(&name) {
345            return Err(ParseError::syntax(
346                line,
347                format!("{name:?} is not a valid function name"),
348            ));
349        }
350        if let Some((_, reason)) = REJECTED_COMMANDS
351            .iter()
352            .find(|(rejected, _)| *rejected == name)
353        {
354            return Err(ParseError::syntax(
355                line,
356                format!("cannot define a function named {name:?}: {reason}"),
357            ));
358        }
359        if RESERVED_WORDS.contains(&name.as_str()) {
360            return Err(ParseError::syntax(
361                line,
362                format!("cannot define a function named {name:?}: it is a reserved word"),
363            ));
364        }
365
366        self.position += 3;
367        self.skip_newlines();
368        if !matches!(self.peek_kind(), Some(TokenKind::LeftBrace)) {
369            let line = self.line();
370            return Err(ParseError::syntax(
371                line,
372                format!("expected `{{` to open the body of function {name:?}"),
373            ));
374        }
375        self.position += 1;
376        let body = self.parse_program(&[])?;
377        if !matches!(self.peek_kind(), Some(TokenKind::RightBrace)) {
378            let line = self.line();
379            return Err(ParseError::syntax(
380                line,
381                format!("expected `}}` to close the body of function {name:?}"),
382            ));
383        }
384        self.position += 1;
385        Ok(Some(FunctionDefinition { name, body }))
386    }
387
388    fn parse_if(&mut self) -> Result<IfStatement, ParseError> {
389        self.expect_reserved("if", "an `if` statement")?;
390        let mut branches = Vec::new();
391        let condition = self.parse_and_or_list()?;
392        self.skip_separators();
393        self.expect_reserved("then", "an `if` statement")?;
394        let body = self.parse_program(&["elif", "else", "fi"])?;
395        branches.push((condition, body));
396
397        let mut otherwise = None;
398        loop {
399            match self.peek_reserved() {
400                Some("elif") => {
401                    self.position += 1;
402                    let condition = self.parse_and_or_list()?;
403                    self.skip_separators();
404                    self.expect_reserved("then", "an `elif` branch")?;
405                    let body = self.parse_program(&["elif", "else", "fi"])?;
406                    branches.push((condition, body));
407                }
408                Some("else") => {
409                    self.position += 1;
410                    otherwise = Some(self.parse_program(&["fi"])?);
411                    break;
412                }
413                _ => break,
414            }
415        }
416        self.expect_reserved("fi", "an `if` statement")?;
417        Ok(IfStatement {
418            branches,
419            otherwise,
420        })
421    }
422
423    fn parse_for(&mut self) -> Result<ForLoop, ParseError> {
424        self.expect_reserved("for", "a `for` loop")?;
425        let line = self.line();
426        // `for (( i=0; i<n; i++ ))` is a C-style loop, not a malformed loop variable.
427        if matches!(self.peek_kind(), Some(TokenKind::LeftParen)) {
428            return Err(ParseError::syntax(
429                line,
430                "C-style `for (( ... ))` loops are not supported; use `for x in ...` over a list, or a `while` loop with `i=$(( i + 1 ))`",
431            ));
432        }
433        let Some(TokenKind::Word(word)) = self.peek_kind() else {
434            return Err(ParseError::syntax(line, "expected a `for` loop variable"));
435        };
436        let Some(variable) = word.as_literal().map(str::to_owned) else {
437            return Err(ParseError::syntax(
438                line,
439                "a `for` loop variable must be a plain name",
440            ));
441        };
442        if !is_valid_name(&variable) {
443            return Err(ParseError::syntax(
444                line,
445                format!("{variable:?} is not a valid `for` loop variable name"),
446            ));
447        }
448        self.position += 1;
449        self.expect_reserved("in", "a `for` loop")?;
450
451        let mut words = Vec::new();
452        while let Some(TokenKind::Word(raw)) = self.peek_kind() {
453            if self.peek_reserved() == Some("do") {
454                break;
455            }
456            let raw = raw.clone();
457            self.position += 1;
458            let depth = self.depth;
459            words.push(convert_word(&raw, line, depth)?);
460        }
461
462        self.skip_separators();
463        self.expect_reserved("do", "a `for` loop")?;
464        let body = self.parse_program(&["done"])?;
465        self.expect_reserved("done", "a `for` loop")?;
466        Ok(ForLoop {
467            variable,
468            words,
469            body,
470        })
471    }
472
473    /// Parses `case WORD in PATTERN) LIST ;; ... esac`.
474    fn parse_case(&mut self) -> Result<CaseStatement, ParseError> {
475        self.expect_reserved("case", "a `case` statement")?;
476        let line = self.line();
477        let Some(TokenKind::Word(raw)) = self.peek_kind() else {
478            return Err(ParseError::syntax(
479                line,
480                "expected a word to match after `case`",
481            ));
482        };
483        let raw = raw.clone();
484        let depth = self.depth;
485        self.position += 1;
486        let subject = convert_word(&raw, line, depth)?;
487        self.skip_newlines();
488        self.expect_reserved("in", "a `case` statement")?;
489
490        let mut clauses = Vec::new();
491        loop {
492            self.skip_separators();
493            if self.peek_reserved() == Some("esac") {
494                break;
495            }
496            if self.peek().is_none() {
497                let line = self.line();
498                return Err(ParseError::syntax(
499                    line,
500                    "expected `esac` in a `case` statement",
501                ));
502            }
503            clauses.push(self.parse_case_clause()?);
504        }
505        self.expect_reserved("esac", "a `case` statement")?;
506        Ok(CaseStatement { subject, clauses })
507    }
508
509    /// Parses one `PATTERN|PATTERN) LIST ;;` clause.
510    fn parse_case_clause(&mut self) -> Result<CaseClause, ParseError> {
511        // bash accepts a decorative `(` before the first pattern; accepting it costs nothing and
512        // rejecting it would blame subshells for a shape that is not one.
513        if matches!(self.peek_kind(), Some(TokenKind::LeftParen)) {
514            self.position += 1;
515        }
516
517        let mut patterns = vec![self.parse_case_pattern()?];
518        while matches!(self.peek_kind(), Some(TokenKind::Pipe)) {
519            self.position += 1;
520            self.skip_newlines();
521            patterns.push(self.parse_case_pattern()?);
522        }
523        if !matches!(self.peek_kind(), Some(TokenKind::RightParen)) {
524            let line = self.line();
525            return Err(ParseError::syntax(
526                line,
527                "expected `)` to close a `case` pattern list",
528            ));
529        }
530        self.position += 1;
531
532        let body = self.parse_program(&["esac"])?;
533        if matches!(self.peek_kind(), Some(TokenKind::DoubleSemicolon)) {
534            self.position += 1;
535        } else if self.peek_reserved() != Some("esac") {
536            let line = self.line();
537            return Err(ParseError::syntax(
538                line,
539                "expected `;;` to end a `case` clause",
540            ));
541        }
542        Ok(CaseClause { patterns, body })
543    }
544
545    /// Parses one `case` alternative, rejecting pattern syntax this shell cannot honor.
546    fn parse_case_pattern(&mut self) -> Result<CasePattern, ParseError> {
547        let line = self.line();
548        let Some(TokenKind::Word(raw)) = self.peek_kind() else {
549            let found = self
550                .peek_kind()
551                .map_or_else(|| "end of script".to_owned(), TokenKind::to_string);
552            return Err(ParseError::syntax(
553                line,
554                format!("expected a `case` pattern, found {found}"),
555            ));
556        };
557        let raw = raw.clone();
558        let depth = self.depth;
559        self.position += 1;
560
561        // A bare `*` is kept because it is the default branch, not a wildcard: every subject
562        // reaches it, which is exactly what a literal matcher would also conclude.
563        if raw.as_literal() == Some("*") {
564            return Ok(CasePattern::Any);
565        }
566
567        let word = convert_word(&raw, line, depth)?;
568        if word_is_constant(&raw) {
569            if let Some((character, meaning)) = literal_pattern_metacharacter(&raw) {
570                return Err(ParseError::syntax(
571                    line,
572                    unsupported_case_pattern(character, meaning),
573                ));
574            }
575            return Ok(CasePattern::Literal(word));
576        }
577        Ok(CasePattern::Expanded(word))
578    }
579
580    fn parse_while(&mut self, until: bool) -> Result<WhileLoop, ParseError> {
581        let keyword = if until { "until" } else { "while" };
582        let context = format!("an `{keyword}` loop");
583        self.expect_reserved(keyword, &context)?;
584        let condition = self.parse_and_or_list()?;
585        self.skip_separators();
586        self.expect_reserved("do", &context)?;
587        let body = self.parse_program(&["done"])?;
588        self.expect_reserved("done", &context)?;
589        Ok(WhileLoop {
590            condition,
591            body,
592            until,
593        })
594    }
595
596    fn parse_and_or_list(&mut self) -> Result<AndOrList, ParseError> {
597        let first = self.parse_pipeline()?;
598        let mut rest = Vec::new();
599        loop {
600            let operator = match self.peek_kind() {
601                Some(TokenKind::AndAnd) => AndOr::And,
602                Some(TokenKind::OrOr) => AndOr::Or,
603                _ => break,
604            };
605            self.position += 1;
606            self.skip_newlines();
607            rest.push((operator, self.parse_pipeline()?));
608        }
609        Ok(AndOrList { first, rest })
610    }
611
612    fn parse_pipeline(&mut self) -> Result<Pipeline, ParseError> {
613        // A leading `!` is the reserved word that inverts a pipeline's status. Dispatching it as a
614        // command word instead would report "!: command not found" and silently invert every
615        // `if ! cmd` branch, so it is recognized here rather than left to the builtin table.
616        let negated = self.eat_pipeline_negation();
617        let mut commands = vec![self.parse_simple_command()?];
618        while matches!(self.peek_kind(), Some(TokenKind::Pipe)) {
619            self.position += 1;
620            self.skip_newlines();
621            commands.push(self.parse_simple_command()?);
622        }
623        Ok(Pipeline { commands, negated })
624    }
625
626    fn eat_pipeline_negation(&mut self) -> bool {
627        let Some(TokenKind::Word(word)) = self.peek_kind() else {
628            return false;
629        };
630        if word.as_literal() != Some("!") {
631            return false;
632        }
633        self.position += 1;
634        true
635    }
636
637    fn parse_simple_command(&mut self) -> Result<SimpleCommand, ParseError> {
638        let mut assignments = Vec::new();
639        let mut words = Vec::new();
640        let mut redirect: Option<Redirect> = None;
641        let mut here_doc: Option<Word> = None;
642        // `arr=(a b c)` lexes as an empty assignment followed by `(`; remembering that shape is
643        // what lets the paren below name array literals instead of blaming subshells.
644        let mut after_empty_assignment = false;
645
646        loop {
647            match self.peek_kind() {
648                Some(TokenKind::Word(raw)) => {
649                    if words.is_empty() && self.peek_reserved().is_some() {
650                        break;
651                    }
652                    let raw = raw.clone();
653                    let line = self.line();
654                    let depth = self.depth;
655                    self.position += 1;
656                    let assignment = if words.is_empty() {
657                        split_assignment(&raw)
658                    } else {
659                        None
660                    };
661                    if let Some((name, value)) = assignment {
662                        after_empty_assignment = value.parts.is_empty();
663                        assignments.push(Assignment {
664                            name,
665                            value: convert_word(&value, line, depth)?,
666                        });
667                        continue;
668                    }
669                    after_empty_assignment = false;
670                    words.push(convert_word(&raw, line, depth)?);
671                }
672                Some(TokenKind::Great | TokenKind::GreatGreat) => {
673                    let append = matches!(self.peek_kind(), Some(TokenKind::GreatGreat));
674                    let line = self.line();
675                    let depth = self.depth;
676                    self.position += 1;
677                    let Some(TokenKind::Word(raw)) = self.peek_kind() else {
678                        return Err(ParseError::syntax(
679                            line,
680                            "expected an in-memory buffer name after a redirection operator",
681                        ));
682                    };
683                    let raw = raw.clone();
684                    self.position += 1;
685                    if redirect.is_some() {
686                        return Err(ParseError::syntax(
687                            line,
688                            "a command accepts at most one buffer redirection",
689                        ));
690                    }
691                    after_empty_assignment = false;
692                    redirect = Some(Redirect {
693                        append,
694                        target: convert_word(&raw, line, depth)?,
695                    });
696                }
697                // Job control is dropped whole. A trailing `&` must never be silently discarded:
698                // a model reading its own script would otherwise believe work was backgrounded.
699                Some(TokenKind::Ampersand) => {
700                    let line = self.line();
701                    return Err(ParseError::syntax(
702                        line,
703                        "backgrounding with `&` is not supported: this shell has no job control, so `&` can only mean something it cannot do",
704                    ));
705                }
706                // Every paren-shaped bash construct arrives here. They are different features with
707                // different answers, so each is named for what it actually is: calling an array
708                // literal a subshell sends a reader looking for a process that was never involved.
709                Some(TokenKind::LeftParen) => {
710                    let line = self.line();
711                    if after_empty_assignment {
712                        return Err(ParseError::syntax(
713                            line,
714                            "bash array literals `name=(a b c)` are not supported: arrays here are real JSON, so write `name='[\"a\",\"b\",\"c\"]'` or `name=$(... | jq ...)` and index it with `${name[0]}`",
715                        ));
716                    }
717                    if matches!(
718                        self.tokens.get(self.position + 1).map(|token| &token.kind),
719                        Some(TokenKind::LeftParen)
720                    ) {
721                        return Err(ParseError::syntax(
722                            line,
723                            "the arithmetic command `(( ... ))` is not supported; use the arithmetic expansion `x=$(( ... ))`, or `[ ... ]` to test a value",
724                        ));
725                    }
726                    return Err(ParseError::syntax(
727                        line,
728                        "subshells `( ... )` are not supported: this shell forks no processes; use a function instead",
729                    ));
730                }
731                Some(TokenKind::RightParen) => {
732                    let line = self.line();
733                    return Err(ParseError::syntax(line, "unexpected `)`"));
734                }
735                // Brace command groups are dropped; only `name() { ... }` uses braces.
736                Some(TokenKind::LeftBrace) => {
737                    let line = self.line();
738                    return Err(ParseError::syntax(
739                        line,
740                        "brace command groups `{ ...; }` are not supported; only function bodies use braces",
741                    ));
742                }
743                // A here-document arrives with its body already collected off the following lines.
744                Some(TokenKind::HereDoc(raw)) => {
745                    let raw = raw.clone();
746                    let line = self.line();
747                    let depth = self.depth;
748                    self.position += 1;
749                    if here_doc.is_some() {
750                        return Err(ParseError::syntax(
751                            line,
752                            "a command accepts at most one here-document",
753                        ));
754                    }
755                    after_empty_assignment = false;
756                    here_doc = Some(convert_word(&raw, line, depth)?);
757                }
758                Some(TokenKind::LessParen) => {
759                    let line = self.line();
760                    return Err(ParseError::syntax(
761                        line,
762                        "process substitution `<( ... )` is not supported: this shell forks no processes and has no file descriptors",
763                    ));
764                }
765                Some(TokenKind::Less) => {
766                    let line = self.line();
767                    return Err(ParseError::syntax(
768                        line,
769                        "input redirection `<` is not supported: there are no files; pipe a value or `cat` a named buffer instead",
770                    ));
771                }
772                _ => break,
773            }
774        }
775
776        if words.is_empty() && assignments.is_empty() {
777            let line = self.line();
778            let found = self
779                .peek_kind()
780                .map_or_else(|| "end of script".to_owned(), TokenKind::to_string);
781            return Err(ParseError::syntax(
782                line,
783                format!("expected a command, found {found}"),
784            ));
785        }
786
787        Ok(SimpleCommand {
788            assignments,
789            words,
790            redirect,
791            here_doc,
792        })
793    }
794}
795
796/// Pattern syntax bash would match as a glob, and what each piece would mean there.
797///
798/// A `case` pattern is matched as literal text here, so silently accepting these would answer a
799/// question the script never asked. The rule, and the shape of its rejection, follow `grep` and
800/// `sed`, whose patterns are literal for the same reason and reject metacharacters the same way.
801/// `]` is deliberately absent: only `[` opens a character class, so `[ab]` is still caught by its
802/// opening bracket while a lone `a]` — ordinary text in bash too — is left alone.
803const CASE_METACHARACTERS: &[(char, &str)] = &[
804    ('*', "any run of characters"),
805    ('?', "any single character"),
806    ('[', "a character class"),
807];
808
809/// Returns the first pattern metacharacter in some text, with what it would have meant.
810pub(crate) fn pattern_metacharacter(text: &str) -> Option<(char, &'static str)> {
811    text.chars().find_map(|character| {
812        CASE_METACHARACTERS
813            .iter()
814            .find(|(candidate, _)| *candidate == character)
815            .map(|(candidate, meaning)| (*candidate, *meaning))
816    })
817}
818
819/// Composes the rejection for a constant `case` pattern this shell cannot honor.
820///
821/// Quoting is offered here and *not* in [`expanded_case_pattern`], because it is only a way out
822/// while the parser can still see it: by the time a pattern has been expanded, its quoting is gone.
823pub(crate) fn unsupported_case_pattern(character: char, meaning: &str) -> String {
824    format!(
825        "a `case` pattern here is literal text, so `{character}` — which would match {meaning} in bash — is not supported; spell the value out, add another `PATTERN|PATTERN` alternative, quote it as `'{character}'` to match the character itself, or use `*)` for the default branch"
826    )
827}
828
829/// Composes the rejection for a `case` pattern that only exists once the script has run.
830pub(crate) fn expanded_case_pattern(character: char, meaning: &str) -> String {
831    format!(
832        "this `case` pattern expanded to text containing `{character}`, which would match {meaning} in bash; patterns here are literal text, and quoting cannot exempt an expanded one because its quotes are already gone — build the pattern without `{character}`, or branch with `if` and `jq` instead"
833    )
834}
835
836/// Reports whether a raw word's text is fully known before the script runs.
837fn word_is_constant(word: &RawWord) -> bool {
838    fn parts_are_constant(parts: &[RawPart]) -> bool {
839        parts.iter().all(|part| match part {
840            RawPart::Literal(_) | RawPart::SingleQuoted(_) => true,
841            RawPart::DoubleQuoted(inner) => parts_are_constant(inner),
842            RawPart::Parameter(_) | RawPart::CommandSubstitution(_) | RawPart::Arithmetic(_) => {
843                false
844            }
845        })
846    }
847    parts_are_constant(&word.parts)
848}
849
850/// Returns the first pattern metacharacter in a constant word's *unquoted* text.
851///
852/// Quoted text is exempt because quoting is how bash itself spells "this asterisk is an asterisk",
853/// so `'*'` stays available as the way to match a literal one.
854fn literal_pattern_metacharacter(word: &RawWord) -> Option<(char, &'static str)> {
855    word.parts.iter().find_map(|part| match part {
856        RawPart::Literal(text) => pattern_metacharacter(text),
857        _ => None,
858    })
859}
860
861fn is_valid_name(name: &str) -> bool {
862    let mut characters = name.chars();
863    let Some(first) = characters.next() else {
864        return false;
865    };
866    if !(first.is_ascii_alphabetic() || first == '_') {
867        return false;
868    }
869    characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
870}
871
872/// Splits `NAME=value` into its parts when the word begins with a valid assignment prefix.
873fn split_assignment(word: &RawWord) -> Option<(String, RawWord)> {
874    let RawPart::Literal(first) = word.parts.first()? else {
875        return None;
876    };
877    let equals = first.find('=')?;
878    let name = &first[..equals];
879    if !is_valid_name(name) {
880        return None;
881    }
882    let remainder = &first[equals + 1..];
883    let mut parts = Vec::new();
884    if !remainder.is_empty() {
885        parts.push(RawPart::Literal(remainder.to_owned()));
886    }
887    parts.extend(word.parts.iter().skip(1).cloned());
888    Some((name.to_owned(), RawWord { parts }))
889}
890
891fn convert_word(raw: &RawWord, line: usize, depth: u32) -> Result<Word, ParseError> {
892    Ok(Word {
893        parts: convert_parts(&raw.parts, line, depth)?,
894    })
895}
896
897fn convert_parts(raw: &[RawPart], line: usize, depth: u32) -> Result<Vec<WordPart>, ParseError> {
898    raw.iter()
899        .map(|part| convert_part(part, line, depth))
900        .collect::<Result<Vec<_>, _>>()
901}
902
903fn convert_part(raw: &RawPart, line: usize, depth: u32) -> Result<WordPart, ParseError> {
904    Ok(match raw {
905        RawPart::Literal(text) => WordPart::Literal(text.clone()),
906        RawPart::SingleQuoted(text) => WordPart::SingleQuoted(text.clone()),
907        RawPart::DoubleQuoted(parts) => WordPart::DoubleQuoted(convert_parts(parts, line, depth)?),
908        RawPart::Parameter(parameter) => {
909            WordPart::Parameter(convert_parameter(parameter, line, depth)?)
910        }
911        // Each `$( ... )` re-enters the parser, so it counts against the same nesting ceiling the
912        // statement productions do.
913        RawPart::CommandSubstitution(body) => {
914            if depth >= MAX_NESTING_DEPTH {
915                return Err(too_deep(line, "command substitution `$( ... )`"));
916            }
917            WordPart::CommandSubstitution(parse_nested(body, depth + 1)?)
918        }
919        RawPart::Arithmetic(body) => WordPart::Arithmetic(parse_arithmetic(body, line, depth)?),
920    })
921}
922
923fn convert_parameter(raw: &RawParameter, line: usize, depth: u32) -> Result<Parameter, ParseError> {
924    Ok(match raw {
925        RawParameter::Named { name, indices } => Parameter::Named {
926            name: name.clone(),
927            indices: indices
928                .iter()
929                .map(|index| convert_word(index, line, depth))
930                .collect::<Result<Vec<_>, _>>()?,
931        },
932        RawParameter::Positional(position) => Parameter::Positional(*position),
933        RawParameter::AllPositional => Parameter::AllPositional,
934        RawParameter::AllPositionalJoined => Parameter::AllPositionalJoined,
935        RawParameter::PositionalCount => Parameter::PositionalCount,
936        RawParameter::LastStatus => Parameter::LastStatus,
937    })
938}
939
940// ---------------------------------------------------------------------------
941// Arithmetic expansion
942// ---------------------------------------------------------------------------
943
944#[derive(Clone, Debug, PartialEq)]
945enum ArithToken {
946    Integer(i64),
947    Float(f64),
948    Name(String),
949    Symbol(&'static str),
950}
951
952/// One arithmetic operator spelling and what this shell does with it.
953///
954/// Rejected spellings are listed alongside the kept ones so the tokenizer can name the operator a
955/// script actually wrote. Consuming `**` as two multiplications and then complaining about a stray
956/// `*` describes a script nobody wrote.
957enum ArithSymbol {
958    Kept,
959    Rejected(&'static str),
960}
961
962/// Operator spellings, longest first so `&&` is never mistaken for a rejected bitwise `&`.
963const ARITH_SYMBOLS: &[(&str, ArithSymbol)] = &[
964    (
965        "**",
966        ArithSymbol::Rejected("`**` is not supported; multiply repeatedly, or use `jq pow`"),
967    ),
968    (
969        "++",
970        ArithSymbol::Rejected("`++` is not supported; write `i=$(( i + 1 ))`"),
971    ),
972    (
973        "--",
974        ArithSymbol::Rejected("`--` is not supported; write `i=$(( i - 1 ))`"),
975    ),
976    ("+=", ArithSymbol::Rejected(COMPOUND_ASSIGNMENT)),
977    ("-=", ArithSymbol::Rejected(COMPOUND_ASSIGNMENT)),
978    ("*=", ArithSymbol::Rejected(COMPOUND_ASSIGNMENT)),
979    ("/=", ArithSymbol::Rejected(COMPOUND_ASSIGNMENT)),
980    ("%=", ArithSymbol::Rejected(COMPOUND_ASSIGNMENT)),
981    ("<<", ArithSymbol::Rejected(BITWISE)),
982    (">>", ArithSymbol::Rejected(BITWISE)),
983    ("&&", ArithSymbol::Kept),
984    ("||", ArithSymbol::Kept),
985    ("<=", ArithSymbol::Kept),
986    (">=", ArithSymbol::Kept),
987    ("==", ArithSymbol::Kept),
988    ("!=", ArithSymbol::Kept),
989    ("+", ArithSymbol::Kept),
990    ("-", ArithSymbol::Kept),
991    ("*", ArithSymbol::Kept),
992    ("/", ArithSymbol::Kept),
993    ("%", ArithSymbol::Kept),
994    ("(", ArithSymbol::Kept),
995    (")", ArithSymbol::Kept),
996    ("<", ArithSymbol::Kept),
997    (">", ArithSymbol::Kept),
998    ("!", ArithSymbol::Kept),
999    (
1000        "=",
1001        ArithSymbol::Rejected(
1002            "assignment inside `$(( ... ))` is not supported; assign the expansion instead, as `name=$(( ... ))`",
1003        ),
1004    ),
1005    ("?", ArithSymbol::Rejected(TERNARY)),
1006    (":", ArithSymbol::Rejected(TERNARY)),
1007    ("&", ArithSymbol::Rejected(BITWISE)),
1008    ("|", ArithSymbol::Rejected(BITWISE)),
1009    ("^", ArithSymbol::Rejected(BITWISE)),
1010    ("~", ArithSymbol::Rejected(BITWISE)),
1011    (
1012        ",",
1013        ArithSymbol::Rejected("the comma operator is not supported; write one expansion per value"),
1014    ),
1015];
1016
1017const COMPOUND_ASSIGNMENT: &str =
1018    "compound assignment is not supported inside `$(( ... ))`; write `name=$(( name + 1 ))`";
1019const BITWISE: &str = "bitwise operators are not supported; this arithmetic is numeric only, so use `jq` for bit manipulation";
1020const TERNARY: &str = "the ternary `? :` is not supported; use `if`/`else`";
1021
1022fn tokenize_arithmetic(source: &str, line: usize) -> Result<Vec<ArithToken>, ParseError> {
1023    let mut tokens = Vec::new();
1024    let bytes = source.as_bytes();
1025    let mut index = 0;
1026    while index < bytes.len() {
1027        if tokens.len() >= MAX_ARITHMETIC_TOKENS {
1028            return Err(ParseError::syntax(
1029                line,
1030                format!(
1031                    "an arithmetic expansion may hold at most {MAX_ARITHMETIC_TOKENS} tokens; split the calculation across assignments"
1032                ),
1033            ));
1034        }
1035        // Decoding a whole character rather than casting one byte keeps a non-ASCII diagnostic
1036        // honest: `bytes[index] as char` reports 'Ã' for an 'é' the script never wrote.
1037        let character = source[index..].chars().next().unwrap_or('\0');
1038        if character.is_ascii_whitespace() {
1039            index += 1;
1040            continue;
1041        }
1042        if character.is_ascii_digit() {
1043            let start = index;
1044            while index < bytes.len() && (bytes[index] as char).is_ascii_digit() {
1045                index += 1;
1046            }
1047            if index < bytes.len() && bytes[index] == b'.' {
1048                index += 1;
1049                while index < bytes.len() && (bytes[index] as char).is_ascii_digit() {
1050                    index += 1;
1051                }
1052                let literal = &source[start..index];
1053                let value = literal.parse::<f64>().map_err(|_| {
1054                    ParseError::syntax(line, format!("invalid arithmetic literal {literal:?}"))
1055                })?;
1056                tokens.push(ArithToken::Float(value));
1057            } else {
1058                let literal = &source[start..index];
1059                let value = literal.parse::<i64>().map_err(|_| {
1060                    ParseError::syntax(
1061                        line,
1062                        format!("arithmetic literal {literal:?} is out of range"),
1063                    )
1064                })?;
1065                tokens.push(ArithToken::Integer(value));
1066            }
1067            continue;
1068        }
1069        if character.is_ascii_alphabetic() || character == '_' || character == '$' {
1070            if character == '$' {
1071                index += 1;
1072            }
1073            let start = index;
1074            while index < bytes.len()
1075                && ((bytes[index] as char).is_ascii_alphanumeric() || bytes[index] == b'_')
1076            {
1077                index += 1;
1078            }
1079            if start == index {
1080                return Err(ParseError::syntax(
1081                    line,
1082                    "expected a variable name after `$` in an arithmetic expansion",
1083                ));
1084            }
1085            tokens.push(ArithToken::Name(source[start..index].to_owned()));
1086            continue;
1087        }
1088        let (matched, symbol) = ARITH_SYMBOLS
1089            .iter()
1090            .find(|(symbol, _)| source[index..].starts_with(*symbol))
1091            .ok_or_else(|| {
1092                ParseError::syntax(
1093                    line,
1094                    format!("unsupported character {character:?} in an arithmetic expansion"),
1095                )
1096            })?;
1097        match symbol {
1098            ArithSymbol::Kept => {}
1099            ArithSymbol::Rejected(reason) => return Err(ParseError::syntax(line, *reason)),
1100        }
1101        index += matched.len();
1102        tokens.push(ArithToken::Symbol(matched));
1103    }
1104    Ok(tokens)
1105}
1106
1107struct ArithParser {
1108    tokens: Vec<ArithToken>,
1109    position: usize,
1110    line: usize,
1111    /// Parenthesis nesting, checked against [`MAX_NESTING_DEPTH`]; see [`ArithParser::parse_primary`].
1112    depth: u32,
1113}
1114
1115fn parse_arithmetic(source: &str, line: usize, depth: u32) -> Result<ArithExpr, ParseError> {
1116    let tokens = tokenize_arithmetic(source, line)?;
1117    if tokens.is_empty() {
1118        return Err(ParseError::syntax(line, "empty arithmetic expansion"));
1119    }
1120    let mut parser = ArithParser {
1121        tokens,
1122        position: 0,
1123        line,
1124        depth,
1125    };
1126    let expression = parser.parse_or()?;
1127    if parser.position != parser.tokens.len() {
1128        return Err(ParseError::syntax(
1129            line,
1130            "trailing tokens in an arithmetic expansion",
1131        ));
1132    }
1133    Ok(expression)
1134}
1135
1136impl ArithParser {
1137    fn peek_symbol(&self) -> Option<&'static str> {
1138        match self.tokens.get(self.position) {
1139            Some(ArithToken::Symbol(symbol)) => Some(symbol),
1140            _ => None,
1141        }
1142    }
1143
1144    fn eat_symbol(&mut self, symbol: &str) -> bool {
1145        if self.peek_symbol() == Some(symbol) {
1146            self.position += 1;
1147            return true;
1148        }
1149        false
1150    }
1151
1152    fn parse_binary_level(
1153        &mut self,
1154        operators: &[(&str, ArithBinaryOp)],
1155        next: fn(&mut Self) -> Result<ArithExpr, ParseError>,
1156    ) -> Result<ArithExpr, ParseError> {
1157        let mut left = next(self)?;
1158        while let Some(symbol) = self.peek_symbol() {
1159            let Some((_, operator)) = operators.iter().find(|(text, _)| *text == symbol) else {
1160                break;
1161            };
1162            let operator = *operator;
1163            self.position += 1;
1164            let right = next(self)?;
1165            left = ArithExpr::Binary(operator, Box::new(left), Box::new(right));
1166        }
1167        Ok(left)
1168    }
1169
1170    fn parse_or(&mut self) -> Result<ArithExpr, ParseError> {
1171        self.parse_binary_level(&[("||", ArithBinaryOp::Or)], Self::parse_and)
1172    }
1173
1174    fn parse_and(&mut self) -> Result<ArithExpr, ParseError> {
1175        self.parse_binary_level(&[("&&", ArithBinaryOp::And)], Self::parse_equality)
1176    }
1177
1178    fn parse_equality(&mut self) -> Result<ArithExpr, ParseError> {
1179        self.parse_binary_level(
1180            &[
1181                ("==", ArithBinaryOp::Equal),
1182                ("!=", ArithBinaryOp::NotEqual),
1183            ],
1184            Self::parse_relational,
1185        )
1186    }
1187
1188    fn parse_relational(&mut self) -> Result<ArithExpr, ParseError> {
1189        self.parse_binary_level(
1190            &[
1191                ("<=", ArithBinaryOp::LessOrEqual),
1192                (">=", ArithBinaryOp::GreaterOrEqual),
1193                ("<", ArithBinaryOp::Less),
1194                (">", ArithBinaryOp::Greater),
1195            ],
1196            Self::parse_additive,
1197        )
1198    }
1199
1200    fn parse_additive(&mut self) -> Result<ArithExpr, ParseError> {
1201        self.parse_binary_level(
1202            &[("+", ArithBinaryOp::Add), ("-", ArithBinaryOp::Subtract)],
1203            Self::parse_multiplicative,
1204        )
1205    }
1206
1207    fn parse_multiplicative(&mut self) -> Result<ArithExpr, ParseError> {
1208        self.parse_binary_level(
1209            &[
1210                ("*", ArithBinaryOp::Multiply),
1211                ("/", ArithBinaryOp::Divide),
1212                ("%", ArithBinaryOp::Remainder),
1213            ],
1214            Self::parse_unary,
1215        )
1216    }
1217
1218    fn parse_unary(&mut self) -> Result<ArithExpr, ParseError> {
1219        if self.eat_symbol("-") {
1220            return Ok(ArithExpr::Unary(
1221                ArithUnaryOp::Negate,
1222                Box::new(self.parse_unary()?),
1223            ));
1224        }
1225        if self.eat_symbol("+") {
1226            return self.parse_unary();
1227        }
1228        if self.eat_symbol("!") {
1229            return Ok(ArithExpr::Unary(
1230                ArithUnaryOp::Not,
1231                Box::new(self.parse_unary()?),
1232            ));
1233        }
1234        self.parse_primary()
1235    }
1236
1237    fn parse_primary(&mut self) -> Result<ArithExpr, ParseError> {
1238        let line = self.line;
1239        match self.tokens.get(self.position).cloned() {
1240            Some(ArithToken::Integer(value)) => {
1241                self.position += 1;
1242                Ok(ArithExpr::Integer(value))
1243            }
1244            Some(ArithToken::Float(value)) => {
1245                self.position += 1;
1246                Ok(ArithExpr::Float(value))
1247            }
1248            Some(ArithToken::Name(name)) => {
1249                self.position += 1;
1250                Ok(ArithExpr::Variable(name))
1251            }
1252            // Each `(` re-enters the top of the precedence chain, roughly eight stack frames per
1253            // level, so it is bounded by the same nesting ceiling the statement grammar uses.
1254            Some(ArithToken::Symbol("(")) => {
1255                if self.depth >= MAX_NESTING_DEPTH {
1256                    return Err(too_deep(line, "an arithmetic expansion"));
1257                }
1258                self.position += 1;
1259                self.depth += 1;
1260                let inner = self.parse_or();
1261                self.depth -= 1;
1262                let inner = inner?;
1263                if !self.eat_symbol(")") {
1264                    return Err(ParseError::syntax(
1265                        line,
1266                        "unbalanced parentheses in an arithmetic expansion",
1267                    ));
1268                }
1269                Ok(inner)
1270            }
1271            Some(ArithToken::Symbol(symbol)) => Err(ParseError::syntax(
1272                line,
1273                format!("unexpected `{symbol}` in an arithmetic expansion"),
1274            )),
1275            None => Err(ParseError::syntax(
1276                line,
1277                "arithmetic expansion ended unexpectedly",
1278            )),
1279        }
1280    }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use crate::ast::{ArithBinaryOp, ArithExpr, CasePattern, Statement, WordPart};
1286
1287    use super::{ParseError, parse};
1288
1289    fn syntax_error(source: &str) -> String {
1290        match parse(source).expect_err("must be rejected") {
1291            ParseError::Syntax { message, .. } => message,
1292            ParseError::Lex(error) => error.message,
1293        }
1294    }
1295
1296    #[test]
1297    fn parses_assignments_pipelines_and_lists() {
1298        let program = parse("x=1\necho $x | grep 1 && echo ok || echo no").expect("valid script");
1299        assert_eq!(program.statements.len(), 2);
1300        let Statement::List(list) = &program.statements[1] else {
1301            panic!("expected a list");
1302        };
1303        assert_eq!(list.first.commands.len(), 2);
1304        assert_eq!(list.rest.len(), 2);
1305    }
1306
1307    #[test]
1308    fn parses_control_flow_and_functions() {
1309        let program = parse(
1310            "greet() { echo \"hi $1\"; }\nfor name in a b; do greet $name; done\nwhile false; do break; done\nif true; then echo y; elif false; then echo m; else echo n; fi",
1311        )
1312        .expect("valid script");
1313        assert_eq!(program.statements.len(), 4);
1314        assert!(matches!(program.statements[0], Statement::Function(_)));
1315        assert!(matches!(program.statements[1], Statement::For(_)));
1316        assert!(matches!(program.statements[2], Statement::While(_)));
1317        assert!(matches!(program.statements[3], Statement::If(_)));
1318    }
1319
1320    #[test]
1321    fn parses_buffer_redirections() {
1322        let program = parse("echo hi > buf\necho there >> buf").expect("valid script");
1323        let Statement::List(list) = &program.statements[0] else {
1324            panic!("expected a list");
1325        };
1326        let redirect = list.first.commands[0]
1327            .redirect
1328            .as_ref()
1329            .expect("a redirect");
1330        assert!(!redirect.append);
1331        let Statement::List(list) = &program.statements[1] else {
1332            panic!("expected a list");
1333        };
1334        assert!(
1335            list.first.commands[0]
1336                .redirect
1337                .as_ref()
1338                .expect("a redirect")
1339                .append
1340        );
1341    }
1342
1343    #[test]
1344    fn parses_arithmetic_with_precedence() {
1345        let program = parse("echo $(( 1 + 2 * 3 ))").expect("valid script");
1346        let Statement::List(list) = &program.statements[0] else {
1347            panic!("expected a list");
1348        };
1349        let WordPart::Arithmetic(expression) = &list.first.commands[0].words[1].parts[0] else {
1350            panic!("expected arithmetic");
1351        };
1352        let ArithExpr::Binary(ArithBinaryOp::Add, left, right) = expression else {
1353            panic!("expected addition at the root, found {expression:?}");
1354        };
1355        assert_eq!(**left, ArithExpr::Integer(1));
1356        assert!(matches!(
1357            **right,
1358            ArithExpr::Binary(ArithBinaryOp::Multiply, _, _)
1359        ));
1360    }
1361
1362    #[test]
1363    fn backgrounding_is_a_hard_parse_error() {
1364        let message = syntax_error("sleep 1 &");
1365        assert!(message.contains("backgrounding"), "{message}");
1366        assert!(message.contains("job control"), "{message}");
1367    }
1368
1369    #[test]
1370    fn dropped_grammar_is_rejected_by_name() {
1371        assert!(syntax_error("(echo hi)").contains("subshells"));
1372        assert!(syntax_error("{ echo hi; }").contains("brace command groups"));
1373        assert!(syntax_error("cat <<<\"$x\"").contains("here-string"));
1374        assert!(syntax_error("diff <(a) b").contains("process substitution"));
1375        assert!(syntax_error("cat < file").contains("input redirection"));
1376        assert!(syntax_error("select x in a; do echo $x; done").contains("select"));
1377        assert!(syntax_error("function f { echo hi; }").contains("`function` keyword"));
1378        assert!(syntax_error("esac").contains("without a matching `case`"));
1379    }
1380
1381    #[test]
1382    fn parses_case_statements_with_alternatives_and_a_default() {
1383        let program =
1384            parse("case $x in\n  a|b) echo ab ;;\n  ready) echo go ;;\n  *) echo other ;;\nesac")
1385                .expect("valid script");
1386        let Statement::Case(statement) = &program.statements[0] else {
1387            panic!(
1388                "expected a case statement, found {:?}",
1389                program.statements[0]
1390            );
1391        };
1392        assert_eq!(statement.clauses.len(), 3);
1393        assert_eq!(statement.clauses[0].patterns.len(), 2);
1394        assert!(matches!(statement.clauses[2].patterns[0], CasePattern::Any));
1395    }
1396
1397    #[test]
1398    fn a_final_case_clause_may_omit_its_terminator() {
1399        let program = parse("case $x in a) echo a ;; *) echo b\nesac").expect("valid script");
1400        let Statement::Case(statement) = &program.statements[0] else {
1401            panic!("expected a case statement");
1402        };
1403        assert_eq!(statement.clauses.len(), 2);
1404    }
1405
1406    #[test]
1407    fn case_patterns_that_would_glob_are_rejected_by_name() {
1408        // A literal matcher would answer `*.json` wrongly and silently, which is the one thing
1409        // this shell will not do. `grep` and `sed` reject their metacharacters for the same reason.
1410        for (source, expected) in [
1411            ("case $f in *.json) echo j ;; esac", "any run of characters"),
1412            ("case $f in a?c) echo q ;; esac", "any single character"),
1413            ("case $f in [ab]) echo c ;; esac", "a character class"),
1414        ] {
1415            let message = syntax_error(source);
1416            assert!(message.contains(expected), "{source}: {message}");
1417            assert!(message.contains("literal text"), "{source}: {message}");
1418        }
1419
1420        // Quoting is how bash itself spells "this asterisk is an asterisk", so it stays available.
1421        assert!(parse("case $f in '*') echo star ;; esac").is_ok());
1422
1423        // A backslash is bash's one-character quote: `\*` is the same pattern as `'*'`. It must
1424        // classify as a literal match, never as the bare `*)` default branch — that would
1425        // silently route every subject through the escaped clause.
1426        let program = parse("case $f in \\*) echo star ;; esac").expect("valid script");
1427        let Statement::Case(statement) = &program.statements[0] else {
1428            panic!("expected a case statement");
1429        };
1430        assert!(matches!(
1431            statement.clauses[0].patterns[0],
1432            CasePattern::Literal(_)
1433        ));
1434        assert!(parse("case $f in a\\*b) echo star ;; esac").is_ok());
1435    }
1436
1437    #[test]
1438    fn malformed_case_statements_are_reported() {
1439        assert!(syntax_error("case $x in a) echo a ;;").contains("expected `esac`"));
1440        assert!(syntax_error("case $x in a echo a ;; esac").contains("expected `)`"));
1441        assert!(syntax_error("case in a) echo a ;; esac").contains("expected `in`"));
1442    }
1443
1444    #[test]
1445    fn a_here_document_becomes_the_command_input() {
1446        let program = parse("jq . <<EOF\n{\"a\": 1}\nEOF\n").expect("valid script");
1447        let Statement::List(list) = &program.statements[0] else {
1448            panic!("expected a list");
1449        };
1450        let command = &list.first.commands[0];
1451        assert_eq!(command.words.len(), 2);
1452        let body = command.here_doc.as_ref().expect("a here-document");
1453        assert_eq!(body.as_literal(), Some("{\"a\": 1}"));
1454    }
1455
1456    #[test]
1457    fn a_command_accepts_at_most_one_here_document() {
1458        assert!(syntax_error("cat <<A <<B\na\nA\nb\nB\n").contains("at most one here-document"));
1459    }
1460
1461    #[test]
1462    fn parses_until_loops_and_nested_control_flow() {
1463        let program = parse(
1464            "outer() {\n  for a in 1 2; do\n    until false; do\n      if true; then break; fi\n    done\n  done\n}\nouter",
1465        )
1466        .expect("nested control flow composes");
1467        assert_eq!(program.statements.len(), 2);
1468        let Statement::Function(definition) = &program.statements[0] else {
1469            panic!("expected a function definition");
1470        };
1471        let Statement::For(loop_statement) = &definition.body.statements[0] else {
1472            panic!("expected a for loop");
1473        };
1474        let Statement::While(inner) = &loop_statement.body.statements[0] else {
1475            panic!("expected an until loop");
1476        };
1477        assert!(inner.until);
1478        assert!(matches!(inner.body.statements[0], Statement::If(_)));
1479    }
1480
1481    #[test]
1482    fn functions_cannot_shadow_rejected_commands() {
1483        let message = syntax_error("eval() { echo hi; }");
1484        assert!(
1485            message.contains("cannot define a function named"),
1486            "{message}"
1487        );
1488    }
1489
1490    #[test]
1491    fn globbing_characters_parse_as_literal_words() {
1492        let program = parse("echo *").expect("an unquoted `*` is an ordinary character");
1493        let Statement::List(list) = &program.statements[0] else {
1494            panic!("expected a list");
1495        };
1496        assert_eq!(
1497            list.first.commands[0].words[1].parts,
1498            vec![WordPart::Literal("*".to_owned())]
1499        );
1500    }
1501
1502    #[test]
1503    fn command_substitution_is_parsed_recursively() {
1504        let program = parse("x=$(echo hi)").expect("valid script");
1505        let Statement::List(list) = &program.statements[0] else {
1506            panic!("expected a list");
1507        };
1508        let assignment = &list.first.commands[0].assignments[0];
1509        assert_eq!(assignment.name, "x");
1510        assert!(assignment.value.is_bare_command_substitution());
1511    }
1512
1513    #[test]
1514    fn unterminated_blocks_are_reported() {
1515        assert!(syntax_error("if true; then echo hi").contains("expected `fi`"));
1516        assert!(syntax_error("for x in a; do echo $x").contains("expected `done`"));
1517        assert!(syntax_error("f() { echo hi").contains("expected `}`"));
1518    }
1519}