Skip to main content

alba_shell/
parser.rs

1//! Recursive-descent parser: turns the lexer's flat token list into a
2//! `Program`. Grammar (newline already lexes as `;`, see `lexer`):
3//!
4//! ```text
5//! program  := and_or ((';') and_or)*        // empty items skipped
6//! and_or   := pipeline (('&&' | '||') pipeline)*
7//! pipeline := ['!'] command ('|' command)*
8//! command  := assignment* (word | redirect)+
9//! ```
10
11use crate::ast::{self, AndOrList, AndOrOp, Assignment, Command, Pipeline, Program, Redirect};
12use crate::error::ShellParseError;
13use crate::lexer::{self, OUT_OF_SUBSET_HELP};
14use crate::token::{self, Span, Token, TokenKind};
15
16/// Parses a whole shell command line.
17pub fn parse(source: &str) -> Result<Program, ShellParseError> {
18    parse_at(source, 0)
19}
20
21/// Parses `source` as if it started at byte `offset` of some larger
22/// string, so spans in errors and in the AST stay absolute. Used both
23/// for the top-level parse and, recursively, for command substitution.
24fn parse_at(source: &str, offset: usize) -> Result<Program, ShellParseError> {
25    let tokens = lexer::lex_at(source, offset)?;
26    Parser { tokens, pos: 0 }.parse_program()
27}
28
29struct Parser {
30    tokens: Vec<Token>,
31    pos: usize,
32}
33
34impl Parser {
35    fn peek(&self) -> Option<&Token> {
36        self.tokens.get(self.pos)
37    }
38
39    fn peek_kind(&self) -> Option<&TokenKind> {
40        self.peek().map(|token| &token.kind)
41    }
42
43    fn advance(&mut self) -> Option<Token> {
44        let token = self.tokens.get(self.pos).cloned();
45        if token.is_some() {
46            self.pos += 1;
47        }
48        token
49    }
50
51    fn parse_program(&mut self) -> Result<Program, ShellParseError> {
52        let mut items = Vec::new();
53        loop {
54            while matches!(self.peek_kind(), Some(TokenKind::Semi)) {
55                self.advance();
56            }
57            if self.peek().is_none() {
58                break;
59            }
60            items.push(self.parse_and_or()?);
61            match self.peek_kind() {
62                Some(TokenKind::Semi) => {
63                    self.advance();
64                }
65                None => break,
66                Some(_) => {
67                    // The grammar below only ever stops an and/or chain on
68                    // `;` or end of input; anything else here would be a
69                    // parser bug rather than a user mistake, but we still
70                    // report it as a diagnostic instead of panicking.
71                    let token = self.peek().expect("checked above");
72                    return Err(ShellParseError {
73                        message: "unexpected token".to_string(),
74                        span: token.span,
75                        suggestion: None,
76                    });
77                }
78            }
79        }
80        Ok(Program { items })
81    }
82
83    fn parse_and_or(&mut self) -> Result<AndOrList, ShellParseError> {
84        let first = self.require_pipeline()?;
85        let mut rest = Vec::new();
86        loop {
87            let op = match self.peek_kind() {
88                Some(TokenKind::AndAnd) => AndOrOp::And,
89                Some(TokenKind::OrOr) => AndOrOp::Or,
90                _ => break,
91            };
92            let op_token = self.advance().expect("peeked the operator above");
93            let op_text = if op == AndOrOp::And { "&&" } else { "||" };
94            let pipeline = self.require_pipeline_after(op_token.span, op_text)?;
95            rest.push((op, pipeline));
96        }
97        Ok(AndOrList { first, rest })
98    }
99
100    /// A pipeline required at the start of an and/or list: no preceding
101    /// operator to blame, so the error points at whatever unexpected
102    /// token stopped it.
103    fn require_pipeline(&mut self) -> Result<Pipeline, ShellParseError> {
104        match self.parse_pipeline()? {
105            Some(pipeline) => Ok(pipeline),
106            None => Err(self.expected_a_command_error()),
107        }
108    }
109
110    /// A pipeline required right after `&&`/`||`: the error blames that
111    /// operator's span when nothing follows it.
112    fn require_pipeline_after(
113        &mut self,
114        op_span: Span,
115        op_text: &str,
116    ) -> Result<Pipeline, ShellParseError> {
117        match self.parse_pipeline()? {
118            Some(pipeline) => Ok(pipeline),
119            None => Err(ShellParseError {
120                message: format!("expected a command after `{op_text}`"),
121                span: op_span,
122                suggestion: None,
123            }),
124        }
125    }
126
127    fn expected_a_command_error(&self) -> ShellParseError {
128        let span = self
129            .peek()
130            .map_or(Span { start: 0, end: 0 }, |token| token.span);
131        ShellParseError {
132            message: "expected a command".to_string(),
133            span,
134            suggestion: None,
135        }
136    }
137
138    fn parse_pipeline(&mut self) -> Result<Option<Pipeline>, ShellParseError> {
139        let bang_span = self.consume_negation();
140        let negated = bang_span.is_some();
141        let first = match self.parse_command()? {
142            Some(command) => command,
143            None => {
144                return match bang_span {
145                    Some(span) => Err(ShellParseError {
146                        message: "expected a command after `!`".to_string(),
147                        span,
148                        suggestion: None,
149                    }),
150                    None => Ok(None),
151                };
152            }
153        };
154        let mut commands = vec![first];
155        loop {
156            if !matches!(self.peek_kind(), Some(TokenKind::Pipe)) {
157                break;
158            }
159            let pipe_token = self.advance().expect("peeked the pipe above");
160            let command = match self.parse_command()? {
161                Some(command) => command,
162                None => {
163                    return Err(ShellParseError {
164                        message: "expected a command after `|`".to_string(),
165                        span: pipe_token.span,
166                        suggestion: None,
167                    });
168                }
169            };
170            commands.push(command);
171        }
172        Ok(Some(Pipeline { negated, commands }))
173    }
174
175    /// A word whose parts are exactly `[Text("!")]` at pipeline
176    /// position negates the pipeline's status.
177    fn consume_negation(&mut self) -> Option<Span> {
178        let Some(Token {
179            kind: TokenKind::Word(word),
180            span,
181        }) = self.peek()
182        else {
183            return None;
184        };
185        if word.parts.len() == 1 && matches!(&word.parts[0], token::WordPart::Text(t) if t == "!") {
186            let span = *span;
187            self.advance();
188            return Some(span);
189        }
190        None
191    }
192
193    /// `command := assignment* (word | redirect)+`. Returns `None` when
194    /// no command token was found at all (used by callers to detect a
195    /// missing command after an operator).
196    fn parse_command(&mut self) -> Result<Option<Command>, ShellParseError> {
197        let mut assignments = Vec::new();
198        let mut words = Vec::new();
199        let mut redirects = Vec::new();
200        let mut seen_word = false;
201        let mut span: Option<Span> = None;
202
203        while let Some(token) = self.peek().cloned() {
204            let token_span = token.span;
205            match token.kind {
206                TokenKind::Word(word) => {
207                    if !seen_word {
208                        if let Some((name, value)) = try_assignment(&word)? {
209                            self.advance();
210                            extend(&mut span, token_span);
211                            assignments.push(Assignment { name, value });
212                            continue;
213                        }
214                        if let Some(message) = reserved_word_message(&word) {
215                            return Err(ShellParseError {
216                                message,
217                                span: token_span,
218                                suggestion: Some(OUT_OF_SUBSET_HELP.to_string()),
219                            });
220                        }
221                    }
222                    self.advance();
223                    seen_word = true;
224                    extend(&mut span, token_span);
225                    words.push(convert_word(token::Word {
226                        parts: word.parts,
227                        span: token_span,
228                    })?);
229                }
230                TokenKind::RedirectOut { stderr, append } => {
231                    self.advance();
232                    let target =
233                        self.expect_redirect_target(token_span, redirect_out_text(stderr, append))?;
234                    extend(&mut span, token_span);
235                    extend(&mut span, target.span);
236                    redirects.push(Redirect::Out {
237                        stderr,
238                        append,
239                        target,
240                    });
241                }
242                TokenKind::RedirectIn => {
243                    self.advance();
244                    let target = self.expect_redirect_target(token_span, "<")?;
245                    extend(&mut span, token_span);
246                    extend(&mut span, target.span);
247                    redirects.push(Redirect::In { target });
248                }
249                TokenKind::StderrToStdout => {
250                    self.advance();
251                    extend(&mut span, token_span);
252                    redirects.push(Redirect::StderrToStdout);
253                }
254                _ => break,
255            }
256        }
257
258        if assignments.is_empty() && words.is_empty() && redirects.is_empty() {
259            return Ok(None);
260        }
261
262        Ok(Some(Command {
263            assignments,
264            words,
265            redirects,
266            span: span.expect("a non-empty command has at least one span-contributing token"),
267        }))
268    }
269
270    /// A redirect target is exactly one word; anything else (nothing
271    /// left, or another operator) is a missing-target error blaming the
272    /// redirect operator's own span.
273    fn expect_redirect_target(
274        &mut self,
275        op_span: Span,
276        op_text: &str,
277    ) -> Result<ast::Word, ShellParseError> {
278        if let Some(Token {
279            kind: TokenKind::Word(_),
280            ..
281        }) = self.peek()
282        {
283            let token = self.advance().expect("peeked a word above");
284            let TokenKind::Word(word) = token.kind else {
285                unreachable!("peeked a word above")
286            };
287            return convert_word(token::Word {
288                parts: word.parts,
289                span: token.span,
290            });
291        }
292        Err(ShellParseError {
293            message: format!("expected a file after `{op_text}`"),
294            span: op_span,
295            suggestion: None,
296        })
297    }
298}
299
300/// Grows `acc` to also cover `span`, or seeds it when this is the first
301/// contributing span.
302fn extend(acc: &mut Option<Span>, span: Span) {
303    *acc = Some(match *acc {
304        Some(current) => Span {
305            start: current.start,
306            end: span.end,
307        },
308        None => span,
309    });
310}
311
312/// The display form of a `>`-family redirect, used in "expected a file
313/// after `X`" diagnostics.
314fn redirect_out_text(stderr: bool, append: bool) -> &'static str {
315    match (stderr, append) {
316        (false, false) => ">",
317        (false, true) => ">>",
318        (true, false) => "2>",
319        (true, true) => "2>>",
320    }
321}
322
323/// At command position (before any word of the command has been seen),
324/// a word matching a shell keyword is out of subset. `None` means the
325/// word is an ordinary argument.
326fn reserved_word_message(word: &token::Word) -> Option<String> {
327    if word.parts.len() != 1 {
328        return None;
329    }
330    let token::WordPart::Text(text) = &word.parts[0] else {
331        return None;
332    };
333    match text.as_str() {
334        "if" | "then" | "elif" | "else" | "fi" => {
335            Some("`if` conditionals are not supported by the embedded shell".to_string())
336        }
337        "for" => Some("`for` loops are not supported by the embedded shell".to_string()),
338        "while" | "until" => {
339            Some("`while` loops are not supported by the embedded shell".to_string())
340        }
341        "case" | "esac" => {
342            Some("`case` statements are not supported by the embedded shell".to_string())
343        }
344        "function" => Some("shell functions are not supported by the embedded shell".to_string()),
345        "do" | "done" | "in" => Some(format!(
346            "`{text}` is a shell keyword the embedded shell does not support"
347        )),
348        _ => None,
349    }
350}
351
352/// Recognizes `NAME=value` at command start: the word's first part must
353/// be unquoted text matching `^[A-Za-z_][A-Za-z0-9_]*=`. The value is
354/// the remainder of that text part plus every part after it.
355fn try_assignment(word: &token::Word) -> Result<Option<(String, ast::Word)>, ShellParseError> {
356    let Some(token::WordPart::Text(first_text)) = word.parts.first() else {
357        return Ok(None);
358    };
359    let Some((name, remainder)) = split_assignment_name(first_text) else {
360        return Ok(None);
361    };
362
363    let mut parts = Vec::new();
364    if !remainder.is_empty() {
365        parts.push(ast::WordPart::Text(remainder.to_string()));
366    }
367    for part in &word.parts[1..] {
368        parts.push(convert_part(part.clone())?);
369    }
370
371    let value_start = word.span.start + name.len() + 1;
372    Ok(Some((
373        name.to_string(),
374        ast::Word {
375            parts,
376            span: Span {
377                start: value_start,
378                end: word.span.end,
379            },
380        },
381    )))
382}
383
384/// Splits `NAME=rest` at the first `=`, requiring `NAME` to look like a
385/// shell identifier. Returns `None` when the text does not match.
386fn split_assignment_name(text: &str) -> Option<(&str, &str)> {
387    let mut chars = text.char_indices();
388    let (_, first) = chars.next()?;
389    if !(first.is_ascii_alphabetic() || first == '_') {
390        return None;
391    }
392    for (i, c) in chars {
393        if c == '=' {
394            return Some((&text[..i], &text[i + 1..]));
395        }
396        if !(c.is_ascii_alphanumeric() || c == '_') {
397            return None;
398        }
399    }
400    None
401}
402
403fn convert_word(word: token::Word) -> Result<ast::Word, ShellParseError> {
404    Ok(ast::Word {
405        parts: convert_parts(word.parts)?,
406        span: word.span,
407    })
408}
409
410fn convert_parts(parts: Vec<token::WordPart>) -> Result<Vec<ast::WordPart>, ShellParseError> {
411    parts.into_iter().map(convert_part).collect()
412}
413
414fn convert_part(part: token::WordPart) -> Result<ast::WordPart, ShellParseError> {
415    Ok(match part {
416        token::WordPart::Text(t) => ast::WordPart::Text(t),
417        token::WordPart::Escaped(c) => ast::WordPart::Escaped(c),
418        token::WordPart::SingleQuoted(t) => ast::WordPart::SingleQuoted(t),
419        token::WordPart::DoubleQuoted(inner) => ast::WordPart::DoubleQuoted(convert_parts(inner)?),
420        token::WordPart::Var(name) => ast::WordPart::Var(name),
421        token::WordPart::LastExit => ast::WordPart::LastExit,
422        token::WordPart::CmdSubst { source, span } => {
423            let program = parse_at(&source, span.start)?;
424            ast::WordPart::CmdSubst(Box::new(program))
425        }
426    })
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn ast(src: &str) -> crate::ast::Program {
434        parse(src).unwrap()
435    }
436
437    fn err(src: &str) -> String {
438        parse(src).unwrap_err().render(src)
439    }
440
441    #[test]
442    fn parses_a_simple_command() {
443        insta::assert_debug_snapshot!(ast("cargo build --release"));
444    }
445
446    #[test]
447    fn parses_sequencing_and_and_or() {
448        insta::assert_debug_snapshot!(ast("a; b && c || d"));
449    }
450
451    #[test]
452    fn and_and_or_are_left_associative_and_equal_precedence() {
453        // POSIX: `a && b || c` is `(a && b) || c`.
454        insta::assert_debug_snapshot!(ast("a && b || c && d"));
455    }
456
457    #[test]
458    fn parses_a_pipeline() {
459        insta::assert_debug_snapshot!(ast("cat f | grep x | wc -l"));
460    }
461
462    #[test]
463    fn parses_negation() {
464        let program = ast("! grep -q TODO src");
465        assert!(program.items[0].first.negated);
466    }
467
468    #[test]
469    fn parses_redirections_in_any_position() {
470        insta::assert_debug_snapshot!(ast("cmd > out.txt 2>&1 < in.txt"));
471    }
472
473    #[test]
474    fn parses_assignment_prefixes() {
475        insta::assert_debug_snapshot!(ast("RUST_LOG=debug FOO=$bar cargo test"));
476    }
477
478    #[test]
479    fn an_assignment_alone_is_a_command_without_words() {
480        let program = ast("GREETING='hello world'");
481        let command = &program.items[0].first.commands[0];
482        assert_eq!(command.assignments.len(), 1);
483        assert!(command.words.is_empty());
484    }
485
486    #[test]
487    fn an_equals_sign_past_the_first_word_is_literal() {
488        // `env A=b` passes `A=b` as an argument, not an assignment.
489        let program = ast("env A=b");
490        let command = &program.items[0].first.commands[0];
491        assert!(command.assignments.is_empty());
492        assert_eq!(command.words.len(), 2);
493    }
494
495    #[test]
496    fn parses_command_substitution_recursively() {
497        insta::assert_debug_snapshot!(ast("echo $(git rev-parse HEAD | cut -c1-7)"));
498    }
499
500    #[test]
501    fn an_empty_source_is_an_empty_program() {
502        assert!(ast("").items.is_empty());
503        assert!(ast("  \n ; ; \n").items.is_empty());
504    }
505
506    #[test]
507    fn rejects_if_conditionals() {
508        insta::assert_snapshot!(err("if true; then echo y; fi"));
509    }
510
511    #[test]
512    fn rejects_for_loops() {
513        insta::assert_snapshot!(err("for f in *.rs; do echo $f; done"));
514    }
515
516    #[test]
517    fn rejects_while_loops() {
518        insta::assert_snapshot!(err("while true; do sleep 1; done"));
519    }
520
521    #[test]
522    fn rejects_case_statements() {
523        insta::assert_snapshot!(err("case $x in a) echo a;; esac"));
524    }
525
526    #[test]
527    fn rejects_function_definitions() {
528        insta::assert_snapshot!(err("greet() { echo hi; }"));
529    }
530
531    // `greet() { ... }` above never reaches the parser's `function`
532    // reserved-word arm (the lexer's `(` error fires first), so this
533    // closes the coverage gap for the `function` keyword itself, written
534    // without parens.
535    #[test]
536    fn rejects_the_function_keyword() {
537        insta::assert_snapshot!(err("function greet { echo hi; }"));
538    }
539
540    #[test]
541    fn reserved_words_are_only_reserved_at_command_position() {
542        // `echo if` is fine: `if` is an argument there.
543        assert!(parse("echo if then done").is_ok());
544    }
545
546    #[test]
547    fn quoting_a_reserved_word_makes_it_ordinary() {
548        assert!(parse("'if' --version").is_ok());
549    }
550
551    #[test]
552    fn rejects_a_trailing_operator() {
553        insta::assert_snapshot!(err("a &&"));
554    }
555
556    #[test]
557    fn rejects_a_redirect_without_target() {
558        insta::assert_snapshot!(err("echo hi >"));
559    }
560
561    #[test]
562    fn an_out_of_subset_error_inside_command_substitution_points_at_the_outer_source() {
563        let error = parse("echo $(for x in a; do echo $x; done)").unwrap_err();
564        assert!(
565            error.span.start >= 7,
566            "span must be inside the substitution"
567        );
568    }
569}