Skip to main content

command_stream/fy/
parser.rs

1//! Recursive-descent shell parser for the `$fy` translator.
2//!
3//! Produces a plain node tree which [`crate::fy::formalizer`] then
4//! materialises as a meta-language links network. Keeping the two steps apart
5//! means the grammar stays readable and the network construction stays
6//! mechanical.
7//!
8//! This parser is deliberately separate from [`crate::shell_parser`], which
9//! parses a *single* command line for the runtime. `$fy` needs whole-script
10//! structure: comments, blank lines, control flow and function definitions.
11
12use std::collections::HashSet;
13
14use super::lexer::{tokenize, Token, TokenType};
15
16/// A parsed shell construct.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Node {
19    /// Construct name, for example `command`, `pipeline` or `if`.
20    pub term: String,
21    /// The node's own text, when it carries one (a variable name, a comment).
22    pub text: Option<String>,
23    pub children: Vec<Node>,
24}
25
26impl Node {
27    pub fn new(term: &str, children: Vec<Node>) -> Self {
28        Node {
29            term: term.to_string(),
30            text: None,
31            children,
32        }
33    }
34
35    pub fn with_text(term: &str, children: Vec<Node>, text: impl Into<String>) -> Self {
36        Node {
37            term: term.to_string(),
38            text: Some(text.into()),
39            children,
40        }
41    }
42
43    pub fn leaf(term: &str, text: impl Into<String>) -> Self {
44        Node::with_text(term, Vec::new(), text)
45    }
46}
47
48/// Reserved words that terminate a command list.
49const BLOCK_TERMINATORS: [&str; 8] = ["then", "elif", "else", "fi", "do", "done", "esac", "}"];
50
51/// Splits `NAME=value` into its two halves.
52fn match_assignment(text: &str) -> Option<(String, String)> {
53    let equals = text.find('=')?;
54    let (name, value) = text.split_at(equals);
55    if !is_identifier(name) {
56        return None;
57    }
58    Some((name.to_string(), value[1..].to_string()))
59}
60
61fn is_identifier(text: &str) -> bool {
62    let mut characters = text.chars();
63    matches!(characters.next(), Some(first) if first.is_ascii_alphabetic() || first == '_')
64        && characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
65}
66
67struct ShellParser {
68    tokens: Vec<Token>,
69    position: usize,
70}
71
72/// The words and redirects making up one simple command.
73struct CommandParts {
74    assignments: Vec<Node>,
75    words: Vec<Node>,
76    redirects: Vec<Node>,
77}
78
79impl ShellParser {
80    fn new(source: &str) -> Self {
81        ShellParser {
82            tokens: tokenize(source),
83            position: 0,
84        }
85    }
86
87    fn current(&self) -> &Token {
88        &self.tokens[self.position]
89    }
90
91    fn at(&self, token_type: TokenType) -> bool {
92        self.current().token_type == token_type
93    }
94
95    fn at_text(&self, token_type: TokenType, text: &str) -> bool {
96        self.at(token_type) && self.current().text == text
97    }
98
99    fn at_word(&self, text: &str) -> bool {
100        self.at_text(TokenType::Word, text)
101    }
102
103    fn consume(&mut self) -> Token {
104        let token = self.tokens[self.position].clone();
105        self.position += 1;
106        token
107    }
108
109    fn expect_word(&mut self, text: &str) -> bool {
110        if self.at_word(text) {
111            self.consume();
112            true
113        } else {
114            false
115        }
116    }
117
118    /// Skips newlines and `;` separators, emitting `blank` nodes for blank lines.
119    fn skip_separators(&mut self, collector: Option<&mut Vec<Node>>) {
120        let mut newlines = 0usize;
121        let mut collector = collector;
122        loop {
123            if self.at(TokenType::Newline) {
124                self.consume();
125                newlines += 1;
126                // The first newline ends the previous statement; each further
127                // one is a blank line worth preserving in the output.
128                if newlines > 1 {
129                    if let Some(statements) = collector.as_deref_mut() {
130                        statements.push(Node::new("blank", Vec::new()));
131                    }
132                }
133                continue;
134            }
135            if self.at_text(TokenType::Operator, ";") {
136                self.consume();
137                continue;
138            }
139            return;
140        }
141    }
142
143    /// Whether the current token closes the enclosing block.
144    fn at_block_end(&self, extra_terminators: &HashSet<String>) -> bool {
145        let token = self.current();
146        if token.token_type == TokenType::Eof {
147            return true;
148        }
149        if token.token_type == TokenType::Operator && (token.text == ")" || token.text == ";;") {
150            return true;
151        }
152        if token.token_type != TokenType::Word {
153            return false;
154        }
155        BLOCK_TERMINATORS.contains(&token.text.as_str())
156            || extra_terminators.contains(token.text.as_str())
157    }
158
159    fn parse_script(&mut self) -> Node {
160        let statements = self.parse_statements(&HashSet::new());
161        Node::new("script", statements)
162    }
163
164    fn parse_statements(&mut self, extra_terminators: &HashSet<String>) -> Vec<Node> {
165        let mut statements = Vec::new();
166        // Leading blank lines are not "blank lines between statements".
167        self.skip_separators(None);
168
169        while !self.at_block_end(extra_terminators) {
170            if self.at(TokenType::Comment) {
171                let text = self.consume().text;
172                let body = text.trim_start_matches('#');
173                let body = body.strip_prefix(' ').unwrap_or(body);
174                statements.push(Node::leaf("comment", body));
175            } else {
176                match self.parse_list() {
177                    Some(statement) => statements.push(statement),
178                    None => break,
179                }
180            }
181            self.skip_separators(Some(&mut statements));
182        }
183
184        // Trailing blank lines add nothing to the translation.
185        while statements.last().is_some_and(|last| last.term == "blank") {
186            statements.pop();
187        }
188        statements
189    }
190
191    /// `pipeline (&& | ||) pipeline ...`, left associative.
192    fn parse_list(&mut self) -> Option<Node> {
193        let mut left = self.parse_pipeline()?;
194        while self.at_text(TokenType::Operator, "&&") || self.at_text(TokenType::Operator, "||") {
195            let operator = if self.consume().text == "&&" {
196                "and"
197            } else {
198                "or"
199            };
200            // An operator may be followed by a newline before its right operand.
201            while self.at(TokenType::Newline) {
202                self.consume();
203            }
204            match self.parse_pipeline() {
205                Some(right) => left = Node::new(operator, vec![left, right]),
206                None => break,
207            }
208        }
209        Some(left)
210    }
211
212    fn parse_pipeline(&mut self) -> Option<Node> {
213        let mut stages = vec![self.parse_command()?];
214        while self.at_text(TokenType::Operator, "|") {
215            self.consume();
216            while self.at(TokenType::Newline) {
217                self.consume();
218            }
219            match self.parse_command() {
220                Some(stage) => stages.push(stage),
221                None => break,
222            }
223        }
224        if stages.len() == 1 {
225            stages.pop()
226        } else {
227            Some(Node::new("pipeline", stages))
228        }
229    }
230
231    fn parse_command(&mut self) -> Option<Node> {
232        if self.at_text(TokenType::Operator, "(") {
233            self.consume();
234            let body = self.parse_statements(&HashSet::new());
235            if self.at_text(TokenType::Operator, ")") {
236                self.consume();
237            }
238            return Some(Node::new("subshell", vec![Node::new("block", body)]));
239        }
240        if self.at_word("{") {
241            self.consume();
242            let body = self.parse_statements(&HashSet::new());
243            self.expect_word("}");
244            return Some(Node::new("block", body));
245        }
246        if self.at_word("if") {
247            return Some(self.parse_if());
248        }
249        if self.at_word("while") || self.at_word("until") {
250            return Some(self.parse_loop());
251        }
252        if self.at_word("for") {
253            return Some(self.parse_for());
254        }
255        if self.at_word("case") {
256            return Some(self.parse_case());
257        }
258        if let Some(function) = self.try_parse_function() {
259            return Some(function);
260        }
261        self.parse_simple_command()
262    }
263
264    /// `if list; then block [elif ...] [else block] fi`
265    fn parse_if(&mut self) -> Node {
266        self.consume(); // `if` / `elif`
267        let condition = self.parse_condition("then");
268        let consequent = Node::new("block", self.parse_statements(&HashSet::new()));
269
270        if self.at_word("elif") {
271            let alternative = self.parse_if();
272            return Node::new("if", vec![condition, consequent, alternative]);
273        }
274        let alternative = if self.expect_word("else") {
275            Some(Node::new("block", self.parse_statements(&HashSet::new())))
276        } else {
277            None
278        };
279        self.expect_word("fi");
280        match alternative {
281            Some(alternative) => Node::new("if", vec![condition, consequent, alternative]),
282            None => Node::new("if", vec![condition, consequent]),
283        }
284    }
285
286    fn parse_loop(&mut self) -> Node {
287        let term = if self.consume().text == "while" {
288            "while"
289        } else {
290            "until"
291        };
292        let condition = self.parse_condition("do");
293        let body = Node::new("block", self.parse_statements(&HashSet::new()));
294        self.expect_word("done");
295        Node::new(term, vec![condition, body])
296    }
297
298    /// `for NAME in word...; do block done`
299    fn parse_for(&mut self) -> Node {
300        self.consume(); // `for`
301        let variable = if self.at(TokenType::Word) {
302            self.consume().text
303        } else {
304            "_".to_string()
305        };
306        let mut items = Vec::new();
307        if self.expect_word("in") {
308            while self.at(TokenType::Word)
309                && !BLOCK_TERMINATORS.contains(&self.current().text.as_str())
310            {
311                let text = self.consume().text;
312                items.push(Node::leaf("word", text));
313            }
314        }
315        self.skip_separators(None);
316        self.expect_word("do");
317        let body = Node::new("block", self.parse_statements(&HashSet::new()));
318        self.expect_word("done");
319        Node::new(
320            "for",
321            vec![
322                Node::leaf("name", variable),
323                Node::new("word-list", items),
324                body,
325            ],
326        )
327    }
328
329    /// `case word in pattern) block ;; ... esac`
330    fn parse_case(&mut self) -> Node {
331        self.consume(); // `case`
332        let subject = if self.at(TokenType::Word) {
333            let text = self.consume().text;
334            Node::leaf("word", text)
335        } else {
336            Node::leaf("word", "")
337        };
338        self.expect_word("in");
339        self.skip_separators(None);
340
341        let mut branches = Vec::new();
342        while !self.at_word("esac") && !self.at(TokenType::Eof) {
343            branches.push(self.parse_case_branch());
344        }
345        self.expect_word("esac");
346        Node::new(
347            "case",
348            vec![subject, Node::new("case-branch-list", branches)],
349        )
350    }
351
352    fn parse_case_branch(&mut self) -> Node {
353        // A pattern list is `a|b|c)`. The lexer already split on `|` and `)`.
354        let mut patterns = Vec::new();
355        while self.at(TokenType::Word) {
356            // `*` is the shell's catch-all pattern, i.e. `default:`.
357            let pattern = self.consume().text;
358            let term = if pattern == "*" {
359                "pattern-default"
360            } else {
361                "pattern"
362            };
363            patterns.push(Node::leaf(term, pattern));
364            if self.at_text(TokenType::Operator, "|") {
365                self.consume();
366                continue;
367            }
368            break;
369        }
370        if self.at_text(TokenType::Operator, ")") {
371            self.consume();
372        }
373        let body = Node::new("block", self.parse_statements(&HashSet::new()));
374        if self.at_text(TokenType::Operator, ";;") {
375            self.consume();
376        }
377        self.skip_separators(None);
378        Node::new(
379            "case-branch",
380            vec![Node::new("pattern-list", patterns), body],
381        )
382    }
383
384    /// Consumes `function name [()]`, or `None` when that is not next.
385    fn parse_keyword_function_name(&mut self) -> Option<String> {
386        self.consume();
387        if !self.at(TokenType::Word) {
388            return None;
389        }
390        let name = self.consume().text;
391        if self.at_text(TokenType::Operator, "(") {
392            self.consume();
393            if self.at_text(TokenType::Operator, ")") {
394                self.consume();
395            }
396        }
397        Some(name)
398    }
399
400    /// Consumes `name ()`, or `None` when that is not next.
401    fn parse_bare_function_name(&mut self) -> Option<String> {
402        let is_operator = |token: Option<&Token>, text: &str| {
403            token.is_some_and(|token| token.token_type == TokenType::Operator && token.text == text)
404        };
405
406        if !self.at(TokenType::Word)
407            || !is_identifier(&self.current().text)
408            || !is_operator(self.tokens.get(self.position + 1), "(")
409            || !is_operator(self.tokens.get(self.position + 2), ")")
410        {
411            return None;
412        }
413        let name = self.consume().text;
414        self.consume();
415        self.consume();
416        Some(name)
417    }
418
419    /// `name() { ... }` or `function name { ... }`
420    fn try_parse_function(&mut self) -> Option<Node> {
421        let start = self.position;
422        let name = if self.at_word("function") {
423            self.parse_keyword_function_name()
424        } else {
425            self.parse_bare_function_name()
426        };
427
428        let Some(name) = name else {
429            self.position = start;
430            return None;
431        };
432
433        self.skip_separators(None);
434        if !self.expect_word("{") {
435            self.position = start;
436            return None;
437        }
438        let body = Node::new("block", self.parse_statements(&HashSet::new()));
439        self.expect_word("}");
440        Some(Node::new("function", vec![Node::leaf("name", name), body]))
441    }
442
443    /// Parses the condition of `if`/`while`/`until` up to its introducer
444    /// keyword (`then`/`do`), which the caller has named.
445    fn parse_condition(&mut self, introducer: &str) -> Node {
446        let terminators: HashSet<String> = HashSet::from([introducer.to_string()]);
447        let mut condition = self.parse_statements(&terminators);
448        self.skip_separators(None);
449        self.expect_word(introducer);
450        if condition.len() == 1 {
451            return condition.remove(0);
452        }
453        Node::new("block", condition)
454    }
455
456    /// True once the current token can no longer belong to a simple command.
457    ///
458    /// `words` are the words read so far; an empty list means the parser is
459    /// still in command position.
460    fn at_command_end(&self, words: &[Node]) -> bool {
461        // Reserved words are only reserved in command position: `echo done`
462        // runs `echo` with the argument `done`, it does not close a loop.
463        // Assignment prefixes do not consume the command position, so the
464        // check stays on `words`.
465        if words.is_empty() && self.at_block_end(&HashSet::new()) {
466            return true;
467        }
468        self.at(TokenType::Eof)
469            || self.at(TokenType::Newline)
470            || self.at(TokenType::Comment)
471            || self.at(TokenType::Operator)
472    }
473
474    /// Reads the assignment prefixes, words and redirects of one command.
475    fn parse_command_parts(&mut self) -> CommandParts {
476        let mut parts = CommandParts {
477            assignments: Vec::new(),
478            words: Vec::new(),
479            redirects: Vec::new(),
480        };
481
482        while !self.at_command_end(&parts.words) {
483            if self.at(TokenType::Redirect) {
484                let operator = self.consume().text;
485                let target = if self.at(TokenType::Word) {
486                    self.consume().text
487                } else {
488                    String::new()
489                };
490                parts.redirects.push(Node::with_text(
491                    "redirect",
492                    vec![Node::leaf("word", target)],
493                    operator,
494                ));
495                continue;
496            }
497
498            let token = self.consume();
499            let assignment = if parts.words.is_empty() {
500                match_assignment(&token.text)
501            } else {
502                None
503            };
504            if let Some((name, value)) = assignment {
505                parts.assignments.push(Node::with_text(
506                    "assignment",
507                    vec![Node::leaf("word", value)],
508                    name,
509                ));
510                continue;
511            }
512            parts.words.push(Node::leaf("word", token.text));
513        }
514
515        parts
516    }
517
518    fn parse_simple_command(&mut self) -> Option<Node> {
519        let CommandParts {
520            mut assignments,
521            words,
522            redirects,
523        } = self.parse_command_parts();
524
525        if words.is_empty() {
526            if assignments.is_empty() {
527                return None;
528            }
529            // Bare `NAME=value` statements are variable declarations.
530            return Some(if assignments.len() == 1 {
531                assignments.remove(0)
532            } else {
533                Node::new("block", assignments)
534            });
535        }
536
537        let name = words[0].text.clone().unwrap_or_default();
538        let rest: Vec<Node> = words[1..].to_vec();
539
540        match name.as_str() {
541            "export" | "local" | "readonly" => return Some(build_declaration(&name, rest)),
542            "set" if !rest.is_empty() => return Some(Node::new("set-option", rest)),
543            "exit" => return Some(Node::new("exit", rest)),
544            "return" => return Some(Node::new("return", rest)),
545            "source" | "." => return Some(Node::new("source", rest)),
546            _ => {}
547        }
548
549        let mut children = vec![
550            Node::new("word-list", words),
551            Node::new("redirect-list", redirects),
552        ];
553        if !assignments.is_empty() {
554            // Prefixed assignments (`FOO=bar cmd`) scope an environment
555            // variable to one command; keep them attached so a rule can
556            // render them.
557            children.push(Node::new("assignment-list", assignments));
558        }
559        Some(Node::new("command", children))
560    }
561}
562
563fn build_declaration(keyword: &str, words: Vec<Node>) -> Node {
564    let term = if keyword == "export" {
565        "export"
566    } else {
567        "local"
568    };
569    let mut declarations: Vec<Node> = words
570        .into_iter()
571        .map(|word| {
572            let text = word.text.unwrap_or_default();
573            match match_assignment(&text) {
574                Some((name, value)) => Node::with_text(term, vec![Node::leaf("word", value)], name),
575                None => Node::with_text(term, vec![Node::leaf("word", "")], text),
576            }
577        })
578        .collect();
579
580    if declarations.len() == 1 {
581        declarations.remove(0)
582    } else {
583        Node::new("block", declarations)
584    }
585}
586
587/// Parses a shell script into a plain node tree rooted at a `script` node.
588pub fn parse_shell_script(source: &str) -> Node {
589    ShellParser::new(source).parse_script()
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    fn terms(node: &Node) -> Vec<String> {
597        node.children
598            .iter()
599            .map(|child| child.term.clone())
600            .collect()
601    }
602
603    #[test]
604    fn parses_a_pipeline_into_stages() {
605        let script = parse_shell_script("ls | grep test | wc -l");
606        assert_eq!(script.term, "script");
607        assert_eq!(script.children[0].term, "pipeline");
608        assert_eq!(script.children[0].children.len(), 3);
609    }
610
611    #[test]
612    fn parses_and_or_left_associatively() {
613        let script = parse_shell_script("a && b || c");
614        assert_eq!(script.children[0].term, "or");
615        assert_eq!(script.children[0].children[0].term, "and");
616    }
617
618    #[test]
619    fn parses_control_flow_functions_and_case() {
620        let script = parse_shell_script(
621            "if [ -f x ]; then echo a; else echo b; fi\n\
622             while true; do echo loop; done\n\
623             for i in 1 2; do echo $i; done\n\
624             greet() { echo hi; }\n\
625             case \"$1\" in start) run ;; *) usage ;; esac\n",
626        );
627        assert_eq!(
628            terms(&script),
629            vec!["if", "while", "for", "function", "case"]
630        );
631    }
632
633    #[test]
634    fn reserved_words_stay_arguments_outside_command_position() {
635        let script = parse_shell_script("echo done | tr a-z A-Z");
636        let pipeline = &script.children[0];
637        assert_eq!(pipeline.term, "pipeline");
638        let words = &pipeline.children[0].children[0];
639        assert_eq!(words.children.len(), 2);
640    }
641}