#![cfg(feature = "parser")]
use mxsh::ast::{
AndOrBinary, AndOrList, ArithmExpr, Assignment, CaseClause, CaseItem, Command, CommandList,
CompoundCommand, ElseClause, ElsePart, ForClause, FunctionDefinition, IfClause, IoRedirect,
IoRedirectOp, LoopClause, ParameterOp, Pipeline, Program, Range, SimpleCommand, Word,
};
use mxsh::parser::Parser;
use proptest::prelude::*;
const BODY_ATOMS: &[&str] = &[
"alpha", "beta", "gamma", "delta", "hello", "world", "payload", "line", "body", "text",
];
const DELIM_ATOMS: &[&str] = &["EOF", "TAG", "DONE", "STOP", "HERE"];
const GLOB_PATTERNS: &[&str] = &[
"*", "?", "foo*", "*bar", "a?c", "[ab]", "[!ab]", "[a-z]", "lib[0-9]", "*/", "/*", "*.txt",
];
const QUOTING_ATOMS: &[&str] = &[
"alpha", "beta", "0", "-", "_", ".", "/", "+", " ", " ", "\t", "\n", "#", ";", "&", "|", "(",
")", "{", "}", "<", ">", "$", "`", "\\", "\"", "'", "~", "=", ":",
];
const QUOTING_ATOMS_NO_SINGLE_QUOTE: &[&str] = &[
"alpha", "beta", "0", "-", "_", ".", "/", "+", " ", " ", "\t", "\n", "#", ";", "&", "|", "(",
")", "{", "}", "<", ">", "$", "`", "\\", "\"", "~", "=", ":",
];
const PATTERN_QUOTING_ATOMS: &[&str] = &[
"foo", "bar", "*", "?", "[ab]", "[!0-9]", "[a-z]", " ", " ", "\t", "#", ";", "&", "|", "(",
")", "<", ">", "$", "`", "\\", "\"", "'",
];
const SHELL_NAMES: &[&str] = &[
"f", "g", "item", "name", "value", "worker", "task", "handler",
];
const RESERVED_WORDS_REQUIRING_BOUNDARY: &[&str] = &[
"if", "then", "else", "elif", "fi", "do", "done", "case", "esac", "while", "until", "for", "in",
];
const NON_BOUNDARY_SUFFIXES: &[&str] = &[":", ".", "-", "+"];
fn literal_line() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(BODY_ATOMS), 1..=3)
.prop_map(|parts| parts.into_iter().collect::<Vec<_>>().join(" "))
}
fn body_atom() -> impl Strategy<Value = String> {
prop::sample::select(BODY_ATOMS).prop_map(str::to_string)
}
fn shell_name() -> impl Strategy<Value = String> {
prop::sample::select(SHELL_NAMES).prop_map(str::to_string)
}
fn reserved_word_requiring_boundary() -> impl Strategy<Value = String> {
prop::sample::select(RESERVED_WORDS_REQUIRING_BOUNDARY).prop_map(str::to_string)
}
fn non_boundary_suffix() -> impl Strategy<Value = String> {
prop::sample::select(NON_BOUNDARY_SUFFIXES).prop_map(str::to_string)
}
fn quoted_delimiter() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(DELIM_ATOMS), 1..=2)
.prop_map(|parts| parts.into_iter().collect::<Vec<_>>().join("_"))
}
fn glob_pattern() -> impl Strategy<Value = String> {
prop::sample::select(GLOB_PATTERNS).prop_map(str::to_string)
}
fn hostile_literal() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(QUOTING_ATOMS), 1..=6)
.prop_map(|parts| parts.into_iter().collect::<Vec<_>>().join(""))
}
fn hostile_literal_no_single_quote() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(QUOTING_ATOMS_NO_SINGLE_QUOTE), 1..=6)
.prop_map(|parts| parts.into_iter().collect::<Vec<_>>().join(""))
}
fn hostile_pattern() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(PATTERN_QUOTING_ATOMS), 1..=6)
.prop_map(|parts| parts.into_iter().collect::<Vec<_>>().join(""))
}
fn expansion_sensitive_literal() -> impl Strategy<Value = String> {
prop::sample::select(vec!["*", "?", "[ab]", "~", "~/tmp"]).prop_map(str::to_string)
}
fn parameter_pattern_op() -> impl Strategy<Value = ParameterOp> {
prop::sample::select(vec![
ParameterOp::Percent,
ParameterOp::DoublePercent,
ParameterOp::Hash,
ParameterOp::DoubleHash,
])
}
fn parse_program(script: &str) -> Result<Program, mxsh::parser::ParseError> {
Parser::from_string(script).parse_program()
}
fn string_word(value: &str, single_quoted: bool, split_fields: bool) -> Word {
Word::string(value, single_quoted, split_fields, None, Range::default())
}
fn bare_word(value: &str) -> Word {
string_word(value, false, true)
}
fn single_quoted_word(value: &str) -> Word {
string_word(value, true, false)
}
fn double_quoted_word(value: &str) -> Word {
Word::list(
vec![string_word(value, false, true)],
true,
Range::default(),
)
}
fn simple_command(name: &str, arguments: Vec<Word>, io_redirects: Vec<IoRedirect>) -> Command {
Command::Simple(SimpleCommand::new(
Some(bare_word(name)),
arguments,
io_redirects,
Vec::new(),
))
}
fn command_list(command: Command) -> CommandList {
CommandList::new(
AndOrList::Pipeline(Pipeline::new(vec![command], false, Default::default())),
false,
Default::default(),
)
}
fn literal_heredoc(body: &str) -> IoRedirect {
IoRedirect::new(
None,
IoRedirectOp::DLess,
single_quoted_word("IGNORED"),
vec![string_word(body, false, false)],
false,
)
}
fn normalize_program(program: &Program, strip_heredoc_names: bool) -> Program {
Program::with_range(
program
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
Range::default(),
)
}
fn normalize_command_list(command_list: &CommandList, strip_heredoc_names: bool) -> CommandList {
CommandList::new(
normalize_and_or_list(command_list.and_or_list(), strip_heredoc_names),
command_list.ampersand(),
Range::default(),
)
}
fn normalize_and_or_list(and_or_list: &AndOrList, strip_heredoc_names: bool) -> AndOrList {
match and_or_list {
AndOrList::Pipeline(pipeline) => AndOrList::Pipeline(Pipeline::new(
pipeline
.commands()
.iter()
.map(|command| normalize_command(command, strip_heredoc_names))
.collect(),
pipeline.bang(),
Range::default(),
)),
AndOrList::BinOp(binary) => AndOrList::BinOp(AndOrBinary::with_range(
binary.op(),
normalize_and_or_list(binary.left(), strip_heredoc_names),
normalize_and_or_list(binary.right(), strip_heredoc_names),
Range::default(),
)),
}
}
fn normalize_command(command: &Command, strip_heredoc_names: bool) -> Command {
match command {
Command::Simple(simple) => Command::Simple(SimpleCommand::with_range(
simple.name().map(normalize_word),
simple.arguments().iter().map(normalize_word).collect(),
simple
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
simple
.assignments()
.iter()
.map(normalize_assignment)
.collect(),
Range::default(),
)),
Command::BraceGroup(group) => Command::BraceGroup(CompoundCommand::with_range(
group
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
group
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
Command::Subshell(group) => Command::Subshell(CompoundCommand::with_range(
group
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
group
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
Command::If(if_clause) => Command::If(IfClause::with_range_and_redirects(
if_clause
.condition()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
if_clause
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
if_clause
.else_part()
.map(|else_part| Box::new(normalize_else_part(else_part, strip_heredoc_names))),
if_clause
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
Command::For(for_clause) => Command::For(ForClause::with_range_and_redirects(
for_clause.name(),
for_clause.has_in(),
for_clause.word_list().iter().map(normalize_word).collect(),
for_clause
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
for_clause
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
Command::Loop(loop_clause) => Command::Loop(LoopClause::with_range_and_redirects(
loop_clause.loop_type(),
loop_clause
.condition()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
loop_clause
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
loop_clause
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
Command::Case(case_clause) => Command::Case(CaseClause::with_range_and_redirects(
normalize_word(case_clause.word()),
case_clause
.items()
.iter()
.map(|item| normalize_case_item(item, strip_heredoc_names))
.collect(),
case_clause
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
Command::FunctionDef(function_definition) => {
Command::FunctionDef(FunctionDefinition::with_range(
function_definition.name(),
normalize_command(function_definition.body(), strip_heredoc_names),
function_definition
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
))
}
}
}
fn normalize_assignment(assignment: &Assignment) -> Assignment {
Assignment::new(
assignment.name(),
normalize_word(assignment.value()),
Range::default(),
)
}
fn normalize_case_item(item: &CaseItem, strip_heredoc_names: bool) -> CaseItem {
CaseItem::with_range(
item.patterns().iter().map(normalize_word).collect(),
item.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
Range::default(),
)
}
fn normalize_else_part(else_part: &ElsePart, strip_heredoc_names: bool) -> ElsePart {
match else_part {
ElsePart::Elif(if_clause) => ElsePart::Elif(IfClause::with_range_and_redirects(
if_clause
.condition()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
if_clause
.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
if_clause
.else_part()
.map(|next| Box::new(normalize_else_part(next, strip_heredoc_names))),
if_clause
.io_redirects()
.iter()
.map(|redirect| normalize_redirect(redirect, strip_heredoc_names))
.collect(),
Range::default(),
)),
ElsePart::Else(body) => ElsePart::Else(ElseClause::with_range(
body.body()
.iter()
.map(|command_list| normalize_command_list(command_list, strip_heredoc_names))
.collect(),
Range::default(),
)),
}
}
fn normalize_redirect(redirect: &IoRedirect, strip_heredoc_names: bool) -> IoRedirect {
let name = if strip_heredoc_names
&& matches!(redirect.op(), IoRedirectOp::DLess | IoRedirectOp::DLessDash)
{
single_quoted_word("__HEREDOC_DELIM__")
} else {
normalize_word(redirect.name())
};
IoRedirect::with_range(
redirect.io_number(),
redirect.op(),
name,
redirect
.here_document()
.iter()
.map(normalize_word)
.collect(),
redirect.here_document_expand(),
Range::default(),
)
}
fn normalize_word(word: &Word) -> Word {
match word {
Word::String(string) => Word::string(
string.value(),
string.single_quoted(),
string.split_fields(),
None,
Range::default(),
),
Word::Parameter(parameter) => Word::parameter(
parameter.name(),
parameter.op(),
parameter.colon(),
parameter.arg().map(|arg| Box::new(normalize_word(arg))),
Default::default(),
Default::default(),
Range::default(),
),
Word::Command(command) => Word::command(
normalize_program(command.program(), true),
None,
false,
Range::default(),
),
Word::Arithmetic(arithm) => {
Word::arithmetic(normalize_arithm(arithm.body()), Range::default())
}
Word::List(list) => Word::list(
list.children().iter().map(normalize_word).collect(),
list.double_quoted(),
Range::default(),
),
}
}
fn normalize_arithm(expr: &ArithmExpr) -> ArithmExpr {
match expr {
ArithmExpr::Literal(literal) => ArithmExpr::literal(literal.value(), Range::default()),
ArithmExpr::Variable(variable) => ArithmExpr::variable(variable.name(), Range::default()),
ArithmExpr::Raw(raw) => ArithmExpr::raw(raw.expr(), Range::default()),
ArithmExpr::BinOp(binary) => ArithmExpr::bin_op(
binary.op(),
normalize_arithm(binary.left()),
normalize_arithm(binary.right()),
Range::default(),
),
ArithmExpr::UnOp(unary) => ArithmExpr::un_op(
unary.op(),
normalize_arithm(unary.operand()),
Range::default(),
),
ArithmExpr::Cond(cond) => ArithmExpr::cond(
normalize_arithm(cond.cond()),
normalize_arithm(cond.then_branch()),
normalize_arithm(cond.else_branch()),
Range::default(),
),
ArithmExpr::Assign(assign) => ArithmExpr::assign(
assign.name(),
assign.op(),
normalize_arithm(assign.value()),
Range::default(),
),
}
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 32,
failure_persistence: Some(Box::new(proptest::test_runner::FileFailurePersistence::WithSource("proptest-regressions"))),
.. ProptestConfig::default()
})]
#[test]
fn canonical_command_substitution_with_heredoc_reparses(body in literal_line()) {
let inner = Program::new(vec![command_list(simple_command(
"cat",
Vec::new(),
vec![literal_heredoc(&body)],
))]);
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![Word::command(inner, None, false, Range::default())],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, true);
let actual = normalize_program(&reparsed, true);
prop_assert_eq!(
actual,
expected,
"command substitution containing a here-doc did not round-trip.\ncanonical:\n{}",
canonical
);
}
#[test]
fn as_str_reports_double_quoted_literal_words(value in literal_line()) {
let word = double_quoted_word(&value);
let literal = word.as_str();
prop_assert_eq!(
literal.as_deref(),
Some(value.as_str()),
"double-quoted literal words should still expose their literal string value"
);
}
#[test]
fn parses_double_quoted_heredoc_delimiters(delimiter in quoted_delimiter(), body in literal_line()) {
let script = format!("cat <<\"{delimiter}\"\n{body}\n{delimiter}\n");
let parsed = parse_program(&script)
.unwrap_or_else(|err| panic!("quoted delimiter should parse: {err:?}\nscript:\n{script}"));
let expected = Program::new(vec![CommandList::new(
AndOrList::Pipeline(Pipeline::new(
vec![Command::Simple(SimpleCommand::new(
Some(bare_word("cat")),
Vec::new(),
vec![IoRedirect::new(
None,
IoRedirectOp::DLess,
double_quoted_word(&delimiter),
vec![string_word(&body, false, false)],
false,
)],
Vec::new(),
))],
false,
Default::default(),
)),
false,
Default::default(),
)]);
let expected = normalize_program(&expected, false);
let actual = normalize_program(&parsed, false);
prop_assert_eq!(
actual,
expected,
"double-quoted here-doc delimiter was not preserved.\nscript:\n{}",
script
);
}
#[test]
fn canonical_case_patterns_with_globs_reparse(pattern in glob_pattern()) {
let original = Program::new(vec![command_list(Command::Case(CaseClause::new(
bare_word("subject"),
vec![CaseItem::new(
vec![bare_word(&pattern)],
vec![command_list(simple_command(
"echo",
vec![bare_word("ok")],
Vec::new(),
))],
)],
)))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, false);
let actual = normalize_program(&reparsed, false);
prop_assert_eq!(
actual,
expected,
"case pattern containing glob metacharacters did not round-trip.\ncanonical:\n{}",
canonical
);
}
#[test]
fn canonical_parameter_patterns_with_globs_reparse(
op in parameter_pattern_op(),
pattern in glob_pattern(),
) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![Word::parameter(
"PATH",
op,
false,
Some(Box::new(bare_word(&pattern))),
Default::default(),
Default::default(),
Range::default(),
)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, false);
let actual = normalize_program(&reparsed, false);
prop_assert_eq!(
actual,
expected,
"parameter pattern containing glob metacharacters did not round-trip.\ncanonical:\n{}",
canonical
);
}
#[test]
fn canonical_adversarial_literal_words_are_idempotent(value in hostile_literal()) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![bare_word(&value)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
prop_assert_eq!(
reparsed.to_canonical(),
canonical.as_str(),
"canonical quoting for an adversarial literal word was not stable.\nvalue: {:?}\ncanonical:\n{}",
value,
canonical
);
}
#[test]
fn canonical_programmatic_expansion_sensitive_literals_reparse(
value in expansion_sensitive_literal(),
) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![bare_word(&value)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, false);
let actual = normalize_program(&reparsed, false);
prop_assert_eq!(
actual,
expected,
"programmatic expansion-sensitive literal did not round-trip.\nvalue: {:?}\ncanonical:\n{}",
value,
canonical
);
}
#[test]
fn canonical_adversarial_double_quoted_literals_reparse(value in hostile_literal()) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![double_quoted_word(&value)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, false);
let actual = normalize_program(&reparsed, false);
prop_assert_eq!(
actual,
expected,
"double-quoted literal with hostile characters did not round-trip.\ncanonical:\n{}",
canonical
);
}
#[test]
fn canonical_composite_words_with_embedded_double_quoted_literals_reparse(
value in hostile_literal(),
) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![Word::list(
vec![
bare_word("pre"),
double_quoted_word(&value),
bare_word("post"),
],
false,
Range::default(),
)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, false);
let actual = normalize_program(&reparsed, false);
prop_assert_eq!(
actual,
expected,
"composite word with an embedded double-quoted hostile literal did not round-trip.\ncanonical:\n{}",
canonical
);
}
#[test]
fn canonical_parameter_defaults_with_hostile_double_quoted_words_reparse(
value in hostile_literal(),
) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![Word::parameter(
"VALUE",
ParameterOp::Minus,
true,
Some(Box::new(double_quoted_word(&value))),
Default::default(),
Default::default(),
Range::default(),
)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
let expected = normalize_program(&original, false);
let actual = normalize_program(&reparsed, false);
prop_assert_eq!(
actual,
expected,
"parameter default word with hostile double-quoted content did not round-trip.\ncanonical:\n{}",
canonical
);
}
#[test]
fn canonical_case_patterns_with_adversarial_literals_are_idempotent(
pattern in hostile_pattern(),
) {
let original = Program::new(vec![command_list(Command::Case(CaseClause::new(
bare_word("subject"),
vec![CaseItem::new(
vec![bare_word(&pattern)],
vec![command_list(simple_command(
"echo",
vec![bare_word("ok")],
Vec::new(),
))],
)],
)))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
prop_assert_eq!(
reparsed.to_canonical(),
canonical.as_str(),
"canonical case-pattern quoting was not stable.\npattern: {:?}\ncanonical:\n{}",
pattern,
canonical
);
}
#[test]
fn canonical_parameter_patterns_with_adversarial_literals_are_idempotent(
op in parameter_pattern_op(),
pattern in hostile_pattern(),
) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![Word::parameter(
"PATH",
op,
false,
Some(Box::new(bare_word(&pattern))),
Default::default(),
Default::default(),
Range::default(),
)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
prop_assert_eq!(
reparsed.to_canonical(),
canonical.as_str(),
"canonical parameter-pattern quoting was not stable.\npattern: {:?}\ncanonical:\n{}",
pattern,
canonical
);
}
#[test]
fn canonical_single_quoted_segments_without_embedded_single_quote_reparse(
value in hostile_literal_no_single_quote(),
) {
let original = Program::new(vec![command_list(simple_command(
"echo",
vec![Word::list(
vec![
bare_word("pre"),
single_quoted_word(&value),
bare_word("post"),
],
false,
Range::default(),
)],
Vec::new(),
))]);
let canonical = original.to_canonical();
let reparsed = parse_program(&canonical)
.unwrap_or_else(|err| panic!("canonical parse failed: {err:?}\ncanonical:\n{canonical}"));
prop_assert_eq!(
reparsed.to_canonical(),
canonical.as_str(),
"canonical form for a composite word with an embedded single-quoted hostile literal was not stable.\ncanonical:\n{}",
canonical
);
}
#[test]
fn malformed_function_definition_prefixes_are_rejected(
name in shell_name(),
tail in body_atom(),
) {
let script = format!("{name}(){tail}\n");
let err = parse_program(&script)
.expect_err("malformed function-definition prefix should be rejected");
prop_assert!(
err.message.contains("compound command"),
"malformed function definition should complain about its missing compound body.\nscript:\n{}\nerr:\n{:?}",
script,
err
);
}
#[test]
fn reserved_words_with_non_boundary_suffixes_parse_as_simple_commands(
keyword in reserved_word_requiring_boundary(),
suffix in non_boundary_suffix(),
argument in body_atom(),
) {
let name = format!("{keyword}{suffix}");
let script = format!("{name} {argument}\n");
let parsed = parse_program(&script)
.unwrap_or_else(|err| panic!("punctuation-suffixed reserved word should parse as a simple command: {err:?}\nscript:\n{script}"));
let expected = normalize_program(
&Program::new(vec![command_list(simple_command(
&name,
vec![bare_word(&argument)],
Vec::new(),
))]),
false,
);
let actual = normalize_program(&parsed, false);
prop_assert_eq!(
actual,
expected,
"reserved word with a non-boundary suffix was not parsed as a simple command.\nscript:\n{}",
script
);
}
#[test]
fn bang_prefixed_words_without_a_boundary_parse_as_simple_commands(tail in body_atom()) {
let name = format!("!{tail}");
let script = format!("{name}\n");
let parsed = parse_program(&script)
.unwrap_or_else(|err| panic!("bang-prefixed word should parse as a simple command: {err:?}\nscript:\n{script}"));
let expected = normalize_program(
&Program::new(vec![command_list(simple_command(&name, Vec::new(), Vec::new()))]),
false,
);
let actual = normalize_program(&parsed, false);
prop_assert_eq!(
actual,
expected,
"bang-prefixed word without a separator was not parsed as a simple command.\nscript:\n{}",
script
);
}
#[test]
fn brace_prefixed_words_require_a_separator_before_the_closing_brace(tail in body_atom()) {
let script = format!("{{{tail};}}\n");
let err = parse_program(&script)
.expect_err("brace-prefixed word without a separator should be rejected");
prop_assert!(
err.message.contains("unexpected token `}'"),
"brace-prefixed word should fail on the stray closing brace.\nscript:\n{}\nerr:\n{:?}",
script,
err
);
}
#[test]
fn for_word_lists_preserve_a_literal_do(
name in shell_name(),
prefix in prop::collection::vec(body_atom(), 0..=2),
suffix in prop::collection::vec(body_atom(), 0..=2),
) {
let mut word_list = prefix;
word_list.push("do".to_string());
word_list.extend(suffix);
let script = format!("for {name} in {}; do echo ok; done\n", word_list.join(" "));
let parsed = parse_program(&script)
.unwrap_or_else(|err| panic!("for loop with a literal `do` word should parse: {err:?}\nscript:\n{script}"));
let expected = normalize_program(
&Program::new(vec![command_list(Command::For(ForClause::new(
name.clone(),
true,
word_list.iter().map(|word| bare_word(word)).collect(),
vec![command_list(simple_command(
"echo",
vec![bare_word("ok")],
Vec::new(),
))],
)))]),
false,
);
let actual = normalize_program(&parsed, false);
prop_assert_eq!(
actual,
expected,
"for loop dropped the literal `do` from its word list.\nscript:\n{}",
script
);
}
}