use super::*;
macro_rules! word_string {
($($tt:tt)*) => {
Word::String(StringWord { $($tt)* })
};
}
macro_rules! word_parameter {
($($tt:tt)*) => {
Word::Parameter(ParameterExpansion {
$($tt)*
range: default_range(),
})
};
}
macro_rules! word_command {
($($tt:tt)*) => {
Word::Command(CommandSubstitution { $($tt)* })
};
}
macro_rules! word_arithmetic {
($($tt:tt)*) => {
Word::Arithmetic(ArithmeticExpansion { $($tt)* })
};
}
macro_rules! word_list {
($($tt:tt)*) => {
Word::List(WordList { $($tt)* })
};
}
macro_rules! simple_command {
($($tt:tt)*) => {
SimpleCommand { $($tt)* range: default_range() }
};
}
macro_rules! program {
(body: $body:expr $(,)?) => {
Program {
body: $body,
range: default_range(),
}
};
}
macro_rules! arithm_bin_op {
($($tt:tt)*) => {
ArithmExpr::BinOp(ArithmBinaryExpr {
$($tt)*
range: default_range(),
})
};
}
macro_rules! arithm_un_op {
($($tt:tt)*) => {
ArithmExpr::UnOp(ArithmUnaryExpr {
$($tt)*
range: default_range(),
})
};
}
macro_rules! arithm_cond {
($($tt:tt)*) => {
ArithmExpr::Cond(ArithmConditionalExpr {
$($tt)*
range: default_range(),
})
};
}
macro_rules! arithm_assign {
($($tt:tt)*) => {
ArithmExpr::Assign(ArithmAssignmentExpr {
$($tt)*
range: default_range(),
})
};
}
macro_rules! io_redirect {
($($tt:tt)*) => {
IoRedirect {
$($tt)*
range: default_range(),
}
};
}
macro_rules! and_or_bin_op {
($($tt:tt)*) => {
AndOrList::BinOp(AndOrBinary {
$($tt)*
range: default_range(),
})
};
}
macro_rules! brace_group {
($($tt:tt)*) => {
Command::BraceGroup(CompoundCommand {
$($tt)*
range: default_range(),
})
};
}
macro_rules! subshell {
($($tt:tt)*) => {
Command::Subshell(CompoundCommand {
$($tt)*
range: default_range(),
})
};
}
macro_rules! case_item {
($($tt:tt)*) => {
CaseItem {
$($tt)*
range: default_range(),
}
};
}
macro_rules! case_clause {
($($tt:tt)*) => {
CaseClause {
$($tt)*
io_redirects: Vec::new(),
range: default_range(),
}
};
}
macro_rules! function_definition {
($($tt:tt)*) => {
FunctionDefinition {
$($tt)*
range: default_range(),
}
};
}
fn default_range() -> Range {
Range::default()
}
fn default_pos() -> Position {
Position::default()
}
fn default_brace_end() -> Option<Position> {
Some(default_pos())
}
fn lit(s: &str) -> Word {
Word::string(s, false, false, None, default_range())
}
fn split_lit(s: &str) -> Word {
Word::string(s, false, true, None, default_range())
}
fn lit_sq(s: &str) -> Word {
Word::string(s, true, false, None, default_range())
}
fn arithm_literal(value: i64) -> ArithmExpr {
ArithmExpr::literal(value, default_range())
}
fn arithm_variable(name: &str) -> ArithmExpr {
ArithmExpr::variable(name, default_range())
}
fn simple_cmd(name: &str) -> Command {
Command::Simple(SimpleCommand::new(
Some(lit(name)),
Vec::new(),
Vec::new(),
Vec::new(),
))
}
fn simple_cmd_with_args(name: &str, args: &[&str]) -> Command {
Command::Simple(SimpleCommand::new(
Some(lit(name)),
args.iter().map(|a| lit(a)).collect(),
Vec::new(),
Vec::new(),
))
}
fn cmd_list(cmd: Command) -> CommandList {
CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![cmd],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
}
}
fn program_from(cmds: Vec<Command>) -> Program {
Program::new(cmds.into_iter().map(cmd_list).collect())
}
#[test]
fn position_default() {
let pos = Position::default();
assert_eq!(pos.offset, 0);
assert_eq!(pos.line, 0);
assert_eq!(pos.column, 0);
}
#[test]
fn position_equality() {
let a = Position {
offset: 10,
line: 2,
column: 5,
};
let b = Position {
offset: 10,
line: 2,
column: 5,
};
let c = Position {
offset: 11,
line: 2,
column: 6,
};
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn range_default() {
let r = Range::default();
assert_eq!(r.begin, Position::default());
assert_eq!(r.end, Position::default());
}
#[test]
fn range_equality() {
let a = Range {
begin: Position {
offset: 0,
line: 1,
column: 1,
},
end: Position {
offset: 5,
line: 1,
column: 6,
},
};
let b = a;
assert_eq!(a, b);
}
#[test]
fn as_str_on_string_word() {
let w = lit("hello");
assert_eq!(w.as_str().as_deref(), Some("hello"));
}
#[test]
fn as_str_on_single_quoted_word() {
let w = lit_sq("world");
assert_eq!(w.as_str().as_deref(), Some("world"));
}
#[test]
fn as_str_on_parameter_word() {
let w = word_parameter! {
name: "HOME".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(w.as_str().as_deref(), None);
}
#[test]
fn as_str_on_list_word() {
let w = word_list! {
children: vec![lit("a"), lit("b")],
double_quoted: false,
range: default_range(),
};
assert_eq!(w.as_str().as_deref(), Some("ab"));
}
#[test]
fn as_str_on_composite_literal_word() {
let w = word_list! {
children: vec![
lit("E"),
word_list! {
children: vec![lit("OF")],
double_quoted: true,
range: default_range(),
},
],
double_quoted: false,
range: default_range(),
};
assert_eq!(w.as_str().as_deref(), Some("EOF"));
}
#[test]
fn as_str_on_arithmetic_word() {
let w = word_arithmetic! {
body: arithm_literal(42),
range: default_range(),
};
assert_eq!(w.as_str().as_deref(), None);
}
#[test]
fn as_str_on_command_word() {
let w = word_command! {
program: program! { body: Vec::new() },
source: None,
back_quoted: false,
range: default_range(),
};
assert_eq!(w.as_str().as_deref(), None);
}
#[test]
fn canonical_simple_command() {
let prog = program_from(vec![simple_cmd("echo")]);
assert_eq!(prog.to_canonical(), "echo\n");
}
#[test]
fn canonical_simple_command_with_args() {
let prog = program_from(vec![simple_cmd_with_args("echo", &["hello", "world"])]);
assert_eq!(prog.to_canonical(), "echo hello world\n");
}
#[test]
fn canonical_multiple_commands() {
let prog = program_from(vec![simple_cmd("echo"), simple_cmd("ls")]);
assert_eq!(prog.to_canonical(), "echo ;\nls\n");
}
#[test]
fn canonical_assignment_only() {
let cmd = Command::Simple(simple_command! {
name: None,
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: vec![Assignment {
name: "FOO".to_string(),
value: lit("bar"),
range: Default::default(),
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "FOO=bar\n");
}
#[test]
fn canonical_assignment_with_command() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cmd")),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: vec![Assignment {
name: "X".to_string(),
value: lit("1"),
range: Default::default(),
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "X=1 cmd\n");
}
#[test]
fn canonical_empty_string_quotes() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![word_string! {
value: String::new(),
single_quoted: false,
split_fields: false,
source: None,
range: default_range(),
}],
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo ''\n");
}
#[test]
fn canonical_single_quoted_value() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![lit_sq("hello world")],
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo 'hello world'\n");
}
#[test]
fn canonical_word_needing_quoting() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![lit("hello world")],
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo 'hello world'\n");
}
#[test]
fn canonical_single_quote_in_value() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![lit("it's")],
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo 'it'\\''s'\n");
}
#[test]
fn canonical_double_quoted_list() {
let word = word_list! {
children: vec![lit("hello"), lit("world")],
double_quoted: true,
range: default_range(),
};
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![word],
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo \"helloworld\"\n");
}
#[test]
fn canonical_double_quoted_with_special_chars() {
let word = word_list! {
children: vec![lit("a$b\\c\"d`e")],
double_quoted: true,
range: default_range(),
};
let result = canonical_word(&word);
assert_eq!(result, "\"a\\$b\\\\c\\\"d\\`e\"");
}
#[test]
fn canonical_safe_chars_unquoted() {
let cases = &["hello", "foo/bar", "file.txt", "a-b", "x_y", "a+b"];
for case in cases {
let result = canonical_word(&lit(case));
assert_eq!(result, *case, "should not quote safe chars: {case}");
}
}
#[test]
fn canonical_unsafe_chars_quoted() {
let cases = &["a b", "x*y", "hello;world", "foo(bar)", "a>b"];
for case in cases {
let result = canonical_word(&lit(case));
assert_eq!(result, format!("'{case}'"), "should quote: {case}");
}
}
#[test]
fn canonical_programmatic_expansion_sensitive_literals_are_escaped_unquoted() {
let cases = &[
("*", "\\*"),
("?", "\\?"),
("[ab]", "\\[ab]"),
("~", "\\~"),
("~/tmp", "\\~/tmp"),
];
for (case, expected) in cases {
assert_eq!(canonical_word(&split_lit(case)), *expected);
}
}
#[test]
fn canonical_parameter_bare() {
let w = word_parameter! {
name: "HOME".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${HOME}");
}
#[test]
fn canonical_parameter_unbraced_name() {
let w = word_parameter! {
name: "HOME".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: None,
};
assert_eq!(canonical_word(&w), "$HOME");
}
#[test]
fn canonical_parameter_unbraced_special() {
let w = word_parameter! {
name: "#".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: None,
};
assert_eq!(canonical_word(&w), "$#");
}
#[test]
fn canonical_parameter_leading_hash() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::LeadingHash,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${#VAR}");
}
#[test]
fn canonical_parameter_default_with_colon() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::Minus,
colon: true,
arg: Some(Box::new(lit("default"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${VAR:-default}");
}
#[test]
fn canonical_parameter_assign() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::Equal,
colon: false,
arg: Some(Box::new(lit("val"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${VAR=val}");
}
#[test]
fn canonical_parameter_error() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::QMark,
colon: true,
arg: Some(Box::new(lit("unset"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${VAR:?unset}");
}
#[test]
fn canonical_parameter_alternative() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::Plus,
colon: true,
arg: Some(Box::new(lit("alt"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${VAR:+alt}");
}
#[test]
fn canonical_parameter_suffix_removal() {
let w = word_parameter! {
name: "FILE".to_string(),
op: ParameterOp::Percent,
colon: false,
arg: Some(Box::new(lit(".txt"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${FILE%.txt}");
}
#[test]
fn canonical_parameter_double_percent() {
let w = word_parameter! {
name: "PATH".to_string(),
op: ParameterOp::DoublePercent,
colon: false,
arg: Some(Box::new(lit("/*"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${PATH%%/*}");
}
#[test]
fn canonical_parameter_prefix_removal() {
let w = word_parameter! {
name: "FILE".to_string(),
op: ParameterOp::Hash,
colon: false,
arg: Some(Box::new(lit("dir/"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${FILE#dir/}");
}
#[test]
fn canonical_parameter_double_hash() {
let w = word_parameter! {
name: "PATH".to_string(),
op: ParameterOp::DoubleHash,
colon: false,
arg: Some(Box::new(lit("*/"))),
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${PATH##*/}");
}
#[test]
fn canonical_parameter_no_arg() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::Minus,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${VAR-}");
}
#[test]
fn canonical_parameter_colon_no_arg() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::Minus,
colon: true,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_word(&w), "${VAR:-}");
}
#[test]
fn canonical_arithm_literal() {
let w = word_arithmetic! {
body: arithm_literal(42),
range: default_range(),
};
assert_eq!(canonical_word(&w), "$((42))");
}
#[test]
fn canonical_arithm_variable() {
let w = word_arithmetic! {
body: arithm_variable("x"),
range: default_range(),
};
assert_eq!(canonical_word(&w), "$((x))");
}
#[test]
fn canonical_arithm_binop() {
let expr = arithm_bin_op! {
op: ArithmBinOp::Add,
left: Box::new(arithm_literal(1)),
right: Box::new(arithm_literal(2)),
};
assert_eq!(canonical_arithm(&expr), "(1 + 2)");
}
#[test]
fn canonical_arithm_all_binops() {
let cases = &[
(ArithmBinOp::Add, "+"),
(ArithmBinOp::Sub, "-"),
(ArithmBinOp::Mul, "*"),
(ArithmBinOp::Div, "/"),
(ArithmBinOp::Mod, "%"),
(ArithmBinOp::Shl, "<<"),
(ArithmBinOp::Shr, ">>"),
(ArithmBinOp::LessThan, "<"),
(ArithmBinOp::LessEq, "<="),
(ArithmBinOp::GreaterThan, ">"),
(ArithmBinOp::GreaterEq, ">="),
(ArithmBinOp::Equal, "=="),
(ArithmBinOp::NotEqual, "!="),
(ArithmBinOp::BitAnd, "&"),
(ArithmBinOp::BitXor, "^"),
(ArithmBinOp::BitOr, "|"),
(ArithmBinOp::LogAnd, "&&"),
(ArithmBinOp::LogOr, "||"),
];
for (op, sym) in cases {
let expr = arithm_bin_op! {
op: *op,
left: Box::new(arithm_literal(3)),
right: Box::new(arithm_literal(4)),
};
assert_eq!(
canonical_arithm(&expr),
format!("(3 {sym} 4)"),
"binop {sym}"
);
}
}
#[test]
fn canonical_arithm_unops() {
let cases = &[
(ArithmUnOp::Plus, "+"),
(ArithmUnOp::Minus, "-"),
(ArithmUnOp::BitNot, "~"),
(ArithmUnOp::LogNot, "!"),
];
for (op, sym) in cases {
let expr = arithm_un_op! {
op: *op,
operand: Box::new(arithm_literal(5)),
};
assert_eq!(canonical_arithm(&expr), format!("({sym}5)"), "unop {sym}");
}
}
#[test]
fn canonical_arithm_ternary() {
let expr = arithm_cond! {
cond: Box::new(arithm_variable("x")),
then_branch: Box::new(arithm_literal(1)),
else_branch: Box::new(arithm_literal(0)),
};
assert_eq!(canonical_arithm(&expr), "(x ? 1 : 0)");
}
#[test]
fn canonical_arithm_all_assign_ops() {
let cases = &[
(ArithmAssignOp::Equal, "="),
(ArithmAssignOp::MulEq, "*="),
(ArithmAssignOp::DivEq, "/="),
(ArithmAssignOp::ModEq, "%="),
(ArithmAssignOp::AddEq, "+="),
(ArithmAssignOp::SubEq, "-="),
(ArithmAssignOp::ShlEq, "<<="),
(ArithmAssignOp::ShrEq, ">>="),
(ArithmAssignOp::AndEq, "&="),
(ArithmAssignOp::XorEq, "^="),
(ArithmAssignOp::OrEq, "|="),
];
for (op, sym) in cases {
let expr = arithm_assign! {
name: "n".to_string(),
op: *op,
value: Box::new(arithm_literal(7)),
};
assert_eq!(
canonical_arithm(&expr),
format!("(n {sym} 7)"),
"assign op {sym}"
);
}
}
#[test]
fn canonical_arithm_nested() {
let expr = arithm_bin_op! {
op: ArithmBinOp::Mul,
left: Box::new(arithm_bin_op! {
op: ArithmBinOp::Add,
left: Box::new(arithm_literal(1)),
right: Box::new(arithm_literal(2)),
}),
right: Box::new(arithm_literal(3)),
};
assert_eq!(canonical_arithm(&expr), "((1 + 2) * 3)");
}
#[test]
fn canonical_command_substitution() {
let inner = program! {
body: vec![cmd_list(simple_cmd("date"))],
};
let w = word_command! {
program: inner,
source: None,
back_quoted: false,
range: default_range(),
};
assert_eq!(canonical_word(&w), "$(date)");
}
#[test]
fn canonical_redirect_input() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Less,
name: lit("file.txt"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "cat < file.txt\n");
}
#[test]
fn canonical_redirect_output() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![lit("hi")],
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out.txt"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo hi > out.txt\n");
}
#[test]
fn canonical_redirect_append() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: vec![lit("more")],
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::DGreat,
name: lit("log.txt"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo more >> log.txt\n");
}
#[test]
fn canonical_redirect_clobber() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("echo")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Clobber,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "echo >| out\n");
}
#[test]
fn canonical_redirect_with_fd() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cmd")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: Some(2),
op: IoRedirectOp::Great,
name: lit("err.log"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "cmd 2> err.log\n");
}
#[test]
fn canonical_redirect_dup_input() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cmd")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: Some(0),
op: IoRedirectOp::LessAnd,
name: lit("3"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "cmd 0<& 3\n");
}
#[test]
fn canonical_redirect_dup_output() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cmd")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: Some(2),
op: IoRedirectOp::GreatAnd,
name: lit("1"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "cmd 2>& 1\n");
}
#[test]
fn canonical_redirect_lessgreat() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cmd")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::LessGreat,
name: lit("file"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "cmd <> file\n");
}
#[test]
fn canonical_all_redirect_ops() {
let cases = &[
(IoRedirectOp::Less, "<"),
(IoRedirectOp::Great, ">"),
(IoRedirectOp::Clobber, ">|"),
(IoRedirectOp::DGreat, ">>"),
(IoRedirectOp::LessAnd, "<&"),
(IoRedirectOp::GreatAnd, ">&"),
(IoRedirectOp::LessGreat, "<>"),
];
for (op, sym) in cases {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cmd")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: *op,
name: lit("target"),
here_document: Vec::new(),
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(
prog.to_canonical(),
format!("cmd {sym} target\n"),
"redirect op {sym}"
);
}
}
#[test]
fn canonical_heredoc_basic() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF"),
here_document: vec![lit("hello world")],
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
let output = prog.to_canonical();
println!("canonical_heredoc_basic: {output:?}");
assert!(output.contains("<<"), "should contain << operator");
assert!(
output.contains("MXSH_HEREDOC_"),
"should use generated delimiter"
);
assert!(output.contains("hello world"), "should contain body text");
}
#[test]
fn canonical_heredoc_strip_tabs() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::DLessDash,
name: lit("EOF"),
here_document: vec![lit("indented line")],
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
let output = prog.to_canonical();
println!("canonical_heredoc_strip_tabs: {output:?}");
assert!(output.contains("<<-"), "should contain <<- operator");
assert!(
output.contains("\tindented line"),
"should have tab before line"
);
}
#[test]
fn canonical_heredoc_expandable_content_uses_unquoted_delimiter() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF"),
here_document: vec![lit("x$y\\z`w")],
here_document_expand: true,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(
prog.to_canonical(),
"cat << MXSH_HEREDOC_0\nx\\$y\\\\z\\`w\nMXSH_HEREDOC_0\n"
);
}
#[test]
fn canonical_heredoc_literal_mode_does_not_infer_from_body_shape() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF"),
here_document: vec![word_list! {
children: vec![
lit("hello "),
word_parameter! {
name: "X".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: None,
},
],
double_quoted: false,
range: default_range(),
}],
here_document_expand: false,
}],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(
prog.to_canonical(),
"cat << 'MXSH_HEREDOC_0'\nhello $X\nMXSH_HEREDOC_0\n"
);
}
#[test]
fn canonical_multiple_heredocs_use_distinct_delimiters() {
let cmd = Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![
io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF1"),
here_document: vec![lit("alpha")],
here_document_expand: false,
},
io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF2"),
here_document: vec![lit("beta")],
here_document_expand: false,
},
],
assignments: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(
prog.to_canonical(),
"cat << 'MXSH_HEREDOC_0' << 'MXSH_HEREDOC_1'\nalpha\nMXSH_HEREDOC_0\n\nbeta\nMXSH_HEREDOC_1\n"
);
}
#[test]
fn canonical_heredoc_on_background_job_separates_next_command() {
let first = CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![Command::Simple(simple_command! {
name: Some(lit("cat")),
arguments: Vec::new(),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF"),
here_document: vec![lit("payload")],
here_document_expand: false,
}],
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
ampersand: true,
range: Default::default(),
};
let second = cmd_list(simple_cmd("echo"));
let prog = program! {
body: vec![first, second],
};
assert_eq!(
prog.to_canonical(),
"cat << 'MXSH_HEREDOC_0' &\npayload\nMXSH_HEREDOC_0\necho\n"
);
}
#[test]
fn canonical_pipeline_simple() {
let cmd = CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("ls"), simple_cmd("grep")],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
};
let prog = program! { body: vec![cmd] };
assert_eq!(prog.to_canonical(), "ls | grep\n");
}
#[test]
fn canonical_pipeline_bang() {
let cmd = CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("test")],
bang: true,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
};
let prog = program! { body: vec![cmd] };
assert_eq!(prog.to_canonical(), "! test\n");
}
#[test]
fn canonical_and_list() {
let aol = and_or_bin_op! {
op: BinOpType::And,
left: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("true")],
bang: false,
range: Default::default(),
})),
right: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("echo")],
bang: false,
range: Default::default(),
})),
};
let cmd = CommandList {
and_or_list: aol,
ampersand: false,
range: Default::default(),
};
let prog = program! { body: vec![cmd] };
assert_eq!(prog.to_canonical(), "true && echo\n");
}
#[test]
fn canonical_or_list() {
let aol = and_or_bin_op! {
op: BinOpType::Or,
left: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("false")],
bang: false,
range: Default::default(),
})),
right: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("echo")],
bang: false,
range: Default::default(),
})),
};
let cmd = CommandList {
and_or_list: aol,
ampersand: false,
range: Default::default(),
};
let prog = program! { body: vec![cmd] };
assert_eq!(prog.to_canonical(), "false || echo\n");
}
#[test]
fn canonical_ampersand() {
let cmd = CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("sleep")],
bang: false,
range: Default::default(),
}),
ampersand: true,
range: Default::default(),
};
let prog = program! { body: vec![cmd] };
assert_eq!(prog.to_canonical(), "sleep &\n");
}
#[test]
fn canonical_ampersand_separator() {
let cl1 = CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("bg")],
bang: false,
range: Default::default(),
}),
ampersand: true,
range: Default::default(),
};
let cl2 = CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("fg")],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
};
let prog = program! {
body: vec![cl1, cl2],
};
let output = prog.to_canonical();
println!("canonical_ampersand_separator: {output:?}");
assert!(!output.contains(';'), "& should separate without needing ;");
}
#[test]
fn canonical_brace_group() {
let cmd = brace_group! {
body: vec![cmd_list(simple_cmd("echo"))],
io_redirects: Vec::new(),
};
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "{ echo; }\n");
}
#[test]
fn canonical_brace_group_with_redirect() {
let cmd = brace_group! {
body: vec![cmd_list(simple_cmd("echo"))],
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
};
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "{ echo; } > out\n");
}
#[test]
fn canonical_subshell() {
let cmd = subshell! {
body: vec![cmd_list(simple_cmd("echo"))],
io_redirects: Vec::new(),
};
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "( echo )\n");
}
#[test]
fn canonical_subshell_with_redirect() {
let cmd = subshell! {
body: vec![cmd_list(simple_cmd("echo"))],
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
};
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "( echo ) > out\n");
}
#[test]
fn canonical_if_then_fi() {
let cmd = Command::If(IfClause::with_range(
vec![cmd_list(simple_cmd("true"))],
vec![cmd_list(simple_cmd("echo"))],
None,
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "if true ; then echo ; fi\n");
}
#[test]
fn canonical_if_with_redirect() {
let cmd = Command::If(IfClause::with_range_and_redirects(
vec![cmd_list(simple_cmd("true"))],
vec![cmd_list(simple_cmd("echo"))],
None,
vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "if true ; then echo ; fi > out\n");
}
#[test]
fn canonical_if_then_else_fi() {
let cmd = Command::If(IfClause::with_range(
vec![cmd_list(simple_cmd("true"))],
vec![cmd_list(simple_cmd("echo"))],
Some(Box::new(ElsePart::Else(ElseClause::with_range(
vec![cmd_list(simple_cmd("false"))],
default_range(),
)))),
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(
prog.to_canonical(),
"if true ; then echo ; else false ; fi\n"
);
}
#[test]
fn canonical_if_elif_else_fi() {
let cmd = Command::If(IfClause::with_range(
vec![cmd_list(simple_cmd("test1"))],
vec![cmd_list(simple_cmd("body1"))],
Some(Box::new(ElsePart::Elif(IfClause::with_range(
vec![cmd_list(simple_cmd("test2"))],
vec![cmd_list(simple_cmd("body2"))],
Some(Box::new(ElsePart::Else(ElseClause::with_range(
vec![cmd_list(simple_cmd("body3"))],
default_range(),
)))),
default_range(),
)))),
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(
prog.to_canonical(),
"if test1 ; then body1 ; elif test2 ; then body2 ; else body3 ; fi\n"
);
}
#[test]
fn canonical_for_with_in() {
let cmd = Command::For(ForClause::with_range(
"x",
true,
vec![lit("a"), lit("b"), lit("c")],
vec![cmd_list(simple_cmd("echo"))],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "for x in a b c ; do echo ; done\n");
}
#[test]
fn canonical_for_with_redirect() {
let cmd = Command::For(ForClause::with_range_and_redirects(
"x",
true,
vec![lit("a"), lit("b")],
vec![cmd_list(simple_cmd("echo"))],
vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "for x in a b ; do echo ; done > out\n");
}
#[test]
fn canonical_for_without_in() {
let cmd = Command::For(ForClause::with_range(
"i",
false,
Vec::new(),
vec![cmd_list(simple_cmd("echo"))],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "for i ; do echo ; done\n");
}
#[test]
fn canonical_for_in_empty_list() {
let cmd = Command::For(ForClause::with_range(
"i",
true,
Vec::new(),
vec![cmd_list(simple_cmd("echo"))],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "for i in ; do echo ; done\n");
}
#[test]
fn canonical_while_loop() {
let cmd = Command::Loop(LoopClause::with_range(
LoopType::While,
vec![cmd_list(simple_cmd("true"))],
vec![cmd_list(simple_cmd("echo"))],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "while true ; do echo ; done\n");
}
#[test]
fn canonical_while_with_redirect() {
let cmd = Command::Loop(LoopClause::with_range_and_redirects(
LoopType::While,
vec![cmd_list(simple_cmd("read"))],
vec![cmd_list(simple_cmd("echo"))],
vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Less,
name: lit("in"),
here_document: Vec::new(),
here_document_expand: false,
}],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "while read ; do echo ; done < in\n");
}
#[test]
fn canonical_until_loop() {
let cmd = Command::Loop(LoopClause::with_range(
LoopType::Until,
vec![cmd_list(simple_cmd("false"))],
vec![cmd_list(simple_cmd("echo"))],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "until false ; do echo ; done\n");
}
#[test]
fn canonical_case_empty_body() {
let cmd = Command::Case(case_clause! {
word: lit("x"),
items: vec![case_item! {
patterns: vec![lit("a")],
body: Vec::new(),
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in a) ;; esac\n");
}
#[test]
fn canonical_case_with_body() {
let cmd = Command::Case(case_clause! {
word: lit("x"),
items: vec![case_item! {
patterns: vec![lit("a")],
body: vec![cmd_list(simple_cmd("echo"))],
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in a) echo ;; esac\n");
}
#[test]
fn canonical_case_with_redirect() {
let cmd = Command::Case(CaseClause::with_range_and_redirects(
lit("x"),
vec![case_item! {
patterns: vec![lit("a")],
body: vec![cmd_list(simple_cmd("echo"))],
}],
vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
default_range(),
));
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in a) echo ;; esac > out\n");
}
#[test]
fn canonical_case_wildcard_pattern() {
let cmd = Command::Case(case_clause! {
word: lit("x"),
items: vec![case_item! {
patterns: vec![lit("*")],
body: vec![cmd_list(simple_cmd("echo"))],
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in *) echo ;; esac\n");
}
#[test]
fn canonical_case_multiple_patterns() {
let cmd = Command::Case(case_clause! {
word: lit("x"),
items: vec![case_item! {
patterns: vec![lit("a"), lit("b"), lit("c")],
body: vec![cmd_list(simple_cmd("echo"))],
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in a | b | c) echo ;; esac\n");
}
#[test]
fn canonical_case_multiple_items() {
let cmd = Command::Case(case_clause! {
word: lit("x"),
items: vec![
case_item! {
patterns: vec![lit("a")],
body: vec![cmd_list(simple_cmd("echo"))],
},
case_item! {
patterns: vec![lit("b")],
body: Vec::new(),
},
],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in a) echo ;; b) ;; esac\n");
}
#[test]
fn canonical_case_item_with_background_body() {
let cmd = Command::Case(case_clause! {
word: lit("x"),
items: vec![case_item! {
patterns: vec![lit("a")],
body: vec![CommandList {
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("bg")],
bang: false,
range: Default::default(),
}),
ampersand: true,
range: Default::default(),
}],
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "case x in a) bg & ;; esac\n");
}
#[test]
fn canonical_function_def() {
let cmd = Command::FunctionDef(function_definition! {
name: "myfn".to_string(),
body: Box::new(brace_group! {
body: vec![cmd_list(simple_cmd("echo"))],
io_redirects: Vec::new(),
}),
io_redirects: Vec::new(),
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "myfn() { echo; }\n");
}
#[test]
fn canonical_function_def_with_redirects() {
let cmd = Command::FunctionDef(function_definition! {
name: "myfn".to_string(),
body: Box::new(brace_group! {
body: vec![cmd_list(simple_cmd("echo"))],
io_redirects: Vec::new(),
}),
io_redirects: vec![io_redirect! {
io_number: None,
op: IoRedirectOp::Great,
name: lit("out"),
here_document: Vec::new(),
here_document_expand: false,
}],
});
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "myfn() { echo; } > out\n");
}
#[test]
fn canonical_empty_program() {
let prog = program! { body: Vec::new() };
assert_eq!(prog.to_canonical(), "\n");
}
#[test]
fn quote_single_plain() {
assert_eq!(quote_single("hello"), "'hello'");
}
#[test]
fn quote_single_with_single_quote() {
assert_eq!(quote_single("it's"), "'it'\\''s'");
}
#[test]
fn escape_double_quoted_special_chars() {
assert_eq!(escape_double_quoted("a$b"), "a\\$b");
assert_eq!(escape_double_quoted("a\\b"), "a\\\\b");
assert_eq!(escape_double_quoted("a\"b"), "a\\\"b");
assert_eq!(escape_double_quoted("a`b"), "a\\`b");
assert_eq!(escape_double_quoted("plain"), "plain");
}
#[test]
fn escape_pattern_unquoted_preserves_globs() {
assert_eq!(escape_pattern_unquoted("*"), "*");
assert_eq!(escape_pattern_unquoted("[!a-z]"), "[!a-z]");
assert_eq!(escape_pattern_unquoted("a b"), "a\\ b");
}
#[test]
fn needs_quoting_safe_chars() {
assert!(!needs_word_quoting("hello"));
assert!(!needs_word_quoting("foo/bar"));
assert!(!needs_word_quoting("a-b"));
assert!(!needs_word_quoting("a_b"));
assert!(!needs_word_quoting("a.b"));
assert!(!needs_word_quoting("a+b"));
assert!(!needs_word_quoting("123"));
}
#[test]
fn needs_quoting_unsafe_chars() {
assert!(needs_word_quoting("a b"));
assert!(needs_word_quoting("a*b"));
assert!(needs_word_quoting("a;b"));
assert!(needs_word_quoting("a(b"));
assert!(needs_word_quoting("a>b"));
}
#[test]
fn needs_quoting_empty_string_is_false() {
assert!(!needs_word_quoting(""));
}
#[test]
fn ends_with_separator_checks() {
assert!(ends_with_separator("cmd &"));
assert!(ends_with_separator("line\n"));
assert!(!ends_with_separator("cmd"));
assert!(!ends_with_separator("cmd ;"));
}
#[test]
fn join_command_lists_basic() {
let parts = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert_eq!(join_command_lists(&parts), "a ; b ; c");
}
#[test]
fn join_command_lists_with_ampersand_separator() {
let parts = vec!["a &".to_string(), "b".to_string()];
assert_eq!(join_command_lists(&parts), "a & b");
}
#[test]
fn join_command_lists_with_newline_separator() {
let parts = vec!["a\n".to_string(), "b".to_string()];
assert_eq!(join_command_lists(&parts), "a\nb");
}
#[test]
fn join_command_lists_single() {
let parts = vec!["only".to_string()];
assert_eq!(join_command_lists(&parts), "only");
}
#[test]
fn join_command_lists_empty() {
let parts: Vec<String> = Vec::new();
assert_eq!(join_command_lists(&parts), "");
}
#[test]
fn choose_heredoc_delimiter_no_conflict() {
let lines = vec!["hello".to_string(), "world".to_string()];
let mut ctx = CanonicalContext::default();
let delim = choose_heredoc_delimiter(&lines, false, &mut ctx);
assert_eq!(delim, "MXSH_HEREDOC_0");
}
#[test]
fn choose_heredoc_delimiter_with_conflict() {
let lines = vec!["MXSH_HEREDOC_0".to_string(), "other".to_string()];
let mut ctx = CanonicalContext::default();
let delim = choose_heredoc_delimiter(&lines, false, &mut ctx);
assert_eq!(delim, "MXSH_HEREDOC_1");
assert_eq!(ctx.next_heredoc_id, 2);
}
#[test]
fn choose_heredoc_delimiter_strip_tabs_conflict() {
let lines = vec!["\tMXSH_HEREDOC_0".to_string()];
let mut ctx = CanonicalContext::default();
let delim = choose_heredoc_delimiter(&lines, true, &mut ctx);
assert_eq!(delim, "MXSH_HEREDOC_1");
}
#[test]
fn choose_heredoc_delimiter_increments_id() {
let lines = vec!["hello".to_string()];
let mut ctx = CanonicalContext::default();
let d1 = choose_heredoc_delimiter(&lines, false, &mut ctx);
let d2 = choose_heredoc_delimiter(&lines, false, &mut ctx);
assert_eq!(d1, "MXSH_HEREDOC_0");
assert_eq!(d2, "MXSH_HEREDOC_1");
}
#[test]
fn choose_heredoc_delimiter_uses_fallback_after_exhausting_candidates() {
let lines: Vec<String> = (0..MAX_HEREDOC_DELIMITER_ATTEMPTS)
.map(|i| format!("MXSH_HEREDOC_{i}"))
.collect();
let mut ctx = CanonicalContext::default();
let delimiter = choose_heredoc_delimiter(&lines, false, &mut ctx);
assert!(!lines.iter().any(|line| line == &delimiter));
assert!(delimiter.starts_with("MXSH_HEREDOC_"));
assert!(ctx.next_heredoc_id > MAX_HEREDOC_DELIMITER_ATTEMPTS);
}
#[test]
fn canonical_here_doc_word_escapes_specials() {
let w = lit("a$b\\c`d");
let result = canonical_here_doc_word(&w);
assert_eq!(result, "a\\$b\\\\c\\`d");
}
#[test]
fn canonical_here_doc_word_parameter() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
let result = canonical_here_doc_word(&w);
assert_eq!(result, "${VAR}");
}
#[test]
fn canonical_here_doc_word_command_sub() {
let inner = program! {
body: vec![cmd_list(simple_cmd("date"))],
};
let w = word_command! {
program: inner,
source: None,
back_quoted: false,
range: default_range(),
};
let result = canonical_here_doc_word(&w);
assert_eq!(result, "$(date)");
}
#[test]
fn canonical_here_doc_word_arithmetic() {
let w = word_arithmetic! {
body: arithm_literal(42),
range: default_range(),
};
let result = canonical_here_doc_word(&w);
assert_eq!(result, "$((42))");
}
#[test]
fn canonical_here_doc_word_list() {
let w = word_list! {
children: vec![lit("hello "), lit("$world")],
double_quoted: false,
range: default_range(),
};
let result = canonical_here_doc_word(&w);
assert_eq!(result, "hello \\$world");
}
#[test]
fn canonical_here_doc_literal_line_returns_raw_value() {
let w = lit("raw text here");
assert_eq!(canonical_here_doc_literal_line(&w), "raw text here");
}
#[test]
fn canonical_here_doc_literal_line_non_string_falls_back_to_canonical_word() {
let w = word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
};
assert_eq!(canonical_here_doc_literal_line(&w), "${VAR}");
}
#[test]
fn canonical_here_doc_literal_line_preserves_backquoted_command_substitution() {
let w = word_command! {
program: program_from(vec![simple_cmd("date")]),
source: None,
back_quoted: true,
range: default_range(),
};
assert_eq!(canonical_here_doc_literal_line(&w), "`date`");
}
#[cfg(feature = "serde")]
#[test]
fn io_redirect_serde_round_trip_preserves_expandable_heredoc() {
let redir = io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: lit("EOF"),
here_document: vec![word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: None,
}],
here_document_expand: true,
};
let value = serde_json::to_value(&redir).expect("redirect should serialize");
let decoded: IoRedirect = serde_json::from_value(value).expect("redirect should deserialize");
assert_eq!(decoded, redir);
}
#[cfg(feature = "serde")]
#[test]
fn io_redirect_serde_round_trip_preserves_literal_heredoc() {
let redir = io_redirect! {
io_number: None,
op: IoRedirectOp::DLess,
name: word_list! {
children: vec![
lit("EO"),
word_list! {
children: vec![lit("F")],
double_quoted: true,
range: default_range(),
},
],
double_quoted: false,
range: default_range(),
},
here_document: vec![lit("hello $VAR")],
here_document_expand: false,
};
let value = serde_json::to_value(&redir).expect("redirect should serialize");
let decoded: IoRedirect = serde_json::from_value(value).expect("redirect should deserialize");
assert_eq!(decoded, redir);
}
#[test]
fn canonical_unquoted_list() {
let w = word_list! {
children: vec![lit("a"), lit("b")],
double_quoted: false,
range: default_range(),
};
assert_eq!(canonical_word(&w), "ab");
}
#[test]
fn canonical_double_quoted_list_with_parameter() {
let w = word_list! {
children: vec![
lit("pre "),
word_parameter! {
name: "VAR".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: default_pos(),
brace_end: default_brace_end(),
},
lit(" post"),
],
double_quoted: true,
range: default_range(),
};
assert_eq!(canonical_word(&w), "\"pre ${VAR} post\"");
}
#[test]
fn canonical_nested_and_or() {
let aol = and_or_bin_op! {
op: BinOpType::And,
left: Box::new(and_or_bin_op! {
op: BinOpType::Or,
left: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("a")],
bang: false,
range: Default::default(),
})),
right: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("b")],
bang: false,
range: Default::default(),
})),
}),
right: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("c")],
bang: false,
range: Default::default(),
})),
};
let cmd = CommandList {
and_or_list: aol,
ampersand: false,
range: Default::default(),
};
let prog = program! { body: vec![cmd] };
assert_eq!(prog.to_canonical(), "a || b && c\n");
}
#[test]
fn canonical_right_nested_mixed_and_or_groups_rhs() {
let aol = and_or_bin_op! {
op: BinOpType::And,
left: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("a")],
bang: false,
range: Default::default(),
})),
right: Box::new(and_or_bin_op! {
op: BinOpType::Or,
left: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("b")],
bang: false,
range: Default::default(),
})),
right: Box::new(AndOrList::Pipeline(Pipeline {
commands: vec![simple_cmd("c")],
bang: false,
range: Default::default(),
})),
}),
};
let prog = program! {
body: vec![CommandList {
and_or_list: aol,
ampersand: false,
range: Default::default(),
}],
};
let canonical = prog.to_canonical();
assert_eq!(canonical, "a && { b || c; }\n");
let reparsed = crate::parser::Parser::from_string(&canonical)
.parse_program()
.expect("grouped canonical form should parse");
assert_eq!(reparsed.to_canonical(), canonical);
}
#[test]
fn canonical_brace_group_multiple_commands() {
let cmd = brace_group! {
body: vec![
cmd_list(simple_cmd("a")),
cmd_list(simple_cmd("b")),
cmd_list(simple_cmd("c")),
],
io_redirects: Vec::new(),
};
let prog = program_from(vec![cmd]);
assert_eq!(prog.to_canonical(), "{ a ; b ; c; }\n");
}