use super::*;
use crate::ast::ParameterOp;
macro_rules! word_string {
($($tt:tt)*) => {
Word::String(crate::ast::StringWord { $($tt)* })
};
}
macro_rules! word_parameter {
($($tt:tt)*) => {
Word::Parameter(crate::ast::ParameterExpansion { $($tt)* })
};
}
macro_rules! word_command {
($($tt:tt)*) => {
Word::Command(crate::ast::CommandSubstitution { $($tt)* })
};
}
macro_rules! word_arithmetic {
($($tt:tt)*) => {
Word::Arithmetic(crate::ast::ArithmeticExpansion { $($tt)* })
};
}
macro_rules! word_list {
($($tt:tt)*) => {
Word::List(crate::ast::WordList { $($tt)* })
};
}
macro_rules! arithm_bin_op {
($($tt:tt)*) => {
crate::ast::ArithmExpr::BinOp(crate::ast::ArithmBinaryExpr { $($tt)* })
};
}
macro_rules! arithm_un_op {
($($tt:tt)*) => {
crate::ast::ArithmExpr::UnOp(crate::ast::ArithmUnaryExpr { $($tt)* })
};
}
macro_rules! arithm_cond {
($($tt:tt)*) => {
crate::ast::ArithmExpr::Cond(crate::ast::ArithmConditionalExpr { $($tt)* })
};
}
macro_rules! arithm_assign {
($($tt:tt)*) => {
crate::ast::ArithmExpr::Assign(crate::ast::ArithmAssignmentExpr { $($tt)* })
};
}
macro_rules! and_or_bin_op {
($($tt:tt)*) => {
AndOrList::BinOp(crate::ast::AndOrBinary { $($tt)* })
};
}
macro_rules! brace_group {
($($tt:tt)*) => {
Command::BraceGroup(crate::ast::CompoundCommand { $($tt)* })
};
}
macro_rules! subshell {
($($tt:tt)*) => {
Command::Subshell(crate::ast::CompoundCommand { $($tt)* })
};
}
fn parse(input: &str) -> Program {
let mut p = Parser::from_string(input);
p.parse_program().expect("parse should succeed")
}
fn first_pipeline(prog: &Program) -> &Pipeline {
assert_eq!(prog.body.len(), 1);
match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => p,
other => panic!("expected Pipeline, got {other:?}"),
}
}
fn first_pipeline_command(prog: &Program) -> &Command {
let p = first_pipeline(prog);
assert!(!p.commands.is_empty(), "expected non-empty pipeline");
&p.commands[0]
}
fn first_simple_command(prog: &Program) -> &SimpleCommand {
match first_pipeline_command(prog) {
Command::Simple(sc) => sc,
other => panic!("expected Simple, got {other:?}"),
}
}
#[test]
fn simple_command_parse() {
let prog = parse("echo hello world");
assert_eq!(prog.body.len(), 1);
let cmd = &prog.body[0].and_or_list;
match cmd {
AndOrList::Pipeline(p) => {
assert_eq!(p.commands.len(), 1);
match &p.commands[0] {
Command::Simple(sc) => {
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
assert_eq!(sc.arguments.len(), 2);
}
other => panic!("expected Simple, got {other:?}"),
}
}
other => panic!("expected Pipeline, got {other:?}"),
}
}
#[test]
fn pipeline_parse() {
let prog = parse("cat file | grep pattern | wc -l");
assert_eq!(first_pipeline(&prog).commands.len(), 3);
}
#[test]
fn and_or_parse() {
let prog = parse("true && echo yes || echo no");
assert_eq!(prog.body.len(), 1);
match &prog.body[0].and_or_list {
and_or_bin_op! {
op: BinOpType::Or,
left,
..
} => {
assert!(matches!(
**left,
and_or_bin_op! {
op: BinOpType::And,
..
}
));
}
other => panic!("expected BinOp Or at top, got {other:?}"),
}
}
#[test]
fn semicolon_list() {
let prog = parse("echo a; echo b; echo c");
assert_eq!(prog.body.len(), 3);
}
#[test]
fn newline_separated() {
let prog = parse("echo a\necho b\necho c\n");
assert_eq!(prog.body.len(), 3);
}
#[test]
fn background() {
let prog = parse("sleep 10 &");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn redirection_out() {
let prog = parse("echo hello > out.txt");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::Great);
}
#[test]
fn redirection_append() {
let prog = parse("echo hello >> out.txt");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::DGreat);
}
#[test]
fn redirection_input() {
let prog = parse("cat < input.txt");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::Less);
}
#[test]
fn fd_redirection() {
let prog = parse("cmd 2>&1");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].io_number, Some(2));
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::GreatAnd);
}
#[test]
fn multi_digit_fd_redirection() {
let prog = parse("cmd 10>out.txt 12<&1");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 2);
assert_eq!(sc.io_redirects[0].io_number, Some(10));
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::Great);
assert_eq!(sc.io_redirects[0].name.as_str().as_deref(), Some("out.txt"));
assert_eq!(sc.io_redirects[1].io_number, Some(12));
assert_eq!(sc.io_redirects[1].op, IoRedirectOp::LessAnd);
assert_eq!(sc.io_redirects[1].name.as_str().as_deref(), Some("1"));
}
#[test]
fn assignment_only() {
let prog = parse("FOO=bar");
let sc = first_simple_command(&prog);
assert!(sc.name.is_none());
assert_eq!(sc.assignments.len(), 1);
assert_eq!(sc.assignments[0].name, "FOO");
}
#[test]
fn assignment_with_command() {
let prog = parse("FOO=bar echo hello");
let sc = first_simple_command(&prog);
assert_eq!(sc.assignments.len(), 1);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
}
#[test]
fn if_clause_parse() {
let prog = parse("if true; then echo yes; fi");
assert!(matches!(first_pipeline_command(&prog), Command::If(_)));
}
#[test]
fn reserved_word_prefix_if_colon_is_simple_command() {
let prog = parse("if: true");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("if:")
);
assert_eq!(sc.arguments.len(), 1);
assert_eq!(sc.arguments[0].as_str().as_deref(), Some("true"));
}
#[test]
fn if_else_parse() {
let prog = parse("if false; then echo no; else echo yes; fi");
match first_pipeline_command(&prog) {
Command::If(ic) => {
assert!(ic.else_part.is_some());
}
other => panic!("expected If, got {other:?}"),
}
}
#[test]
fn while_loop_parse() {
let prog = parse("while true; do echo loop; done");
match first_pipeline_command(&prog) {
Command::Loop(lc) => {
assert_eq!(lc.loop_type, LoopType::While);
}
other => panic!("expected Loop, got {other:?}"),
}
}
#[test]
fn for_loop_parse() {
let prog = parse("for x in a b c; do echo $x; done");
match first_pipeline_command(&prog) {
Command::For(fc) => {
assert_eq!(fc.name, "x");
assert!(fc.has_in);
assert_eq!(fc.word_list.len(), 3);
}
other => panic!("expected For, got {other:?}"),
}
}
#[test]
fn for_loop_keeps_literal_do_in_word_list() {
let prog = parse("for x in do; do echo $x; done");
match first_pipeline_command(&prog) {
Command::For(fc) => {
assert_eq!(fc.word_list.len(), 1);
assert_eq!(fc.word_list[0].as_str().as_deref(), Some("do"));
}
other => panic!("expected For, got {other:?}"),
}
}
#[test]
fn brace_group_parse() {
let prog = parse("{ echo hello; echo world; }");
assert!(matches!(first_pipeline_command(&prog), brace_group! { .. }));
}
#[test]
fn subshell_parse() {
let prog = parse("(echo hello; echo world)");
assert!(matches!(first_pipeline_command(&prog), subshell! { .. }));
}
#[test]
fn function_def_parse() {
let prog = parse("myfunc() { echo hello; }");
match first_pipeline_command(&prog) {
Command::FunctionDef(fd) => {
assert_eq!(fd.name, "myfunc");
}
other => panic!("expected FunctionDef, got {other:?}"),
}
}
#[test]
fn bang_pipeline() {
let prog = parse("! false");
assert!(first_pipeline(&prog).bang);
}
#[test]
fn bang_without_token_boundary_is_simple_command() {
let prog = parse("!false");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("!false")
);
assert!(sc.arguments.is_empty());
}
#[test]
fn empty_program() {
let prog = parse("");
assert!(prog.body.is_empty());
}
#[test]
fn comment_only() {
let prog = parse("# this is a comment\n");
assert!(prog.body.is_empty());
}
#[test]
fn multiline_with_continuation() {
let prog = parse("echo \\\nhello");
assert_eq!(first_simple_command(&prog).arguments.len(), 1);
}
#[test]
fn case_clause_parse() {
let prog = parse("case $x in\na) echo a;;\nb) echo b;;\nesac");
match first_pipeline_command(&prog) {
Command::Case(cc) => {
assert_eq!(cc.items.len(), 2);
}
other => panic!("expected Case, got {other:?}"),
}
}
#[test]
fn here_document_body_stored() {
let prog = parse("cat <<EOF\nhello world\nEOF\n");
println!("prog: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1, "should have one redirect");
let redir = &sc.io_redirects[0];
assert_eq!(redir.op, IoRedirectOp::DLess);
assert!(redir.here_document_expand);
assert!(
!redir.here_document.is_empty(),
"here-document body should be filled; got: {:?}",
redir.here_document
);
assert_eq!(redir.here_document.len(), 1, "should have one body line");
assert_eq!(
redir.here_document[0].as_str().as_deref(),
Some("hello world")
);
}
#[test]
fn backgrounded_brace_group() {
let prog = parse("{ echo a; } &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn backgrounded_subshell() {
let prog = parse("( echo a ) &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn backgrounded_if_clause() {
let prog = parse("if echo a; then echo b; fi &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn backgrounded_for_clause() {
let prog = parse("for x in a b; do echo x; done &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn backgrounded_while_clause() {
let prog = parse("while echo a; do echo b; done &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn backgrounded_until_clause() {
let prog = parse("until echo a; do echo b; done &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn backgrounded_case_clause() {
let prog = parse("case x in a) echo b ;; esac &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn pipeline_with_brace_group() {
let prog = parse("{ echo a; } | echo b\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn pipeline_with_if_clause() {
let prog = parse("if echo a; then echo b; fi | echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn pipeline_with_for_clause() {
let prog = parse("for x in a b; do echo x; done | echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn pipeline_with_while_clause() {
let prog = parse("while echo a; do echo b; done | echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn pipeline_with_case_clause() {
let prog = parse("case x in a) echo b ;; esac | echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn and_or_with_brace_group() {
let prog = parse("{ echo a; } && echo b\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn and_or_with_if_clause() {
let prog = parse("if echo a; then echo b; fi && echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn and_or_with_for_clause() {
let prog = parse("for x in a b; do echo x; done && echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn and_or_with_while_clause() {
let prog = parse("while echo a; do echo b; done && echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn and_or_with_case_clause() {
let prog = parse("case x in a) echo b ;; esac && echo c\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn bang_brace_group() {
let prog = parse("! { echo a; }\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn bang_if_clause() {
let prog = parse("! if echo a; then echo b; fi\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn bang_for_clause() {
let prog = parse("! for x in a b; do echo x; done\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn bang_while_clause() {
let prog = parse("! while echo a; do echo b; done\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_in_if_condition() {
let prog = parse("if echo a &; then echo b; fi\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_in_while_condition() {
let prog = parse("while echo a &; do echo b; done\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_in_brace_body() {
let prog = parse("{ echo a &; echo b; }\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_in_subshell_body() {
let prog = parse("( echo a &; echo b )\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_in_for_body() {
let prog = parse("for x in a b; do echo a &; done\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_in_if_condition_compound_list() {
let prog = parse("if echo a & ; echo b; then echo c; fi\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn for_loop_empty_in_list() {
let prog = parse("for x in; do echo x; done\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn pipeline_two_brace_groups() {
let prog = parse("{ echo a; } | { echo b; }\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn backgrounded_compound_pipeline() {
let prog = parse("{ echo a; } | echo b &\n");
assert_eq!(prog.body.len(), 1);
assert!(prog.body[0].ampersand);
}
#[test]
fn and_or_chain_of_brace_groups() {
let prog = parse("{ echo a; } && { echo b; } || { echo c; }\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn bang_brace_group_in_pipeline() {
let prog = parse("! { echo a; } | echo b\n");
assert_eq!(prog.body.len(), 1);
}
#[test]
fn ampersand_semi_sequence() {
let prog = parse("echo a &; echo b\n");
assert_eq!(prog.body.len(), 2);
}
fn assert_round_trips(input: &str) {
let prog = parse(input);
let canonical = prog.to_canonical();
let reparsed = parse(&canonical);
let prog_dbg = format!("{:#?}", strip_positions(&prog));
let reparsed_dbg = format!("{:#?}", strip_positions(&reparsed));
assert_eq!(
prog_dbg, reparsed_dbg,
"round-trip failed.\ninput: {input:?}\ncanonical: {canonical:?}"
);
}
fn strip_positions(prog: &Program) -> Program {
let mut p = prog.clone();
strip_program(&mut p);
p
}
fn strip_program(prog: &mut Program) {
prog.range = Range::default();
for cl in &mut prog.body {
strip_command_list(cl);
}
}
fn strip_command_list(cl: &mut CommandList) {
cl.range = Range::default();
strip_and_or(&mut cl.and_or_list);
}
fn strip_and_or(aol: &mut AndOrList) {
match aol {
AndOrList::Pipeline(p) => {
p.range = Range::default();
for c in &mut p.commands {
strip_command(c);
}
}
and_or_bin_op! {
left,
right,
range,
..
} => {
*range = Range::default();
strip_and_or(left);
strip_and_or(right);
}
}
}
fn strip_command(c: &mut Command) {
match c {
Command::Simple(sc) => {
sc.range = Range::default();
if let Some(n) = &mut sc.name {
strip_word(n);
}
for w in &mut sc.arguments {
strip_word(w);
}
for r in &mut sc.io_redirects {
r.range = Range::default();
strip_word(&mut r.name);
for w in &mut r.here_document {
strip_word(w);
}
}
for a in &mut sc.assignments {
a.range = Range::default();
strip_word(&mut a.value);
}
}
brace_group! {
body,
io_redirects,
range,
..
}
| subshell! {
body,
io_redirects,
range,
..
} => {
*range = Range::default();
for cl in body {
strip_command_list(cl);
}
for r in io_redirects {
r.range = Range::default();
strip_word(&mut r.name);
}
}
Command::If(ic) => {
ic.range = Range::default();
for cl in &mut ic.condition {
strip_command_list(cl);
}
for cl in &mut ic.body {
strip_command_list(cl);
}
if let Some(ep) = &mut ic.else_part {
strip_else(ep.as_mut());
}
}
Command::For(fc) => {
fc.range = Range::default();
for w in &mut fc.word_list {
strip_word(w);
}
for cl in &mut fc.body {
strip_command_list(cl);
}
}
Command::Loop(lc) => {
lc.range = Range::default();
for cl in &mut lc.condition {
strip_command_list(cl);
}
for cl in &mut lc.body {
strip_command_list(cl);
}
}
Command::Case(cc) => {
cc.range = Range::default();
strip_word(&mut cc.word);
for item in &mut cc.items {
item.range = Range::default();
for p in &mut item.patterns {
strip_word(p);
}
for cl in &mut item.body {
strip_command_list(cl);
}
}
}
Command::FunctionDef(fd) => {
fd.range = Range::default();
strip_command(fd.body.as_mut());
for r in &mut fd.io_redirects {
r.range = Range::default();
strip_word(&mut r.name);
}
}
}
}
fn strip_else(ep: &mut ElsePart) {
match ep {
ElsePart::Elif(ic) => {
ic.range = Range::default();
for cl in &mut ic.condition {
strip_command_list(cl);
}
for cl in &mut ic.body {
strip_command_list(cl);
}
if let Some(nested) = &mut ic.else_part {
strip_else(nested.as_mut());
}
}
ElsePart::Else(body) => {
body.range = Range::default();
for cl in body.body_mut() {
strip_command_list(cl);
}
}
}
}
fn strip_word(w: &mut Word) {
match w {
word_string! { source, range, .. } => {
*source = None;
*range = Range::default();
}
word_parameter! {
dollar_pos,
brace_end,
arg,
range,
..
} => {
*range = Range::default();
*dollar_pos = Default::default();
*brace_end = None;
if let Some(a) = arg {
strip_word(a.as_mut());
}
}
word_command! {
source,
range,
back_quoted,
program,
..
} => {
*source = None;
*range = Range::default();
*back_quoted = false;
strip_program(program);
}
word_arithmetic! { body, range } => {
*range = Range::default();
strip_arithm(body);
}
word_list! {
children, range, ..
} => {
*range = Range::default();
for c in children {
strip_word(c);
}
}
}
}
fn strip_arithm(e: &mut crate::ast::ArithmExpr) {
match e {
crate::ast::ArithmExpr::Literal(literal) => {
literal.range = Range::default();
}
crate::ast::ArithmExpr::Variable(variable) => {
variable.range = Range::default();
}
crate::ast::ArithmExpr::Raw(raw) => {
raw.range = Range::default();
}
arithm_bin_op! { left, right, range, .. } => {
*range = Range::default();
strip_arithm(left);
strip_arithm(right);
}
arithm_un_op! { operand, range, .. } => {
*range = Range::default();
strip_arithm(operand);
}
arithm_cond! {
cond,
then_branch,
else_branch,
range,
..
} => {
*range = Range::default();
strip_arithm(cond);
strip_arithm(then_branch);
strip_arithm(else_branch);
}
arithm_assign! { value, range, .. } => {
*range = Range::default();
strip_arithm(value);
}
}
}
#[test]
fn round_trip_simple_command() {
assert_round_trips("echo hello world\n");
}
#[test]
fn round_trip_pipeline() {
assert_round_trips("echo a | echo b\n");
}
#[test]
fn round_trip_and_or() {
assert_round_trips("echo a && echo b || echo c\n");
}
#[test]
fn round_trip_background() {
assert_round_trips("echo a &\n");
}
#[test]
fn round_trip_semicolon_list() {
assert_round_trips("echo a; echo b\n");
}
#[test]
fn round_trip_brace_group() {
assert_round_trips("{ echo a; }\n");
}
#[test]
fn round_trip_subshell() {
assert_round_trips("( echo a )\n");
}
#[test]
fn round_trip_if_clause() {
assert_round_trips("if echo a; then echo b; fi\n");
}
#[test]
fn round_trip_if_else() {
assert_round_trips("if echo a; then echo b; else echo c; fi\n");
}
#[test]
fn round_trip_if_elif_else() {
assert_round_trips("if echo a; then echo b; elif echo c; then echo d; else echo e; fi\n");
}
#[test]
fn round_trip_for() {
assert_round_trips("for x in a b c; do echo x; done\n");
}
#[test]
fn round_trip_for_no_in() {
assert_round_trips("for x; do echo x; done\n");
}
#[test]
fn round_trip_while() {
assert_round_trips("while echo a; do echo b; done\n");
}
#[test]
fn round_trip_until() {
assert_round_trips("until echo a; do echo b; done\n");
}
#[test]
fn round_trip_case() {
assert_round_trips("case x in a) echo a ;; b) echo b ;; esac\n");
}
#[test]
fn round_trip_case_wildcard_pattern() {
assert_round_trips("case x in *) echo ok ;; esac\n");
}
#[test]
fn round_trip_bang_pipeline() {
assert_round_trips("! echo a\n");
}
#[test]
fn round_trip_assignment() {
assert_round_trips("FOO=bar\n");
}
#[test]
fn round_trip_assignment_with_command() {
assert_round_trips("FOO=bar echo hello\n");
}
#[test]
fn round_trip_redirect() {
assert_round_trips("echo hello > file\n");
}
#[test]
fn round_trip_parameter_expansion() {
assert_round_trips("echo ${VAR:-default}\n");
}
#[test]
fn round_trip_parameter_pattern_expansion() {
assert_round_trips("echo ${PATH##*/}\n");
}
#[test]
fn round_trip_arithmetic_expansion() {
assert_round_trips("echo $((1 + 2))\n");
}
#[test]
fn round_trip_backgrounded_brace_group() {
assert_round_trips("{ echo a; } &\n");
}
#[test]
fn round_trip_backgrounded_compound_in_list() {
assert_round_trips("{ echo a; } & ; echo b\n");
}
#[test]
fn round_trip_compound_pipeline() {
assert_round_trips("{ echo a; } | { echo b; }\n");
}
#[test]
fn round_trip_backgrounded_in_condition() {
assert_round_trips("if echo a &; then echo b; fi\n");
}
#[test]
fn round_trip_case_empty_body() {
assert_round_trips("case x in a) ;; esac\n");
}
#[test]
fn round_trip_for_empty_in_list() {
assert_round_trips("for x in; do echo x; done\n");
}
#[test]
fn round_trip_fd_dup_close() {
assert_round_trips("cmd 0<&-\n");
}
#[test]
fn round_trip_fd_dup_digit() {
assert_round_trips("cmd 2>&1\n");
}
#[test]
fn round_trip_fd_dup_close_out() {
assert_round_trips("cmd 1>&-\n");
}
#[test]
fn round_trip_fd_dup_close_space() {
let prog = parse("cmd >& -\n");
println!("parsed >& -: {prog:#?}");
let canon = prog.to_canonical();
println!("canonical: {canon:?}");
assert_round_trips("cmd >& -\n");
}
#[test]
fn round_trip_fd_dup_close_space_in() {
assert_round_trips("cmd <& -\n");
}
#[test]
fn round_trip_fd_dup_with_io_number_space() {
assert_round_trips("cmd 0<& -\n");
}
#[test]
fn round_trip_fd_dup_with_io_number_and_space() {
assert_round_trips("cmd 2>& -\n");
}
#[test]
fn round_trip_compound_command_with_fd_close_and_or() {
assert_round_trips("cmd 0<&- && echo b\n");
}
#[test]
fn round_trip_compound_command_with_fd_close_space_and_or() {
assert_round_trips("cmd 0<& - && echo b\n");
}
#[test]
fn round_trip_single_quoted_word() {
assert_round_trips("echo 'hello world'\n");
}
#[test]
fn round_trip_double_quoted_word() {
assert_round_trips("echo \"hello $VAR world\"\n");
}
#[test]
fn round_trip_composite_word() {
assert_round_trips("echo foo${VAR}bar\n");
}
#[test]
fn round_trip_bare_glob_word() {
assert_round_trips("echo *\n");
}
#[test]
fn round_trip_bare_question_word() {
assert_round_trips("echo ?\n");
}
#[test]
fn round_trip_bare_bracket_word() {
assert_round_trips("echo [abc]\n");
}
#[test]
fn round_trip_bare_tilde_word() {
assert_round_trips("echo ~\n");
}
#[test]
fn round_trip_brace_group_background_then_and() {
assert_round_trips("{ echo a &; } && echo b\n");
}
#[test]
fn round_trip_bang_if_top_level() {
assert_round_trips("! if echo a; then echo b; fi | echo c\n");
}
#[test]
fn round_trip_two_statements_second_bang_if() {
assert_round_trips("echo a;\n! if echo b; then echo c; fi | echo d\n");
}
#[test]
fn canonical_brace_background_and_debug() {
let prog = parse("{ echo a &; } && echo b\n");
let canonical = prog.to_canonical();
println!("canonical: {:?}", canonical);
let reparsed = parse(&canonical);
println!("reparsed body len: {}", reparsed.body.len());
}
#[test]
fn round_trip_complex_if_brace_background_and() {
assert_round_trips(
"if if echo a; then echo b; fi | { echo c &\n} && for x in a; do echo d; done | echo e \
&\nthen echo f; fi\n",
);
}
#[test]
fn round_trip_nested_if_with_brace_background() {
assert_round_trips(
"if ! if echo a && echo b; then echo c &\n\
elif echo d; then echo e &\n\
else echo f; fi | { echo g &\n\
} && echo h; then echo i; fi\n",
);
}
#[test]
fn round_trip_while_background_in_body() {
assert_round_trips("while echo a &\ndo echo b &\ndone\n");
}
#[test]
fn round_trip_for_background_in_body() {
assert_round_trips("for x; do echo a &\ndone\n");
}
#[test]
fn round_trip_program_canonical_line_sep() {
assert_round_trips("echo a &\n! echo b\n");
}
#[test]
fn round_trip_bang_if_as_second_statement() {
let input = "echo a ;\n! if echo b; then echo c; fi | ( echo d )\n";
let prog = parse(input);
println!("prog body len: {}", prog.body.len());
for (i, cl) in prog.body.iter().enumerate() {
println!(
"body[{i}]: ampersand={}, and_or={:?}",
cl.ampersand,
match &cl.and_or_list {
AndOrList::Pipeline(p) =>
format!("Pipeline(bang={}, cmds={})", p.bang, p.commands.len()),
and_or_bin_op! { op, .. } => format!("BinOp({op:?})"),
}
);
}
let canonical = prog.to_canonical();
println!("canonical: {:?}", canonical);
assert!(
canonical.contains("! if"),
"canonical should contain '! if', got: {canonical}"
);
assert_round_trips(input);
}
#[test]
fn round_trip_shrunk_seed() {
assert_round_trips(
"for a; do for a; do value \"cmd\" 'cmd' 'a' < cmd && VAR=echo ITEM=cmd cmd 'cmd' cmd cmd &\n\
done; done | { case 'a' in cmd | a ) ;; esac && ! COUNT='printf' A=\"loop\" value \"echo${VAR:=echo}printf\" \"echo\" cmd${A#a}cmd | while ! VAR=cmd cmd < cmd && ! ITEM=cmd VAR=loop cmd 'a' | A='cmd' VAR=\"value\" a 'loop' 'cmd' cmd >> 'file' &\n\
do ! three 'loop' < cmd | A=cmd cmd &\n\
done &\n\
} && ! for printf in a; do { ! cmd loop | A='cmd' cmd a && ! a 'echo' < cmd | VAR=printf ITEM=cmd cmd 'echo' value value 0<> 'three'; } | if ITEM=\"three\" cmd cmd 0>| \"printf\" | ITEM='cmd' ITEM=cmd cmd < 'a' &\n\
then A=\"loop\" cmd | echo cmd cmd &\n\
\n\
elif a cmd < cmd && ! cmd 'a'; then ! ITEM=\"loop\" VAR='a' cmd \"cmd\" 'a' 0< 'echo' && ! ITEM=cmd cmd 0>> \"echo\" | cmd echo 'loop' < cmd; fi && ! if ITEM='echo' a a 'cmd' < cmd | cmd && ! A='cmd' A=cmd a printf a cmd | VAR='cmd' cmd; then ! cmd 'echo' < cmd; fi | case cmd in value ) loop | COUNT='path' A=cmd loop && ITEM=cmd A='echo' echo cmd cmd cmd >> 'printf' | VAR='printf' ITEM=\"cmd\" a 'cmd' echo >| cmd &\n\
;; cmd ) VAR=loop cmd cmd printf < cmd | VAR=a A=cmd cmd 'loop' && ! A=cmd printf \"loop\" | A='cmd' COUNT='a' echo cmd loop; ;; esac; done\n",
);
}
#[test]
fn round_trip_while_case_pipe_in_condition() {
assert_round_trips("while case x in a) cmd | cmd & ;; esac; do echo b; done\n");
}
#[test]
fn round_trip_while_case_pipe_complex() {
assert_round_trips(
"while ! case item in bar) cmd 'two' | cmd || cmd | path &\n;; esac | { cmd &\n} && cmd; do echo done; done\n",
);
}
#[test]
fn round_trip_while_case_and_brace_pipe() {
assert_round_trips(
"while case x in a) cmd | cmd & ;; esac | { cmd &\n} && cmd; do echo b; done\n",
);
}
#[test]
fn round_trip_while_case_pipe_background_pattern() {
let input = "while ! case item in \
bar) cmd | cmd || cmd | path & ;; \
esac | { cmd & ; } && cmd ; \
do echo b ; done\n";
let prog = parse(input);
let canonical = prog.to_canonical();
println!("canonical: {:?}", canonical);
assert_round_trips(input);
}
#[test]
fn round_trip_case_pipe_background_in_body() {
assert_round_trips("case x in a) cmd | cmd || cmd | path & ;; esac | cmd\n");
}
#[test]
fn round_trip_while_with_full_third_line() {
let input = "NAME=x item three ok value 1<&0 | while ! case item in \
bar) cmd 'two' | cmd || cmd 1> 'item' | path 'test' 'value' &\n\
;; esac | { cmd &\n\
} && cmd; do cmd | case z in no) cmd | cmd || cmd | path &\n\
;; esac &\ndone\n";
let prog = parse(input);
let canonical = prog.to_canonical();
println!("canonical:\n{}", canonical);
let result = Parser::from_string(&canonical).parse_program();
if let Err(e) = &result {
let lines: Vec<&str> = canonical.lines().collect();
let err_line = e.pos.line as usize;
let err_col = e.pos.column as usize;
if err_line > 0 && err_line <= lines.len() {
let line = lines[err_line - 1];
let start = err_col.saturating_sub(60);
let end = (err_col + 60).min(line.len());
println!("error at {}:{}: {}", err_line, err_col, e.message);
println!("context: ...{}...", &line[start..end]);
}
}
assert_round_trips(input);
}
fn parse_line_ok(input: &str) -> Option<Program> {
let mut p = Parser::from_string(input);
p.parse_line().expect("parse_line should succeed")
}
fn parse_line_err(input: &str) -> crate::parser::ParseError {
let mut p = Parser::from_string(input);
p.parse_line().expect_err("parse_line should fail")
}
#[test]
fn parse_line_empty_input_returns_none() {
let result = parse_line_ok("");
assert!(result.is_none(), "empty input should return None");
}
#[test]
fn parse_line_bare_newline_returns_empty_program() {
let prog = parse_line_ok("\n").expect("bare newline should return Some");
assert!(prog.body.is_empty(), "bare newline should give empty body");
}
#[test]
fn parse_line_single_command() {
let prog = parse_line_ok("echo hello\n").expect("should parse one command");
assert_eq!(prog.body.len(), 1);
let sc = match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => match &p.commands[0] {
Command::Simple(sc) => sc,
other => panic!("expected Simple, got {other:?}"),
},
other => panic!("expected Pipeline, got {other:?}"),
};
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
println!("parse_line_single_command: name={:?}", sc.name);
}
#[test]
fn parse_line_semicolon_list() {
let prog = parse_line_ok("echo a; echo b\n").expect("should parse semicolon list");
assert_eq!(prog.body.len(), 2);
println!("parse_line_semicolon_list: body_len={}", prog.body.len());
}
#[test]
fn parse_line_unterminated_if_gives_error() {
let err = parse_line_err("if true; then echo yes\n");
println!("parse_line_unterminated_if error: {}", err.message);
assert!(
err.message.contains("fi"),
"error should mention 'fi', got: {}",
err.message
);
}
#[test]
fn parse_subshell_body_simple() {
let mut p = Parser::from_string("echo hello; echo world");
let prog = parse_subshell_body(&mut p);
assert_eq!(prog.body.len(), 2);
println!("parse_subshell_body_simple: body_len={}", prog.body.len());
}
#[test]
fn parse_subshell_body_empty() {
let mut p = Parser::from_string("");
let prog = parse_subshell_body(&mut p);
assert!(prog.body.is_empty(), "empty subshell body should be empty");
}
#[test]
fn parse_subshell_body_with_pipeline() {
let mut p = Parser::from_string("echo a | grep b");
let prog = parse_subshell_body(&mut p);
assert_eq!(prog.body.len(), 1);
match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => {
assert_eq!(p.commands.len(), 2, "pipeline should have two commands");
println!(
"parse_subshell_body_with_pipeline: cmd_count={}",
p.commands.len()
);
}
other => panic!("expected Pipeline, got {other:?}"),
}
}
fn parse_err(input: &str) -> crate::parser::ParseError {
let mut p = Parser::from_string(input);
p.parse_program().expect_err("should produce a parse error")
}
#[test]
fn error_unterminated_if() {
let err = parse_err("if true; then echo yes");
println!("error_unterminated_if: {}", err.message);
assert!(
err.message.contains("fi"),
"should mention 'fi', got: {}",
err.message
);
}
#[test]
fn error_missing_then() {
let err = parse_err("if true; echo yes; fi");
println!("error_missing_then: {}", err.message);
assert!(
err.message.contains("then"),
"should mention 'then', got: {}",
err.message
);
assert_eq!(err.range.begin.line, 1);
assert!(err.range.begin.column > 0);
assert!(err.range.end.offset > err.range.begin.offset);
}
#[test]
fn error_function_definition_without_compound_body() {
let err = parse_err("foo()bar");
assert!(
err.message.contains("compound command"),
"should mention missing function body, got: {}",
err.message
);
}
#[test]
fn error_unterminated_while() {
let err = parse_err("while true; do echo loop");
println!("error_unterminated_while: {}", err.message);
assert!(
err.message.contains("done"),
"should mention 'done', got: {}",
err.message
);
}
#[test]
fn error_missing_do_in_for() {
let err = parse_err("for x in a b c; echo x; done");
println!("error_missing_do_in_for: {}", err.message);
assert!(
err.message.contains("do"),
"should mention 'do', got: {}",
err.message
);
}
#[test]
fn error_unterminated_case() {
let err = parse_err("case $x in\na) echo a;;");
println!("error_unterminated_case: {}", err.message);
assert!(
err.message.contains("esac"),
"should mention 'esac', got: {}",
err.message
);
}
#[test]
fn error_unterminated_subshell() {
let err = parse_err("(echo hello");
println!("error_unterminated_subshell: {}", err.message);
assert!(
err.message.contains(")"),
"should mention ')', got: {}",
err.message
);
}
#[test]
fn error_unterminated_brace_group() {
let err = parse_err("{ echo hello;");
println!("error_unterminated_brace_group: {}", err.message);
assert!(
err.message.contains("}"),
"should mention '}}', got: {}",
err.message
);
}
#[test]
fn error_punctuation_suffixed_brace_is_not_brace_group() {
let err = parse_err("{foo;}");
assert!(
err.message.contains("unexpected token `}'"),
"should report unexpected closing brace, got: {}",
err.message
);
}
#[test]
fn error_pipe_missing_command() {
let err = parse_err("echo hello |");
println!("error_pipe_missing_command: {}", err.message);
assert!(
err.message.contains("command") || err.message.contains("expected"),
"should mention missing command, got: {}",
err.message
);
}
#[test]
fn error_redirect_great_missing_operand() {
let err = parse_err("echo >");
assert!(
err.message.contains("redirection operator `>`"),
"should mention missing redirect operand, got: {}",
err.message
);
}
#[test]
fn error_redirect_less_missing_operand() {
let err = parse_err("echo <");
assert!(
err.message.contains("redirection operator `<`"),
"should mention missing redirect operand, got: {}",
err.message
);
}
#[test]
fn error_redirect_greatand_missing_operand() {
let err = parse_err("echo >&");
assert!(
err.message.contains("redirection operator `>&`"),
"should mention missing redirect operand, got: {}",
err.message
);
}
#[test]
fn error_redirect_lessand_missing_operand() {
let err = parse_err("echo <&");
assert!(
err.message.contains("redirection operator `<&`"),
"should mention missing redirect operand, got: {}",
err.message
);
}
#[test]
fn error_redirect_dless_missing_operand() {
let err = parse_err("cat <<");
assert!(
err.message.contains("redirection operator `<<`"),
"should mention missing redirect operand, got: {}",
err.message
);
}
#[test]
fn error_redirect_dlessdash_missing_operand() {
let err = parse_err("cat <<-");
assert!(
err.message.contains("redirection operator `<<-`"),
"should mention missing redirect operand, got: {}",
err.message
);
}
#[test]
fn until_loop_parse() {
let prog = parse("until false; do echo loop; done");
match first_pipeline_command(&prog) {
Command::Loop(lc) => {
assert_eq!(lc.loop_type, LoopType::Until);
assert!(!lc.condition.is_empty(), "condition should not be empty");
assert!(!lc.body.is_empty(), "body should not be empty");
println!(
"until_loop_parse: condition_len={}, body_len={}",
lc.condition.len(),
lc.body.len()
);
}
other => panic!("expected Loop, got {other:?}"),
}
}
#[test]
fn case_multiple_patterns() {
let prog = parse("case $x in\na|b|c) echo matched;;\nesac");
match first_pipeline_command(&prog) {
Command::Case(cc) => {
assert_eq!(cc.items.len(), 1);
assert_eq!(cc.items[0].patterns.len(), 3, "should have 3 patterns");
println!(
"case_multiple_patterns: pattern_count={}",
cc.items[0].patterns.len()
);
}
other => panic!("expected Case, got {other:?}"),
}
}
#[test]
fn case_leading_paren() {
let prog = parse("case $x in\n(a) echo a;;\nesac");
match first_pipeline_command(&prog) {
Command::Case(cc) => {
assert_eq!(cc.items.len(), 1);
assert_eq!(
cc.items[0].patterns[0].as_str().as_deref(),
Some("a"),
"pattern should be 'a' even with leading paren"
);
println!(
"case_leading_paren: pattern={:?}",
cc.items[0].patterns[0].as_str().as_deref()
);
}
other => panic!("expected Case, got {other:?}"),
}
}
#[test]
fn case_empty_body_item() {
let prog = parse("case x in\na) ;;\nesac");
match first_pipeline_command(&prog) {
Command::Case(cc) => {
assert_eq!(cc.items.len(), 1);
assert!(
cc.items[0].body.is_empty(),
"empty case body should be empty"
);
println!("case_empty_body_item: body_len={}", cc.items[0].body.len());
}
other => panic!("expected Case, got {other:?}"),
}
}
#[test]
fn case_multiple_items() {
let prog = parse("case $x in\na) echo a;;\nb) echo b;;\nc) echo c;;\nesac");
match first_pipeline_command(&prog) {
Command::Case(cc) => {
assert_eq!(cc.items.len(), 3, "should have 3 case items");
println!("case_multiple_items: item_count={}", cc.items.len());
}
other => panic!("expected Case, got {other:?}"),
}
}
#[test]
fn here_document_strip_tabs() {
let prog = parse("cat <<-EOF\n\thello world\n\tline two\nEOF\n");
println!("here_document_strip_tabs: {prog:#?}");
let sc = first_simple_command(&prog);
let redir = &sc.io_redirects[0];
assert_eq!(redir.op, IoRedirectOp::DLessDash);
assert!(redir.here_document_expand);
assert_eq!(redir.here_document.len(), 2, "should have two body lines");
assert_eq!(
redir.here_document[0].as_str().as_deref(),
Some("hello world")
);
assert_eq!(redir.here_document[1].as_str().as_deref(), Some("line two"));
}
#[test]
fn here_document_multiple() {
let prog = parse("cat <<EOF1 <<EOF2\nfirst body\nEOF1\nsecond body\nEOF2\n");
println!("here_document_multiple: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(
sc.io_redirects.len(),
2,
"should have two here-doc redirects"
);
assert_eq!(sc.io_redirects[0].here_document.len(), 1);
assert_eq!(
sc.io_redirects[0].here_document[0].as_str().as_deref(),
Some("first body")
);
assert_eq!(sc.io_redirects[1].here_document.len(), 1);
assert_eq!(
sc.io_redirects[1].here_document[0].as_str().as_deref(),
Some("second body")
);
}
#[test]
fn here_document_empty_body() {
let prog = parse("cat <<EOF\nEOF\n");
println!("here_document_empty_body: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::DLess);
assert!(
sc.io_redirects[0].here_document.is_empty(),
"empty here-doc body should be empty"
);
}
#[test]
fn here_document_multiline_body() {
let prog = parse("cat <<EOF\nline1\nline2\nline3\nEOF\n");
println!("here_document_multiline_body: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects[0].here_document.len(), 3);
}
#[test]
fn alias_single_word_expansion() {
let mut p = Parser::from_string("ll\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "ll" {
Some("ls -la".to_string())
} else {
None
}
}));
let prog = p.parse_program().expect("alias expansion should succeed");
println!("alias_single_word_expansion: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("ls")
);
assert_eq!(sc.arguments.len(), 1);
assert_eq!(sc.arguments[0].as_str().as_deref(), Some("-la"));
}
#[test]
fn alias_with_arguments() {
let mut p = Parser::from_string("ll /tmp\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "ll" {
Some("ls -la".to_string())
} else {
None
}
}));
let prog = p.parse_program().expect("alias with args should succeed");
println!("alias_with_arguments: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("ls")
);
assert_eq!(sc.arguments.len(), 2);
assert_eq!(sc.arguments[0].as_str().as_deref(), Some("-la"));
assert_eq!(sc.arguments[1].as_str().as_deref(), Some("/tmp"));
}
#[test]
fn alias_no_match_passthrough() {
let mut p = Parser::from_string("echo hello\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "ll" {
Some("ls -la".to_string())
} else {
None
}
}));
let prog = p.parse_program().expect("no alias match should succeed");
println!("alias_no_match_passthrough: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
}
#[test]
fn alias_empty_expansion_uses_original() {
let mut p = Parser::from_string("myalias\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "myalias" {
Some(String::new())
} else {
None
}
}));
let prog = p.parse_program().expect("empty alias should succeed");
println!("alias_empty_expansion_uses_original: {prog:#?}");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("myalias"),
"empty alias expansion should keep original name"
);
}
#[test]
fn alias_expansion_can_be_disabled() {
let mut p = Parser::from_string("ll\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "ll" {
Some("ls -la".to_string())
} else {
None
}
}));
p.set_alias_expansion_enabled(false);
let prog = p
.parse_program()
.expect("parse should succeed with aliases disabled");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("ll")
);
assert!(sc.arguments.is_empty());
}
#[test]
fn alias_can_expand_to_operator_tokens() {
let mut p = Parser::from_string("a\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "a" {
Some("echo left | cat".to_string())
} else {
None
}
}));
let prog = p
.parse_program()
.expect("alias expansion with pipeline should succeed");
match first_pipeline_command(&prog) {
Command::Simple(sc) => {
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
assert_eq!(sc.arguments[0].as_str().as_deref(), Some("left"));
}
other => panic!("expected Simple, got {other:?}"),
}
let first_list = &prog.body[0];
match &first_list.and_or_list {
AndOrList::Pipeline(pipeline) => {
assert_eq!(pipeline.commands.len(), 2);
match &pipeline.commands[1] {
Command::Simple(sc) => {
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("cat")
);
}
other => panic!("expected Simple, got {other:?}"),
}
}
other => panic!("expected Pipeline, got {other:?}"),
}
}
#[test]
fn command_substitution_reparse_keeps_alias_inserted_closing_paren_as_data() {
let mut p = Parser::from_string("myalias arg-two)");
p.set_alias_inserted_rparen_is_data(true);
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "myalias" {
Some("echo )".to_string())
} else {
None
}
}));
let prog = p
.parse_command_substitution_reparse()
.expect("command substitution reparse should succeed");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
assert_eq!(sc.arguments.len(), 2);
assert_eq!(sc.arguments[0].as_str().as_deref(), Some(")"));
assert_eq!(sc.arguments[1].as_str().as_deref(), Some("arg-two"));
}
#[test]
fn alias_expansion_preserves_trailing_redirects() {
let mut p = Parser::from_string("a >out\n");
p.set_alias_func(crate::parser::AliasFn::new(|name| {
if name == "a" {
Some("echo hi".to_string())
} else {
None
}
}));
let prog = p
.parse_program()
.expect("alias expansion with redirect should succeed");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
assert_eq!(sc.arguments.len(), 1);
assert_eq!(sc.arguments[0].as_str().as_deref(), Some("hi"));
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].name.as_str().as_deref(), Some("out"));
}
#[test]
fn function_definitions_can_be_disabled() {
let mut parser = Parser::from_string("myfunc() { echo hello; }\n");
parser.set_function_definitions_enabled(false);
assert!(
parser.parse_program().is_err(),
"function definitions should be rejected when disabled"
);
}
#[test]
fn backquote_subparser_inherits_language_flags() {
let mut parser = Parser::from_string("echo `myfunc() { :; }`\n");
parser.set_function_definitions_enabled(false);
let prog = parser.parse_program().expect("outer program should parse");
let warnings = parser.take_diagnostics();
assert!(
!warnings.is_empty(),
"backquote reparsing should report a nested syntax warning"
);
let sc = first_simple_command(&prog);
let word_command! { program, .. } = &sc.arguments[0] else {
panic!("expected backquoted command substitution");
};
assert!(
program.body.is_empty(),
"disabled function definitions should prevent parsing the nested function"
);
}
#[test]
fn nested_if_in_while() {
let prog = parse("while true; do if false; then echo a; fi; done");
match first_pipeline_command(&prog) {
Command::Loop(lc) => {
assert_eq!(lc.loop_type, LoopType::While);
assert!(!lc.body.is_empty());
let body_cmd = &lc.body[0].and_or_list;
match body_cmd {
AndOrList::Pipeline(p) => {
assert!(matches!(&p.commands[0], Command::If(_)));
println!("nested_if_in_while: body has if clause");
}
other => panic!("expected Pipeline, got {other:?}"),
}
}
other => panic!("expected Loop, got {other:?}"),
}
}
#[test]
fn nested_for_in_if() {
let prog = parse("if true; then for x in a b; do echo $x; done; fi");
match first_pipeline_command(&prog) {
Command::If(ic) => {
assert!(!ic.body.is_empty());
let body_cmd = &ic.body[0].and_or_list;
match body_cmd {
AndOrList::Pipeline(p) => {
assert!(matches!(&p.commands[0], Command::For(_)));
println!("nested_for_in_if: body has for clause");
}
other => panic!("expected Pipeline, got {other:?}"),
}
}
other => panic!("expected If, got {other:?}"),
}
}
#[test]
fn nested_subshell_in_brace_group() {
let prog = parse("{ (echo a; echo b); }");
match first_pipeline_command(&prog) {
brace_group! { body, .. } => {
assert!(!body.is_empty());
let inner = &body[0].and_or_list;
match inner {
AndOrList::Pipeline(p) => {
assert!(matches!(&p.commands[0], subshell! { .. }));
println!("nested_subshell_in_brace_group: body has subshell");
}
other => panic!("expected Pipeline, got {other:?}"),
}
}
other => panic!("expected BraceGroup, got {other:?}"),
}
}
#[test]
fn redirection_clobber() {
let prog = parse("echo hello >| out.txt");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::Clobber);
println!("redirection_clobber: op={:?}", sc.io_redirects[0].op);
}
#[test]
fn redirection_less_great() {
let prog = parse("cmd <> file");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::LessGreat);
println!("redirection_less_great: op={:?}", sc.io_redirects[0].op);
}
#[test]
fn redirection_less_and() {
let prog = parse("cmd <&3");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::LessAnd);
println!("redirection_less_and: op={:?}", sc.io_redirects[0].op);
}
#[test]
fn multiple_redirections() {
let prog = parse("cmd < in.txt > out.txt 2>&1");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 3, "should have three redirections");
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::Less);
assert_eq!(sc.io_redirects[1].op, IoRedirectOp::Great);
assert_eq!(sc.io_redirects[2].op, IoRedirectOp::GreatAnd);
assert_eq!(sc.io_redirects[2].io_number, Some(2));
println!(
"multiple_redirections: redir_count={}",
sc.io_redirects.len()
);
}
#[test]
fn redirection_prefix() {
let prog = parse("< input.txt cat");
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].op, IoRedirectOp::Less);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("cat")
);
println!(
"redirection_prefix: name={:?}",
sc.name.as_ref().and_then(|w| w.as_str()).as_deref()
);
}
#[test]
fn multiple_assignments_only() {
let prog = parse("FOO=bar BAZ=qux");
let sc = first_simple_command(&prog);
assert!(sc.name.is_none());
assert_eq!(sc.assignments.len(), 2);
assert_eq!(sc.assignments[0].name, "FOO");
assert_eq!(sc.assignments[1].name, "BAZ");
println!(
"multiple_assignments_only: assign_count={}",
sc.assignments.len()
);
}
#[test]
fn multiple_assignments_with_command() {
let prog = parse("A=1 B=2 C=3 echo hello");
let sc = first_simple_command(&prog);
assert_eq!(sc.assignments.len(), 3);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
println!(
"multiple_assignments_with_command: assign_count={}",
sc.assignments.len()
);
}
#[test]
fn assignment_empty_value() {
let prog = parse("FOO=");
let sc = first_simple_command(&prog);
assert_eq!(sc.assignments.len(), 1);
assert_eq!(sc.assignments[0].name, "FOO");
assert_eq!(
sc.assignments[0].value.as_str().as_deref(),
Some(""),
"empty assignment value should be empty string"
);
println!(
"assignment_empty_value: value={:?}",
sc.assignments[0].value.as_str().as_deref()
);
}
#[test]
fn function_def_with_spaces_around_parens() {
let prog = parse("myfunc ( ) { echo hello; }");
match first_pipeline_command(&prog) {
Command::FunctionDef(fd) => {
assert_eq!(fd.name, "myfunc");
println!("function_def_with_spaces: name={}", fd.name);
}
other => panic!("expected FunctionDef, got {other:?}"),
}
}
#[test]
fn function_def_with_subshell_body() {
let prog = parse("myfunc() (echo hello)");
match first_pipeline_command(&prog) {
Command::FunctionDef(fd) => {
assert_eq!(fd.name, "myfunc");
assert!(
matches!(*fd.body, subshell! { .. }),
"function body should be a subshell"
);
println!("function_def_with_subshell_body: body is subshell");
}
other => panic!("expected FunctionDef, got {other:?}"),
}
}
#[test]
fn function_def_with_redirect() {
let prog = parse("myfunc() { echo hello; } > /dev/null");
match first_pipeline_command(&prog) {
Command::FunctionDef(fd) => {
assert_eq!(fd.name, "myfunc");
assert_eq!(fd.io_redirects.len(), 1);
assert_eq!(fd.io_redirects[0].op, IoRedirectOp::Great);
println!(
"function_def_with_redirect: redirect_count={}",
fd.io_redirects.len()
);
}
other => panic!("expected FunctionDef, got {other:?}"),
}
}
#[test]
fn if_elif_else_chain() {
let prog =
parse("if a; then echo 1; elif b; then echo 2; elif c; then echo 3; else echo 4; fi");
match first_pipeline_command(&prog) {
Command::If(ic) => {
let ep = ic.else_part.as_ref().expect("should have else_part");
match ep.as_ref() {
ElsePart::Elif(elif1) => {
let ep2 = elif1.else_part.as_ref().expect("should have nested elif");
match ep2.as_ref() {
ElsePart::Elif(elif2) => {
let ep3 = elif2.else_part.as_ref().expect("should have else");
assert!(
matches!(ep3.as_ref(), ElsePart::Else(_)),
"final part should be Else"
);
println!("if_elif_else_chain: 3-level elif chain");
}
other => panic!("expected Elif, got {other:?}"),
}
}
other => panic!("expected Elif, got {other:?}"),
}
}
other => panic!("expected If, got {other:?}"),
}
}
#[test]
fn if_elif_no_else() {
let prog = parse("if a; then echo 1; elif b; then echo 2; fi");
match first_pipeline_command(&prog) {
Command::If(ic) => {
let ep = ic.else_part.as_ref().expect("should have elif");
match ep.as_ref() {
ElsePart::Elif(elif) => {
assert!(elif.else_part.is_none(), "elif should not have else");
println!("if_elif_no_else: elif without else");
}
other => panic!("expected Elif, got {other:?}"),
}
}
other => panic!("expected If, got {other:?}"),
}
}
#[test]
fn for_loop_no_in_keyword() {
let prog = parse("for x do echo $x; done");
match first_pipeline_command(&prog) {
Command::For(fc) => {
assert_eq!(fc.name, "x");
assert!(!fc.has_in, "should not have 'in'");
assert!(fc.word_list.is_empty(), "no in means no word list");
println!(
"for_loop_no_in_keyword: name={}, has_in={}",
fc.name, fc.has_in
);
}
other => panic!("expected For, got {other:?}"),
}
}
#[test]
fn for_loop_no_in_with_semicolon() {
let prog = parse("for x; do echo $x; done");
match first_pipeline_command(&prog) {
Command::For(fc) => {
assert_eq!(fc.name, "x");
assert!(!fc.has_in, "should not have 'in'");
println!("for_loop_no_in_with_semicolon: has_in={}", fc.has_in);
}
other => panic!("expected For, got {other:?}"),
}
}
#[test]
fn pipeline_four_stages() {
let prog = parse("cat file | grep pat | sort | uniq");
let p = first_pipeline(&prog);
assert_eq!(p.commands.len(), 4, "should have four pipeline stages");
println!("pipeline_four_stages: cmd_count={}", p.commands.len());
}
#[test]
fn bang_not_in_pipeline() {
let prog = parse("echo hello");
assert!(!first_pipeline(&prog).bang, "should not be negated");
}
#[test]
fn and_or_chain_three() {
let prog = parse("a && b && c");
assert_eq!(prog.body.len(), 1);
match &prog.body[0].and_or_list {
and_or_bin_op! { op, left, .. } => {
assert_eq!(*op, BinOpType::And);
assert!(matches!(
left.as_ref(),
and_or_bin_op! {
op: BinOpType::And,
..
}
));
println!("and_or_chain_three: left-associated && chain");
}
other => panic!("expected BinOp, got {other:?}"),
}
}
#[test]
fn or_chain() {
let prog = parse("a || b || c");
assert_eq!(prog.body.len(), 1);
match &prog.body[0].and_or_list {
and_or_bin_op! { op, .. } => {
assert_eq!(*op, BinOpType::Or);
println!("or_chain: top-level is Or");
}
other => panic!("expected BinOp, got {other:?}"),
}
}
#[test]
fn whitespace_only_program() {
let prog = parse(" \t \n\n \n");
assert!(prog.body.is_empty());
}
#[test]
fn inline_comment() {
let prog = parse("echo hello # this is a comment\n");
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
assert_eq!(
sc.arguments.len(),
1,
"comment words should not be parsed as arguments"
);
println!("inline_comment: arg_count={}", sc.arguments.len());
}
#[test]
fn multiple_comments_and_blank_lines() {
let prog = parse("# comment 1\n\n# comment 2\necho hello\n# comment 3\n");
assert_eq!(prog.body.len(), 1);
let sc = first_simple_command(&prog);
assert_eq!(
sc.name.as_ref().and_then(|w| w.as_str()).as_deref(),
Some("echo")
);
println!("multiple_comments: body_len={}", prog.body.len());
}
#[test]
fn background_and_foreground_mixed() {
let prog = parse("sleep 1 & echo done; wait");
assert_eq!(prog.body.len(), 3);
assert!(prog.body[0].ampersand, "first should be backgrounded");
assert!(!prog.body[1].ampersand, "second should not be backgrounded");
assert!(!prog.body[2].ampersand, "third should not be backgrounded");
println!(
"background_and_foreground_mixed: body_len={}",
prog.body.len()
);
}
#[test]
fn multiple_backgrounded() {
let prog = parse("cmd1 & cmd2 & cmd3 &");
assert_eq!(prog.body.len(), 3);
for (i, cl) in prog.body.iter().enumerate() {
assert!(cl.ampersand, "command {i} should be backgrounded");
}
println!("multiple_backgrounded: all 3 backgrounded");
}
#[test]
fn brace_group_with_redirect() {
let prog = parse("{ echo hello; } > out.txt");
match first_pipeline_command(&prog) {
brace_group! { io_redirects, .. } => {
assert_eq!(io_redirects.len(), 1);
assert_eq!(io_redirects[0].op, IoRedirectOp::Great);
println!(
"brace_group_with_redirect: redirect_count={}",
io_redirects.len()
);
}
other => panic!("expected BraceGroup, got {other:?}"),
}
}
#[test]
fn subshell_with_redirect() {
let prog = parse("(echo hello) > out.txt");
match first_pipeline_command(&prog) {
subshell! { io_redirects, .. } => {
assert_eq!(io_redirects.len(), 1);
assert_eq!(io_redirects[0].op, IoRedirectOp::Great);
println!(
"subshell_with_redirect: redirect_count={}",
io_redirects.len()
);
}
other => panic!("expected Subshell, got {other:?}"),
}
}
#[test]
fn case_in_pipeline() {
let prog = parse("case x in a) echo a ;; esac | grep a");
let p = first_pipeline(&prog);
assert_eq!(p.commands.len(), 2, "case piped to grep");
assert!(matches!(&p.commands[0], Command::Case(_)));
println!("case_in_pipeline: pipeline cmd_count={}", p.commands.len());
}
#[test]
fn while_in_and_or() {
let prog = parse("while true; do echo a; done && echo b");
assert_eq!(prog.body.len(), 1);
match &prog.body[0].and_or_list {
and_or_bin_op! { op, .. } => {
assert_eq!(*op, BinOpType::And);
println!("while_in_and_or: top-level is And");
}
other => panic!("expected BinOp, got {other:?}"),
}
}
#[test]
fn continuation_in_pipeline() {
let prog = parse("echo a |\\\necho b");
let p = first_pipeline(&prog);
assert_eq!(p.commands.len(), 2, "pipeline should span continuation");
println!("continuation_in_pipeline: cmd_count={}", p.commands.len());
}
#[test]
fn continuation_in_arguments() {
let prog = parse("echo hello\\\nworld");
let sc = first_simple_command(&prog);
assert_eq!(sc.arguments.len(), 1);
println!(
"continuation_in_arguments: arg={:?}",
sc.arguments[0].as_str().as_deref()
);
}
#[test]
fn here_document_quoted_delimiter_literal() {
let prog = parse("cat <<'EOF'\nhello $X\nEOF\n");
println!("prog: {prog:#?}");
match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => match &p.commands[0] {
Command::Simple(sc) => {
let redir = &sc.io_redirects[0];
assert!(!redir.here_document_expand);
assert_eq!(redir.here_document.len(), 1, "should have one body line");
assert_eq!(
redir.here_document[0].as_str().as_deref(),
Some("hello $X"),
"quoted here-doc body should be literal"
);
if let word_string! { single_quoted, .. } = &redir.here_document[0] {
assert!(
!*single_quoted,
"quoted here-doc literal mode should not be smuggled through single_quoted"
);
}
}
other => panic!("expected Simple, got {other:?}"),
},
other => panic!("expected Pipeline, got {other:?}"),
}
}
#[test]
fn here_document_composite_quoted_delimiter_literal() {
let prog = parse("cat <<E\"OF\"\nhello $X\nEOF\n");
match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => match &p.commands[0] {
Command::Simple(sc) => {
let redir = &sc.io_redirects[0];
assert_eq!(redir.name.as_str().as_deref(), Some("EOF"));
assert!(!redir.here_document_expand);
assert_eq!(
redir.here_document[0].as_str().as_deref(),
Some("hello $X"),
"quoted composite here-doc delimiter should disable expansion"
);
if let word_string! { single_quoted, .. } = &redir.here_document[0] {
assert!(
!*single_quoted,
"quoted here-doc literal mode should not be smuggled through single_quoted"
);
}
}
other => panic!("expected Simple, got {other:?}"),
},
other => panic!("expected Pipeline, got {other:?}"),
}
}
#[test]
fn here_document_backslash_quoted_delimiter_literal() {
let prog = parse("cat <<\\EOF\nhello $X\nEOF\n");
match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => match &p.commands[0] {
Command::Simple(sc) => {
let redir = &sc.io_redirects[0];
assert_eq!(redir.name.as_str().as_deref(), Some("EOF"));
assert!(!redir.here_document_expand);
assert_eq!(
redir.here_document[0].as_str().as_deref(),
Some("hello $X"),
"backslash-quoted here-doc delimiter should disable expansion"
);
}
other => panic!("expected Simple, got {other:?}"),
},
other => panic!("expected Pipeline, got {other:?}"),
}
}
#[test]
fn here_document_parameter_syntax_delimiter_is_matched_literally() {
let prog = parse("cat <<$EOF\nhello\n$EOF\n");
match &prog.body[0].and_or_list {
AndOrList::Pipeline(p) => match &p.commands[0] {
Command::Simple(sc) => {
let redir = &sc.io_redirects[0];
match &redir.name {
word_parameter! {
name,
op,
brace_end,
..
} => {
assert_eq!(name, "EOF");
assert_eq!(*op, ParameterOp::None);
assert!(brace_end.is_none());
}
other => panic!("expected parameter delimiter, got {other:?}"),
}
assert!(redir.here_document_expand);
assert_eq!(redir.here_document.len(), 1);
assert_eq!(redir.here_document[0].as_str().as_deref(), Some("hello"));
}
other => panic!("expected Simple, got {other:?}"),
},
other => panic!("expected Pipeline, got {other:?}"),
}
}
#[test]
fn parse_here_document_requires_delimiter() {
let mut parser = Parser::from_string("cat <<EOF\nhello\n");
let err = parser
.parse_program()
.expect_err("missing delimiter should fail");
assert!(err.message.contains("here-document"));
}
#[test]
fn parse_here_document_with_delimiter_is_ok() {
let mut parser = Parser::from_string("cat <<EOF\nhello\nEOF\n");
assert!(parser.parse_program().is_ok());
}
#[test]
fn parse_here_document_with_delimiter_without_trailing_newline_is_ok() {
let mut parser = Parser::from_string("cat <<EOF\nhello\nEOF");
let prog = parser
.parse_program()
.expect("delimiter at EOF should parse");
assert_eq!(prog.body.len(), 1);
let sc = first_simple_command(&prog);
assert_eq!(sc.io_redirects.len(), 1);
assert_eq!(sc.io_redirects[0].here_document.len(), 1);
assert_eq!(
sc.io_redirects[0].here_document[0].as_str().as_deref(),
Some("hello")
);
}