1use 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
16pub fn parse(source: &str) -> Result<Program, ShellParseError> {
18 parse_at(source, 0)
19}
20
21fn 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 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 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 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 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 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 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
300fn 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
312fn 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
323fn 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
352fn 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
384fn 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 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 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 #[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 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}