pub mod ast;
use crate::error::{self, ParseErrorKind, ShellError};
use crate::lexer::Lexer;
use crate::lexer::token::{Span, SpannedToken, Token};
use ast::{
AndOrList, AndOrOp, Assignment, CaseItem, CaseTerminator, Command, CompleteCommand,
CompoundCommand, CompoundCommandKind, FunctionDef, HereDoc, Pipeline, Program, Redirect,
RedirectKind, SeparatorOp, SimpleCommand, Word, WordPart,
};
use std::rc::Rc;
pub struct Parser {
lexer: Lexer,
current: SpannedToken,
pre_current_pos: usize,
}
impl Parser {
pub fn new(input: &str) -> Self {
let mut lexer = Lexer::new(input);
let current = lexer.next_token().unwrap_or(SpannedToken {
token: Token::Eof,
span: Span::default(),
});
Self {
lexer,
current,
pre_current_pos: 0,
}
}
pub fn new_with_aliases(input: &str, aliases: &crate::env::aliases::AliasStore) -> Self {
let mut lexer = Lexer::new_with_aliases(input, aliases);
let current = lexer.next_token().unwrap_or(SpannedToken {
token: Token::Eof,
span: Span::default(),
});
Self {
lexer,
current,
pre_current_pos: 0,
}
}
pub fn new_with_aliases_at_line(
input: &str,
aliases: &crate::env::aliases::AliasStore,
start_line: usize,
) -> Self {
let mut lexer = Lexer::new_with_aliases_at_line(input, aliases, start_line);
let current = lexer.next_token().unwrap_or(SpannedToken {
token: Token::Eof,
span: Span::default(),
});
Self {
lexer,
current,
pre_current_pos: 0,
}
}
pub fn consumed_bytes(&self) -> usize {
self.pre_current_pos
}
#[allow(dead_code)]
pub fn current_token(&self) -> &Token {
&self.current.token
}
pub fn current_span(&self) -> Span {
self.current.span
}
pub fn advance(&mut self) -> error::Result<()> {
self.pre_current_pos = self.lexer.position();
self.current = self.lexer.next_token()?;
Ok(())
}
pub fn eat(&mut self, expected: &Token) -> error::Result<bool> {
if self.current.token == *expected {
self.advance()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn expect_reserved(&mut self, keyword: &str) -> error::Result<()> {
if self.current.token.matches_keyword(keyword) {
self.advance()?;
Ok(())
} else {
let span = self.current_span();
Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
format!("expected '{}', got unexpected token", keyword),
))
}
}
pub fn skip_newlines(&mut self) -> error::Result<()> {
while self.current.token == Token::Newline {
self.advance()?;
if self.lexer.has_pending_heredocs() {
self.lexer.process_pending_heredocs()?;
}
}
Ok(())
}
pub fn is_at_end(&self) -> bool {
self.current.token == Token::Eof
}
pub fn is_reserved(&self, keyword: &str) -> bool {
self.current.token.matches_keyword(keyword)
}
pub fn parse_program(&mut self) -> error::Result<Program> {
self.skip_newlines()?;
let mut commands = Vec::new();
while !self.is_at_end() {
let cmd = self.parse_complete_command()?;
commands.push(cmd);
self.skip_newlines()?;
}
Ok(Program { commands })
}
pub fn parse_complete_command(&mut self) -> error::Result<CompleteCommand> {
let mut items = Vec::new();
let first_aol = self.parse_and_or()?;
let was_newline = self.current.token == Token::Newline;
let sep = self.parse_separator_op()?;
let ended = sep.is_none() || was_newline;
items.push((first_aol, sep));
if !ended {
loop {
if self.is_at_end() || self.is_complete_command_end() {
break;
}
if self.current.token == Token::Newline {
break;
}
let aol = self.parse_and_or()?;
let was_newline = self.current.token == Token::Newline;
let sep = self.parse_separator_op()?;
let ended = sep.is_none() || was_newline;
items.push((aol, sep));
if ended {
break;
}
}
}
Ok(CompleteCommand { items })
}
pub fn parse_separator_op(&mut self) -> error::Result<Option<SeparatorOp>> {
match self.current.token {
Token::Semi => {
self.advance()?;
Ok(Some(SeparatorOp::Semi))
}
Token::Amp => {
self.advance()?;
Ok(Some(SeparatorOp::Amp))
}
Token::Newline => {
self.advance()?;
if self.lexer.has_pending_heredocs() {
self.lexer.process_pending_heredocs()?;
}
Ok(Some(SeparatorOp::Semi))
}
_ => Ok(None),
}
}
pub fn parse_and_or(&mut self) -> error::Result<AndOrList> {
let first = self.parse_pipeline()?;
let mut rest = Vec::new();
loop {
let op = match &self.current.token {
Token::AndIf => AndOrOp::And,
Token::OrIf => AndOrOp::Or,
_ => break,
};
self.advance()?;
self.skip_newlines()?;
let pipeline = self.parse_pipeline()?;
rest.push((op, pipeline));
}
Ok(AndOrList { first, rest })
}
pub fn parse_pipeline(&mut self) -> error::Result<Pipeline> {
let negated = if self.is_reserved("!") {
self.advance()?;
true
} else {
false
};
let mut commands = Vec::new();
commands.push(self.parse_command()?);
while self.current.token == Token::Pipe {
self.advance()?;
self.skip_newlines()?;
commands.push(self.parse_command()?);
}
for cmd in &mut commands {
match cmd {
Command::Simple(simple) => {
self.fill_heredoc_bodies(&mut simple.redirects);
}
Command::Compound(_, redirects) => {
self.fill_heredoc_bodies(redirects);
}
Command::FunctionDef(_) => {}
}
}
Ok(Pipeline { negated, commands })
}
pub fn parse_command(&mut self) -> error::Result<Command> {
if self.is_compound_command_start() {
let compound = self.parse_compound_command()?;
let redirects = self.parse_redirect_list()?;
return Ok(Command::Compound(compound, redirects));
}
if let Some(func_def) = self.try_parse_function_def()? {
return Ok(Command::FunctionDef(func_def));
}
let simple = self.parse_simple_command()?;
Ok(Command::Simple(simple))
}
pub fn parse_simple_command(&mut self) -> error::Result<SimpleCommand> {
let line = self.current.span.line;
let mut assignments = Vec::new();
let mut words = Vec::new();
let mut redirects = Vec::new();
loop {
if let Some(redirect) = self.try_parse_redirect()? {
redirects.push(redirect);
continue;
}
if let Token::Word(word) = &self.current.token.clone() {
let word = word.clone();
if words.is_empty()
&& let Some(assignment) = Self::try_parse_assignment(&word)
{
self.advance()?;
assignments.push(assignment);
continue;
}
self.advance()?;
words.push(word);
continue;
}
if self.current.token == Token::Newline && self.lexer.has_pending_heredocs() {
self.lexer.process_pending_heredocs()?;
}
break;
}
if assignments.is_empty()
&& words.is_empty()
&& redirects.is_empty()
&& !matches!(self.current.token, Token::Newline | Token::Eof)
{
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
"syntax error: unexpected token at start of command",
));
}
Ok(SimpleCommand {
assignments,
words,
redirects,
line,
})
}
pub fn try_parse_assignment(word: &Word) -> Option<Assignment> {
use ast::WordPart;
if word.parts.is_empty() {
return None;
}
let first_part_text = match &word.parts[0] {
WordPart::Literal(s) => s.clone(),
_ => return None,
};
let eq_pos = first_part_text.find('=')?;
let name = &first_part_text[..eq_pos];
if !is_valid_name(name) {
return None;
}
let after_eq = &first_part_text[eq_pos + 1..];
let remaining_parts = &word.parts[1..];
if after_eq.is_empty() && remaining_parts.is_empty() {
return Some(Assignment {
name: name.to_string(),
value: None,
});
}
let mut value_parts = Vec::new();
let mut at_boundary = true;
if !after_eq.is_empty() {
let (parts, ends_colon) = split_tildes_in_literal(after_eq, at_boundary);
value_parts.extend(parts);
at_boundary = ends_colon;
}
for part in remaining_parts {
match part {
WordPart::Literal(s) => {
let (parts, ends_colon) = split_tildes_in_literal(s, at_boundary);
value_parts.extend(parts);
at_boundary = ends_colon;
}
other => {
value_parts.push(other.clone());
at_boundary = false;
}
}
}
Some(Assignment {
name: name.to_string(),
value: Some(Word { parts: value_parts }),
})
}
pub fn is_complete_command_end(&self) -> bool {
match &self.current.token {
Token::Eof => true,
Token::RParen => true,
Token::Word(_) => {
self.is_reserved("}")
|| self.is_reserved("fi")
|| self.is_reserved("done")
|| self.is_reserved("esac")
|| self.is_reserved("then")
|| self.is_reserved("else")
|| self.is_reserved("elif")
|| self.is_reserved("do")
}
_ => false,
}
}
pub fn is_compound_command_start(&self) -> bool {
match &self.current.token {
Token::LParen => true,
Token::Word(_) => {
self.is_reserved("if")
|| self.is_reserved("for")
|| self.is_reserved("while")
|| self.is_reserved("until")
|| self.is_reserved("case")
|| self.is_reserved("{")
}
_ => false,
}
}
pub fn parse_compound_command(&mut self) -> error::Result<CompoundCommand> {
let line = self.current.span.line;
let kind = if self.is_reserved("if") {
self.parse_if_clause()?
} else if self.is_reserved("for") {
self.parse_for_clause()?
} else if self.is_reserved("while") {
self.parse_while_clause()?
} else if self.is_reserved("until") {
self.parse_until_clause()?
} else if self.is_reserved("case") {
self.parse_case_clause()?
} else if self.is_reserved("{") {
self.parse_brace_group()?
} else if self.current.token == Token::LParen {
self.parse_subshell()?
} else {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
"expected compound command",
));
};
Ok(CompoundCommand { kind, line })
}
pub fn parse_compound_list(&mut self, context: &str) -> error::Result<Vec<CompleteCommand>> {
self.skip_newlines()?;
let mut commands = Vec::new();
while !self.is_at_end() && !self.is_complete_command_end() {
let cmd = self.parse_complete_command()?;
commands.push(cmd);
self.skip_newlines()?;
}
if commands.is_empty() {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
format!("syntax error: empty compound list in {context}"),
));
}
Ok(commands)
}
pub fn parse_if_clause(&mut self) -> error::Result<CompoundCommandKind> {
self.expect_reserved("if")?;
let condition = self.parse_compound_list("'if' condition")?;
self.expect_reserved("then")?;
let then_part = self.parse_compound_list("'then' body")?;
let mut elif_parts = Vec::new();
let mut else_part = None;
loop {
if self.is_reserved("elif") {
self.advance()?;
let elif_cond = self.parse_compound_list("'elif' condition")?;
self.expect_reserved("then")?;
let elif_body = self.parse_compound_list("'elif' body")?;
elif_parts.push((elif_cond, elif_body));
} else if self.is_reserved("else") {
self.advance()?;
else_part = Some(self.parse_compound_list("'else' body")?);
break;
} else {
break;
}
}
self.expect_reserved("fi")?;
Ok(CompoundCommandKind::If {
condition,
then_part,
elif_parts,
else_part,
})
}
pub fn parse_for_clause(&mut self) -> error::Result<CompoundCommandKind> {
self.expect_reserved("for")?;
let var = match &self.current.token.clone() {
Token::Word(word) => {
let name = word.as_literal().ok_or_else(|| {
let span = self.current_span();
ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
"expected valid variable name after 'for'",
)
})?;
if !is_valid_name(name) {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
format!("'{}' is not a valid variable name", name),
));
}
if crate::lexer::reserved::is_posix_reserved_word(name) {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
format!(
"'{}' is a reserved word and cannot be used as a for-loop variable name",
name
),
));
}
let name = name.to_string();
self.advance()?;
name
}
_ => {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
"expected variable name after 'for'",
));
}
};
self.skip_newlines()?;
let words = if self.is_reserved("in") {
self.advance()?;
let mut word_list = Vec::new();
loop {
if self.is_at_end()
|| self.current.token == Token::Semi
|| self.current.token == Token::Newline
|| self.is_reserved("do")
{
break;
}
if let Token::Word(_) = &self.current.token {
let w = self.expect_word("for word list")?;
word_list.push(w);
} else {
break;
}
}
if self.current.token == Token::Semi || self.current.token == Token::Newline {
self.advance()?;
}
Some(word_list)
} else {
if self.current.token == Token::Semi {
self.advance()?;
}
None
};
self.skip_newlines()?;
let body = self.parse_do_group()?;
Ok(CompoundCommandKind::For { var, words, body })
}
pub fn parse_do_group(&mut self) -> error::Result<Vec<CompleteCommand>> {
self.expect_reserved("do")?;
let body = self.parse_compound_list("'do' body")?;
self.expect_reserved("done")?;
Ok(body)
}
pub fn parse_while_clause(&mut self) -> error::Result<CompoundCommandKind> {
self.expect_reserved("while")?;
let condition = self.parse_compound_list("'while' condition")?;
let body = self.parse_do_group()?;
Ok(CompoundCommandKind::While { condition, body })
}
pub fn parse_until_clause(&mut self) -> error::Result<CompoundCommandKind> {
self.expect_reserved("until")?;
let condition = self.parse_compound_list("'until' condition")?;
let body = self.parse_do_group()?;
Ok(CompoundCommandKind::Until { condition, body })
}
pub fn parse_case_clause(&mut self) -> error::Result<CompoundCommandKind> {
self.expect_reserved("case")?;
let word = self.expect_word("case subject")?;
self.skip_newlines()?;
self.expect_reserved("in")?;
self.skip_newlines()?;
let mut items = Vec::new();
while !self.is_at_end() && !self.is_reserved("esac") {
let _ = self.eat(&Token::LParen)?;
let mut patterns = Vec::new();
let first_pattern = self.expect_word("case pattern")?;
patterns.push(first_pattern);
while self.current.token == Token::Pipe {
self.advance()?;
let pat = self.expect_word("case pattern")?;
patterns.push(pat);
}
if !self.eat(&Token::RParen)? {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
"expected ')' after case pattern",
));
}
self.skip_newlines()?;
let mut body = Vec::new();
while !self.is_at_end()
&& self.current.token != Token::DSemi
&& self.current.token != Token::SemiAnd
&& !self.is_reserved("esac")
{
let cmd = self.parse_complete_command()?;
body.push(cmd);
self.skip_newlines()?;
}
let terminator = if self.current.token == Token::SemiAnd {
self.advance()?;
CaseTerminator::FallThrough
} else if self.current.token == Token::DSemi {
self.advance()?;
CaseTerminator::Break
} else {
CaseTerminator::Break
};
self.skip_newlines()?;
items.push(CaseItem {
patterns,
body,
terminator,
});
}
self.expect_reserved("esac")?;
Ok(CompoundCommandKind::Case { word, items })
}
pub fn parse_brace_group(&mut self) -> error::Result<CompoundCommandKind> {
self.expect_reserved("{")?;
let body = self.parse_compound_list("brace group")?;
self.expect_reserved("}")?;
Ok(CompoundCommandKind::BraceGroup { body })
}
pub fn parse_subshell(&mut self) -> error::Result<CompoundCommandKind> {
self.eat(&Token::LParen)?;
let body = self.parse_compound_list("subshell")?;
if !self.eat(&Token::RParen)? {
let span = self.current_span();
return Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
"expected ')' to close subshell",
));
}
Ok(CompoundCommandKind::Subshell { body })
}
pub fn try_parse_function_def(&mut self) -> error::Result<Option<FunctionDef>> {
let name = match &self.current.token {
Token::Word(word) => {
if let Some(lit) = word.as_literal() {
if is_valid_name(lit) {
lit.to_string()
} else {
return Ok(None);
}
} else {
return Ok(None);
}
}
_ => return Ok(None),
};
let saved_lexer_state = self.lexer.save_state();
let saved_current = self.current.clone();
self.advance()?;
if self.current.token != Token::LParen {
self.lexer.restore_state(saved_lexer_state);
self.current = saved_current;
return Ok(None);
}
self.advance()?;
if self.current.token != Token::RParen {
self.lexer.restore_state(saved_lexer_state);
self.current = saved_current;
return Ok(None);
}
self.advance()?;
self.skip_newlines()?;
let body = self.parse_compound_command()?;
let redirects = self.parse_redirect_list()?;
Ok(Some(FunctionDef {
name,
body: Rc::new(body),
redirects,
}))
}
pub fn try_parse_redirect(&mut self) -> error::Result<Option<Redirect>> {
let fd = if let Token::IoNumber(n) = &self.current.token {
let n = *n;
self.advance()?;
Some(n)
} else {
None
};
let span = self.current_span();
let kind = match &self.current.token {
Token::Less => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::Input(word)
}
Token::Great => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::Output(word)
}
Token::DGreat => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::Append(word)
}
Token::Clobber => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::OutputClobber(word)
}
Token::LessAnd => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::DupInput(word)
}
Token::GreatAnd => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::DupOutput(word)
}
Token::LessGreat => {
self.advance()?;
let word = self.expect_word("redirect target")?;
RedirectKind::ReadWrite(word)
}
Token::DLess => {
self.advance()?;
let delimiter_word = self.expect_word("here-document delimiter")?;
let (delimiter, quoted) = self.extract_heredoc_delimiter(&delimiter_word);
self.lexer.register_heredoc(delimiter, quoted, false);
RedirectKind::HereDoc(HereDoc {
body: vec![],
strip_tabs: false,
quoted,
})
}
Token::DLessDash => {
self.advance()?;
let delimiter_word = self.expect_word("here-document delimiter")?;
let (delimiter, quoted) = self.extract_heredoc_delimiter(&delimiter_word);
self.lexer.register_heredoc(delimiter, quoted, true);
RedirectKind::HereDoc(HereDoc {
body: vec![],
strip_tabs: true,
quoted,
})
}
_ => {
if fd.is_some() {
return Err(ShellError::parse(
ParseErrorKind::InvalidRedirect,
span.line,
span.column,
"expected redirect operator after IO number",
));
}
return Ok(None);
}
};
Ok(Some(Redirect { fd, kind }))
}
pub fn parse_redirect_list(&mut self) -> error::Result<Vec<Redirect>> {
let mut redirects = Vec::new();
while let Some(redirect) = self.try_parse_redirect()? {
redirects.push(redirect);
}
Ok(redirects)
}
fn extract_heredoc_delimiter(&self, word: &Word) -> (String, bool) {
let mut delimiter = String::new();
let mut quoted = false;
for part in &word.parts {
match part {
WordPart::Literal(s) => delimiter.push_str(s),
WordPart::EscapedLiteral(s) => {
delimiter.push_str(s);
quoted = true;
}
WordPart::SingleQuoted(s) => {
delimiter.push_str(s);
quoted = true;
}
WordPart::DoubleQuoted(parts) => {
quoted = true;
for p in parts {
match p {
WordPart::Literal(s) | WordPart::EscapedLiteral(s) => {
delimiter.push_str(s);
}
_ => {}
}
}
}
WordPart::DollarSingleQuoted(s) => {
delimiter.push_str(s);
quoted = true;
}
_ => {}
}
}
(delimiter, quoted)
}
fn fill_heredoc_bodies(&mut self, redirects: &mut Vec<Redirect>) {
for redir in redirects {
if let RedirectKind::HereDoc(ref mut hd) = redir.kind
&& hd.body.is_empty()
&& let Some(body) = self.lexer.take_heredoc_body()
{
hd.body = body;
}
}
}
pub fn expect_word(&mut self, context: &str) -> error::Result<Word> {
if let Token::Word(word) = &self.current.token.clone() {
let word = word.clone();
self.advance()?;
Ok(word)
} else {
let span = self.current_span();
Err(ShellError::parse(
ParseErrorKind::UnexpectedToken,
span.line,
span.column,
format!("expected word for {}", context),
))
}
}
}
fn is_valid_name(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(crate) fn split_tildes_in_literal(
s: &str,
start_at_boundary: bool,
) -> (Vec<ast::WordPart>, bool) {
use ast::WordPart;
fn is_name_safe(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-'
}
let mut out: Vec<WordPart> = Vec::new();
let push_literal = |out: &mut Vec<WordPart>, s: &str| {
if s.is_empty() {
return;
}
if let Some(WordPart::Literal(last)) = out.last_mut() {
last.push_str(s);
} else {
out.push(WordPart::Literal(s.to_string()));
}
};
for (i, segment) in s.split(':').enumerate() {
if i > 0 {
push_literal(&mut out, ":");
}
let eligible = if i == 0 { start_at_boundary } else { true };
if eligible
&& let Some(rest_after_tilde) = segment.strip_prefix('~')
{
let (user, tail) = match rest_after_tilde.find('/') {
Some(p) => (&rest_after_tilde[..p], &rest_after_tilde[p..]),
None => (rest_after_tilde, ""),
};
if user.is_empty() || user.chars().all(is_name_safe) {
if user.is_empty() {
out.push(WordPart::Tilde(None));
} else {
out.push(WordPart::Tilde(Some(user.to_string())));
}
if !tail.is_empty() {
push_literal(&mut out, tail);
}
continue;
}
}
push_literal(&mut out, segment);
}
(out, s.ends_with(':'))
}
#[cfg(test)]
mod tests {
use super::*;
use ast::{AndOrOp, CaseTerminator, CompoundCommandKind, RedirectKind, SeparatorOp, WordPart};
fn parse(input: &str) -> Program {
let mut parser = Parser::new(input);
parser.parse_program().unwrap()
}
fn parse_first_simple(input: &str) -> SimpleCommand {
let prog = parse(input);
let cmd = &prog.commands[0].items[0].0.first.commands[0];
match cmd {
Command::Simple(sc) => sc.clone(),
_ => panic!("expected simple command"),
}
}
#[test]
fn test_empty_program() {
let prog = parse("");
assert!(prog.commands.is_empty());
}
#[test]
fn test_simple_command() {
let sc = parse_first_simple("echo hello world");
assert_eq!(sc.words.len(), 3);
assert_eq!(sc.words[0].as_literal(), Some("echo"));
assert_eq!(sc.words[1].as_literal(), Some("hello"));
assert_eq!(sc.words[2].as_literal(), Some("world"));
assert!(sc.assignments.is_empty());
assert!(sc.redirects.is_empty());
}
#[test]
fn test_assignment_only() {
let sc = parse_first_simple("FOO=bar");
assert!(sc.words.is_empty());
assert_eq!(sc.assignments.len(), 1);
assert_eq!(sc.assignments[0].name, "FOO");
assert_eq!(
sc.assignments[0].value.as_ref().unwrap().as_literal(),
Some("bar")
);
}
#[test]
fn test_assignment_with_command() {
let sc = parse_first_simple("FOO=bar echo hello");
assert_eq!(sc.assignments.len(), 1);
assert_eq!(sc.words.len(), 2);
}
#[test]
fn test_assignment_empty_value() {
let sc = parse_first_simple("FOO=");
assert_eq!(sc.assignments.len(), 1);
assert_eq!(sc.assignments[0].name, "FOO");
assert_eq!(sc.assignments[0].value, None);
}
#[test]
fn test_multiple_newlines() {
let prog = parse("\n\necho hello\n\n");
assert_eq!(prog.commands.len(), 1);
}
#[test]
fn test_pipeline() {
let prog = parse("echo hello | grep h");
let pipeline = &prog.commands[0].items[0].0.first;
assert_eq!(pipeline.commands.len(), 2);
assert!(!pipeline.negated);
}
#[test]
fn test_negated_pipeline() {
let prog = parse("! echo hello");
let pipeline = &prog.commands[0].items[0].0.first;
assert!(pipeline.negated);
}
#[test]
fn test_and_or_list() {
let prog = parse("true && echo yes || echo no");
let aol = &prog.commands[0].items[0].0;
assert_eq!(aol.rest.len(), 2);
assert_eq!(aol.rest[0].0, AndOrOp::And);
assert_eq!(aol.rest[1].0, AndOrOp::Or);
}
#[test]
fn test_semicolon_list() {
let prog = parse("echo a; echo b; echo c");
assert!(prog.commands[0].items.len() >= 3);
}
#[test]
fn test_async_command() {
let prog = parse("echo hello &");
let sep = &prog.commands[0].items[0].1;
assert_eq!(*sep, Some(SeparatorOp::Amp));
}
#[test]
fn test_output_redirect() {
let sc = parse_first_simple("echo hello > out.txt");
assert_eq!(sc.words.len(), 2);
assert_eq!(sc.redirects.len(), 1);
assert_eq!(sc.redirects[0].fd, None);
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::Output(w) if w.as_literal() == Some("out.txt"))
);
}
#[test]
fn test_input_redirect() {
let sc = parse_first_simple("cat < input.txt");
assert_eq!(sc.redirects.len(), 1);
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::Input(w) if w.as_literal() == Some("input.txt"))
);
}
#[test]
fn test_append_redirect() {
let sc = parse_first_simple("echo hello >> log.txt");
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::Append(w) if w.as_literal() == Some("log.txt"))
);
}
#[test]
fn test_fd_redirect() {
let sc = parse_first_simple("cmd 2>/dev/null");
assert_eq!(sc.redirects[0].fd, Some(2));
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::Output(w) if w.as_literal() == Some("/dev/null"))
);
}
#[test]
fn test_dup_output() {
let sc = parse_first_simple("cmd 2>&1");
assert_eq!(sc.redirects[0].fd, Some(2));
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::DupOutput(w) if w.as_literal() == Some("1"))
);
}
#[test]
fn test_heredoc_redirect() {
let sc = parse_first_simple("cat <<EOF");
assert_eq!(sc.redirects.len(), 1);
assert!(matches!(&sc.redirects[0].kind, RedirectKind::HereDoc(_)));
}
#[test]
fn test_clobber_redirect() {
let sc = parse_first_simple("echo hello >| out.txt");
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::OutputClobber(w) if w.as_literal() == Some("out.txt"))
);
}
#[test]
fn test_read_write_redirect() {
let sc = parse_first_simple("cmd 3<>file");
assert_eq!(sc.redirects[0].fd, Some(3));
assert!(
matches!(&sc.redirects[0].kind, RedirectKind::ReadWrite(w) if w.as_literal() == Some("file"))
);
}
#[test]
fn test_multiple_redirects() {
let sc = parse_first_simple("cmd < in > out 2>&1");
assert_eq!(sc.redirects.len(), 3);
}
fn parse_first_compound(input: &str) -> CompoundCommandKind {
let prog = parse(input);
let cmd = &prog.commands[0].items[0].0.first.commands[0];
match cmd {
Command::Compound(cc, _) => cc.kind.clone(),
_ => panic!("expected compound command"),
}
}
#[test]
fn test_if_then_fi() {
let kind = parse_first_compound("if true; then echo yes; fi");
match kind {
CompoundCommandKind::If {
condition,
then_part,
elif_parts,
else_part,
} => {
assert!(!condition.is_empty());
assert!(!then_part.is_empty());
assert!(elif_parts.is_empty());
assert!(else_part.is_none());
}
_ => panic!("expected if"),
}
}
#[test]
fn test_if_else() {
let kind = parse_first_compound("if true; then echo yes; else echo no; fi");
match kind {
CompoundCommandKind::If { else_part, .. } => assert!(else_part.is_some()),
_ => panic!(),
}
}
#[test]
fn test_if_elif() {
let kind =
parse_first_compound("if false; then echo 1; elif true; then echo 2; else echo 3; fi");
match kind {
CompoundCommandKind::If {
elif_parts,
else_part,
..
} => {
assert_eq!(elif_parts.len(), 1);
assert!(else_part.is_some());
}
_ => panic!(),
}
}
#[test]
fn test_for_loop_with_words() {
let kind = parse_first_compound("for i in a b c; do echo $i; done");
match kind {
CompoundCommandKind::For { var, words, body } => {
assert_eq!(var, "i");
assert_eq!(words.unwrap().len(), 3);
assert!(!body.is_empty());
}
_ => panic!(),
}
}
#[test]
fn test_for_loop_without_in() {
let kind = parse_first_compound("for i; do echo $i; done");
match kind {
CompoundCommandKind::For { var, words, .. } => {
assert_eq!(var, "i");
assert!(words.is_none());
}
_ => panic!(),
}
}
#[test]
fn test_for_loop_with_do_on_newline() {
let kind = parse_first_compound("for i in a b c\ndo\necho $i\ndone");
match kind {
CompoundCommandKind::For { words, .. } => assert!(words.is_some()),
_ => panic!(),
}
}
#[test]
fn test_while_loop() {
let kind = parse_first_compound("while true; do echo loop; done");
assert!(matches!(kind, CompoundCommandKind::While { .. }));
}
#[test]
fn test_until_loop() {
let kind = parse_first_compound("until false; do echo loop; done");
assert!(matches!(kind, CompoundCommandKind::Until { .. }));
}
#[test]
fn test_case_basic() {
let kind = parse_first_compound("case $x in\na) echo a;;\nb) echo b;;\nesac");
match kind {
CompoundCommandKind::Case { items, .. } => {
assert_eq!(items.len(), 2);
assert_eq!(items[0].terminator, CaseTerminator::Break);
}
_ => panic!(),
}
}
#[test]
fn test_case_fallthrough() {
let kind = parse_first_compound("case $x in\na) echo a;&\nb) echo b;;\nesac");
match kind {
CompoundCommandKind::Case { items, .. } => {
assert_eq!(items[0].terminator, CaseTerminator::FallThrough);
assert_eq!(items[1].terminator, CaseTerminator::Break);
}
_ => panic!(),
}
}
#[test]
fn test_case_multiple_patterns() {
let kind = parse_first_compound("case $x in\na|b|c) echo match;;\nesac");
match kind {
CompoundCommandKind::Case { items, .. } => {
assert_eq!(items[0].patterns.len(), 3);
}
_ => panic!(),
}
}
#[test]
fn test_case_empty() {
let kind = parse_first_compound("case $x in\nesac");
match kind {
CompoundCommandKind::Case { items, .. } => assert!(items.is_empty()),
_ => panic!(),
}
}
#[test]
fn test_brace_group() {
let kind = parse_first_compound("{ echo hello; }");
assert!(matches!(kind, CompoundCommandKind::BraceGroup { .. }));
}
#[test]
fn test_subshell() {
let kind = parse_first_compound("(echo hello)");
assert!(matches!(kind, CompoundCommandKind::Subshell { .. }));
}
#[test]
fn test_function_def() {
let prog = parse("myfunc() { echo hello; }");
let cmd = &prog.commands[0].items[0].0.first.commands[0];
match cmd {
Command::FunctionDef(fd) => assert_eq!(fd.name, "myfunc"),
_ => panic!(),
}
}
#[test]
fn test_function_def_with_redirect() {
let prog = parse("myfunc() { echo hello; } > out.txt");
let cmd = &prog.commands[0].items[0].0.first.commands[0];
match cmd {
Command::FunctionDef(fd) => {
assert_eq!(fd.name, "myfunc");
assert_eq!(fd.redirects.len(), 1);
}
_ => panic!(),
}
}
#[test]
fn test_heredoc_body() {
let sc = parse_first_simple("cat <<EOF\nhello world\nEOF");
assert_eq!(sc.redirects.len(), 1);
match &sc.redirects[0].kind {
RedirectKind::HereDoc(hd) => {
assert_eq!(
hd.body,
vec![WordPart::Literal("hello world\n".to_string())]
);
assert!(!hd.strip_tabs);
}
_ => panic!("expected heredoc"),
}
}
#[test]
fn test_heredoc_strip_tabs() {
let sc = parse_first_simple("cat <<-EOF\n\thello\n\tworld\n\tEOF");
match &sc.redirects[0].kind {
RedirectKind::HereDoc(hd) => {
assert!(hd.strip_tabs);
assert_eq!(
hd.body,
vec![WordPart::Literal("hello\nworld\n".to_string())]
);
}
_ => panic!("expected heredoc"),
}
}
#[test]
fn test_heredoc_quoted_delimiter() {
let sc = parse_first_simple("cat <<'EOF'\nhello $name\nEOF");
match &sc.redirects[0].kind {
RedirectKind::HereDoc(hd) => {
assert_eq!(
hd.body,
vec![WordPart::Literal("hello $name\n".to_string())]
);
}
_ => panic!("expected heredoc"),
}
}
#[test]
fn test_heredoc_with_command_after() {
let prog = parse("cat <<EOF\nhello\nEOF\necho done");
assert_eq!(prog.commands.len(), 2);
}
fn lit(s: &str) -> WordPart {
WordPart::Literal(s.to_string())
}
#[test]
fn split_no_tilde_returns_single_literal() {
assert_eq!(split_tildes_in_literal("foo/bar", true).0, vec![lit("foo/bar")]);
}
#[test]
fn split_leading_tilde_only() {
assert_eq!(split_tildes_in_literal("~", true).0, vec![WordPart::Tilde(None)]);
}
#[test]
fn split_leading_tilde_slash() {
assert_eq!(
split_tildes_in_literal("~/bin", true).0,
vec![WordPart::Tilde(None), lit("/bin")]
);
}
#[test]
fn split_leading_tilde_user() {
assert_eq!(
split_tildes_in_literal("~user/bin", true).0,
vec![WordPart::Tilde(Some("user".to_string())), lit("/bin")]
);
}
#[test]
fn split_colon_separated_tildes() {
assert_eq!(
split_tildes_in_literal("~/a:~/b", true).0,
vec![
WordPart::Tilde(None),
lit("/a:"),
WordPart::Tilde(None),
lit("/b"),
]
);
}
#[test]
fn split_middle_segment_with_tilde() {
assert_eq!(
split_tildes_in_literal("/usr:~/bin", true).0,
vec![lit("/usr:"), WordPart::Tilde(None), lit("/bin")]
);
}
#[test]
fn split_trailing_colon() {
assert_eq!(
split_tildes_in_literal("~/a:", true).0,
vec![WordPart::Tilde(None), lit("/a:")]
);
}
#[test]
fn split_leading_colon() {
assert_eq!(
split_tildes_in_literal(":~/a", true).0,
vec![lit(":"), WordPart::Tilde(None), lit("/a")]
);
}
#[test]
fn split_consecutive_colons() {
assert_eq!(
split_tildes_in_literal("::~/a", true).0,
vec![lit("::"), WordPart::Tilde(None), lit("/a")]
);
}
#[test]
fn split_mid_word_tilde_stays_literal() {
assert_eq!(split_tildes_in_literal("foo~/bin", true).0, vec![lit("foo~/bin")]);
}
#[test]
fn split_double_tilde_invalid_user() {
assert_eq!(split_tildes_in_literal("~~/bin", true).0, vec![lit("~~/bin")]);
}
#[test]
fn split_user_name_with_dot_and_dash() {
assert_eq!(
split_tildes_in_literal("~a.b-c/bin", true).0,
vec![WordPart::Tilde(Some("a.b-c".to_string())), lit("/bin")]
);
}
#[test]
fn split_two_tildes_joined_by_colon_no_slash() {
assert_eq!(
split_tildes_in_literal("~:~", true).0,
vec![WordPart::Tilde(None), lit(":"), WordPart::Tilde(None),]
);
}
#[test]
fn split_not_at_boundary_skips_leading_tilde() {
assert_eq!(
split_tildes_in_literal("~/bin", false),
(vec![lit("~/bin")], false)
);
}
#[test]
fn split_not_at_boundary_then_colon_restarts() {
assert_eq!(
split_tildes_in_literal(":~/bin", false),
(vec![lit(":"), WordPart::Tilde(None), lit("/bin")], false)
);
}
#[test]
fn split_returns_ends_with_colon_flag() {
assert_eq!(
split_tildes_in_literal("a:", true),
(vec![lit("a:")], true)
);
}
fn parse_first_assignment(source: &str) -> Option<(String, Vec<WordPart>)> {
let mut parser = Parser::new(source);
let program = parser.parse_program().ok()?;
let cc = program.commands.into_iter().next()?;
let (aol, _) = cc.items.into_iter().next()?;
let cmd = aol.first.commands.into_iter().next()?;
let Command::Simple(sc) = cmd else {
return None;
};
let a = sc.assignments.into_iter().next()?;
let parts = a.value.map(|w| w.parts).unwrap_or_default();
Some((a.name, parts))
}
#[test]
fn assignment_rhs_unquoted_tilde_becomes_tilde_part() {
let (name, parts) = parse_first_assignment("x=~/bin\n").unwrap();
assert_eq!(name, "x");
assert_eq!(parts, vec![WordPart::Tilde(None), lit("/bin")]);
}
#[test]
fn assignment_rhs_multi_colon_tildes() {
let (name, parts) = parse_first_assignment("PATH=~/a:~/b\n").unwrap();
assert_eq!(name, "PATH");
assert_eq!(
parts,
vec![
WordPart::Tilde(None),
lit("/a:"),
WordPart::Tilde(None),
lit("/b"),
]
);
}
#[test]
fn assignment_rhs_backslash_tilde_stays_literal() {
let (_, parts) = parse_first_assignment("x=\\~/bin\n").unwrap();
let has_tilde = parts.iter().any(|p| matches!(p, WordPart::Tilde(_)));
assert!(!has_tilde, "parts = {:?}", parts);
}
#[test]
fn assignment_rhs_single_quoted_tilde_stays_quoted() {
let (_, parts) = parse_first_assignment("x='~'/bin\n").unwrap();
let has_tilde = parts.iter().any(|p| matches!(p, WordPart::Tilde(_)));
assert!(!has_tilde, "parts = {:?}", parts);
}
#[test]
fn assignment_rhs_param_then_tilde_expands_after_colon() {
let (_, parts) = parse_first_assignment("x=$var:~/bin\n").unwrap();
let has_tilde = parts.iter().any(|p| matches!(p, WordPart::Tilde(_)));
assert!(has_tilde, "parts = {:?}", parts);
}
#[test]
fn assignment_rhs_param_then_colon_tilde_expands() {
let (name, parts) = parse_first_assignment("x=$var:~/bin\n").unwrap();
assert_eq!(name, "x");
use ast::ParamExpr;
assert_eq!(
parts,
vec![
WordPart::Parameter(ParamExpr::Simple("var".to_string())),
lit(":"),
WordPart::Tilde(None),
lit("/bin"),
]
);
}
#[test]
fn assignment_rhs_param_then_tilde_no_colon_stays_literal() {
let (name, parts) = parse_first_assignment("x=$var~/bin\n").unwrap();
assert_eq!(name, "x");
use ast::ParamExpr;
assert_eq!(
parts,
vec![
WordPart::Parameter(ParamExpr::Simple("var".to_string())),
lit("~/bin"),
]
);
}
#[test]
fn assignment_rhs_backslash_tilde_after_colon_stays_literal() {
let (_, parts) = parse_first_assignment("x=foo:\\~/bin\n").unwrap();
let has_tilde = parts.iter().any(|p| matches!(p, WordPart::Tilde(_)));
assert!(!has_tilde, "parts = {:?}", parts);
}
#[test]
fn assignment_rhs_param_then_escaped_tilde_stays_literal() {
let (_, parts) = parse_first_assignment("x=$var:\\~/bin\n").unwrap();
let has_tilde = parts.iter().any(|p| matches!(p, WordPart::Tilde(_)));
assert!(!has_tilde, "parts = {:?}", parts);
}
#[test]
fn assignment_rhs_line_continuation_tilde_expands() {
let (_, parts) = parse_first_assignment("x=foo:\\\n~/bin\n").unwrap();
let has_tilde = parts.iter().any(|p| matches!(p, WordPart::Tilde(_)));
assert!(has_tilde, "parts = {:?}", parts);
}
fn parse_err(source: &str) -> ShellError {
Parser::new(source).parse_program().unwrap_err()
}
fn parse_ok(source: &str) {
Parser::new(source)
.parse_program()
.unwrap_or_else(|e| panic!("expected OK, got: {e}"));
}
#[test]
fn empty_if_then_errors() {
let err = parse_err("if true; then fi\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("syntax"), "message: {s}");
assert!(s.contains("'then' body"), "message: {s}");
}
#[test]
fn empty_if_condition_errors() {
let err = parse_err("if then true; fi\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("syntax"), "message: {s}");
assert!(s.contains("'if' condition"), "message: {s}");
}
#[test]
fn empty_elif_condition_errors() {
let err = parse_err("if true; then :; elif then :; fi\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'elif' condition"), "message: {s}");
}
#[test]
fn empty_elif_body_errors() {
let err = parse_err("if true; then :; elif true; then fi\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'elif' body"), "message: {s}");
}
#[test]
fn empty_else_body_errors() {
let err = parse_err("if true; then :; else fi\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'else' body"), "message: {s}");
}
#[test]
fn empty_while_condition_errors() {
let err = parse_err("while do done\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'while' condition"), "message: {s}");
}
#[test]
fn empty_while_body_errors() {
let err = parse_err("while true; do done\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'do' body"), "message: {s}");
}
#[test]
fn empty_until_condition_errors() {
let err = parse_err("until do done\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'until' condition"), "message: {s}");
}
#[test]
fn empty_until_body_errors() {
let err = parse_err("until false; do done\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'do' body"), "message: {s}");
}
#[test]
fn empty_for_body_errors() {
let err = parse_err("for i in a b; do done\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("'do' body"), "message: {s}");
}
#[test]
fn empty_brace_group_errors() {
let err = parse_err("{ }\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("brace group"), "message: {s}");
}
#[test]
fn empty_subshell_errors() {
let err = parse_err("( )\n");
assert_eq!(err.exit_code(), 2);
let s = err.to_string();
assert!(s.contains("subshell"), "message: {s}");
}
#[test]
fn nonempty_if_parses_ok() {
parse_ok("if true; then :; fi\n");
}
#[test]
fn case_empty_body_still_parses_ok() {
parse_ok("case x in pat) ;; esac\n");
}
#[test]
fn comment_only_body_errors_per_posix() {
let err = parse_err("if true; then\n#only comment\nfi\n");
assert_eq!(err.exit_code(), 2);
assert!(err.to_string().contains("'then' body"));
}
fn first_simple_cmd(source: &str) -> ast::SimpleCommand {
let program = Parser::new(source).parse_program().unwrap();
let cc = program.commands.into_iter().next().unwrap();
let (aol, _) = cc.items.into_iter().next().unwrap();
let cmd = aol.first.commands.into_iter().next().unwrap();
match cmd {
Command::Simple(s) => s,
_ => panic!("expected simple command"),
}
}
fn first_compound_cmd(source: &str) -> ast::CompoundCommand {
let program = Parser::new(source).parse_program().unwrap();
let cc = program.commands.into_iter().next().unwrap();
let (aol, _) = cc.items.into_iter().next().unwrap();
let cmd = aol.first.commands.into_iter().next().unwrap();
match cmd {
Command::Compound(c, _) => c,
_ => panic!("expected compound command"),
}
}
#[test]
fn parse_simple_command_captures_line() {
let cmd = first_simple_cmd("echo hi\n");
assert_eq!(cmd.line, 1);
}
#[test]
fn parse_simple_command_on_third_line() {
let cmd = first_simple_cmd("\n\necho hi\n");
assert_eq!(cmd.line, 3);
}
#[test]
fn parse_compound_if_captures_line() {
let cmd = first_compound_cmd("if true; then :; fi\n");
assert_eq!(cmd.line, 1);
assert!(matches!(cmd.kind, CompoundCommandKind::If { .. }));
}
#[test]
fn parse_compound_if_on_second_line() {
let cmd = first_compound_cmd("\nif true; then :; fi\n");
assert_eq!(cmd.line, 2);
}
#[test]
fn parse_brace_group_captures_line() {
let cmd = first_compound_cmd("{ :; }\n");
assert_eq!(cmd.line, 1);
assert!(matches!(cmd.kind, CompoundCommandKind::BraceGroup { .. }));
}
#[test]
fn parse_subshell_captures_line() {
let cmd = first_compound_cmd("( :; )\n");
assert_eq!(cmd.line, 1);
assert!(matches!(cmd.kind, CompoundCommandKind::Subshell { .. }));
}
#[test]
fn parse_while_captures_line() {
let cmd = first_compound_cmd("while true; do :; done\n");
assert_eq!(cmd.line, 1);
assert!(matches!(cmd.kind, CompoundCommandKind::While { .. }));
}
#[test]
fn parse_nested_if_then_captures_body_line() {
let outer = first_compound_cmd("if true; then\necho hi\nfi\n");
assert_eq!(outer.line, 1);
if let CompoundCommandKind::If { then_part, .. } = &outer.kind {
let inner_cc = then_part.first().expect("then body non-empty");
let (inner_aol, _) = inner_cc.items.first().expect("inner AOL");
let inner_cmd = inner_aol.first.commands.first().expect("inner cmd");
if let Command::Simple(inner_simple) = inner_cmd {
assert_eq!(inner_simple.line, 2);
} else {
panic!("expected inner simple command");
}
} else {
panic!("expected If kind");
}
}
#[test]
fn parse_for_reserved_word_if_rejected() {
let src = "for if in a; do :; done\n";
let err = Parser::new(src).parse_program().unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("reserved word") || msg.contains("not a valid"),
"expected reserved-word error, got: {}",
msg
);
}
#[test]
fn parse_for_reserved_word_in_rejected() {
let src = "for in in a; do :; done\n";
let err = Parser::new(src).parse_program().unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("reserved word") || msg.contains("not a valid"),
"expected reserved-word error, got: {}",
msg
);
}
#[test]
fn parse_for_valid_name_ok() {
let src = "for i in a b c; do echo $i; done\n";
assert!(
Parser::new(src).parse_program().is_ok(),
"valid for-loop should parse"
);
}
#[test]
fn parse_for_time_word_ok() {
let src = "for time in a; do :; done\n";
assert!(
Parser::new(src).parse_program().is_ok(),
"'for time' should parse because `time` is not in RESERVED_WORDS"
);
}
#[test]
fn parse_program_on_leading_dsemi_errs_not_hangs() {
let mut p = Parser::new(";;");
let err = p
.parse_program()
.expect_err("';;' must not parse as a program");
assert!(
err.message.contains("unexpected token")
|| err.message.contains("syntax error"),
"unexpected message: {}",
err.message
);
}
#[test]
fn parse_program_on_leading_pipe_errs() {
let mut p = Parser::new("|");
assert!(p.parse_program().is_err());
}
#[test]
fn parse_program_on_dsemi_in_then_body_errs_not_hangs() {
let mut p = Parser::new("if true; then\n\n;;\nesac\n");
assert!(p.parse_program().is_err());
}
}